Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81637b8502 | |||
| 0f2e109aa2 | |||
| 3312facd82 | |||
| 8dc409777e | |||
| e24ea9e71d | |||
| d1e9425b9c | |||
| e6c050581f | |||
| 224ff0b935 | |||
| b824308182 | |||
| 610a274103 | |||
| 432acba9c1 | |||
| 9164bf3228 | |||
| 5f3257a4d8 | |||
| 97bdf605e2 | |||
| 178bc78bbb | |||
| 6814b055cb | |||
| cb1839e36b | |||
| fd2d9935c1 | |||
| a9030fc0c0 | |||
| 12113088c8 | |||
| 7e47a153b8 | |||
| d2115941c7 | |||
| 9016ef038d | |||
| c70edce8c1 | |||
| 9d7a5beec2 | |||
| 08a4570b2d | |||
| cca6fc46f7 | |||
| 1f6c97be62 | |||
| b7ff298789 | |||
| b568fa3fdc | |||
| 01af863fd8 | |||
| 53554ce42a | |||
| abfc6595e7 | |||
| 88d6d0e648 | |||
| 57ade27eee | |||
| f1385d8a08 | |||
| b795517d9f | |||
| 88cff5ca87 | |||
| 3ee0137d0d | |||
| b24bd78ddd | |||
| e021f7d1ff | |||
| 56d4b9df67 | |||
| cd9dd28adc | |||
| 3f5d2cd017 | |||
| 81ed53cf78 | |||
| d37671c359 | |||
| 0e9f78bd5e | |||
| 64f1598076 | |||
| c2a4740a3f | |||
| 4795a2a0ee | |||
| 7a8c0aa2b5 | |||
| d55b7b49ca | |||
| 438788e031 | |||
| 3c8a1acc69 | |||
| e8a0c0d076 | |||
| cf0091b741 | |||
| 3bfa82e7f2 | |||
| a0965101e1 | |||
| a2c12a2638 | |||
| 28a688fd82 | |||
| 52566f6966 | |||
| a5d195c5d2 | |||
| d9bababfd7 | |||
| 3a09674831 | |||
| 500068e63e | |||
| b9ac7746aa | |||
| f201deee46 | |||
| 1d4c6b1d66 | |||
| 3d273a3188 | |||
| c403dd04c8 | |||
| 80512d11b5 | |||
| 2ee49cfd41 | |||
| e1f6943f8e | |||
| bb51b83399 | |||
| cfe8512636 | |||
| 7f2a5b0b5a | |||
| 339d36eb3e | |||
| 7dea0f1e7b | |||
| 88afdf6611 | |||
| 1d96269139 | |||
| b394bad09e | |||
| 711e2fc5d6 | |||
| 2f78216b6f | |||
| c0c99a9672 | |||
| f9a40a9e1c | |||
| 59d90ae068 | |||
| 428e7fa682 | |||
| 52b672deec | |||
| efe578f61c | |||
| 6afb7d9fea | |||
| 15bacacafa | |||
| e57444e88b | |||
| 2da045f232 | |||
| d3c5c47973 | |||
| d25649a26b | |||
| f263413313 | |||
| 07fbadcdc5 | |||
| 4057566a14 | |||
| 27035c4acb | |||
| 10466e122a | |||
| 1b71c0196f | |||
| 8a889db069 | |||
| a35ecf4f1c | |||
| 260517c547 | |||
| ee2c402438 | |||
| 4d0d44170f | |||
| d7d7be2c6b | |||
| abaa980f93 | |||
| 78fe2b097b | |||
| 97836fde8a | |||
| dcea6b926f | |||
| 72d5ce1c3e | |||
| 27099c2877 | |||
| 8308b3e65f | |||
| 89beb5f160 | |||
| 8a7192eae8 | |||
| 1c4cdf8af1 | |||
| 0627f8dd91 | |||
| cf4b01d4b3 | |||
| f17cb08a63 | |||
| 567f650bbb | |||
| 0f52572f20 | |||
| 8259bd468d | |||
| e437946ee0 | |||
| 86a93f46be | |||
| b4d5d2926b | |||
| 43d86b4b6f | |||
| 8e6c1493e8 | |||
| 40177cb63f | |||
| 6bbf77860b | |||
| 2917c034ed | |||
| 7b79e49c20 | |||
| bd2f08aaf7 | |||
| f13fc37b5a | |||
| ffdefd2c9c | |||
| 9ba2667c6c | |||
| f1174b8c57 | |||
| 3af5a315e2 | |||
| 99be7e96bd | |||
| 8747b1686b | |||
| 014b3e5a4b | |||
| 6e8a600a82 | |||
| 751beb52c1 | |||
| cc313f2ea9 | |||
| cc88471256 | |||
| 51e572b9ce | |||
| 1446e852b1 | |||
| a684b178b6 | |||
| d31d945827 | |||
| f2e5e9abc9 | |||
| 340f0374fc | |||
| dace19aec4 | |||
| c00c605dd7 | |||
| 8c6e7930db | |||
| 608901bd0f | |||
| 73c063a6ac | |||
| 8b4c4aa591 | |||
| cef3ddb307 |
@@ -0,0 +1,106 @@
|
||||
# Kế hoạch: Real-time Skia filter trên camera live view dùng vision-camera v4
|
||||
|
||||
## Vấn đề
|
||||
`expo-camera` render camera preview lên native surface (PreviewView/SurfaceView) không thể intercept bằng Skia. Periodic capture bị từ chối vì mất live view.
|
||||
|
||||
## Giải pháp
|
||||
Dùng `react-native-vision-camera@4.7.3` với `useSkiaFrameProcessor` — frame processor chạy native thread, mỗi frame camera được vẽ qua Skia với ColorMatrix trước khi hiển thị.
|
||||
|
||||
## Các bước thực hiện
|
||||
|
||||
### Bước 1: Cài react-native-vision-camera@4.7.3
|
||||
```bash
|
||||
npm install react-native-vision-camera@4.7.3
|
||||
```
|
||||
- v4.7.3 là latest v4, có export `useSkiaFrameProcessor` và `frameProcessor` prop trên `Camera`
|
||||
- v5+ đã xoá Skia integration, không dùng được
|
||||
|
||||
### Bước 2: Thêm Expo Config Plugin
|
||||
Sửa `app.json`:
|
||||
- Thay `expo-camera` plugin bằng `react-native-vision-camera` plugin (v4 có plugin riêng)
|
||||
- Thêm `withEnableFrameProcessorsAndroid` để bật frame processor trên Android
|
||||
```json
|
||||
"plugins": [
|
||||
"react-native-vision-camera",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "..."
|
||||
}
|
||||
]
|
||||
]
|
||||
```
|
||||
Cần kiểm tra: v4 có expo plugin riêng (`withVisionCamera`) hay dùng thẳng tên package.
|
||||
|
||||
### Bước 3: Cập nhật App.tsx
|
||||
- Import `useCameraPermission` từ `react-native-vision-camera` thay vì `useCameraPermissions` từ `expo-camera`
|
||||
- Props: `hasPermission`, `requestPermission`
|
||||
|
||||
### Bước 4: Viết lại Viewfinder.tsx
|
||||
Component mới dùng:
|
||||
- `Camera` từ vision-camera với prop `frameProcessor={frameProcessor}`
|
||||
- `useSkiaFrameProcessor` hook — worklet chạy native, mỗi frame:
|
||||
1. Tạo `Skia.Paint()` với `Skia.ColorFilter.MakeMatrix(colorMatrix)`
|
||||
2. Gọi `frame.render(paint)` — vẽ frame qua color filter
|
||||
- `colorMatrix` là `SharedValue` từ react-native-reanimated (để worklet đọc được)
|
||||
- Grain noise, frame border, GPS watermark: giữ nguyên overlay React Native / Skia Canvas trên top
|
||||
|
||||
```tsx
|
||||
const App = () => {
|
||||
const frameProcessor = useSkiaFrameProcessor((frame) => {
|
||||
'worklet';
|
||||
const paint = Skia.Paint();
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSV.value));
|
||||
frame.render(paint);
|
||||
}, [colorMatrixSV]);
|
||||
|
||||
return (
|
||||
<Camera
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
isActive={true}
|
||||
frameProcessor={frameProcessor}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Bước 5: Loại bỏ expo-camera khỏi code
|
||||
- `CameraView` → `Camera` (vision-camera)
|
||||
- Xoá imports không dùng
|
||||
- Giữ `expo-camera` trong package.json (không gỡ, tránh lỗi autolinking)
|
||||
|
||||
### Bước 6: Re-prebuild & build APK
|
||||
```bash
|
||||
rm -rf android
|
||||
npx expo prebuild --platform android
|
||||
echo "sdk.dir=/home/locpham/android-sdk" > android/local.properties
|
||||
cd android && ./gradlew assembleDebug
|
||||
```
|
||||
(Không chạy `clean` — tránh lỗi CMake codegen paths)
|
||||
|
||||
## API đã xác nhận (v4.7.3)
|
||||
| Export | Path |
|
||||
|---|---|
|
||||
| `Camera` | `react-native-vision-camera` |
|
||||
| `useCameraDevice` | `react-native-vision-camera` |
|
||||
| `useCameraPermission` | `react-native-vision-camera` |
|
||||
| `useSkiaFrameProcessor` | `react-native-vision-camera` |
|
||||
| `SkiaCameraCanvas` | `react-native-vision-camera` |
|
||||
| Expo plugin (`withVisionCamera`) | `lib/module/expo-plugin/withVisionCamera` |
|
||||
|
||||
## Rủi ro & Xử lý
|
||||
| Rủi ro | Xử lý |
|
||||
|---|---|
|
||||
| `react-native-worklets-core` conflict | Đã có sẵn qua `react-native-reanimated` dependency |
|
||||
| CameraX dependency (`NoClassDefFoundError`) | v4 dùng `CameraX` không phải Nitro — build an toàn hơn v5 |
|
||||
| CMake codegen paths lỗi sau `clean` | Không dùng `./gradlew clean`, dùng `--rerun-tasks` nếu cần |
|
||||
| Frame processor không chạy trên Android | Bật flag qua Expo plugin: `withEnableFrameProcessorsAndroid` |
|
||||
|
||||
## Kiểm thử
|
||||
1. Build APK thành công
|
||||
2. Camera live view hiển thị với filter:
|
||||
- B&W/monochrome → true grayscale
|
||||
- Saturation, temp, tint, exposure thay đổi real-time
|
||||
3. Grain noise, frame border, GPS watermark vẫn hoạt động
|
||||
4. Library mode không bị ảnh hưởng
|
||||
@@ -1,234 +0,0 @@
|
||||
# 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
@@ -1,264 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,191 +0,0 @@
|
||||
# 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
@@ -1,158 +0,0 @@
|
||||
# 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".
|
||||
@@ -1,715 +0,0 @@
|
||||
# 8 — PORT BỘ CÔNG CỤ CHỈNH ẢNH TỪ WEB SANG APP ANDROID
|
||||
|
||||
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`d5e5da4`**). Ngày: 2026-09-26.
|
||||
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`6814b05`** (bản web đã chạy và đo được).
|
||||
|
||||
Tài liệu này là **spec để code**, không phải báo cáo. Mỗi mục có: nguồn web (file/hàm),
|
||||
hiện trạng Android, việc phải làm, mã mẫu, và **tiêu chí nghiệm thu**. Toàn bộ số dòng
|
||||
đã đối chiếu trực tiếp trên hai nhánh tại thời điểm viết.
|
||||
|
||||
> **Cách lấy nguồn tham chiếu:** nhánh `feat/vision-camera-v5` **không chứa** thư mục
|
||||
> `docker/frontend`. Đọc bản web bằng:
|
||||
>
|
||||
> ```sh
|
||||
> git show origin/recipes-web:docker/frontend/shared/utils/toneShader.ts
|
||||
> git show origin/recipes-web:docker/frontend/src/engine/exportEngine.ts
|
||||
> ```
|
||||
>
|
||||
> Đường dẫn "web" trong tài liệu này luôn tính từ `docker/frontend/`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Phạm vi và bảng khoảng trống
|
||||
|
||||
Bảy hạng mục người dùng yêu cầu, theo đúng thứ tự sẽ làm:
|
||||
|
||||
| # | Hạng mục | Web có (nguồn) | Android hiện có | Việc phải làm |
|
||||
|---|----------|----------------|-----------------|----------------|
|
||||
| 1 | EXPOSURE / EV / HIGHLIGHT | `shared/utils/toneShader.ts` `EXPOSURE_SKSL` + `TONE_SKSL`; `shared/utils/colorUtils.ts` `EV_PER_UNIT`/`SIM_EXPOSURE_BIAS_EV`/`exposureStops` (`171-193`) | EV nhét trong ma trận màu (`src/utils/colorUtils.ts:425-426`, `applyExposureGain:444-455`); highlight knee `smoothstep(0.65,1.0)` gain `0.22` không có `(1-t)` (`src/utils/toneShader.ts:131,134`) | Tách EV sang pass linear; port đúng knee/`(1-t)` của web; **áp luôn fix thứ tự pass của `4_FIX_HIGHLIGHT_ORDER.md`** |
|
||||
| 2 | AUTO (auto setting) | `src/ui/Histogram.tsx` `readHistogram` + `autoExposureStops`; `src/App.tsx:2497-2504` | Không có (ROTATE có AUTO riêng cho straighten — khác việc) | Histogram 256 bin từ pixel ảnh gốc + `autoExposureStops`, ghi vào `exposureCompensation` |
|
||||
| 3 | WHITE / BLACK trong WB | `TONE_SKSL` web dòng `168-169`; uniform `wh`/`bl` (`78-79`, `338-339`, `390-391`, `433-434`, `454`, `466-467`); `paramDefs.ts:137,148` | Không có `whites`/`blacks`. `paramDefs.wb` chỉ có `temperature`,`tint` (`src/utils/paramDefs.ts:109-132`) | Thêm 2 uniform + 2 entry `paramDefs.wb` |
|
||||
| 4 | TONE CURVE | `shared/utils/toneCurve.ts` (203 dòng) + `src/ui/ToneCurvePanel.tsx` | Không có | Port toán + LUT + panel graph |
|
||||
| 5 | GRADIENT MASK | `shared/utils/gradientMask.ts` (169 dòng) + `App.tsx` `gradientChips:2750`/`maskRulers:2355` | Không có | Port shader + geometry + UI chip/strip/cột chỉnh |
|
||||
| 6 | FIX (HEAL, MOSAIC) | `shared/utils/heal.ts` (324), `mosaic.ts` (104), `brush.ts` (26) | Không có | Port shader + pixel sampler + UI |
|
||||
| 7 | H-FLIP / V-FLIP trong ROTATE | `shared/utils/skiaImage.ts` `flipSkImage:84`, `applyPhotoRotation:114` | `applyPhotoRotation(image, quarter, straighten)` — **không có flip** (`src/utils/skiaImage.ts:69`) | Thêm flip + state + UI strip |
|
||||
|
||||
### 0.1 Đã có sẵn trên nhánh này — KHÔNG làm lại
|
||||
|
||||
Các commit `c8e2b6e`…`d5e5da4` đã port xong và **không** nằm trong phạm vi tài liệu này:
|
||||
|
||||
- **HSL selective colour mixer** (`cbd0178`): đã có `hslBands`/`hslHue`/`hslSat`/`hslLum`
|
||||
trong `ColorAdjustments` (`src/types/index.ts:77-104`), `HSL_BANDS`/`hslBandGaps`/
|
||||
`sanitizeHslBands` trong `colorUtils.ts:11-115`, band block trong `TONE_SKSL`
|
||||
(`toneShader.ts:51-58,76-82,194`) và tab `HSL` trong `ToolRail.tsx:18`.
|
||||
⇒ **Bỏ** mọi ghi chú cũ kiểu "HSL web-only".
|
||||
- **Stock `classic-vivid` và `mono-high-contrast`** (`c8e2b6e`, `85b55dc`): đã có trong
|
||||
`BaseFilter` (`src/types/index.ts:69`) **và** đã có entry `FILM_TONE`
|
||||
(`src/utils/toneShader.ts:299-322`, gồm `'mono-high-contrast': { sh: -0.32, hl: 0.26 }`).
|
||||
- **`STOCK_BIAS`** (`colorUtils.ts:161-168`) đã thay vai trò của `SIM_EXPOSURE_BIAS_EV` +
|
||||
`SIM_CONTRAST_BIAS` bên web, nhưng còn ở **đơn vị cũ** — xem mục 3.3.
|
||||
|
||||
Ghi chú phạm vi khác: ba nhánh light/chroma, split-tone, Color Chrome, vibrance trong
|
||||
`TONE_SKSL` Android **đã khớp** web — không đụng.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiện trạng Android
|
||||
|
||||
### 1.1 Ba đường render — mọi pass mới phải khớp cả ba
|
||||
|
||||
| Đường | File | Đặc điểm |
|
||||
|-------|------|----------|
|
||||
| Export JS/Skia (imperative) | `src/utils/exportEngine.ts` → `processAndExportPhoto` | Toàn quyền: surface phụ, snapshot, LUT, cache effect theo count. Đây là đường "chuẩn" để so. |
|
||||
| Preview library (declarative) | `src/components/Viewfinder.tsx`, `libPhoto()` (`3127-3200`) | Chỉ lồng được `<Shader source={effect}>` quanh một chuỗi shader của ảnh. **Không** chèn được pass giữa tuỳ ý. |
|
||||
| Camera preview (worklet) | `Viewfinder.tsx` `<SkiaCamera onFrame={handleFrame}>`, `drawFullFrame` | Mỗi frame; tránh thêm pass nặng. |
|
||||
| Export native Kotlin | `src/utils/nativeExport.ts:47` + `modules/recipescam-export/android/.../RecipescamExportModule.kt` | Bật bằng `EXPO_PUBLIC_NATIVE_EXPORT=1` (`App.tsx:61`). Chỉ dùng khi `pro` (`App.tsx:1376`). |
|
||||
|
||||
`Viewfinder.tsx:461` dựng ảnh tĩnh đã quay cho chế độ library bằng memo
|
||||
`applyPhotoRotation(loadedImage, photoRotation, photoStraighten)`.
|
||||
|
||||
### 1.2 Hạ tầng đã có
|
||||
|
||||
- `Skia.RuntimeEffect.Make` + `makeShaderWithChildren` + `<Shader source>` — đã dùng
|
||||
(`exportEngine.ts:371-380`, `Viewfinder.tsx:3141-3146`).
|
||||
- Mẫu đọc pixel: `src/utils/horizon.ts:18-56` — `Skia.Surface.Make(w,h)` (`32`) →
|
||||
`drawImageRectOptions` downscale → `makeImageSnapshot()` → `readPixels()` (`50`)
|
||||
(Uint8Array RGBA hoặc Float32Array). Dùng lại cho histogram/AUTO và `findHealSource`.
|
||||
- Undo: `App.tsx` `commit(coalesce, mutate)` (`757-775`, `PEEK_GAP_MS = 700` tại `751`),
|
||||
`applyLook` (`804`), `handleUndo` (`820`), `Look` (`719-720`) — thêm field vào `Look`
|
||||
là tự động sống qua undo/redo/session (`lookNow` tại `728`).
|
||||
- Chip UI: `AdjustmentPanel.tsx` `chipRow` (`867`), `paramChips` (`475`),
|
||||
`groupDefs` (`652`), `groupDefs.rotate` (`713-733`), `rotateChip()` (`791-803`).
|
||||
- `RecipeCreateModal.handleSave` spread `...seedAdj`, nên knob **không có row** trong form
|
||||
vẫn được lưu (không bị zero).
|
||||
|
||||
### 1.3 Điều KHÔNG có (phải dựng mới)
|
||||
|
||||
Không có: brush, heal, mosaic, mask vẽ tay, curve editor, flip. Không SVG, không
|
||||
`react-native-gesture-handler` — mọi touch đi qua RN responder. Không i18n (chuỗi
|
||||
hardcode tiếng Anh, giữ nguyên).
|
||||
|
||||
`exportEngine.ts` **không** cache `RuntimeEffect` theo count: chỉ có `watermarkFonts`
|
||||
(`184`) ở module scope, mọi effect khác dựng lại trong hàm. Heal/mosaic/mask bắt buộc
|
||||
phải cache — xem mục 2.4.
|
||||
|
||||
### 1.4 Thứ tự pass hiện tại của `exportEngine.ts`
|
||||
|
||||
```
|
||||
218 1 nạp ảnh → 241 1 rotate → 281 1b crop → 250 1c deviceFloor → 337 2 surface
|
||||
350 3 color matrix (+ STOCK_BIAS, +2^EV gain) ← CÙNG paint với 3b/3c: SAI (xem doc 4)
|
||||
359 3b tone shader → 392 3c cinema
|
||||
414 4 denoise/clarity/soften → 477 5 draw → 489 5b HDF
|
||||
572 6 grain → 619 6b vignette → 648 7 frame
|
||||
832 8 watermark → 924 9 snapshot/JPEG/EXIF → 976 10 file → 988 11 gallery
|
||||
```
|
||||
|
||||
`4_FIX_HIGHLIGHT_ORDER.md` đã chỉ ra `SkPaint` chạy **shader TRƯỚC colorFilter**, nên
|
||||
ma trận (mang exposure) hiện đang áp **sau** tone → HIGHLIGHT bị xoá sạch. Kiểm tra
|
||||
`grep -rn gradeThrough src/` → **rỗng**, fix này **chưa được áp**. Mục 3 dưới đây áp nó
|
||||
cùng lúc với việc tách EV.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quy tắc chung cho mọi pass mới
|
||||
|
||||
### 2.1 Thứ tự pass đích
|
||||
|
||||
```
|
||||
rotate(+flip) → crop → matrix → [EXPOSURE linear] → tone(hl/sh/wh/bl) → cinema
|
||||
→ curve → denoise/clarity/soften → draw → HDF
|
||||
→ masks → heal → mosaic → grain → vignette → frame → watermark
|
||||
```
|
||||
|
||||
### 2.2 Sai lệch **có chủ ý** so với web
|
||||
|
||||
Web đặt `masks → heal → mosaic` **sau** grain và vignette
|
||||
(`exportEngine.ts:708-800`). Android đặt chúng **trước** grain/vignette. Lý do:
|
||||
|
||||
1. Preview library chỉ lồng được shader trong chuỗi shader của ảnh — mask/heal/mosaic
|
||||
phải nằm trong chuỗi đó, tức trước lớp grain/vignette vốn là node riêng
|
||||
(`Viewfinder.tsx:3230-3233`/`3288-3291` grain, `renderVignette:2809,3237,3295,3348`).
|
||||
2. Grain/vignette chạy sau sẽ **phủ đều** lên vết sửa, nên vết heal/mosaic trông tự
|
||||
nhiên hơn (nhiễu hạt chồng lên đúng như mọi vùng khác).
|
||||
|
||||
Điều phải giữ bằng mọi giá: `masks` chạy **trước** `heal` (web có comment giải thích ở
|
||||
`gradientMask.ts:149-155` và `exportEngine.ts:708-737`) — vết heal phải mượn pixel **đã
|
||||
mang ánh sáng của mask**.
|
||||
|
||||
### 2.3 Điểm chèn
|
||||
|
||||
| Đường | Vị trí chèn |
|
||||
|-------|-------------|
|
||||
| `exportEngine.ts` | Ngay sau khối vignette, trước `// 7. Frame.` (`648`) |
|
||||
| `Viewfinder.tsx` | Trong `libPhoto()` (`3127`), sau `<Blur>`/clarity và trước HDF glow |
|
||||
| `nativeExport.ts` / Kotlin | Theo `4_FIX_HIGHLIGHT_ORDER.md` §4.3 rồi nối tiếp |
|
||||
|
||||
### 2.4 Quy ước code
|
||||
|
||||
- Shader dựng theo **số lượng** phần tử (`healSkSL(n)`, `mosaicSkSL(n)`,
|
||||
`gradientMaskSkSL(n)`) và **cache** effect theo count — copy mẫu web
|
||||
`exportEngine.ts:154-210` (`healEffectFor`/`mosaicEffectFor`/`maskEffectFor`).
|
||||
- Mọi pass mới phải có nhánh "tắt": uniform toàn 0 / danh sách rỗng ⇒ **không** tạo
|
||||
surface, **không** đổi đường vẽ cũ. Đây là điều kiện để không regress ảnh hiện tại.
|
||||
- Không đổi ngữ nghĩa `evFromCamera` (`exportEngine.ts:35,353`): ảnh chụp từ camera đã
|
||||
nhận AE bias phần cứng, **không** cộng thêm EV của `exposureCompensation`.
|
||||
|
||||
---
|
||||
|
||||
## 3. EXPOSURE / EV / HIGHLIGHT
|
||||
|
||||
### 3.1 Nguồn web
|
||||
|
||||
- `shared/utils/toneShader.ts` — `EXPOSURE_SKSL`: linear hoá sRGB (`0.04045/12.92` +
|
||||
`2.4`), nhân `exp2(ev)`, encode lại. Chạy **giữa** ảnh đã grade và tone shader.
|
||||
- `shared/utils/colorUtils.ts:171-193`:
|
||||
- `SIM_EXPOSURE_BIAS_EV = { 'leica-vivid': 0.25 }`, `SIM_CONTRAST_BIAS = { 'mono-high-contrast': 4 }`
|
||||
- `EV_PER_UNIT = 0.25` → núm EXPOSURE ±10 = ±2.5 EV
|
||||
- `exposureStops(adj, baseFilter) = (adj.exposure ?? 0) * EV_PER_UNIT + bias`
|
||||
- Comment khẳng định: EV/biến thiên sim **không** nằm trong ma trận màu.
|
||||
- `exportEngine.ts:458-476` — bước 3: ma trận riêng một ảnh, rồi `evStops = exposureStops() + userEv`.
|
||||
|
||||
### 3.2 Hiện trạng Android (sai)
|
||||
|
||||
`src/utils/colorUtils.ts`:
|
||||
|
||||
```
|
||||
186 const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
|
||||
425 const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
|
||||
426 const expOffset = (exposure / 10) * 0.15;
|
||||
444 export function applyExposureGain(matrix, evStops) // gain 2^ev trong gamma-space
|
||||
```
|
||||
|
||||
`exposureCompensation` ±3 EV (`src/types/index.ts:104`), áp qua `applyExposureGain` tại
|
||||
`exportEngine.ts:353-357` và `nativeExport.ts:61`. Kết quả: +1 EV đẩy mid-grey 0.5 →
|
||||
**1.0** (cháy), đúng như web đã sửa.
|
||||
|
||||
`STOCK_BIAS['leica-vivid'] = { exposure: 2 }` (`colorUtils.ts:163`) là **đơn vị cũ**: web
|
||||
đo ra ≈ **0.25 EV** và đã chuyển sang thang stops.
|
||||
|
||||
### 3.3 Việc phải làm
|
||||
|
||||
**Bước A — tách EV khỏi ma trận.**
|
||||
|
||||
1. `colorUtils.ts:186` — bỏ `+ (STOCK_BIAS[baseFilter]?.exposure ?? 0)`; xoá khối
|
||||
`expScale`/`expOffset` (`425-438`) khỏi `getSkiaColorMatrix`; xoá
|
||||
`applyExposureGain` (`444-455`) khỏi mọi caller. Giữ `STOCK_BIAS.contrast`
|
||||
(`mono-high-contrast: 4`) — web cũng giữ `SIM_CONTRAST_BIAS` trong ma trận.
|
||||
2. Đổi `STOCK_BIAS['leica-vivid']` từ `{ exposure: 2 }` → **bỏ khỏi `STOCK_BIAS`**, và
|
||||
thêm đúng bản web:
|
||||
|
||||
```ts
|
||||
// src/utils/colorUtils.ts
|
||||
export const EV_PER_UNIT = 0.25; // núm EXPOSURE ±10 = ±2.5 EV (web: giống hệt)
|
||||
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = { 'leica-vivid': 0.25 };
|
||||
|
||||
/** EV người dùng (núm), biến thiên sim, và EV thô — cộng ở thang stops. */
|
||||
export function exposureStops(
|
||||
adj: ColorAdjustments, baseFilter: BaseFilter | undefined, userEv: number
|
||||
): number {
|
||||
return (adj.exposure ?? 0) * EV_PER_UNIT
|
||||
+ (SIM_EXPOSURE_BIAS_EV[baseFilter ?? 'none'] ?? 0)
|
||||
+ userEv;
|
||||
}
|
||||
```
|
||||
|
||||
**Bước B — thêm `EXPOSURE_SKSL`** vào `src/utils/toneShader.ts` (copy nguyên văn web,
|
||||
giữ comment gốc):
|
||||
|
||||
```ts
|
||||
export const EXPOSURE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float ev;
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
|
||||
}
|
||||
vec3 toEncoded(vec3 c) {
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
**Bước C — áp `4_FIX_HIGHLIGHT_ORDER.md` §4.1/§4.2/§4.3 nguyên trạng.** Ma trận phải
|
||||
thành ảnh riêng (`gradeThrough`) trước khi tone/cinema đọc. Đồng thời **bỏ lần áp ma
|
||||
trận thứ hai** trong khối HDF (`exportEngine.ts:529,549`, `Viewfinder.tsx:1369,1387`).
|
||||
|
||||
Thứ tự sau khi sửa: `matrix → exposure(linear) → tone → cinema`. Exposure chèn cùng chỗ
|
||||
với ma trận: `gradeImage` (kết quả `gradeThrough`) → shader exposure → tone shader con.
|
||||
`ev = exposureStops(adjustments, recipe.baseFilter, evFromCamera ? 0 : exposureCompensation)`.
|
||||
Với `evFromCamera` thì **vẫn** giữ phần núm EXPOSURE + biến thiên sim (chúng chưa từng
|
||||
đi qua phần cứng), chỉ bỏ `exposureCompensation`.
|
||||
|
||||
### 3.4 Nghiệm thu
|
||||
|
||||
1. Parity với web trên **cùng ảnh + cùng recipe** (so pixel, không so cảm giác).
|
||||
2. Mid-grey +1 EV cho ra **≈ 0.69** encoded, **không** cháy 1.00.
|
||||
3. Bảng p95 của `4_FIX_HIGHLIGHT_ORDER.md` §5: nguồn gradient dọc 0.98→0.30,
|
||||
`HIGHLIGHT −10` → p95 **0.780**, `HIGHLIGHT −10 + EXPOSURE +10` → p95 **vẫn 0.780**,
|
||||
midtone 0.502 → 0.722.
|
||||
4. `leica-vivid` sau khi sửa sáng thêm **≈ 0.25 EV** so với trước (đo trên cùng ảnh), và
|
||||
**giống** bản web.
|
||||
5. Chạy đủ ba đường (export JS, preview library, Kotlin) và so ảnh với nhau.
|
||||
|
||||
> **Thay đổi hành vi phải báo trước:** núm EXPOSURE từ "±10 đơn vị ≈ ×0.8…×1.2" thành
|
||||
> "±2.5 EV". Recipe cũ đặt EXPOSURE ≠ 0 sẽ sáng/tối hơn rõ rệt. Web đã chấp nhận đánh
|
||||
> đổi này (`colorUtils.ts` comment "every recipe already saved with a non-zero EXPOSURE
|
||||
> gets brighter with it").
|
||||
|
||||
---
|
||||
|
||||
## 4. HIGHLIGHT
|
||||
|
||||
### 4.1 Nguồn web (`TONE_SKSL`)
|
||||
|
||||
```
|
||||
57 float hlMask = smoothstep(0.50, 1.00, t);
|
||||
60 float o = t + hl * hlMask * (1.0 - t) + sh * 0.34 * shMask;
|
||||
63 o -= dr * 0.18 * hlMask * t;
|
||||
```
|
||||
|
||||
Knee **0.50**, biên độ **1.0** (không scale), dạng **`(1-t)`** (càng gần trắng càng ít
|
||||
đẩy) ⇒ monotonic, giữ được vùng sáng không cháy. `shMask = 1 - smoothstep(0, 0.55, t)`.
|
||||
|
||||
### 4.2 Hiện trạng Android (`src/utils/toneShader.ts`)
|
||||
|
||||
```
|
||||
131 float hlMask = smoothstep(0.65, 1.00, t);
|
||||
134 float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask;
|
||||
```
|
||||
|
||||
Knee 0.65, gain 0.22, cộng thẳng không có `(1-t)` ⇒ vùng sáng đã cháy vẫn bị đẩy lên và
|
||||
thanh "chết" đúng như `4_FIX_HIGHLIGHT_ORDER.md` §1 mô tả.
|
||||
|
||||
### 4.3 Việc phải làm
|
||||
|
||||
Thay hai dòng `131` và `134` bằng bản web. Giữ `sh * 0.34` (hai bên giống nhau). Không
|
||||
đổi `dr` (dòng `137`, đã khớp). Cập nhật comment đầu file (mô tả knee 0.65) cho khớp.
|
||||
|
||||
`FILM_TONE` (`toneShader.ts:299-322`) đã có đủ sáu stock web hỗ trợ, gồm
|
||||
`classic-vivid` và `mono-high-contrast` — **không đụng**.
|
||||
|
||||
### 4.4 Nghiệm thu
|
||||
|
||||
- `HIGHLIGHT −10` trên ảnh có vùng trắng đẩy p95 xuống ~0.78 và **không** đổi midtone
|
||||
(so với ảnh gốc, mid lệch < 0.01).
|
||||
- `HIGHLIGHT +10` rồi `−10` quay về đúng ảnh gốc (đối xứng, sai số ≤ 1/255).
|
||||
- `mono-high-contrast` (dùng `hl: 0.26`) giữ nguyên diện mạo sau khi đổi knee — kiểm bằng
|
||||
ảnh so trước/sau.
|
||||
|
||||
---
|
||||
|
||||
## 5. WHITE / BLACK trong WB
|
||||
|
||||
### 5.1 Nguồn web
|
||||
|
||||
`TONE_SKSL` — chèn **sau** nhánh lightness/chroma, trước split-tone:
|
||||
|
||||
```
|
||||
168 vec3 dk = 1.0 - rgb;
|
||||
169 rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 0.0, 1.0);
|
||||
```
|
||||
|
||||
Trọng số **cubic** theo khoảng cách của kênh tới đầu mút: trong tối kênh tối nhất dịch
|
||||
nhiều nhất, trong sáng kênh sáng nhất dịch nhiều nhất ⇒ kéo R/G/B về chung một toe và
|
||||
shoulder — đây là **động tác white balance**, không phải thêm một thanh tone. Monotonic:
|
||||
đạo hàm `1 − 3×0.18 = 0.46` tại đầu mút, không thể đảo chiều.
|
||||
|
||||
Uniform web: `uniform float wh; uniform float bl;` (`toneShader.ts:78-79`), nhận
|
||||
`Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10))` (`390-391`), đưa vào mảng đúng thứ tự
|
||||
khai báo sau `dr, hl, sh` (`454`), và `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
|
||||
(`466-467`).
|
||||
|
||||
`shared/utils/paramDefs.ts:137,148` — thứ tự WB: `temperature, tint, whites` (label
|
||||
`WHITE`), `blacks` (`BLACK`), min/max ±10, step 1, `display: sign`.
|
||||
|
||||
### 5.2 Việc phải làm
|
||||
|
||||
1. `src/types/index.ts` — thêm `whites?: number; // -10..+10` và `blacks?: number` vào
|
||||
`ColorAdjustments` (interface ở dòng `77`), đặt ngay sau `tint`/`wbBlue` cho khớp web.
|
||||
2. `src/utils/toneShader.ts`:
|
||||
- thêm `uniform float wh; uniform float bl;` **ngay sau** `uniform float sh;` (dòng `66`);
|
||||
- thêm khối `dk` ở đúng vị trí web (sau nhánh lightness/chroma, trước split-tone ở `147`);
|
||||
- thêm `wh`/`bl` vào `ToneUniforms` (`275`), `getToneUniforms` (`325`, chia 10 và kẹp ±1),
|
||||
`toneUniformArray` (`388`, đúng thứ tự khai báo), `toneIsActive` (`396`).
|
||||
3. `src/utils/paramDefs.ts:109-132` — thêm 2 entry vào `wb` sau `tint`, label
|
||||
`WHITE`/`BLACK`, ±10 step 1, `display: sign`, `get: (a) => a.whites ?? 0`,
|
||||
`set: (v) => ({ whites: v })` (tương tự `blacks`). `chipRow` của tab WB
|
||||
(`AdjustmentPanel.tsx:957`) tự hiện chip — không phải sửa UI.
|
||||
4. `RecipeCreateModal` — **không cần** thêm row: `handleSave` đã spread `seedAdj`.
|
||||
|
||||
### 5.3 Nghiệm thu
|
||||
|
||||
- `WHITE +10` chỉ nâng vùng sáng, vùng tối và mid lệch < 1/255.
|
||||
- `BLACK +10` chỉ nâng vùng tối (đen thành xám nhẹ), vùng sáng lệch < 1/255.
|
||||
- Trên ảnh xám trung tính, `WHITE −10` **không** sinh cast màu (ba kênh dịch như nhau).
|
||||
- Recipe cũ (không có `whites`/`blacks`) render **y hệt** trước khi sửa.
|
||||
|
||||
---
|
||||
|
||||
## 6. AUTO (auto setting)
|
||||
|
||||
### 6.1 Nguồn web
|
||||
|
||||
`src/ui/Histogram.tsx`:
|
||||
|
||||
```ts
|
||||
const BINS = 256; // 0..255
|
||||
const SAMPLE = 320; // downscale cạnh dài trước khi bin
|
||||
export const AUTO_EV_MAX = 2.5;
|
||||
|
||||
export function autoExposureStops(lum: number[]): number {
|
||||
let sum = 0, weighted = 0;
|
||||
for (let i = 0; i < lum.length; i++) { sum += lum[i]; weighted += (i / (lum.length - 1)) * lum[i]; }
|
||||
const avg = Math.max(0.001, sum > 0 ? weighted / sum : 0);
|
||||
const stops = Math.log2(0.48 / avg);
|
||||
return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops));
|
||||
}
|
||||
```
|
||||
|
||||
Luma weights `0.2126/0.7152/0.0722` (đúng trọng số shader dùng). Target **0.48**, guard
|
||||
`≥ 0.001`, kẹp ±2.5 EV.
|
||||
|
||||
`src/App.tsx:2497-2504` `autoExposure()`: đọc histogram của **ảnh gốc đã load**, rồi
|
||||
`setAdjustment({ exposureCompensation: Math.round(stops * 10) / 10 })`. Chip `AUTO` đứng
|
||||
**đầu** row tab LIGHT (`App.tsx:2586-2587`).
|
||||
|
||||
### 6.2 Việc phải làm (Android)
|
||||
|
||||
1. Thêm `src/utils/histogram.ts`:
|
||||
|
||||
```ts
|
||||
export const AUTO_EV_MAX = 2.5;
|
||||
|
||||
// Pixel đã downscale (cạnh dài 320) từ readPixels — cùng mẫu horizon.ts:32-56.
|
||||
export function lumaBins(rgba: Uint8Array | Float32Array, isFloat: boolean): number[] { /* 256 bin */ }
|
||||
export function autoExposureStops(lum: number[]): number { /* y như web */ }
|
||||
```
|
||||
|
||||
Nguồn pixel: `Skia.Surface.Make` + `drawImageRectOptions` downscale về cạnh dài **320**
|
||||
trên **ảnh gốc đã load** (rotation không đổi histogram, không cần `applyPhotoRotation`),
|
||||
theo đúng mẫu `src/utils/horizon.ts:32-56`. Nhớ `dispose()` surface/snapshot — Hermes
|
||||
không tự thu hồi buffer native (`exportEngine.ts:113-121`).
|
||||
|
||||
2. Chip `AUTO` **đứng đầu** row LIGHT: `AdjustmentPanel.tsx:951` hiện là
|
||||
`chipRow([...paramChips(paramDefs.iq), groupChip('dr')])` → đổi thành
|
||||
`chipRow([autoChip(), ...paramChips(paramDefs.iq), groupChip('dr')])`, nhãn `AUTO`.
|
||||
3. `onPress`: chạy `autoExposureStops` → `handleUpdateAdjustments({ exposureCompensation: ev })`
|
||||
(`App.tsx:869`). Đi qua `handleUpdateAdjustments` để có `commit(true, …)` ⇒ **một bước undo**.
|
||||
|
||||
### 6.3 Nghiệm thu
|
||||
|
||||
- **Idempotent:** bấm AUTO hai lần liên tiếp ra cùng một giá trị EV (vì đọc ảnh gốc,
|
||||
không đọc ảnh đã áp EV).
|
||||
- Ảnh phơi sáng đúng (avg ≈ 0.48) ⇒ EV ≈ 0 (|EV| ≤ 0.1).
|
||||
- Ảnh tối đen ⇒ EV = +2.5 (không `Infinity`, không `NaN`).
|
||||
- Ảnh cháy trắng ⇒ EV = −2.5.
|
||||
- Undo một lần quay về EV trước khi bấm.
|
||||
|
||||
Ngoài phạm vi: web **chỉ** auto EV. Không auto contrast/white balance/tone.
|
||||
|
||||
---
|
||||
|
||||
## 7. TONE CURVE
|
||||
|
||||
### 7.1 Nguồn web — `shared/utils/toneCurve.ts` (203 dòng)
|
||||
|
||||
| Hàm/hằng | Vai trò |
|
||||
|----------|---------|
|
||||
| `CURVE_CHANNELS = ['rgb','r','g','b']` | master + 3 kênh |
|
||||
| `CURVE_LUT_SIZE = 256`, `IDENTITY_CURVE = [[0,0],[1,1]]` | LUT 256 mức |
|
||||
| `CURVE_MIN_GAP = 0.02`, `FLAT = 0.002` | khoảng cách điểm tối thiểu; ngưỡng "phẳng" |
|
||||
| `curvePoints(curve, ch)` | clamp01 + sort + ghim hai đầu về (0,0) và (1,1) |
|
||||
| `curveIsActive(curve)` | có kênh nào lệch `FLAT` khỏi đường chéo |
|
||||
| `tangents(pts)` | tiếp tuyến **Fritsch–Carlson** (monotone cubic) |
|
||||
| `sampleCurve(pts, x)`, `curveLut(curve)` | master ∘ kênh → `Uint8Array` RGBA, A=255 |
|
||||
| `CURVE_SKSL` | 2 child (`src`, `lut`), sample `v * 255 + 0.5` |
|
||||
| `addCurvePoint` | thêm điểm **trên** đường (click không nhảy giá trị) |
|
||||
| `moveCurvePoint` | hai đầu bị ghim theo trục x |
|
||||
| `removeCurvePoint`, `isFlatCurve` | xoá điểm, kiểm tra phẳng |
|
||||
|
||||
`curveLut` áp theo thứ tự **master ∘ channel**.
|
||||
|
||||
### 7.2 UI web — `src/ui/ToneCurvePanel.tsx` (357), `src/App.tsx`
|
||||
|
||||
- Graph `SIZE = 224`, `STEPS = 64` vẽ đường, `HIT = 11px` bán kính bắt điểm.
|
||||
- Tabs `RGB / R / G / B` + `RESET` (`{ }` = xoá toàn bộ curve).
|
||||
- Histogram vẽ **sau** lưu đồ (nền).
|
||||
- Kéo card bằng head; pointer capture khi kéo điểm.
|
||||
- `put()` xoá channel khi phẳng (không lưu curve rỗng).
|
||||
- Chip `TONE CURVE` nằm sau group D.RANGE trong row LIGHT; amber khi `curveIsActive`.
|
||||
|
||||
### 7.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/toneCurve.ts` — port nguyên toán + hằng số + `CURVE_SKSL`. `curveLut` trả
|
||||
`Uint8Array(256 * 4)`.
|
||||
2. Dựng LUT thành ảnh Skia cho shader: `Skia.Image.MakeImage({ width: 256, height: 1,
|
||||
colorType: RGBA_8888, alphaType: Premul }, data, 256 * 4)`; shader con thứ hai là
|
||||
`lutImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear,
|
||||
MipmapMode.None)` — như web (`exportEngine.ts:533-560`, khối 3e).
|
||||
3. `ColorAdjustments.toneCurve?: ToneCurve`.
|
||||
4. Chip `TONE CURVE` trong row LIGHT **sau** `groupChip('dr')` (`AdjustmentPanel.tsx:951`);
|
||||
mở panel graph khi `openParam === 'curve'`; amber khi `curveIsActive`.
|
||||
5. Panel: graph 224px dựng bằng Skia (`<Canvas>` + `<Path>` + `<Circle>`), tabs
|
||||
RGB/R/G/B, RESET, histogram nền (dùng lại hàm của mục 6), kéo điểm bằng RN responder
|
||||
trên `<View>` phủ (không có pointer capture sẵn — dùng `PanResponder`), tap-trên-đường
|
||||
để thêm điểm, double-tap để xoá (`CURVE_MIN_GAP 0.02`, HIT 11px).
|
||||
6. Một cử chỉ kéo = **một** bước undo: copy cách `handleUpdateAdjustments` gộp
|
||||
(`App.tsx:869`, `PEEK_GAP_MS = 700` tại `751`).
|
||||
|
||||
### 7.4 Nghiệm thu
|
||||
|
||||
- Curve identity ⇒ ảnh **không đổi** (diff = 0) và pass bị bỏ (không tạo LUT/surface).
|
||||
- Master kéo 1/4 → 0 làm nửa dưới tối đúng theo đường monotone; `R` kéo xuống ⇒ ảnh ngả
|
||||
cyan, và **chỉ** kênh đỏ đổi.
|
||||
- `Fritsch–Carlson` không overshoot: LUT monotone khi các điểm vào monotone.
|
||||
- Export == preview (cùng LUT).
|
||||
- **Rủi ro cao cần kiểm trên device:** nhánh declarative `<Shader source={curveEffect}>
|
||||
{graded}</Shader>` cần **2 child shader** (chuỗi đã grade + `ImageShader` của LUT với
|
||||
`fit="none"` và `rect={rect(0, 0, 256, 1)}`). Nếu `@shopify/react-native-skia` không
|
||||
nhận `rect` như mong đợi, fallback: render curve trong đường **imperative** cho preview
|
||||
tĩnh (dùng đúng `gradeThrough` của mục 3), và giữ declarative chỉ khi tone/cinema.
|
||||
Đường export imperative thì không có rủi ro này.
|
||||
|
||||
---
|
||||
|
||||
## 8. GRADIENT MASK
|
||||
|
||||
### 8.1 Nguồn web — `shared/utils/gradientMask.ts` (169 dòng)
|
||||
|
||||
Hằng số: `MASK_KIND = { linear: 0, radial: 1 }`, `MASK_EXPOSURE_MAX = 5`,
|
||||
`MASK_DEFAULT_FEATHER = 0.5`, `MASK_MIN = 0.01`.
|
||||
|
||||
`maskUniforms` — layout `(3n + 1) × vec4`:
|
||||
|
||||
| offset | nội dung |
|
||||
|--------|----------|
|
||||
| `i * 4` | `[x, y, ex, ey]` — gốc (pin) và đầu kéo (linear) |
|
||||
| `(n + i) * 4` | `[rx, ry, angle, feather]` — ellipse (radial) |
|
||||
| `(2n + i) * 4` | `[exposure, contrast / 10, saturation / 10, kind]` |
|
||||
| `3n * 4` | `[width, height, 0, 0]` — khung mà các phân số là của nó |
|
||||
|
||||
Alpha (web, `maskBlock`): linear `a = smoothstep(0, 1, clamp(dot(pos − pin·size, d) / |d|²))`;
|
||||
radial `st = (pos − center·size) / size.x`, xoay `−angle`, `d = length(r / max(rx, ry, 1e-5))`,
|
||||
`a = 1 − smoothstep(max(0, 1 − feather), 1, d)`. `maskAdjust` = exposure (luỹ thừa 2)
|
||||
→ contrast quanh 0.5 → saturation như mix khỏi luma REC-709, rồi clamp.
|
||||
Chạy **trước** HEAL (`gradientMask.ts:149-155`, `exportEngine.ts:708-737`).
|
||||
|
||||
`readMasks` lọc: kind hợp lệ, kẹp trong khung, và loại mask suy biến — linear có
|
||||
`hypot(ex−x, ey−y) > MASK_MIN`, radial có `rx > 0 && ry > 0`.
|
||||
|
||||
### 8.2 UI web (`App.tsx`)
|
||||
|
||||
- Chip `GRADIENT MASK` trong row FX, amber khi `masks.length > 0` (`2636`).
|
||||
- `gradientChips` (`2750-2765`): `LINEAR` / `RADIAL` / `CLEAR`.
|
||||
- Cột chỉnh mask (`3136-3155`) + `maskRulers` (`2355-2406`): EXPOSURE ±5 EV step 0.1,
|
||||
CONTRAST ±10, SATURATION ±10, FEATHER 0-100% (chỉ radial), DELETE.
|
||||
- Geometry kéo: `maskPin` / `maskFromDrag` / `maskDragged` (`121-230`).
|
||||
- `setMaskKnob` (`1123-1145`).
|
||||
|
||||
### 8.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/gradientMask.ts` — port hằng số + `readMasks` + `maskUniforms` +
|
||||
`maskAdjust` + `gradientMaskSkSL(count)`.
|
||||
2. **Thêm uniform `origin`.** Web vẽ lên canvas chính là ảnh, nên `pos` đã là toạ độ ảnh.
|
||||
Preview Android có `dispRect` (`Viewfinder.tsx:1705,3331`) nên shader phải dùng
|
||||
`pos − origin` trước khi chia `size`. Gói `origin` vào `vec4 size` đang có
|
||||
(`[width, height, originX, originY]`) để **không** đổi layout 3n+1.
|
||||
3. `GradientMask` interface (kind, x, y, ex, ey, rx, ry, angle, feather, exposure,
|
||||
contrast, saturation) + `ColorAdjustments.masks?: GradientMask[]`.
|
||||
4. Pass: cache `maskEffectFor(n)` (mẫu web `exportEngine.ts:204-210`), snapshot → clear →
|
||||
vẽ lại như web (`exportEngine.ts:708-737`).
|
||||
5. UI: chip `GRADIENT MASK` trong row FX + strip `LINEAR`/`RADIAL`/`CLEAR`
|
||||
+ cột chỉnh (EXPOSURE ±5 EV 0.1 / CONTRAST ±10 / SATURATION ±10 / FEATHER 0-100%
|
||||
chỉ radial / DELETE). Touch: RN responder trên `<View>` phủ — kéo pin/kéo đầu/đổi
|
||||
bán kính ellipse; vẽ outline + handle + đường chéo bằng Skia.
|
||||
6. Drag < `MASK_MIN` khi thả ⇒ huỷ, không tạo mask (khớp `readMasks`).
|
||||
|
||||
### 8.4 Nghiệm thu
|
||||
|
||||
- `+2 EV` trong mask làm vùng trong mask sáng **≈ 2 stop**, vùng ngoài lệch < 1/255.
|
||||
- Hai mask chồng nhau: mask thứ hai đọc kết quả mask thứ nhất (đúng "stack").
|
||||
- Kéo thả < 0.01 không sinh mask rác trong state.
|
||||
- MASK chạy trước HEAL: heal đặt trong mask mượn pixel đã mang ánh sáng mask (ảnh kiểm:
|
||||
vết heal trên vùng mask tối không còn sáng hơn nền).
|
||||
- FEATHER = 0 (radial) ⇒ rìa cứng, `a` nhảy 1 → 0 tại `d = 1`.
|
||||
- Toạ độ đúng khi zoom/pan: mask dán vào **ảnh**, không dán vào khung nhìn
|
||||
(kiểm bằng cách zoom rồi so vị trí outline với pixel).
|
||||
|
||||
---
|
||||
|
||||
## 9. FIX — HEAL và MOSAIC
|
||||
|
||||
### 9.1 Nguồn web
|
||||
|
||||
`shared/utils/heal.ts` (324 dòng) — mọi hằng số phải giữ nguyên:
|
||||
|
||||
```
|
||||
HEAL_FEATHER 0.85 HEAL_DEFAULT_R 0.012
|
||||
SEARCH_DISTANCES [2.6, 4.2, 6.5] SEARCH_DIRS 8 RING_R 1.15 RING_PAD 1
|
||||
RING_TAPS: 12 điểm vành, lấy MEDIAN INSIDE_TAPS: 16
|
||||
LIGHT_GATE 20 LIGHT_WEIGHT 3 BLEND_TAPS 16
|
||||
```
|
||||
|
||||
- `healUniforms` — `(2n + 1)` vec4 (mỗi spot 2 vec4: vị trí/bán kính + nguồn `sx,sy`).
|
||||
- `healSkSL(count)` — `correction` theo góc, sample `pos + ps − pd`.
|
||||
- `findHealSource(sample, x, y, r)` — 8 hướng × 3 khoảng × bản mirror, tính điểm lệch
|
||||
sáng có trọng số (`LIGHT_WEIGHT 3`, `LIGHT_GATE 20`), từ chối (`null`) nếu không có
|
||||
nguồn hợp lệ.
|
||||
|
||||
`shared/utils/mosaic.ts` (104) — `MOSAIC_CELL 0.02`, `MOSAIC_DEFAULT_R 0.05`;
|
||||
`mosaicUniforms` `(n + 1)` vec4, vec4 cuối `[width, height, cell, 0]`,
|
||||
`cell = max(1, MOSAIC_CELL * width)`; `mosaicSkSL(count)` lấy ô
|
||||
`img.eval((floor(pos / cell) + 0.5) * cell)`, **rim cứng**.
|
||||
|
||||
`shared/utils/brush.ts` (26) — `BRUSH_MIN_R 0.003`, `BRUSH_MAX_R 0.25`,
|
||||
`BRUSH_SPACING 0.6`, `wheelBrushR(r, deltaY)`.
|
||||
|
||||
Types web: `HealSpot { x, y, r, sx, sy }`, `MosaicSpot { x, y, r }`.
|
||||
|
||||
### 9.2 UI web (`App.tsx`)
|
||||
|
||||
- Chip `FIX` amber khi heal hoặc mosaic có spot; `fixChips` (`2715-2745`): `HEAL` /
|
||||
`MOSAIC` kèm readout `%`, `CLEAR`, `brushTool`.
|
||||
- Chọn spot HEAL để kéo và có nút `×` xoá; MOSAIC **không** kéo được.
|
||||
- `spotUnder` bắt theo bán kính `max(4, r * box.width)`.
|
||||
- Một cử chỉ vẽ = một bước undo.
|
||||
|
||||
### 9.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/brush.ts`, `heal.ts`, `mosaic.ts` — port **nguyên hằng số và công thức**,
|
||||
gồm `findHealSource`.
|
||||
2. Pixel sampler cho `findHealSource`: dùng đúng mẫu `horizon.ts:32-56` — render ảnh
|
||||
(đã grade, **chưa** mask/heal) xuống surface cạnh dài ~512, `readPixels()` →
|
||||
`Uint8Array` RGBA. Đây là "sample" đầu vào. **Không** dùng ảnh preview đã áp mask.
|
||||
3. UI: chip `FIX` trong row FX + strip `HEAL` / `CLEAR` / `MOSAIC` / `CLEAR`.
|
||||
Android **không có con lăn** ⇒ thêm một row slider `SIZE` (thay `wheelBrushR`), map
|
||||
tuyến tính vào `BRUSH_MIN_R..BRUSH_MAX_R`.
|
||||
4. HEAL: chạm/kéo tạo spot, chạm vào spot để chọn, kéo để dời, nút `×` để xoá.
|
||||
MOSAIC: chạm/kéo tạo spot, không dời. Một cử chỉ = `commit(true, …)` một bước undo.
|
||||
5. Shader theo count + cache (`healEffectFor(n)`, `mosaicEffectFor(n)`), snapshot → clear
|
||||
→ vẽ lại. Thứ tự: **masks → heal → mosaic** (web `exportEngine.ts:708-737` (masks), `740-775` (heal), `776-800` (mosaic)).
|
||||
6. Nếu `findHealSource` trả `null`: **giữ nguyên** pixel gốc (không bịa nguồn).
|
||||
|
||||
### 9.4 Nghiệm thu
|
||||
|
||||
- Xoá hạt bụi trên nền phẳng (trời, tường): vết sửa không lộ, seam viền ≤ ~2/255 so với
|
||||
nền (đo dọc vành spot).
|
||||
- Không có nguồn hợp lệ (hạt nằm trên biên tương phản cao) ⇒ ảnh **không đổi**.
|
||||
- Mosaic rim **cứng** — kiểm pixel tại rìa: nhảy bậc, không gradient.
|
||||
- 30 spot vẫn render (đo thời gian; cache effect hoạt động, không dựng lại shader mỗi frame).
|
||||
- Export == preview.
|
||||
|
||||
---
|
||||
|
||||
## 10. H-FLIP / V-FLIP trong ROTATE
|
||||
|
||||
### 10.1 Nguồn web — `shared/utils/skiaImage.ts`
|
||||
|
||||
```
|
||||
84 export function flipSkImage(image, horizontal, vertical)
|
||||
canvas.scale(h ? -1 : 1, v ? -1 : 1);
|
||||
canvas.translate(h ? -w : 0, v ? -h : 0);
|
||||
canvas.drawImage(image, 0, 0);
|
||||
114 export function applyPhotoRotation(image, quarter = 0, straighten = 0, flipH = false, flipV = false)
|
||||
turn (rotateSkImage90 × turns) → straighten (rotateSkImageBy) → flip
|
||||
```
|
||||
|
||||
Comment web: *"a flip is what the user sees, so it mirrors the photo as it stands,
|
||||
whatever turn and angle are already on it"* ⇒ flip áp **sau** turn + straighten.
|
||||
`rotateSkImage90` đổi chiều rộng/cao; flip **không** đổi dims.
|
||||
|
||||
### 10.2 Hiện trạng Android
|
||||
|
||||
`src/utils/skiaImage.ts:69-73` — `applyPhotoRotation(image, quarter = 0, straighten = 0)`,
|
||||
không flip. Callers phải sửa:
|
||||
- `src/utils/exportEngine.ts:241`
|
||||
- `src/components/Viewfinder.tsx:461` (memo) và `:803` (`detectTilt(applyPhotoRotation(src.image, src.quarter, 0))`)
|
||||
|
||||
### 10.3 Việc phải làm
|
||||
|
||||
1. `src/utils/skiaImage.ts` — thêm `flipSkImage` + hai tham số `flipH`/`flipV` cho
|
||||
`applyPhotoRotation`, copy nguyên web (gồm `dispose()` các bản trung gian).
|
||||
2. State: `photoFlipH`/`photoFlipV` (boolean) cạnh `photoRotation`/`photoStraighten`
|
||||
(`App.tsx:104-105`); thêm vào `Look` (`719-720`), `lookNow()` (`728`), `applyLook`
|
||||
(`804-812`), "look đã đổi" (`699-700`), và `ExportOptions` (`exportEngine.ts:91-92`).
|
||||
3. Truyền xuống: options export (`App.tsx:1753-1754`), `Viewfinder` (`App.tsx:1983-1984`
|
||||
và `2100-2101`), `nativeExport`.
|
||||
4. RESET của strip ROTATE phải reset **cả hai** flip (như đang reset turn + angle —
|
||||
`AdjustmentPanel.tsx:727-729`).
|
||||
5. UI: `groupDefs.rotate.options` (`AdjustmentPanel.tsx:718-726`) thêm
|
||||
`{ v: 'fliph', d: 'H-FLIP' }` và `{ v: 'flipv', d: 'V-FLIP' }`; `onPick`
|
||||
(`730-745`) toggle cờ. `rotateChip()` (`791-803`) thêm phần `H-FLIP`/`V-FLIP` vào
|
||||
`parts` và vào `amberValue`.
|
||||
**Lưu ý:** strip ROTATE hiện là radio (một `value`), `value` đang là
|
||||
`openParam === 'straighten' ? 'straighten' : String(photoRotation)` (`716`). Thêm hai
|
||||
toggle thì `value` phải ưu tiên `fliph`/`flipv` khi cờ bật — cùng cách `straighten`
|
||||
đang chiếm `value`.
|
||||
|
||||
### 10.4 Nghiệm thu
|
||||
|
||||
- `H-FLIP` ↔ `V-FLIP`: mirror đúng trục (kiểm 4 pixel góc đối xứng).
|
||||
- `H-FLIP + V-FLIP` **pixel-identical** với `180°` (diff = 0 trên ảnh không đối xứng).
|
||||
- Dims **không** đổi sau flip (kể cả khi đang có turn 90 và straighten ≠ 0).
|
||||
- Crop vẫn hợp lệ: `cropRect` áp sau rotate+flip, không tràn khung.
|
||||
- Export == preview.
|
||||
- Sống qua reload/session và qua undo (một lần bấm = một bước undo).
|
||||
|
||||
---
|
||||
|
||||
## 11. Kiểm thử chung và parity gate
|
||||
|
||||
1. **Parity gate ba đường** (theo `PLAN.md`): cùng ảnh + cùng recipe → export JS,
|
||||
preview library (ảnh tĩnh), và Kotlin (nếu `EXPO_PUBLIC_NATIVE_EXPORT=1`) phải khớp
|
||||
(đo diff pixel, ngưỡng đã dùng trước đây trong repo).
|
||||
2. **Không regress:** một recipe cũ (chỉ matrix + tone + grain, không mask/heal/curve/flip)
|
||||
phải render **y hệt** trước và sau khi port. Đây là test hồi quy bắt buộc chạy đầu tiên.
|
||||
3. **Bảng số của doc 4** phải tái lập được (mục 3.4).
|
||||
4. **Idempotence:** AUTO bấm hai lần ra cùng EV; ROTATE `AUTO` (straighten) bấm hai lần
|
||||
ra cùng góc (đã có).
|
||||
5. **Undo:** mỗi knob mới (whites/blacks/curve/mask/heal/mosaic/flip) đều phải undo được
|
||||
một bước và redo khớp.
|
||||
6. **Session:** mọi field mới sống qua reload (đi qua `Look`/session serialise).
|
||||
7. **Perf:** đo fps preview library sau khi thêm pass; mask + heal + mosaic + curve cùng
|
||||
lúc là trường hợp nặng nhất. Nếu tụt quá ngưỡng, dùng phương án rẻ của doc 4 §4.2
|
||||
(nhân gain bên trong SKSL) — nhưng **không** phá thứ tự `matrix → exposure → tone`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Checklist thứ tự làm và rủi ro
|
||||
|
||||
Thứ tự cố định — mỗi bước phải xanh test trước khi sang bước sau:
|
||||
|
||||
| Bước | Việc | Phụ thuộc | Rủi ro |
|
||||
|------|------|-----------|--------|
|
||||
| A | §3 tách EV + `EXPOSURE_SKSL` + **áp doc 4** (`gradeThrough`, bỏ ma trận lần hai ở HDF) | — | Vuốt: đây là thay đổi chạm mọi ảnh. Làm riêng, có test hồi quy (11.2). |
|
||||
| B | §4 knee HIGHLIGHT (2 dòng) | A | Thấp; nhưng phải làm sau A mới thấy đúng. |
|
||||
| C | §5 WHITE/BLACK (uniform + paramDefs) | B | Thấp. Chỉ thêm uniform, không đổi layout cũ. |
|
||||
| D | §10 H-FLIP/V-FLIP | — | Thấp; chạm state/session nên phải kiểm reload + undo. |
|
||||
| E | §6 AUTO | A | Trung bình; `readPixels` tốn — đo thời gian, `dispose()` đủ. |
|
||||
| F | §7 TONE CURVE | A | Cao ở nhánh declarative (2 child shader). Fallback imperative. |
|
||||
| G | §8 GRADIENT MASK | A | Trung bình; uniform `origin` là điểm dễ sai toạ độ. |
|
||||
| H | §9 FIX (HEAL/MOSAIC) | G | Cao nhất: pixel sampler + `findHealSource` + brush UI mới. |
|
||||
|
||||
Rủi ro tổng:
|
||||
- **Hermes không thu hồi buffer native** — mọi surface/snapshot/image mới đều phải
|
||||
`dispose()` tường minh (xem comment đầu `exportEngine.ts:113-121`).
|
||||
- **Declarative preview không chèn pass tuỳ ý** — mask/heal/mosaic/curve đều phải nằm
|
||||
trong chuỗi shader của ảnh; nếu một pass không lồng được, chuyển nhánh preview tĩnh
|
||||
sang imperative.
|
||||
- **`RuntimeEffect` dựng lại mỗi frame** là bug hiệu năng kinh điển — cache theo count.
|
||||
|
||||
---
|
||||
|
||||
## 13. Những gì KHÔNG port
|
||||
|
||||
- **Per-mask invert / highlight-shadow weighting** — web hiện không có; không thêm.
|
||||
- **`grainSize`** — web-only ở thời điểm này.
|
||||
- **Chuột lăn đổi cỡ brush** (`wheelBrushR`) — Android dùng row slider SIZE.
|
||||
- **Halation** — không có trong phạm vi 7 hạng mục.
|
||||
- **`hslOn`/band HSL, `classic-vivid`, `mono-high-contrast`, `STOCK_BIAS.contrast`** —
|
||||
**đã** port xong trên nhánh này (mục 0.1). Không làm lại, chỉ điều chỉnh
|
||||
`STOCK_BIAS.exposure` của `leica-vivid` theo mục 3.3.
|
||||
@@ -1,264 +0,0 @@
|
||||
# 9 — RECIPE / QR: NHỮNG GÌ WEB VỪA SỬA VÀ APP ANDROID PHẢI KHỚP
|
||||
|
||||
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`f3d26e3`**). Ngày: 2026-09-29.
|
||||
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`3b92e4e`** — bản web đã chạy ở
|
||||
`http://127.0.0.1:8090`, bundle live `index-J8ISFv_z.js`.
|
||||
|
||||
Bốn commit web trong đợt này, tất cả ngày 2026-09-29:
|
||||
|
||||
```
|
||||
34f8601 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
|
||||
c0aaa67 Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
|
||||
bd57dd7 EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store
|
||||
3b92e4e A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and TINT
|
||||
```
|
||||
|
||||
Tài liệu 8 (`8_EDITOR_TOOLS_PORT.md`) đối chiếu web ở `6814b05`. Tài liệu này ghi **phần
|
||||
lệch phát sinh sau đó** và, quan trọng hơn cho đội Android, **cái gì trong file `.recipe`
|
||||
(QR) đã đổi / chưa đổi**. Lệnh đọc nguồn web vẫn như doc 8:
|
||||
|
||||
```sh
|
||||
git show origin/recipes-web:docker/frontend/shared/utils/gradientMask.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. Bảng khoảng trống
|
||||
|
||||
| # | Web vừa đổi gì | Nguồn web | Ảnh hưởng `.recipe` | Android phải làm |
|
||||
|---|----------------|-----------|---------------------|------------------|
|
||||
| 1 | EXPOSURE đọc theo **stop** (−5..+5, 2 số lẻ) | `shared/utils/paramDefs.ts:53-66`, `shared/utils/colorUtils.ts:201` | **Store không đổi** (±10 unit, 1 unit = 0.25 EV) | Chỉ UI/đơn vị hiển thị. Recipe cũ đọc y nguyên |
|
||||
| 2 | Mọi slider "develop" thành **−100..+100** | `shared/utils/paramDefs.ts:40-43,160,184` | **Store không đổi** (±10) — ×10 là của slider | Không bắt buộc. Không port thì recipe vẫn khớp |
|
||||
| 3 | Rail **10 tab → 8 tab** (WB, FX gộp vào LIGHT) | `src/ui/ToolRail.tsx:15-27` | Không | Không (layout, không vào recipe) |
|
||||
| 4 | Bốn knob tone thành **knot của một ramp** + knee giữ hue | `shared/utils/toneShader.ts:109,196-221,242-247` | Không có field mới, nhưng **look đổi** | Port theo doc 8 §4/§5 (xem §3.3) |
|
||||
| 5 | Mask gradient mang **COLOR TEMP + TINT** | `shared/utils/gradientMask.ts:63-65,116-117,166-167,201,320`, `shared/types/index.ts:139-175,228` | **Có 2 field mới trong `adjustments.masks[]`** | Port mask (doc 8 §8) + 2 field + 1 array uniform `wb` |
|
||||
|
||||
Điểm đáng chú ý cho đội Android: **bốn trong năm thay đổi không đụng tới payload**. Chỉ mục 5
|
||||
thêm field. Nhưng mục 1 + 4 làm **cùng một recipe cho ra look khác** giữa hai bên — xem §3.3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Đơn vị: slider web vs store (và vs Android)
|
||||
|
||||
Web tách hẳn **đơn vị slider** khỏi **đơn vị store**: `paramDefs.ts` có `get`/`set` dịch hai
|
||||
chiều, nên một recipe viết trước đợt này vẫn đọc ra đúng look.
|
||||
|
||||
```ts
|
||||
// shared/utils/paramDefs.ts:40-43
|
||||
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites',
|
||||
'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
|
||||
const deepen = (def) => HUNDRED.has(def.key)
|
||||
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
|
||||
: def;
|
||||
```
|
||||
|
||||
```ts
|
||||
// shared/utils/paramDefs.ts:53-66
|
||||
{ key: 'exposure', min: -5, max: 5, step: 0.01, display: twoStops, // '+0.50' / '-0.25'
|
||||
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
|
||||
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }) }
|
||||
```
|
||||
|
||||
| Trường | Đơn vị store (web **và** Android) | Slider web (`3b92e4e`) | Slider Android (`src/utils/paramDefs.ts`) |
|
||||
|--------|-----------------------------------|------------------------|-------------------------------------------|
|
||||
| `adjustments.exposure` | ±10 unit, 1 unit = **0.25 EV** (`EV_PER_UNIT`, `colorUtils.ts:201`) | −5..+5 **stop**, 2 số lẻ | −10..+10, step 1, `display: sign` (`paramDefs.ts:32-41`) |
|
||||
| `contrast` `highlight` `shadow` `whites` `blacks` `tint` `denoise` `clarity` `dehaze` `sharpening` `vibrance` `color` | ±10 | −100..+100 (`HUNDRED` + `deepen`) | ±10, step 1 |
|
||||
| `temperature` | 2500..10000 K | 2500..10000, step 100, `'${v}K'` (`paramDefs.ts:163-172`) | 2500..10000 (đã khớp) |
|
||||
| `masks[].exposure` | **chính là EV**, ±5 (`MASK_EXPOSURE_MAX`, `gradientMask.ts:45`) | −5..+5, step 0.1 | — (chưa có mask) |
|
||||
| `masks[].temperature` | 2500..10000 K, **default 5500** (`NEUTRAL_K`, `gradientMask.ts:63`) | 2500..10000, step 100 | — |
|
||||
| `masks[].tint` | ±10 (default 0) | −100..+100 (maskKnobRow ×10, `App.tsx:2474-2492`) | — |
|
||||
|
||||
**Hệ quả cho Android:** ba dòng đầu **không cần làm gì để recipe khớp** — store giữ nguyên
|
||||
thang cũ. Chỉ nên đổi UI nếu muốn hai bên trông giống nhau (Android `EXPOSURE` ±10 unit =
|
||||
±2.5 EV, web đọc thành −5..+5 stop; cùng một con số lưu, khác nhãn). Chú ý `EV_PER_UNIT`
|
||||
**đã export** ở cả hai phía — dùng nó, đừng viết lại `0.25`.
|
||||
|
||||
Chú ý riêng: `masks[].exposure` **không** theo thang ±10 của `adjustments.exposure`. Nó là
|
||||
EV trực tiếp vì shader đọc `pow(2.0, a.x)`. Đây là chỗ dễ sai nhất khi port.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cái gì thực sự đi qua file `.recipe` / QR
|
||||
|
||||
Không đổi gì so với `7_SCAN_QR.md`: **QR chỉ chứa URL** `GET /api/photos/:id/preset.recipe`,
|
||||
file trả về là XML bọc payload xor16-v1 (`docker/backend/src/recipeFile.ts`, bản sao của
|
||||
`shared/utils/recipeShare.ts`; nguồn sự thật là bản web).
|
||||
|
||||
Hình dạng shareable — **web và Android giống hệt nhau, từng chữ**:
|
||||
|
||||
```ts
|
||||
// recipeShare.ts:59-77 (web) == src/utils/recipeShare.ts:59-77 (Android)
|
||||
const shareable = { name, baseFilter, adjustments, frameId, useGeotag };
|
||||
```
|
||||
|
||||
`adjustments` được gửi **nguyên khối**. Nghĩa là:
|
||||
|
||||
1. **Field mới tự chảy qua.** Web ghi `adjustments.masks[].temperature/tint`; không cần bump
|
||||
`version="1"`, không cần đổi `ALGORITHM`, không cần salt mới.
|
||||
2. **Android hiện giữ được field lạ.** `importRecipeXml` làm
|
||||
`{ ...DEFAULT_ADJUSTMENTS, ...raw.adjustments }` rồi **chỉ** sanitize `hslBands`
|
||||
(`recipeShare.ts:97-102`). `masks` vào bộ nhớ, sống qua `Look`/session, và **xuất lại
|
||||
nguyên vẹn** vì `exportRecipeXml` gửi lại chính `recipe.adjustments`.
|
||||
⇒ Web → Android → Web **không mất mask**. Đây là hành vi phải **khoá bằng test**, không
|
||||
phải tính năng tình cờ (xem §4.2).
|
||||
3. **Nhưng Android không render mask** (`grep -n masks src/types/index.ts` → rỗng;
|
||||
`ColorAdjustments` `src/types/index.ts:81-105` không có `masks`). Ảnh xuất từ Android vì
|
||||
thế **thiếu toàn bộ mask** dù recipe có. Đó là việc của doc 8 §8, không phải lỗi format.
|
||||
4. `RecipeCreateModal.handleSave` spread `...seedAdj`, nên mở một recipe import được rồi bấm
|
||||
LƯU **không** làm rơi `masks`. Phải giữ đúng như vậy sau khi thêm field (đừng liệt kê tay
|
||||
từng knob).
|
||||
5. Web clamp khi **đọc** (`readMasks`, `gradientMask.ts:87-121`), không clamp khi ghi. Android
|
||||
cũng nên clamp ở `readMasks` tương đương, không nhét clamp vào `importRecipeXml`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Việc Android phải làm
|
||||
|
||||
### 3.1 Tách `whiteBalanceGain` (thuần refactor, kết quả không đổi)
|
||||
|
||||
Android **đã có** công thức WB frame-wide, viết inline trong `getSkiaColorMatrix`:
|
||||
|
||||
```
|
||||
src/utils/colorUtils.ts:384-394
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
const wbGain = normalizeGainLuma([
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
```
|
||||
|
||||
Web đã rút đúng khối này ra thành một hàm export, **vì nó được hỏi hai lần** (frame-wide và
|
||||
từng mask). Việc cần làm: chép y nguyên chữ ký web, cho `getSkiaColorMatrix` gọi lại nó.
|
||||
|
||||
```ts
|
||||
// shared/utils/colorUtils.ts:130-137 (web) — chép sang src/utils/colorUtils.ts
|
||||
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
|
||||
const rgbTemp = kelvinToRGB(temperature); // kelvinToRGB đã có ở Android:117
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
return normalizeGainLuma([ // đã có ở Android:147
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Gain tại 5500 K / tint 0 = `(1, 1, 1)` chính xác (`kelvinToRGB(5500) === kelvinToRGB(5500)`,
|
||||
rồi `normalizeGainLuma`). `KELVIN_TAME = 0.5` nằm trong `kelvinToRGB`, không đụng.
|
||||
|
||||
**Nghiệm thu bắt buộc:** `getSkiaColorMatrix` trước và sau refactor phải ra **cùng mảng 20
|
||||
số** với mọi cặp `(baseFilter, temperature, tint)` trong lưới thử (kể cả `wbRed/wbBlue`, kể
|
||||
cả stock mono — nhánh `isMonochromeBase` phải bỏ qua WB **trước khi** gọi hàm).
|
||||
|
||||
### 3.2 Mask gradient (doc 8 §8) + hai field mới
|
||||
|
||||
Port theo doc 8 §8, thêm đúng ba điểm so với spec cũ:
|
||||
|
||||
1. `GradientMask` thêm `temperature?: number` (2500..10000) và `tint?: number` (±10) —
|
||||
giống `shared/types/index.ts:174-175` (comment web ở `:170-173` giải thích: cùng đơn vị,
|
||||
cùng gain, chỉ khác vùng đọc).
|
||||
2. `readMasks` resolve default: `temperature = clampK(num(m.temperature, 5500))`,
|
||||
`tint = clampA(num(m.tint, 0))` (`gradientMask.ts:116-117`) — mask lưu trước đó ⇒ gain 1.
|
||||
3. Uniform: thêm **một mảng `float4` nữa**, `wb[count]`, **sau** `fx[count]`; buffer thành
|
||||
`6*n + 1 (+1 nếu air)` float4, mảng `wb` ở index `5*n + i`, `size` (width/height) ở `6*n`,
|
||||
`air` ở `6*n+1` (`gradientMask.ts:132-171, 314-322`).
|
||||
|
||||
Shader — một dòng, ngay sau EXPOSURE, trước CONTRAST:
|
||||
|
||||
```sksl
|
||||
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark /*, half3 blur, float3 air*/) {
|
||||
c = c * half(pow(2.0, a.x)); // EXPOSURE, EV
|
||||
c = clamp(c * wb, half3(0.0), half3(1.0)); // ← MỚI: WB của mask
|
||||
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
||||
...
|
||||
```
|
||||
|
||||
`wb` là gain **hoisted lên JS**, không tính trong SkSL: fit Kelvin là một đường cong, viết lại
|
||||
trong shader là bản sao thứ hai của nó. Call site: `maskAdjust(c.rgb, wb[i].rgb, adj[i], tone[i], fx[i], dark)`.
|
||||
|
||||
**Chiều đã đo trên web** (đừng đảo): 2500 K = **lạnh**, 10000 K = **ấm**. Trong mask, đặt
|
||||
10000 K đo được R/B `1.250 → 1.724`; pixel ngoài mask `0.994 → 0.994` (không đổi). Đó là
|
||||
bằng chứng mask-ảnh-hưởng-cục-bộ phải tái lập được.
|
||||
|
||||
### 3.3 Cảnh báo lệch: tone **trong mask** chưa bằng tone frame-wide
|
||||
|
||||
Đây là điểm dễ hiểu sai nhất khi đọc song song doc 8 và bản web mới nhất.
|
||||
|
||||
- Frame-wide (`shared/utils/toneShader.ts:242-247`, từ `c0aaa67`): knee giữ hue, không còn
|
||||
`cg = clamp(o / max(t, 0.0004), 0.55, 1.35)`:
|
||||
```sksl
|
||||
float k = 1.0;
|
||||
float hiC = max(max(rgb.r, rgb.g), rgb.b);
|
||||
float loC = min(min(rgb.r, rgb.g), rgb.b);
|
||||
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
|
||||
if (loC < t) k = min(k, o / (t - loC));
|
||||
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
|
||||
```
|
||||
và bốn knob tone là **knot của một ramp** (`TONE_ANCHOR = 0.25`; `blMask/shMask/whMask/hlMask`
|
||||
tại `:196-199`; `a0..a4` tại `:214-221`).
|
||||
- Trong mask (`shared/utils/gradientMask.ts:218-220`): **vẫn công thức cũ**
|
||||
`half cg = clamp(lifted / max(l, 0.0004), 0.55, 1.35); c = clamp(lifted + (c - l) * cg, …)`,
|
||||
và mask **không** có knot ramp. Comment ngay trên đó (`:207-213`) vẫn nói "TONE_SKSL's own
|
||||
cg" — **comment đã cũ** sau `c0aaa67`.
|
||||
|
||||
⇒ Trên web hiện tại, HIGHLIGHT của một mask **không** phải cùng một phép move như HIGHLIGHT
|
||||
toàn khung, đúng cái điều comment hứa. Đã hỏi chủ dự án có align không — chưa có quyết định.
|
||||
Cho tới lúc đó:
|
||||
|
||||
- **Android port mask theo bản web đang chạy** (công thức `cg` cũ trong mask), nếu mục tiêu là
|
||||
parity với web **hôm nay**.
|
||||
- Nếu chủ dự án chốt align, việc cần làm là thay khối `:218-220` bằng công thức `k` giữ hue —
|
||||
và khi đó **mask phải chạy trước** phép move tone nào khác của khung (`masks` đứng **sau**
|
||||
tone frame-wide trong chuỗi pass, xem doc 8 §2.1). Ghi lại ở đây để cả hai bên cùng đổi một
|
||||
lần, tránh web sửa trước rồi Android port theo bản cũ.
|
||||
|
||||
### 3.4 Port tối thiểu nếu chỉ muốn "giữ field, chưa render"
|
||||
|
||||
Nếu ưu tiên là **không mất dữ liệu khi chia sẻ QR** trước khi kịp port shader mask, chỉ cần:
|
||||
|
||||
```ts
|
||||
// src/types/index.ts — đủ để TS không nuốt field khi ai đó truy cập
|
||||
masks?: GradientMask[];
|
||||
```
|
||||
|
||||
Ở runtime Android **đã** giữ field rồi (§2.2), nên thay đổi này là để code đọc/ghi tường minh,
|
||||
không phải để cứu dữ liệu. **Không** thêm sanitize cho `masks` ở giai đoạn này — sanitize nửa
|
||||
vời sẽ âm thầm cắt dữ liệu mà web vẫn gửi (ví dụ bỏ mask chưa có `temperature`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Tiêu chí nghiệm thu
|
||||
|
||||
1. **Round-trip QR, không mất field.** Recipe web có mask + WB (ví dụ 8000 K / tint −3):
|
||||
web xuất `.recipe` → `importRecipeXml` Android → `exportRecipeXml` Android → import lại
|
||||
web. So `adjustments.masks` sau khi bỏ salt: phải **bằng byte** bản gốc, gồm cả
|
||||
`temperature`, `tint`, `kind`, `angle`, `feather`, `exposure` (EV), và thứ tự các mask.
|
||||
2. **Không có test nào phụ thuộc vào việc "field lạ bị bỏ".** Thêm một test khoá hành vi §2.2
|
||||
(một field lạ bất kỳ trong `adjustments` phải sống qua import → export).
|
||||
3. **Clamp đúng biên.** Mask gửi `temperature: 99999` ⇒ đọc ra 10000; `2000` ⇒ 2500; `tint: 40`
|
||||
⇒ 10. Mask **không** có hai field ⇒ gain `(1,1,1)` và ảnh **không đổi một pixel** so với
|
||||
trước khi port.
|
||||
4. **Refactor WB trung tính.** `getSkiaColorMatrix` ra cùng 20 số trước/sau §3.1, trên lưới
|
||||
`(baseFilter × temperature × tint)` gồm cả stock mono.
|
||||
5. **WB của mask là cục bộ.** Đặt 10000 K: pixel **trong** mask đổi tỉ lệ R/B (ấm lên), pixel
|
||||
**ngoài** mask **không đổi** (web đo `1.250 → 1.724` trong mask, `0.994 → 0.994` ngoài mask).
|
||||
6. **Đơn vị EV của mask.** `masks[].exposure = +1` phải sáng đúng **1 stop** (không phải 1/4
|
||||
stop như nếu lỡ dùng thang ±10 của `adjustments.exposure`).
|
||||
7. **Parity ba đường** như doc 8 §11.1, và **look cũ không regress** (recipe chỉ có matrix +
|
||||
tone + grain phải render y hệt trước/sau).
|
||||
8. **Tone:** nếu chủ dự án chốt align tone trong mask (§3.3), phải có số đo hue trước/sau
|
||||
tương đương bảng trong commit `c0aaa67` — HIGHLIGHT −100 Δhue `0.00°` trên mọi patch.
|
||||
|
||||
---
|
||||
|
||||
## 5. Những gì KHÔNG làm
|
||||
|
||||
- **Không đổi `version` / `ALGORITHM` / salt** của envelope `.recipe`. `test/security.mjs`
|
||||
(backend 134/134) khoá hình dạng này.
|
||||
- **Không nhét mask vào QR**: mã QR vẫn chỉ là URL (§2). Recipe nằm ở nội dung file tải về.
|
||||
- **Không commit đi theo đợt này:** `preview`/`mask` UI của web chỉ đổi layout, không có gì
|
||||
để port sang app.
|
||||
- **Không port thang −100..+100** sang Android chỉ để "giống web" — đó là lựa chọn UI, store
|
||||
hai bên đã khớp; đổi thì phải đổi cả `paramDefs`, cả chip readout, cả test ảnh.
|
||||
@@ -10,29 +10,23 @@ 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, { HslPickPanel } from './src/components/AdjustmentPanel';
|
||||
import AdjustmentPanel 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, HslBand, HslBandId } from './src/types';
|
||||
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 { 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';
|
||||
@@ -60,6 +54,13 @@ 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
|
||||
@@ -75,6 +76,10 @@ 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');
|
||||
@@ -138,16 +143,6 @@ 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.
|
||||
@@ -282,9 +277,6 @@ 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);
|
||||
@@ -335,17 +327,7 @@ 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({
|
||||
// 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',
|
||||
qualityPrioritization: CAPTURE_MODE,
|
||||
});
|
||||
// Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as
|
||||
// a live output only while rawEnabled — CameraX cannot bind two ImageCapture
|
||||
@@ -778,29 +760,6 @@ 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);
|
||||
@@ -890,40 +849,6 @@ 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);
|
||||
@@ -1103,18 +1028,6 @@ 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 () => {
|
||||
@@ -1125,30 +1038,13 @@ export default function App() {
|
||||
try {
|
||||
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
||||
if (picked.canceled) return;
|
||||
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());
|
||||
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.`);
|
||||
} catch (e) {
|
||||
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
@@ -1498,56 +1394,6 @@ 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
|
||||
@@ -1608,14 +1454,12 @@ export default function App() {
|
||||
// shutter instant as the EXIF capture date.
|
||||
exported: false,
|
||||
capturedAt,
|
||||
// 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.
|
||||
// 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.
|
||||
// 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,
|
||||
@@ -1627,10 +1471,7 @@ export default function App() {
|
||||
frameWindowZoom: frameZoom,
|
||||
};
|
||||
const paint = await loadRenderer(nativeExport);
|
||||
// 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);
|
||||
return paint(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options);
|
||||
};
|
||||
enqueueExport(async () => {
|
||||
try {
|
||||
@@ -1938,10 +1779,6 @@ 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
|
||||
@@ -1984,9 +1821,6 @@ 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}
|
||||
@@ -2021,22 +1855,6 @@ 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>
|
||||
|
||||
@@ -2109,8 +1927,6 @@ export default function App() {
|
||||
openParam={openParamKey}
|
||||
onOpenParamChange={setOpenParamKey}
|
||||
currentBaseFilter={selectedRecipe?.baseFilter}
|
||||
monoOn={monoOn}
|
||||
onToggleMono={handleToggleMono}
|
||||
onCreateRecipe={handleCreateRecipe}
|
||||
editingRecipe={editingRecipe}
|
||||
onCloseEdit={() => setEditingRecipe(null)}
|
||||
@@ -2123,7 +1939,6 @@ export default function App() {
|
||||
onToggleGeotag={handleToggleGeotag}
|
||||
onSaveRecipe={handleSaveRecipe}
|
||||
onImportRecipe={handleImportRecipe}
|
||||
onScanRecipe={() => setScanVisible(true)}
|
||||
recipeLimitReached={liteSaveBlocked}
|
||||
onRecipeLimit={promptRecipeLimit}
|
||||
onDeleteRecipe={handleDeleteRecipe}
|
||||
@@ -2137,14 +1952,6 @@ 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>
|
||||
)}
|
||||
@@ -2158,14 +1965,6 @@ 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,258 @@
|
||||
# Camera trực tiếp: chỉ port những gì web có mà Android chưa có
|
||||
|
||||
Phạm vi tài liệu: **chỉ liệt kê delta web-only**. Mọi thứ Android đã có thì ghi nhận một dòng rồi bỏ qua,
|
||||
không mô tả cách làm lại.
|
||||
|
||||
- Web (tham chiếu): `docker/frontend/` — Vite/React/TS, CanvasKit WASM. Camera thêm ở `cb1839e` +
|
||||
`6814b05`.
|
||||
- Android (đích): `App.tsx` + `src/` ở gốc repo — Expo/RN, `react-native-vision-camera-skia` (worklet +
|
||||
GPU texture).
|
||||
|
||||
Quy ước trích dẫn: cột **Web** trỏ vào file trong `docker/frontend/`; cột **Android** trỏ vào `App.tsx` /
|
||||
`src/` ở gốc repo.
|
||||
|
||||
**Tóm tắt:** web-only đáng port chỉ có **3** việc — P1 (quan trắc FPS/độ phân giải), P2 (gate PRO ở lối
|
||||
vào camera, *cần quyết định*), P3 (luồng "chụp rồi chỉnh"). Web-only còn lại hoặc không port được, hoặc
|
||||
Android đã có bản tương đương. §6 ghi thêm phương án thay thế: đóng gói chính web studio thành app mobile.
|
||||
|
||||
---
|
||||
|
||||
## 1. Đối chiếu tính năng — cái nào thực sự web-only
|
||||
|
||||
| | **Web** | **Android** | Delta |
|
||||
|---|---|---|---|
|
||||
| Lối vào | Nút `OPEN CAMERA` + badge `PRO` (`ui/ImageStage.tsx:1443-1444`, `:2002-2006`) | Chế độ `mode === 'camera'` (`App.tsx:1675`, `:1978`) | khác chỗ đặt, không phải thiếu |
|
||||
| Gate | **Khoá cả camera** sau PRO: `cameraSupported() ? () => (pro ? setShooting(true) : promptPro()) : undefined` (`App.tsx:3237-3241`), `pro = !!user?.verified` (`:522`) | Gate ở **lúc render look**: `proLookInUse` (`App.tsx:1269`), `promptPro` (`:1275-1285`). `CameraControls.tsx` không có `pro` nào | **WEB-ONLY → P2** |
|
||||
| Số đo trên viewfinder | `{w}×{h} · {fps} fps` (`ui/CameraView.tsx:214-218`, `i18n/en.ts:40`) | Không hiển thị gì (nhịp tim `frameBeatSync` chỉ phục vụ watchdog) | **WEB-ONLY → P1** |
|
||||
| Ảnh chụp đi đâu | Still **full cảm biến → studio**; look do studio áp, file ghi sau bằng export chung (`docker/frontend/src/App.tsx:902-918`) | Render look **ngay** → file + gallery + `pushPhoto`; **không mở editor** (`App.tsx:1476-1489`) | **WEB-ONLY → P3** |
|
||||
| Xem trước khi chỉnh | Cùng `renderPhoto`, khác đích ra (`drawTo` so với `encodeToBytes`) | Cùng SkSL, chạy trong worklet | tương đương |
|
||||
| Lens | Chỉ `facingMode user/environment` (`engine/camera.ts:12-14`) | Flip, zoom 1–10x, ultra-wide 0.5x qua module Camera2 ẩn (`Viewfinder.tsx:599-713`), RAW DNG (`App.tsx:337-340`) | Android hơn |
|
||||
| Panel khi đang ngắm | Rail + cột PRESETS vẫn cạnh viewfinder (`.stage-slot`, `styles/app.css:723-750`) | Overlay dp + panel ngay dưới khung (`Viewfinder.tsx:2774-2819`) | tương đương |
|
||||
| Chuỗi UI | i18n EN/VI, 9 khoá `cam.*` (`i18n/en.ts:36-44`, `i18n/vi.ts:45-53`) | Hardcode tiếng Anh (app không có hạ tầng i18n) | khác phạm vi, xem P6 |
|
||||
| Chống treo phiên | không cần (rAF tự dừng/tiếp) | Watchdog remount sau ~6 s không có khung (`Viewfinder.tsx:1846-1900`) | Android hơn |
|
||||
|
||||
---
|
||||
|
||||
## 2. Delta kỹ thuật theo lớp
|
||||
|
||||
| Lớp | Web | Android | Kết luận |
|
||||
|---|---|---|---|
|
||||
| Nhận biết khả năng | `navigator.mediaDevices?.getUserMedia` + yêu cầu **secure context** (`engine/camera.ts:27-32`) | `useCameraPermission()` (`App.tsx:322`), manifest `CAMERA` | web-only, **không port** (không có khái niệm tương ứng) |
|
||||
| Phân loại lỗi | `err.name === 'NotAllowedError'` → `cam.denied`, còn lại `cam.fail` (`CameraView.tsx:176-181`) | 3 alert riêng: `Permission Needed` / `Camera Not Ready` / `Capture Failed` (`App.tsx:1338`, `:1348`, `:1499`) | ngữ nghĩa đã tương đương |
|
||||
| Mở phiên | `getUserMedia` → `<video>` **phải nằm trong DOM** (Safari vẽ trắng nếu tách) → `.camera-source-host` 1×1 px (`CameraView.tsx:137-141`, `app.css:764-777`) | `isActive={appActive && !ultraWideOn}` (`Viewfinder.tsx:2876`), `onStarted/onStopped` (`:544-554`) | web-only, **không port** |
|
||||
| Đóng phiên | Stop mọi track + `srcObject=null` + `remove()` (`engine/camera.ts:62-67`) | Unmount (đổi `mode`/`key`) + `frame.dispose()` (`Viewfinder.tsx:1489`) | tương đương |
|
||||
| Độ phân giải preview | `ideal 1280×720`, **không bao giờ `exact`**; lý do **nhiệt** (`engine/camera.ts:3-7`, `:18-20`) | Chọn 4:3 gần 1280×960 từ `getSupportedResolutions('stream')`; lý do **hình học** (`Viewfinder.tsx:400-420`) | Android pin tỉ lệ tốt hơn; phần *lý do nhiệt* là web-only → **P5 (hoãn)** |
|
||||
| Đổi giữa phiên | Buffer chỉ resize khi **aspect** đổi > 0.01; cùng aspect thì scale qua scratch canvas (`CameraView.tsx:67-117`) | Chỉ có bản hẹp cho handoff 0.5x (`uwCover`+`holdCover`, `:628-713`); remount thì màn trống tới khung đầu | **web-only, một phần → P4** |
|
||||
| Render preview | vòng `requestAnimationFrame` → `MakeImageFromCanvasImageSource(video)` → `renderPhoto({sourceImage, drawTo})` → `readPixels` → `putImageData` (`CameraView.tsx:143-173`, `:76-118`) | worklet đồng bộ vẽ thẳng lên GPU texture: color matrix + CLARITY + `toneEffect`→`cinemaEffect`→`glowEffect` qua `canvas.drawRect` (`Viewfinder.tsx:1177-1371`) | khác cơ chế vì khác nền tảng; **không port** |
|
||||
| Slider → khung kế | `liveRef` gán lại mỗi render, vòng lặp đọc lại mỗi khung (`CameraView.tsx:60-62`, `:158`) | `createSynchronizable` + `getDirty()` trong worklet (`Viewfinder.tsx:479-528`, `:1149-1170`) | tương đương |
|
||||
| Nhịp khung | `busyRef`: một render tại một thời điểm, khung tới khi bận thì **bỏ, không xếp hàng** (`CameraView.tsx:12-15`, `:152-172`) | Worklet đồng bộ, không có hàng đợi để phình | **không port** |
|
||||
| Quan trắc nhịp | Đếm khung trong cửa sổ 500 ms rồi in (`FPS_WINDOW_MS`, `CameraView.tsx:19`, `:143-151`) | Không có số nào | **WEB-ONLY → P1** |
|
||||
| Màn trập | 3 mức: `takePhoto` → `grabFrame` → copy 2D (`engine/camera.ts:69-96`) | `photoOutput.capturePhotoToFile` (+ `UltraWide.captureAsync`, RAW, burst) (`App.tsx:1381-1410`) | Android hơn; **không port** |
|
||||
| Đường ảnh ra | Still → editor, file ghi lúc export (`docker/frontend/src/App.tsx:902-918`) | Render ngay → file + gallery + lịch sử (`App.tsx:1476-1489`) | **WEB-ONLY → P3** |
|
||||
| Parity look | Cùng `renderPhoto`; chỉ khác đích ra (`exportEngine.ts:1068-1073` vs `:1075`) | Cùng `colorUtils` + SkSL (`Viewfinder.tsx:34-36,471-474` ↔ `exportEngine.ts:7-10,327-329`) | đã parity |
|
||||
| Gate PRO | Ở lối vào, badge trên nút (`App.tsx:3237-3241`, `ImageStage.tsx:1443-1444`) | Ở lúc export theo look + `LITE_MARK` (`App.tsx:1269-1295`, `:1321-1322`) | **WEB-ONLY → P2** |
|
||||
| Lỗi biên | `cam.unsupported` / `cam.denied` / `cam.fail` / `cam.shotFail` (`CameraView.tsx:121-124,176-181,199-202`) | Gate quyền + không device + lỗi phiên + lỗi chụp, mỗi thứ một alert riêng (`Viewfinder.tsx:2838-2863`, `App.tsx:950-961`, `:1499`) | tương đương |
|
||||
|
||||
---
|
||||
|
||||
## 3. Web-only, ĐÁNG PORT
|
||||
|
||||
### P1 — Dòng số `W×H · N fps` trên viewfinder *(rẻ, không đổi hành vi)*
|
||||
|
||||
**Mục tiêu.** Web in độ phân giải khung đang chạy và nhịp thật của pipeline (`CameraView.tsx:214-218`).
|
||||
Android có đủ dữ liệu nhưng vứt đi: `frameBeatSync` được worklet tăng mỗi khung, JS đọc bằng `getDirty()`,
|
||||
chỉ dùng cho watchdog (`Viewfinder.tsx:1180`, `:1866-1877`).
|
||||
|
||||
**Kỹ thuật.** Đừng đo trên JS thread (khung do worklet nhận — đếm ở JS là vô nghĩa). Tái dùng đúng nhịp
|
||||
tim; độ phân giải lấy `frameTarget` (`:400-420`).
|
||||
|
||||
```tsx
|
||||
// Số của thiết bị, không phải control — chỉ cần đủ mới để đọc (như FPS_WINDOW_MS của web).
|
||||
const FPS_WINDOW_MS = 500;
|
||||
const [fps, setFps] = useState(0);
|
||||
useEffect(() => {
|
||||
if (mode !== 'camera') return;
|
||||
let last = frameBeatSync.getDirty();
|
||||
const id = setInterval(() => {
|
||||
const beat = frameBeatSync.getDirty();
|
||||
setFps(Math.round(((beat - last) * 1000) / FPS_WINDOW_MS));
|
||||
last = beat;
|
||||
}, FPS_WINDOW_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [mode, frameBeatSync]);
|
||||
```
|
||||
|
||||
**Cạm bẫy.** `frameBeatSync` đếm khung **đã giao cho worklet**, không phải FPS cảm biến — đúng thứ cần
|
||||
đo, nhưng đừng ghi nhãn "FPS camera".
|
||||
|
||||
**Nghiệm thu.** Mở camera: số khớp `frameTarget` (mặc định 4:3 ≈ 1280×960); che tối để máy tự hạ nhịp —
|
||||
số phải tụt; chụp/burst/0.5x không đổi.
|
||||
|
||||
---
|
||||
|
||||
### P2 — Badge/gate PRO ở lối vào camera *(cần quyết định sản phẩm — mặc định KHÔNG làm)*
|
||||
|
||||
**Sự thật.** Web khoá **cả camera** sau PRO (`App.tsx:3237-3241`). Android để camera mở cho mọi người,
|
||||
chỉ chặn ở lúc render look (`App.tsx:1269`) và đóng dấu `RECIPESCAM` lên bản LITE.
|
||||
|
||||
**Khuyến nghị.** **Không port gate cứng**: khoá camera là biến app thành app không chụp được. Web làm
|
||||
được vì web là bề mặt dẫn khách sang tài khoản; Android là sản phẩm chính.
|
||||
|
||||
Nếu vẫn muốn, đây là wiring đúng chỗ — dùng lại `promptProFeature` sẵn có, không thêm cơ chế:
|
||||
|
||||
```tsx
|
||||
// App.tsx, cạnh handleSetMode (1675) — chặn ở lối vào, không chặn trong viewfinder.
|
||||
const openCameraMode = () => {
|
||||
if (!pro) { promptProFeature('The live camera'); return; } // :1288-1295
|
||||
handleSetMode('camera');
|
||||
};
|
||||
// …và truyền openCameraMode vào chỗ đang gọi handleSetMode('camera') (App.tsx:1978).
|
||||
```
|
||||
|
||||
Badge: `CameraControls.tsx` chưa import entitlement; thêm tag góc theo đúng `renderChip`
|
||||
(`AdjustmentPanel.tsx:348-371`, `proTag = c.pro && !pro`).
|
||||
|
||||
**Nghiệm thu.** LITE (`src/provariant.ts:3`, `node tools/set-variant.mjs`) → bấm CAMERA → `PRO FEATURE` +
|
||||
`ProModal`, `mode` không đổi. PRO → mở như cũ.
|
||||
|
||||
---
|
||||
|
||||
### P3 — "Chụp rồi chỉnh": đưa ảnh chụp vào editor với look đang áp *(đáng làm nếu muốn luồng web)*
|
||||
|
||||
**Mục tiêu.** Web: màn trập → still full cảm biến vào studio, look do studio áp, file ghi sau
|
||||
(`docker/frontend/src/App.tsx:902-918`, `CameraView.tsx:37-39`). Android: render ngay, không mở editor
|
||||
(`App.tsx:1476-1489`).
|
||||
|
||||
**Kỹ thuật (quan trọng).** Đừng đưa ảnh **đã render** vào editor — look bị áp hai lần (lúc chụp và lúc
|
||||
export) và `LITE_MARK` in hai lần. Đường đúng: chụp lấy **file thô** từ `photoOutput.capturePhotoToFile`
|
||||
(`App.tsx:1384-1394`), rồi vào editor bằng chính hàm mà ảnh thư viện đi (`handleLibraryPicked`,
|
||||
`App.tsx:1186`) — **bỏ `resetEditsToRecipe()`** (`:1198`) để `adjustments`/`selectedFrame` giữ nguyên look:
|
||||
|
||||
```tsx
|
||||
// trong handleCapture, nhánh mode === 'camera', thay cho enqueueExport(...)
|
||||
const shot = await photoOutput.capturePhotoToFile({ /* như :1384-1394 */ }, {});
|
||||
if (shotToEditor) { // cờ mới
|
||||
await handleLibraryPicked({ uri: toUri(shot.filePath), assetId: null });
|
||||
return; // KHÔNG render, KHÔNG ghi gallery ở đây
|
||||
}
|
||||
enqueueExport(...); // đường cũ giữ nguyên
|
||||
```
|
||||
|
||||
**Hệ quả phải chấp nhận.** Một ảnh chụp đi **một** trong hai đường: (a) render ngay → file + gallery +
|
||||
lịch sử (hiện tại), hoặc (b) vào editor, file chỉ ra đời ở export. Không thể vừa render ngay vừa edit mà
|
||||
không tốn gấp đôi công render. `pushPhoto` cũng sẽ không có entry ngay sau khi chụp.
|
||||
|
||||
**Nghiệm thu.** Chụp → sang `mode === 'library'` với ảnh **thô**; slider/HDF/frame giữ nguyên giá trị
|
||||
trước khi chụp; gallery **không** có `RC_*.jpg` mới; export ra đúng **một** `LITE_MARK`.
|
||||
|
||||
---
|
||||
|
||||
### P4 — Che khung khi phiên khởi động lại *(web có, Android chỉ có một phần)*
|
||||
|
||||
Web giữ khung cuối khi độ phân giải đổi giữa phiên — "mờ chứ không đen" (`CameraView.tsx:67-117`).
|
||||
Android đã làm điều này **cho handoff 0.5x** (`uwCover` + `holdCover` + đợi 3 nhịp khung,
|
||||
`Viewfinder.tsx:628-713`) nhưng không che khi `isActive` bật lại hay watchdog remount (`:1856-1875`) —
|
||||
màn trống tới khung đầu. Cách làm: tổng quát hoá `holdCover` thành "che tới khi nhịp tim tiến", gọi trong
|
||||
`remountCameraSession` và ở nhánh `setAppActive(true)`, trần 1500 ms.
|
||||
|
||||
### P5 — Chọn độ phân giải preview theo nhiệt *(web-only, HOÃN)*
|
||||
|
||||
Web có lý do nhiệt rõ cho 720p (`engine/camera.ts:3-7`); Android không có xử lý nhiệt nào. Muốn làm đúng
|
||||
phải thêm module Kotlin đọc `PowerManager.getCurrentThermalStatus()` rồi hạ `frameTarget` một bậc — tức
|
||||
module native mới + đường tính lại + test máy nóng thật. YAGNI: chỉ làm khi có phản hồi thật về máy nóng.
|
||||
|
||||
---
|
||||
|
||||
## 4. Web-only nhưng KHÔNG port
|
||||
|
||||
| Thứ web-only | Vì sao bỏ |
|
||||
|---|---|
|
||||
| `cameraSupported()` + yêu cầu secure context (`engine/camera.ts:27-32`) | Trình duyệt mới có khái niệm origin an toàn |
|
||||
| `<video>` phải nằm trong DOM + `.camera-source-host` (`CameraView.tsx:137-141`) | Mẹo riêng của Safari |
|
||||
| Chuỗi dự phòng `ImageCapture` → `grabFrame` → copy 2D (`engine/camera.ts:69-96`) | Android luôn có `photoOutput` full-res |
|
||||
| Cổng `busyRef` "bỏ chứ không xếp hàng" (`CameraView.tsx:12-15`) | Worklet chạy đồng bộ, không có hàng đợi để phình |
|
||||
| Scratch canvas để scale khung lệch cỡ (`CameraView.tsx:109-117`) | Chỉ có nghĩa khi phải giữ buffer cố định; ở đây vẽ thẳng lên texture |
|
||||
| Đặt tên `camera-<timestamp>.jpg` (`engine/camera.ts:114-120`) | Ảnh web không EXIF/tên gốc; Android đã có `RC_<yyyymmdd_hhmmss>.jpg` (`nativeExport.ts:113`) |
|
||||
| i18n `cam.*` (9 khoá, `i18n/en.ts:36-44`) | App Android chưa có hạ tầng i18n (0 tham chiếu) — việc của cả app, không phải camera |
|
||||
| `savePhoto` để ảnh chụp sống qua reload (`docker/frontend/src/App.tsx:910-911`) | Android có gallery + `pushPhoto` là bền hơn |
|
||||
|
||||
---
|
||||
|
||||
## 5. Android đã có — không port lại
|
||||
|
||||
| Lớp | Bằng chứng |
|
||||
|---|---|
|
||||
| Phiên + quyền + gate thiếu quyền | `useCameraPermission` (`App.tsx:322`), `Viewfinder.tsx:2838-2863` |
|
||||
| Chọn lens, flip, zoom 1–10x, ultra-wide 0.5x | `Viewfinder.tsx:599-713`, `:735-741` |
|
||||
| Pin tỉ lệ khung 4:3 theo cảm biến | `Viewfinder.tsx:400-420` |
|
||||
| Màn trập full-res + flash + RAW DNG + burst | `App.tsx:1381-1420`, `:1652-1672` |
|
||||
| Render look trong worklet, parity export | `Viewfinder.tsx:1177-1371` ↔ `exportEngine.ts` |
|
||||
| Overlay dp (grain, vignette, frame, watermark) | `Viewfinder.tsx:2774-2819` |
|
||||
| Watchdog + keep-awake + AppState | `Viewfinder.tsx:571-585`, `:1846-1900` |
|
||||
| Lịch sử + gallery + EXIF/DPI | `App.tsx:1482-1489`, `exportEngine.ts:923-935` |
|
||||
| Gate PRO theo look + `LITE_MARK` + badge chip | `App.tsx:1269-1295`, `entitlement.ts:52-60`, `AdjustmentPanel.tsx:348-371` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Phương án B — đóng gói chính web studio thành app mobile
|
||||
|
||||
Đúng: build web là **SPA tĩnh**, không router, nên bọc được. Nhưng có 4 chỗ chắc chắn phải xử lý, và
|
||||
chúng quyết định chọn cách bọc nào.
|
||||
|
||||
**Hiện trạng build:** `dist/` = 45 MB (`public/wasm` 35 MB gồm canvaskit + onnxruntime, `public/models`
|
||||
7.1 MB), 5 trang định tuyến bằng `window.location.pathname`, SPA fallback do nginx lo — **không có**
|
||||
`manifest.webmanifest`, **không** service worker (`docker/frontend/index.html`), nên hiện **chưa phải
|
||||
PWA**.
|
||||
|
||||
| Vấn đề | Chi tiết trong code | Cách xử lý |
|
||||
|---|---|---|
|
||||
| API là đường dẫn tương đối | `fetch('/api' + path)` (`src/api.ts:152`, `:205`, `:320-325`); `src/track.ts:6`; nginx proxy `/api` → container `api` | App bọc phải có origin phục vụ `/api`: dùng **domain https thật** (CORS + cookie) hoặc thêm base URL cấu hình được trong `api.ts` |
|
||||
| Camera cần **secure context** | `engine/camera.ts:27-29` — `http://192.168.x.x:8090` không có `mediaDevices` | WebView phải là `https://` (Capacitor cấu hình được scheme qua `androidScheme` — phải để `https`), **và** tầng native phải cấp quyền: `WebChromeClient.onPermissionRequest` → `PermissionRequest.grant`. TWA giải quyết sẵn vì chạy bằng Chrome |
|
||||
| Xuất ảnh | `navigator.share({files})` (`docker/frontend/src/App.tsx:1775`), fallback `download()` (blob + `<a download>`) | Android WebView **không** tải blob bằng `<a download>` nếu thiếu `DownloadListener` → cần plugin Filesystem/Share; muốn ảnh vào thư viện ảnh máy thì phải qua MediaStore (web không có khái niệm này) |
|
||||
| WASM nặng + ONNX upscale | `canvaskit.wasm`, `public/wasm/ort`, `public/models/realesr-*.onnx`; `vite.config.ts` nạp wasm lúc chạy | WebView có WASM; WebGPU có thể không có ⇒ rơi về WASM; WASM threads cần `SharedArrayBuffer` ⇒ cần COOP/COEP |
|
||||
|
||||
**Ba cách bọc, chọn theo cái phải bỏ ra:**
|
||||
|
||||
1. **TWA (Trusted Web Activity)** — rẻ nhất về code: không bundle gì, Chrome lo camera/download/share/CSS
|
||||
hiện đại, cảm giác như app thật. Đổi lại: **bắt buộc có domain https** + `.well-known/assetlinks.json`
|
||||
+ đăng ký store. Web giữ nguyên một bản.
|
||||
2. **Capacitor** — bundle `dist/` để mở offline. Đổi lại phải tự làm: quyền camera trong WebView, plugin
|
||||
file/share, base URL cho `/api`, và vẫn cần backend https (hoặc API remote).
|
||||
3. **PWA** — thêm `manifest.webmanifest` + service worker rồi cài từ Chrome. Rẻ nhất, không store, nhưng
|
||||
vẫn cần https và người dùng phải tự "Thêm vào màn hình chính".
|
||||
|
||||
**Cái bọc không cho được** (nên cân nhắc trước khi chọn B thay vì port P1–P3): ultra-wide 0.5x, zoom
|
||||
quang, RAW DNG, burst, ghi trực tiếp vào thư viện ảnh, IAP của Google Play (web gắn PRO với **tài khoản
|
||||
`verified`**, `App.tsx:522` — không phải hoá đơn store), và độ mượt/nhiệt của pipeline native.
|
||||
|
||||
**Khuyến nghị.** Nếu mục tiêu là "có app Android nhanh từ web studio" → **TWA**, vì nó không đụng vào code
|
||||
web và giữ nguyên camera/download/share. Nếu mục tiêu là app chụp ảnh hằng ngày → port P1 + P3 vào app
|
||||
Android hiện có (vài chục dòng mỗi việc), không bọc.
|
||||
|
||||
---
|
||||
|
||||
## 7. Nghiệm thu
|
||||
|
||||
**Web (đã có):** Playwright với `APP_URL=http://localhost:8090/app` (localhost = secure context), camera
|
||||
giả `--use-fake-device-for-media-stream`. Tên probe theo commit: `cam-pro-gate` (6/6), `cam-live-edit`,
|
||||
`cam-smoke`, `cam-renegotiate`, `cam-close-flip`.
|
||||
|
||||
**Android (phải máy thật):** hai biến thể bằng `node tools/set-variant.mjs` (`src/provariant.ts:3`):
|
||||
P1 đọc được dòng số và thấy nó tụt khi che tối; P2 LITE bấm CAMERA ra `PRO FEATURE`; P3 chụp → editor với
|
||||
ảnh thô + đúng một `LITE_MARK` lúc export; P4 bật/tắt app liên tục không thấy màn đen. Hồi quy: burst 30,
|
||||
handoff 0.5x, RAW, flip, `pushPhoto`.
|
||||
|
||||
**Nếu chọn bọc (§6):** kiểm tra theo thứ tự — `window.isSecureContext` trong WebView, `mediaDevices` có
|
||||
tồn tại không, quyền camera của app, `/api` trả 200, và xuất một ảnh ra được thư viện ảnh máy.
|
||||
|
||||
## 8. Bản đồ file
|
||||
|
||||
| Việc | Web | Android |
|
||||
|---|---|---|
|
||||
| Truy cập thiết bị, phiên, màn trập | `docker/frontend/src/engine/camera.ts` | `App.tsx:320-343`, `:1327-1502` |
|
||||
| Viewfinder + vòng render | `docker/frontend/src/ui/CameraView.tsx` | `src/components/Viewfinder.tsx` |
|
||||
| Nút chụp / flip | `CameraView.tsx:225-247` | `src/components/CameraControls.tsx` |
|
||||
| Pipeline look | `docker/frontend/src/engine/exportEngine.ts` (`renderPhoto`, `sourceImage`, `drawTo`) | `src/utils/exportEngine.ts`, `toneShader.ts`, `frameUtils.ts` |
|
||||
| Tài nguyên GPU | `docker/frontend/src/engine/skiaShim.ts` | `react-native-vision-camera-skia` + `disposeTrash` |
|
||||
| Gate PRO | `App.tsx:522,585-587,3237-3241` | `App.tsx:1269-1295`, `src/utils/entitlement.ts`, `ProModal.tsx` |
|
||||
| Đóng gói mobile (§6) | `docker/frontend/index.html`, `vite.config.ts`, `nginx.conf`, `src/api.ts` | — |
|
||||
+865
@@ -0,0 +1,865 @@
|
||||
# Port HSL (selective colour mixer) — `docker/frontend` → Android
|
||||
|
||||
Tài liệu này mô tả **chính xác** cách đưa tính năng HSL của bản web (`docker/frontend/`) sang app Android (Expo + Skia, source ở repo root).
|
||||
|
||||
Web là **bản tham chiếu**: `docker/frontend/shared/` là bản copy của `src/` bên Android nhưng đã đi trước. Hướng port là:
|
||||
|
||||
```
|
||||
docker/frontend/shared/** → src/**
|
||||
docker/frontend/src/** → src/components/**, App.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. Phạm vi — chỉ HSL
|
||||
|
||||
Diff giữa `src/` và `docker/frontend/shared/` **không chỉ có HSL**. Các delta sau nằm cùng file nhưng **KHÔNG thuộc tài liệu này**, tuyệt đối không port kèm:
|
||||
|
||||
| Delta web-only | Nơi xuất hiện | Kết luận |
|
||||
|---|---|---|
|
||||
| WB white/black point (`whites`/`blacks`) | `types`, `paramDefs`, `toneShader` (`wh`/`bl`), `defaultRecipes` | **BỎ** |
|
||||
| Base filter `classic-vivid` (`sim-classic-vivid`) | `types`, `toneShader` `FILM_TONE`, `defaultRecipes` | **BỎ** |
|
||||
| Frame `wallframe-landscape` | `types` `FrameId`, `recipeShare` `FRAMES`, chips ROTATE/wall | **BỎ** |
|
||||
| `GLOW_T0/GLOW_T1` 0.55/0.85 → 0.45/0.75 | `toneShader` | **BỎ** — đây là chỉnh demo của web; Android giữ `0.55/0.85` |
|
||||
| `exifWrite.ts` diff (22 dòng) | — | không liên quan HSL |
|
||||
|
||||
Chỉ port: **types HSL**, **`colorUtils` HSL**, **`toneShader` mixer**, **`recipeShare` sanitize**, **`defaultRecipes` 3 field toàn ảnh**, **UI tab HSL**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ngữ nghĩa (đọc trước khi code)
|
||||
|
||||
Mixer chọn màu theo **8 dải hue**. Mỗi dải có 3 knob `[hue, sat, lum]`, mỗi knob `-10..+10`:
|
||||
|
||||
- **hue** — xoay hue, ±30° khi full (`acc.x * 30/360`).
|
||||
- **sat** — scale saturation, `-10` = xám (`s * (1 + acc.y)`, `acc.y = -1`).
|
||||
- **lum** — cộng lightness, ±0.25 khi full (`acc.z * 0.25`), additive nên không đảo ramp.
|
||||
|
||||
Ba knob **toàn ảnh** (`hslHue`, `hslSat`, `hslLum`) là "seed" của accumulator: mọi hue nhận full weight.
|
||||
|
||||
Quy tắc bất biến — port phải giữ đúng:
|
||||
|
||||
1. **Partition of unity.** Weight mỗi dải là tent tuyến tính: full tại anchor của nó, về 0 tại anchor hai dải kề. Hai tent kề cắt nhau đúng tại 0.5 ở midpoint ⇒ 8 weight **cộng lại bằng 1 tại mọi hue**. Không pixel nào bị tính hai lần, không hue nào rơi vào khe.
|
||||
2. **Anchor cố định 1 nguồn.** `HSL_BANDS` (cho chip) và `BAND_BLOCK` (cho shader) sinh từ cùng bảng qua `hslBandGaps()` ⇒ anchor không thể lệch.
|
||||
3. **Gate xám.** `gate = smoothstep(0.0, 0.08, hsl.y)` — pixel có saturation < 8% bị loại khỏi weight, vì `rgb2hsl` trả hue 0 cho xám và nếu không gate thì **mọi pixel trung tính sẽ chạy theo dải RED**.
|
||||
4. **`gl` KHÔNG gate.** Lightness toàn ảnh (`gl`) được cộng **sau** gate, nên ảnh đã bị `-SAT` rút về xám vẫn còn `+LUM`.
|
||||
5. **Monochrome tắt mixer.** `if (!colour) hslOn = 0;` — stock đen trắng không có hue để chọn.
|
||||
6. **Dải toàn 0 bị xoá.** Band `[0,0,0]` không bao giờ vào recipe; kéo knob về 0 thì dải biến mất không để lại dấu.
|
||||
7. **Mixer chạy CUỐI**, sau split-tone/kv và sau vibrance — để band edit được đánh giá trên màu người dùng thực sự sample.
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/types/index.ts`
|
||||
|
||||
Thêm 2 type (đặt cạnh `BaseFilter`/`ColorAdjustments`):
|
||||
|
||||
```ts
|
||||
export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta';
|
||||
// [hue, sat, lum], mỗi số -10..+10; dải toàn 0 bị xoá khỏi recipe
|
||||
export type HslBand = [number, number, number];
|
||||
```
|
||||
|
||||
Trong `interface ColorAdjustments` (sau `vibrance?`, trước `exposureCompensation`):
|
||||
|
||||
```ts
|
||||
hslBands?: Partial<Record<HslBandId, HslBand>>; // selective colour: mỗi dải [hue, sat, lum], -10..+10
|
||||
hslHue?: number; // -10..+10 xoay hue toàn ảnh
|
||||
hslSat?: number; // -10..+10 scale saturation toàn ảnh
|
||||
hslLum?: number; // -10..+10 cộng lightness toàn ảnh
|
||||
```
|
||||
|
||||
> `hslBands` là **một field lồng**, không phải 24 field phẳng — đây là lý do nó cần gate riêng khi import (mục 5).
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/utils/colorUtils.ts`
|
||||
|
||||
File Android hiện chỉ có `kelvinToRGB`, `getSkiaColorMatrix`, `applyExposureGain`. Thêm nguyên khối sau **lên đầu file** (sau import):
|
||||
|
||||
```ts
|
||||
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
|
||||
|
||||
// 8 dải của mixer chọn màu, và anchor hue mỗi dải đứng (độ trên vòng HSL).
|
||||
// Anchor cố tình không đều: red/orange/yellow cách nhau 30° vì mắt phân biệt
|
||||
// kỹ vùng này, còn phía blue là một khúc 60°. TONE_SKSL dựng đúng 8 weight này
|
||||
// từ bảng dưới, nên anchor chip sửa và anchor shader mask không thể lệch.
|
||||
//
|
||||
// `label` là chữ chip in ra — chuỗi của app, KHÔNG nằm trong 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 (độ) của hai dải kề mỗi dải, đúng như TONE_SKSL cần. Suy ra từ HSL_BANDS
|
||||
// để hai bảng không thể cãi nhau: tent weight của một dải về 0 tại anchor của
|
||||
// hai dải kề, khiến 8 weight là partition of unity trên vòng hue (cộng = 1 tại
|
||||
// mọi 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°) wrap: dải kề trái là magenta 320°, tức 40° về sau.
|
||||
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
|
||||
});
|
||||
}
|
||||
|
||||
// 0..255 sRGB → HSL. Hue theo độ, saturation/lightness 0..1 — chiều ngược của
|
||||
// hsl2rgb trong shader, và là thứ readout của eyedropper in ra.
|
||||
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 };
|
||||
}
|
||||
|
||||
// Dải mà một hue sample thuộc về: anchor gần nhất. Các dải chồng nhau trong
|
||||
// shader, nên hàm này chỉ quyết định ruler đang trỏ vào dải nào.
|
||||
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;
|
||||
}
|
||||
|
||||
// Một band triple như khi lưu: số nguyên -10..10, dải toàn 0 bị xoá để recipe
|
||||
// chỉ mang theo thứ người dùng thực sự đổi.
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
Không sửa `getSkiaColorMatrix`: mixer nằm ở tone shader, matrix 4×5 không làm được per-pixel hue.
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/utils/toneShader.ts` — shader mixer
|
||||
|
||||
Đây là phần lõi. Sáu sửa đổi, theo thứ tự trong file.
|
||||
|
||||
### 4.1 Import + `BAND_BLOCK`
|
||||
|
||||
```ts
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
import { HSL_BANDS, hslBandGaps } from './colorUtils';
|
||||
```
|
||||
|
||||
Thêm **trước** `export const TONE_SKSL`:
|
||||
|
||||
```ts
|
||||
// Tám dòng band của mixer trong TONE_SKSL, sinh từ HSL_BANDS để anchor và gap
|
||||
// trong shader chính là số mà chip dựng ra. Mỗi dòng đọc 3 giá trị của dải nó
|
||||
// với index HẰNG — SkSL chỉ index uniform array bằng hằng, nên block này phải
|
||||
// unroll chứ không loop được.
|
||||
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('');
|
||||
```
|
||||
|
||||
### 4.2 Comment header
|
||||
|
||||
Thêm vào khối comment đầu file (cạnh `vib`):
|
||||
|
||||
```
|
||||
// hslOn/hslH/hslS/hslL - mixer chọn màu: 8 dải hue, mỗi dải một hue shift, một
|
||||
// saturation scale và một lightness offset (-1..1, từ knob -10..10). Dải
|
||||
// nào sở hữu pixel nào được quyết định Ở ĐÂY, per-pixel theo hue — nên
|
||||
// khác mọi thứ phía trên, 8 dải không phải một phép move toàn cục và
|
||||
// không thể nằm trong colour matrix. Xem band block ở cuối TONE_SKSL.
|
||||
// gh/gs/gl - move toàn ảnh của mixer: đúng ba đại lượng đó cho TOÀN ảnh, nên
|
||||
// chúng chỉ là giá trị khởi tạo của accumulator và mọi hue nhận full
|
||||
// weight. Lightness KHÔNG bị gate theo saturation (khác band), nên ảnh bị
|
||||
// -SAT rút về xám vẫn trả lời +LUM.
|
||||
```
|
||||
|
||||
### 4.3 Uniform khai báo trong `TONE_SKSL`
|
||||
|
||||
Thêm **sau `uniform float ccb;`** (Android không có `wh`/`bl`, nên HSL nối thẳng sau `ccb`):
|
||||
|
||||
```glsl
|
||||
uniform float hslOn;
|
||||
uniform float hslH[8];
|
||||
uniform float hslS[8];
|
||||
uniform float hslL[8];
|
||||
uniform float gh;
|
||||
uniform float gs;
|
||||
uniform float gl;
|
||||
```
|
||||
|
||||
Rồi **ngay trước `vec4 main(vec2 xy)`**, thêm 4 helper + mixer vào chuỗi SkSL:
|
||||
|
||||
```glsl
|
||||
// sRGB <-> HSL. Mixer làm việc trong HSL vì đó là không gian knob được đặt tên
|
||||
// theo: một hue shift không được đổi độ sáng của màu, và một lightness move
|
||||
// không được đổi hue — đúng thứ mà scale RGB làm sai.
|
||||
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)
|
||||
);
|
||||
}
|
||||
// Mức sở hữu một hue của một dải: full tại anchor của dải, giảm tuyến tính về 0
|
||||
// tại anchor hai dải kề (gap không đều — red cách orange 30° và cách magenta
|
||||
// 40°). Tính tuyến tính là mấu chốt: hai tent kề cắt nhau đúng 0.5 tại midpoint,
|
||||
// nên 8 weight cộng lại bằng 1 tại mọi hue. Không pixel nào bị tính hai lần,
|
||||
// không pixel nào rơi vào khe, và hue nằm đúng anchor nhận full giá trị của dải
|
||||
// đó thay vì một phần.
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Mixer trong `main()` — thay đoạn kết
|
||||
|
||||
Trong `main`, đoạn cuối Android hiện là:
|
||||
|
||||
```glsl
|
||||
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
|
||||
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
|
||||
}
|
||||
```
|
||||
|
||||
Đổi thành:
|
||||
|
||||
```glsl
|
||||
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
|
||||
rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0);
|
||||
// Selective colour theo dải hue — move CUỐI, để một band edit được đánh giá
|
||||
// trên đúng màu người dùng sample từ render.
|
||||
//
|
||||
// Pixel xám bị loại trước khi đọc weight: rgb2hsl trả hue 0 cho nó, nên nếu
|
||||
// không gate thì MỌI pixel trung tính trong khung sẽ bị coi là đỏ nguyên chất
|
||||
// và trôi theo dải red. Dưới 8% saturation thì cũng không có hue để move.
|
||||
//
|
||||
// Ba accumulator là giá trị band nhân mức sở hữu, nên một hue nằm giữa hai
|
||||
// anchor nhận hỗn hợp tỉ lệ của hai edit — đúng blend mà weight đã cộng ra.
|
||||
// Hue là phép xoay (±30° khi full), saturation là scale (0 = xám tại -10),
|
||||
// lightness là additive (±0.25 khi full) nên không thể đảo ramp.
|
||||
if (hslOn > 0.5) {
|
||||
vec3 hsl = rgb2hsl(rgb);
|
||||
float gate = smoothstep(0.0, 0.08, hsl.y);
|
||||
float hd = hsl.x * 360.0;
|
||||
// Move toàn ảnh là seed: mọi hue nhận hue turn và saturation scale ở full
|
||||
// weight, các dải cộng thêm phần của mình lên trên. Lightness được cộng
|
||||
// bên dưới KHÔNG gate, nên nó vẫn nâng một màu đã bị -SAT rút về xám.
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
> `${BAND_BLOCK}` phải nằm trong template literal — 8 dòng sinh ra nối liền ngay trước `hsl.x =`. Giữ đúng dạng đó, đừng thụt lề lại: SkSL không quan tâm, nhưng giữ khớp bản web để diff sau này đọc được.
|
||||
|
||||
### 4.5 `ToneUniforms`
|
||||
|
||||
Thêm vào interface (sau `ccb`):
|
||||
|
||||
```ts
|
||||
hslOn: number; // 1 khi có band hoặc move toàn ảnh được set (0 = skip mixer)
|
||||
hslH: number[]; // 8 × -1..1 theo dải, thứ tự HSL_BANDS (±30° hue khi full)
|
||||
hslS: number[]; // 8 × -1..1 (saturation scale, -1 = xám)
|
||||
hslL: number[]; // 8 × -1..1 (lightness additive, ±0.25 khi full)
|
||||
gh: number; // -1..1 xoay hue toàn ảnh (±30° khi full)
|
||||
gs: number; // -1..1 scale saturation toàn ảnh
|
||||
gl: number; // -1..1 cộng lightness toàn ảnh (±0.25 khi full, ungated)
|
||||
```
|
||||
|
||||
### 4.6 `getToneUniforms`
|
||||
|
||||
Trong thân hàm, **trước `return {`**:
|
||||
|
||||
```ts
|
||||
// Selective colour: một slot mỗi dải, theo thứ tự HSL_BANDS, để flat buffer
|
||||
// khớp với array của shader. Dải người dùng chưa move giữ ba số 0 và chỉ tốn
|
||||
// slot của nó.
|
||||
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;
|
||||
}
|
||||
// Stock monochrome không có hue để chọn theo.
|
||||
if (!colour) hslOn = 0;
|
||||
// Move toàn ảnh của mixer, mọi hue nhận full weight.
|
||||
const gh = tenth(adj.hslHue);
|
||||
const gs = tenth(adj.hslSat);
|
||||
const gl = tenth(adj.hslLum);
|
||||
if (colour && (gh || gs || gl)) hslOn = 1;
|
||||
```
|
||||
|
||||
Và thêm vào object return (cuối, sau `ccb`):
|
||||
|
||||
```ts
|
||||
hslOn,
|
||||
hslH,
|
||||
hslS,
|
||||
hslL,
|
||||
gh,
|
||||
gs,
|
||||
gl,
|
||||
```
|
||||
|
||||
> `colour` đã có sẵn trong hàm (`const colour = baseFilter !== 'monochrome'`). Đặt khối HSL **sau** dòng đó.
|
||||
|
||||
### 4.7 `toneUniformArray`
|
||||
|
||||
```ts
|
||||
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,
|
||||
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Tổng độ dài: `12 + 1 + 8 + 8 + 8 + 3 = 40`.
|
||||
|
||||
### 4.8 `toneIsActive`
|
||||
|
||||
`u.hslOn !== 0 ||` là **clause đầu tiên**:
|
||||
|
||||
```ts
|
||||
export function toneIsActive(u: ToneUniforms): boolean {
|
||||
return (
|
||||
u.hslOn !== 0 ||
|
||||
u.dr !== 0 ||
|
||||
... // phần còn lại giữ nguyên
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `src/utils/recipeShare.ts`
|
||||
|
||||
Import:
|
||||
|
||||
```ts
|
||||
import { sanitizeHslBands } from './colorUtils';
|
||||
```
|
||||
|
||||
Trong `importRecipeXml`, **trước** dòng `const frameId: FrameId = ...`:
|
||||
|
||||
```ts
|
||||
// Mixer là adjustment duy nhất đến dưới dạng object lồng, nên là cái duy nhất
|
||||
// cần gate riêng: band id lạ bị bỏ, mọi giá trị bị kẹp về range knob, và một
|
||||
// dải để nguyên 0 bị xoá thay vì mang theo vô ích.
|
||||
adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands);
|
||||
```
|
||||
|
||||
`exportRecipeXml` **không cần sửa**: `adjustments` được nhân bản nguyên khối vào payload JSON, `hslBands` tự đi theo. Format `.recipe` (XML bọc hex xorshift) giữ nguyên `version="1"`.
|
||||
|
||||
> `storageUtils.getAllRecipes` merge `{ ...DEFAULT_ADJUSTMENTS, ...r.adjustments }` — đủ cho 3 field toàn ảnh. `hslBands` từ storage là do chính app ghi nên không cần sanitize; nếu muốn chắc, gọi thêm `sanitizeHslBands` trong `fill`.
|
||||
|
||||
---
|
||||
|
||||
## 6. `src/utils/defaultRecipes.ts`
|
||||
|
||||
Thêm 3 field vào `DEFAULT_ADJUSTMENTS` (sau `vibrance` nếu có, trước `exposureCompensation`):
|
||||
|
||||
```ts
|
||||
hslHue: 0,
|
||||
hslSat: 0,
|
||||
hslLum: 0,
|
||||
```
|
||||
|
||||
Không thêm `hslBands` vào đây — nó optional và `undefined` mới là "không có dải nào"; thêm `hslBands: {}` sẽ làm `differs()` trong `App.tsx` báo dirty sai.
|
||||
|
||||
---
|
||||
|
||||
## 7. Uniform buffer — các điểm Android phải sửa thêm
|
||||
|
||||
`exportEngine.ts` **không cần sửa**: nó gọi `toneUniformArray(tone)` + `toneIsActive(tone)`, cả hai đã tự mang HSL.
|
||||
|
||||
`nativeExport.ts` (dev probe, `EXPO_PUBLIC_NATIVE_EXPORT`) **không mang HSL** — giống `colorChrome`, nó chỉ truyền `toneDr/toneHl/toneSh` vào `PhotoAdjust`. Ghi rõ trong comment cạnh comment "port them into applyTone()" hiện có; không cần làm gì thêm cho tới khi native path thành đường chính.
|
||||
|
||||
### 7.1 `src/components/Viewfinder.tsx` — BẮT BUỘC, dễ sót
|
||||
|
||||
Camera worklet đọc flat buffer qua `toneSync`, có hai chỗ hardcode theo độ dài cũ:
|
||||
|
||||
```tsx
|
||||
// dòng ~499
|
||||
const toneSync = useMemo(
|
||||
() => createSynchronizable<number[]>(new Array(10).fill(0)),
|
||||
[],
|
||||
);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// dòng ~1270
|
||||
const tone = toneSync.getDirty();
|
||||
const hasTone =
|
||||
toneEffect != null &&
|
||||
(tone[0] !== 0 || tone[1] !== 0 || ... || tone[11] !== 0);
|
||||
```
|
||||
|
||||
Sửa:
|
||||
|
||||
1. `new Array(40).fill(0)` — đúng bằng độ dài `toneUniformArray`.
|
||||
2. `hasTone` thêm `tone[12] !== 0` (đây là `hslOn`).
|
||||
|
||||
Nếu bỏ sót, worklet đọc `undefined` ở các slot mới ⇒ uniform `NaN` ⇒ **cả tone pass hỏng trên preview camera**, không chỉ HSL.
|
||||
|
||||
Library (declarative `<Shader>`) — thêm key vào object `toneUniforms` (dòng ~2999):
|
||||
|
||||
```tsx
|
||||
const toneUniforms = {
|
||||
dr: toneParams[0],
|
||||
hl: toneParams[1],
|
||||
sh: toneParams[2],
|
||||
vib: toneParams[3],
|
||||
shTr: toneParams[4],
|
||||
shTg: toneParams[5],
|
||||
shTb: toneParams[6],
|
||||
hlTr: toneParams[7],
|
||||
hlTg: toneParams[8],
|
||||
hlTb: toneParams[9],
|
||||
cc: toneParams[10],
|
||||
ccb: toneParams[11],
|
||||
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],
|
||||
};
|
||||
```
|
||||
|
||||
`toneOn = toneParams.some((v) => v !== 0)` giữ nguyên — buffer phẳng nên vẫn đúng.
|
||||
|
||||
> **Cần xác nhận trên máy:** nhánh declarative `<Shader uniforms>` của RN Skia phải nhận **array** uniform (`float hslH[8]`). Nếu nó flatten/không nhận, chuyển nhánh library preview sang `makeShaderWithChildren(toneUniformArray(tone), [imageShader])` như camera/export đã làm — cùng một nguồn số, bỏ hẳn object named-uniform.
|
||||
|
||||
---
|
||||
|
||||
## 8. UI Android
|
||||
|
||||
### 8.1 `src/components/ToolRail.tsx`
|
||||
|
||||
```ts
|
||||
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
|
||||
```
|
||||
|
||||
Thêm vào `TOOLS`, **giữa `filters` và `frame`** (web đặt HSL cùng nhóm màu):
|
||||
|
||||
```ts
|
||||
{ id: 'filters', label: 'FX' },
|
||||
{ id: 'hsl', label: 'HSL' },
|
||||
{ id: 'frame', label: 'FRAME' },
|
||||
```
|
||||
|
||||
### 8.2 State — nâng lên `App.tsx`
|
||||
|
||||
Web để state HSL trong `App`. Android cũng phải vậy, vì **cả `AdjustmentPanel` (chip) lẫn `Viewfinder` (eyedropper + panel nổi) đều cần**:
|
||||
|
||||
```tsx
|
||||
const [hslBand, setHslBand] = useState<HslBandId>('red'); // dải ruler đang sửa
|
||||
const [picking, setPicking] = useState(false); // eyedropper đã arm chưa
|
||||
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
|
||||
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null); // vị trí trên ảnh
|
||||
```
|
||||
|
||||
Truyền xuống `Viewfinder`: `picking`, `hslPickedAt`, `onPickColor`, và một node `hslPanel` (JSX panel, xem 8.5). Truyền xuống `AdjustmentPanel`: `hslBand`, `onHslBand`, `picking`, `onPicking`, `hslSample`, `adjustments`, `onUpdateAdjustments` (đã có).
|
||||
|
||||
Hai setter (đặt cạnh nhau, cả hai gọi `onUpdateAdjustments`):
|
||||
|
||||
```tsx
|
||||
// Một band triple như khi lưu. Dải về [0,0,0] bị XOÁ, và khi không còn dải nào
|
||||
// thì field trả về undefined (không phải {}) để `differs(adjustments,
|
||||
// DEFAULT_ADJUSTMENTS)` trong resetDirty không báo dirty sai.
|
||||
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;
|
||||
onUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
|
||||
};
|
||||
|
||||
// Move toàn ảnh: một cửa cho cả ba knob, nên ruler và field nó ghi luôn là cùng
|
||||
// một giá trị dù mở từ đâu.
|
||||
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
|
||||
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
|
||||
onUpdateAdjustments({ [key]: Math.max(-10, Math.min(10, Math.round(v))) } as Partial<ColorAdjustments>);
|
||||
};
|
||||
```
|
||||
|
||||
`pickColor` (App đưa xuống Viewfinder):
|
||||
|
||||
```tsx
|
||||
// Báo cáo của eyedropper: in màu đọc được, trỏ ruler về dải của màu đó, treo
|
||||
// panel lên đúng điểm nó được đọc, rồi cất dụng cụ đi — một lần pick một màu.
|
||||
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);
|
||||
};
|
||||
```
|
||||
|
||||
### 8.3 `src/components/AdjustmentPanel.tsx`
|
||||
|
||||
1. `type TabId` (dòng 20) thêm `| 'hsl'`.
|
||||
2. `paramDefs: Record<TabId, ParamDef[]>` thêm `hsl: []` **hoặc** xử lý riêng trong `chipRow` (xem dưới). Ba knob toàn ảnh **không** vào `PARAM_DEFS` — chúng đọc/ghi field của `adjustments` nhưng label là `HUE IMAGE`/`SAT IMAGE`/`LUM IMAGE`, và mở row qua cùng cơ chế `openParam` với `PARAM_GROUP` để nút back đóng row.
|
||||
|
||||
Cách gọn nhất, khớp cấu trúc sẵn có: mở rộng `paramDefs.hsl` bằng 3 `ParamDef` với `key` là `'hsl.h' | 'hsl.s' | 'hsl.l'` và `onChange: (v) => setHslGlobal(which, v)`. Khi đó `openParamDef` + row slider sẵn có tự hoạt động, chip hiện value, RESET/back hoạt động.
|
||||
|
||||
```tsx
|
||||
// Ba knob toàn ảnh của mixer không nằm trong PARAM_DEFS: chúng đọc/ghi move
|
||||
// của CHÍNH ẢNH chứ không phải một field phẳng của adjustments, và khác panel
|
||||
// trên ảnh, chúng không gắn với dải mà mixer đang trỏ.
|
||||
hsl: [0, 1, 2].map((i) => {
|
||||
const own = [a.hslHue ?? 0, a.hslSat ?? 0, a.hslLum ?? 0];
|
||||
const key = i === 0 ? 'hsl.h' : i === 1 ? 'hsl.s' : 'hsl.l';
|
||||
const name = i === 0 ? 'HUE' : i === 1 ? 'SAT' : 'LUM';
|
||||
return {
|
||||
key,
|
||||
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),
|
||||
};
|
||||
}),
|
||||
```
|
||||
|
||||
3. Thêm `case 'hsl'` vào switch `chipRow`:
|
||||
|
||||
```tsx
|
||||
case 'hsl': {
|
||||
// PICK arm eyedropper; 8 chip dải chọn dải mà ruler sửa; ruler do ba
|
||||
// knob bên dưới mở. Ba knob đó là move toàn ảnh, không phải của dải:
|
||||
// chip dải chọn màu cho panel trên ảnh, còn HUE/SAT/LUM dưới divider
|
||||
// move mọi hue trong khung cùng lúc.
|
||||
return chipRow([
|
||||
{ key: 'hsl-pick', label: 'PICK', active: picking, onPress: () => onSetPicking(!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,
|
||||
// Chip gọi tên một màu nên nó hiện đúng màu đó.
|
||||
color: hslToHex(b.hue, 70, 50),
|
||||
active: hslBand === b.id,
|
||||
amberValue: hslBand !== b.id && moved,
|
||||
onPress: () => onSetHslBand(b.id),
|
||||
};
|
||||
}),
|
||||
// Divider giữ ba slider toàn ảnh ra ngoài hàng màu: hàng trên chọn
|
||||
// một màu, ba cái này move tất cả.
|
||||
{ key: 'hsl-image', label: 'IMAGE', disabled: true, active: false, onPress: () => {} },
|
||||
...paramChips(paramDefs.hsl),
|
||||
], 8);
|
||||
}
|
||||
```
|
||||
|
||||
4. `ChipDef` cần thêm `color?: string` (chip màu). Trong `renderChip`, khi `c.color` có mặt, vẽ một dot màu trước label (Android dùng `View` với `backgroundColor`):
|
||||
|
||||
```tsx
|
||||
{c.color ? <View style={{ width: 10, height: 10, borderRadius: 999, backgroundColor: c.color, marginRight: 6 }} /> : null}
|
||||
```
|
||||
|
||||
Chip màu vẫn giữ text label (RED/ORANGE/…) — dot là phụ, không thay chữ.
|
||||
|
||||
5. Readout khi `activeTab === 'hsl'`: Android không có cột phụ như web. Đặt một **dòng readout mỏng ngay trên hàng chip** (chỗ `openParamDef` row đang render), chỉ khi tab hsl:
|
||||
|
||||
```tsx
|
||||
{activeTab === 'hsl' && (
|
||||
<View className="flex-row items-center gap-2 px-3 py-1">
|
||||
{hslSample ? (
|
||||
<>
|
||||
<View style={{ width: 22, height: 22, borderRadius: 4, borderWidth: 1, borderColor: '#3f3f46',
|
||||
backgroundColor: `rgb(${hslSample.r}, ${hslSample.g}, ${hslSample.b})` }} />
|
||||
<Text className="font-mono text-[11px] text-zinc-400">
|
||||
R {hslSample.r} · G {hslSample.g} · B {hslSample.b}
|
||||
</Text>
|
||||
<Text className="font-mono text-[11px] text-zinc-400">
|
||||
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).h)}° ·{' '}
|
||||
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).s * 100)}% ·{' '}
|
||||
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).l * 100)}%
|
||||
</Text>
|
||||
<Text className="font-mono text-[11px] 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>
|
||||
)}
|
||||
```
|
||||
|
||||
`bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''`.
|
||||
|
||||
### 8.4 `hslToHex` (dùng cho chip màu)
|
||||
|
||||
Copy nguyên từ web (`docker/frontend/src/App.tsx` dòng 108) vào `src/utils/colorUtils.ts` hoặc ngay trong `AdjustmentPanel.tsx`:
|
||||
|
||||
```ts
|
||||
// HSL -> #rrggbb. Chip gọi tên một màu nên nó phải hiện đúng màu đó; hex là
|
||||
// format duy nhất RN nhận thẳng từ chuỗi.
|
||||
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)}`;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.5 Eyedropper + panel nổi — `src/components/Viewfinder.tsx`
|
||||
|
||||
Đây là phần **chưa có tương ứng Android**, phải viết mới.
|
||||
|
||||
**Props thêm:**
|
||||
|
||||
```tsx
|
||||
picking?: boolean;
|
||||
onPickColor?: (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => void;
|
||||
hslPanel?: React.ReactNode;
|
||||
hslPanelAt?: { fx: number; fy: number } | null;
|
||||
```
|
||||
|
||||
**Lớp pick.** Web đặt một `div` phủ đúng **box của ảnh** (`box` = rect ảnh sau zoom/pan, không phải cả canvas). Android đã có `imageFitRect` và sẵn một touch layer trong library mode (`onLibTouchStart` với toạ độ fraction). Cách port đúng và ít code nhất:
|
||||
|
||||
- Khi `picking`, thêm một `Pressable` (hoặc `View` với `onStartShouldSetResponder`) phủ **đúng `imageFitRect`**, chặn touch để pan/zoom không khởi động.
|
||||
- Toạ độ fraction tính từ rect **của lớp đó**, không phải của canvas:
|
||||
|
||||
```tsx
|
||||
const fx = (e.nativeEvent.locationX) / rectW;
|
||||
const fy = (e.nativeEvent.locationY) / rectH;
|
||||
if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return;
|
||||
```
|
||||
|
||||
- Hiện một icon eyedropper đi theo ngón tay (`pick-icon` của web là SVG; Android dùng `lucide-react-native` đã có sẵn: `<Pipette size={22} color="#fff" />`).
|
||||
- Chỉ arm trong **library mode**. Camera mode không có pixel để sample (và preview là luồng sống).
|
||||
|
||||
**Lấy màu.** Web sample chính **ảnh preview đã render** (`previewUrl`), không phải file gốc — để màu đọc được đúng là màu đang thấy. Android tương đương: snapshot canvas đang vẽ. Đường ngắn nhất:
|
||||
|
||||
```tsx
|
||||
// canvasRef = useCanvasRef() trên canvas library đang vẽ `graded`.
|
||||
const shot = canvasRef.current?.makeImageSnapshot(); // ảnh đã render
|
||||
const px = await shot.readPixels(Math.floor(fx * shot.width()), Math.floor(fy * shot.height()), {
|
||||
width: 1, height: 1, colorType: ColorType.RGBA_8888, alphaType: AlphaType.Unpremul,
|
||||
});
|
||||
onPickColor({ r: px[0], g: px[1], b: px[2] }, { fx, fy });
|
||||
```
|
||||
|
||||
Đọc **1 pixel** từ snapshot là đủ và rẻ; cache snapshot trong lúc `picking` để mỗi lần chạm không phải snapshot lại.
|
||||
|
||||
> **ponytail:** snapshot canvas mỗi lần pick là đủ cho một lần chạm/ảnh; nếu độ trễ cảm nhận được trên máy thật, chuyển sang decode `skiaImage` một lần và `readPixels` trên đó — nhưng khi đó màu sample là màu **gốc**, không phải màu đã grade, và hue có thể lệch khỏi thứ người dùng nhìn thấy nếu các knob khác đã move. Chỉ hạ cấp khi có số đo.
|
||||
|
||||
**Panel nổi.** Web đặt panel tại `left = fx*100%`, `top = fy*100%` **trong box ảnh**, với `transform: translate(-50%, 14px)`, hoặc `translate(-50%, calc(-100% - 14px))` khi `fy > 0.55` (điểm nằm thấp thì panel treo lên trên để không rơi khỏi ảnh). Android absolute-position trong chính box đó:
|
||||
|
||||
```tsx
|
||||
{hslPanel && hslPanelAt && (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0, top: 0, width: rectW, height: rectH,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: hslPanelAt.fx * rectW,
|
||||
top: hslPanelAt.fy * rectH,
|
||||
transform: [
|
||||
{ translateX: -PANEL_HALF_W }, // translate(-50%)
|
||||
{ translateY: hslPanelAt.fy > 0.55 ? -(PANEL_H + 14) : 14 },
|
||||
],
|
||||
}}
|
||||
>
|
||||
{hslPanel}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
```
|
||||
|
||||
Panel dừng pointer để không rơi xuống lớp pan của ảnh.
|
||||
|
||||
### 8.6 Panel nội dung (`hslPanel`)
|
||||
|
||||
Web dựng `pickPanel` ở `App.tsx`; Android dựng trong `AdjustmentPanel` (nó đã có `Slider` + haptics) rồi App truyền node xuống Viewfinder, hoặc dựng thẳng trong App. Nội dung:
|
||||
|
||||
- **Swatch** — màu của sample sau khi áp knob của dải đang chọn:
|
||||
|
||||
```tsx
|
||||
const picked = hslSample ? rgbToHsl(hslSample.r, hslSample.g, hslSample.b) : null;
|
||||
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
|
||||
const mixedHex = picked
|
||||
? 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])),
|
||||
)
|
||||
: null;
|
||||
```
|
||||
|
||||
- **Tên dải + hex**, nút đóng `✕` (`onPress={() => setHslPickedAt(null)}`).
|
||||
- **Ba MiniSlider** `HUE` / `SAT` / `LUM`, mỗi cái `value = band[i]`, `onValueChange = (v) => setBandKnob(i, v)`, và reset về 0 (web: double-click; Android: thêm một `TouchableOpacity` nhỏ hoặc `onSlidingComplete` khi |v| < 0.5 ⇒ 0). Dùng `Slider` (`@react-native-community/slider`) với `minimumValue={-10} maximumValue={10} step={1}`.
|
||||
|
||||
### 8.7 `resetDirty` — `App.tsx`
|
||||
|
||||
`differs()` là **shallow**. Với `setBandKnob` trả `hslBands: undefined` khi rỗng (8.2), `differs(adjustments, DEFAULT_ADJUSTMENTS)` đã bắt đúng cả `hslBands` lẫn `hslHue/hslSat/hslLum`. **Không cần thêm clause** — miễn là đừng để `hslBands: {}`.
|
||||
|
||||
Nếu vì lý do nào đó muốn giữ `{}`, thì phải thêm `|| Object.keys(adjustments.hslBands ?? {}).length > 0` (đúng như web) — nhưng cách `undefined` sạch hơn.
|
||||
|
||||
### 8.8 Camera mode + PRO gate — quyết định
|
||||
|
||||
- **Camera mode:** mixer tự động chạy trong preview camera (7.1 sửa xong `toneSync`), nên tab HSL cứ hiện ở cả hai mode. **PICK + panel chỉ có nghĩa ở library** — ở camera mode cho chip `PICK` ở trạng thái `disabled` (chip xám, không mở). Đây là điểm khác web (web library-only) và là chỗ dễ tranh cãi nhất — **xác nhận với người dùng nếu muốn khác**.
|
||||
- **PRO gate:** web **không** gate HSL (`proLookInUse()` chỉ phủ `PRO_FRAMES` + GPS + HDF; tab HSL mở cho guest). Port giữ nguyên: **HSL là tính năng LITE**, không thêm vào `entitlement.ts`.
|
||||
- **`RecipeCreateModal`:** không cần thêm dòng HSL. Web cũng không có HSL trong form CREATE — mixer chỉ chỉnh trên ảnh, recipe lưu qua `adjustments` khi SAVE.
|
||||
|
||||
---
|
||||
|
||||
## 9. Kiểm tra
|
||||
|
||||
### 9.1 Trên Android (self-check + unit)
|
||||
|
||||
1. **`HSL_BANDS` / `hslBandGaps`** — bất biến partition of unity:
|
||||
|
||||
```ts
|
||||
// Với mọi hue 0..359, tổng 8 tent weight phải = 1 (±1e-6).
|
||||
const gaps = hslBandGaps();
|
||||
for (let h = 0; h < 360; h += 1) {
|
||||
const sum = gaps.reduce((s, b) => {
|
||||
const d = ((h - b.hue + 540) % 360) - 180;
|
||||
const w = d <= 0 ? Math.max(0, 1 + d / b.left) : Math.max(0, 1 - d / b.right);
|
||||
return s + w;
|
||||
}, 0);
|
||||
if (Math.abs(sum - 1) > 1e-6) throw new Error(`weights != 1 at ${h}: ${sum}`);
|
||||
}
|
||||
```
|
||||
|
||||
Và `hslBandGaps()` trả `red.left = 40`, `red.right = 30` (wrap đúng).
|
||||
|
||||
2. **`sanitizeHslBands`**:
|
||||
- `{ red: [99, -99, 0] }` → `{ red: [10, -10, 0] }`
|
||||
- `{ red: [0,0,0] }` → `undefined`
|
||||
- `{ red: [1,2] }`, `{ nope: [1,2,3] }` → bỏ
|
||||
- `null` / `'x'` → `undefined`
|
||||
|
||||
3. **`rgbToHsl` ↔ `nearestHslBand`**: đỏ thuần `(255,0,0)` → `red`; `(255,128,0)` → `orange`; xám `(128,128,128)` → `s = 0`, `nearestHslBand(0) = 'red'` (rồi shader gate nó đi).
|
||||
|
||||
4. **Shader compile** — `Skia.RuntimeEffect.Make(TONE_SKSL)` không throw. Bắt buộc, vì `${BAND_BLOCK}` nội suy vào chuỗi.
|
||||
|
||||
5. **`toneUniformArray`** — `length === 40`; `hslOn` ở index 12; `gh/gs/gl` ở 37/38/39.
|
||||
|
||||
6. **`toneIsActive`** — `{ ...all zero, hslOn: 1 }` → `true`; `{ ...all zero }` → `false`.
|
||||
|
||||
7. **`getToneUniforms`** — `baseFilter: 'monochrome'` + `hslBands` có giá trị ⇒ `hslOn === 0`; `colour` + `hslLum: 10` ⇒ `gl === 1`, `hslOn === 1`.
|
||||
|
||||
8. **Round-trip `.recipe`** — recipe có `hslBands: { blue: [3,-2,5], red: [0,0,0] }` export rồi import: `red` biến mất, `blue` giữ nguyên; `hslHue/Sat/Lum` sống qua vòng.
|
||||
|
||||
9. **Máy / emulator** (kiểm tra số, không nhìn ảnh):
|
||||
- Preview camera với `+10` dải RED và ảnh có mảng đỏ: sample pixel đỏ trước/sau bằng `readPixels` trên cùng toạ độ ⇒ hue trôi ~+30°.
|
||||
- `-10` SAT dải RED trên vùng đỏ ⇒ saturation về ~0; `+10` LUM ⇒ lightness tăng ~0.25.
|
||||
- `hslLum = +10` trên một ảnh đã `saturation = -10`: lightness vẫn phải tăng (kiểm tra `gl` không bị gate).
|
||||
- Monochrome stock: uniform HSL toàn 0 bất kể `hslBands` có gì.
|
||||
- Export ra file: pixel cuối phải khớp pixel preview (cùng một `toneUniformArray`).
|
||||
|
||||
### 9.2 Tham chiếu hành vi từ web
|
||||
|
||||
Các script e2e của web là **hợp đồng hành vi** để đối chiếu, nằm trong scratchpad:
|
||||
|
||||
```
|
||||
/home/locpham/.penguin/data/default_project/agents/default_agent/scratchpad/session-2026-09-06-18-30-42-c24c8c7b/
|
||||
hsl-test.cjs # chips dải, setBandKnob, xoá dải toàn 0
|
||||
hsl-panel-test.cjs # PICK -> pickColor -> panel nổi -> knob panel
|
||||
hsl-shader-test.cjs # partition of unity + kết quả shader theo hue
|
||||
hsl-bundle.cjs # build standalone
|
||||
hsl-entry.ts # entry
|
||||
```
|
||||
|
||||
Web đã deploy (`docker/frontend`) là bản chạy được để so từng bước: mở tab `hsl`, PICK một màu, kéo HUE/SAT/LUM trên panel và trên ruler `IMAGE`, đối chiếu `R n · G n · B n` / `h° · s% · l%`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Thứ tự thực hiện (đề xuất)
|
||||
|
||||
1. `types` → 2. `colorUtils` (HSL_BANDS … sanitize) → 3. `toneShader` (4.1–4.8) → 4. self-check 9.1.1–9.1.7 chạy pass → 5. `defaultRecipes` + `recipeShare` → 6. `Viewfinder` buffer (7.1) — **làm trước UI**, nếu không preview camera sẽ vỡ → 7. `ToolRail` + `AdjustmentPanel` chip/readout → 8. `Viewfinder` eyedropper + panel + state App → 9. máy thật.
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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,8 +82,6 @@ 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`).
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,78 @@
|
||||
# RecipesCam
|
||||
|
||||
RecipesCam is a camera and photo-editing app built around **recipes** — reusable
|
||||
looks that carry a film simulation plus a full set of adjustments. You shoot or
|
||||
open a photo, dial in a look, and keep it as a recipe you can apply again, share
|
||||
as a `.recipe` file, or save to your account.
|
||||
|
||||
It ships twice from one repository: a **React Native (Expo) app** for iOS and
|
||||
Android, and a **self-contained web build** under `docker/` that runs the same
|
||||
render pipeline in the browser.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Shoot with a recipe applied.** Live viewfinder, GPS-tagged captures, and the
|
||||
recipe baked into the saved file.
|
||||
- **Film simulations.** Built-in looks — PROVIPES, VELVIPES, CLASSIC CHRIPES,
|
||||
CLASSIC NEGIPES, ASTIPES, ETERNIPES, ACRIPES, B&W HIGH CONTRAST and the LC
|
||||
STREETLIFE pair — each with its own grain and tone curve.
|
||||
- **The full adjustment set.** Exposure, contrast, highlights and shadows,
|
||||
saturation, colour temperature, clarity, grain, and an HSL mixer with a
|
||||
colour picker that samples straight off the photo.
|
||||
- **Geometry.** Crop to a fixed ratio or free-form, quarter turns, and a
|
||||
straighten ruler, plus printed frames (classic border, retro instant, wall
|
||||
frame portrait/landscape).
|
||||
- **Finishing.** Watermark and GPS stamp, EXIF carried through the export, JPEG
|
||||
written with a proper 300 DPI JFIF header.
|
||||
- **On-device upscaling.** A Real-ESRGAN pass runs locally when an export asks
|
||||
for more pixels than the source has — no server sees the photo.
|
||||
- **Recipes.** Save, favourite, export and import `.recipe` files; the web
|
||||
build keeps them in your account, the phone build also keeps them on device.
|
||||
|
||||
## The two builds
|
||||
|
||||
| Build | Where | Stack |
|
||||
| --- | --- | --- |
|
||||
| iOS / Android | repo root | Expo + React Native, `@shopify/react-native-skia` for the render pipeline, NativeWind for styling |
|
||||
| Web | `docker/` | Vite + React, CanvasKit (`canvaskit-wasm`) for the same pipeline, Fastify + SQLite API for accounts and recipes |
|
||||
|
||||
The render engine is shared by design: the web build compiles the app's own
|
||||
`src/utils/*` and type definitions unchanged, with `@shopify/react-native-skia`
|
||||
aliased to a CanvasKit shim (`docker/frontend/src/engine/skiaShim.ts`). A look
|
||||
looks the same on both because it is the same code.
|
||||
|
||||
## Running the web build
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
# → http://localhost:8090
|
||||
```
|
||||
|
||||
Photos never leave the browser: grading, framing, watermarking and JPEG
|
||||
encoding all run in the visitor's tab; the API only stores accounts and recipe
|
||||
JSON. See `docker/README.md` for the layout and the proxy setup.
|
||||
|
||||
## Running the app
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start # Expo Go / dev client
|
||||
npx expo run:android # or run:ios for a native build
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
App.tsx, src/ the Expo app: screens, tool rail, viewfinder, shaders
|
||||
docker/ the web build (frontend + API + compose file)
|
||||
frontend/shared/ vendored copies of the app's types and utils
|
||||
frontend/src/engine/ CanvasKit shim, export engine, super-resolution
|
||||
docs/ privacy policy
|
||||
THIRD_PARTY_NOTICES.md licences of the bundled fonts, models and libraries
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
See `LICENSE` and `THIRD_PARTY_NOTICES.md`.
|
||||
|
||||
@@ -1,52 +1,5 @@
|
||||
# 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
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
# WEB_PLAN — RecipesCam Web (Docker, branch `recipes-web`)
|
||||
|
||||
Trạng thái: kế hoạch đã chốt yêu cầu, chưa code. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: app RN hiện tại (`App.tsx`, `src/components/*`, `src/utils/*`).
|
||||
|
||||
## Mục tiêu
|
||||
Một web app chạy bằng Docker, gồm 2 phần:
|
||||
|
||||
1. **Landing page** (`/`, responsive cả desktop lẫn mobile) — giới thiệu app RecipesCam: hero, tính năng, ảnh before/after, review người dùng, badge Google Play / App Store, CTA **DÙNG THỬ DEMO** và **ĐĂNG KÝ**.
|
||||
2. **WebUI chỉnh ảnh** (`/app`) — tái hiện giao diện app điện thoại:
|
||||
- **Cột trái**: rail 6 tab (PRESETS / FAVORITED / LIGHT / WB / FX / FRAME); chip công cụ của tab đang chọn nằm trong cột đó.
|
||||
- **Giữa**: khung ảnh lớn, **kéo-thả ảnh từ máy tính** vào để nạp (kèm chọn file + paste clipboard). Trên mobile: nút chọn ảnh / camera roll.
|
||||
- Đầy đủ tool như app → **EXPORT / RENDER** ra JPEG (DPI 300) tải về.
|
||||
- **Add-on BATCH EXPORT**: nạp nhiều ảnh, áp cùng 1 recipe (của người dùng hoặc bundle), export ra cùng một tông màu.
|
||||
3. **Tài khoản**: bắt buộc đăng nhập để dùng bản đầy đủ; vẫn cho **demo không cần đăng nhập**.
|
||||
4. Toàn bộ xử lý ảnh chạy **client-side** (không upload ảnh lên server) — server chỉ phục vụ file tĩnh + API tài khoản/recipe.
|
||||
|
||||
## Quyết định đã chốt (2026-09-06)
|
||||
1. **Cổng/domain**: chạy `IP:port` (mặc định `8090`), đổi được qua `docker-compose.yml`; domain thật gắn sau bằng **Nginx Proxy Manager** bên ngoài → không cần TLS trong container, chỉ cần `X-Forwarded-*` đúng.
|
||||
2. **Auth/multi-user**: có. Đăng ký/đăng nhập email + mật khẩu, session cookie; **vẫn cho dùng demo** không cần tài khoản.
|
||||
3. **Batch export**: có, là công cụ add-on cho người dùng đã đăng nhập — nhiều ảnh dùng chung 1 recipe, render tuần tự có tiến trình.
|
||||
4. **Responsive**: hỗ trợ cả desktop và mobile browser.
|
||||
5. **EXIF GPS**: đọc GPS từ ảnh nạp vào để tự điền watermark; nếu ảnh không có GPS thì cho nhập tay (place name + toạ độ).
|
||||
6. **Review trên landing**: nội dung tĩnh trong repo (`web/src/content/reviews.json`) — sửa file + rebuild; không cần CRUD/admin ở v1.
|
||||
`ponytail:` seed tĩnh là đủ vì review đổi rất chậm; nâng lên bảng DB + admin API khi cần tự đăng review.
|
||||
7. **Theme**: nhiều bộ theme — `light` / `dark` + các nhóm màu accent (xem mục Theme).
|
||||
8. **Đa ngôn ngữ**: có — tiếng Việt + tiếng Anh ở v1 (xem mục i18n).
|
||||
|
||||
### Phân hạng tính năng
|
||||
| Hạng | Điều kiện | Quyền |
|
||||
|---|---|---|
|
||||
| Guest | không đăng nhập | Landing, xem review, **DEMO**: nạp 1 ảnh, dùng tool (cấu hình ở `web/src/config/tiers.ts`), export **tối đa 2048px + watermark "RecipesCam demo"**, không lưu recipe |
|
||||
| User | đã đăng nhập | Toàn bộ tool như app, export **full-res không watermark**, lưu/favorite/share recipe (đồng bộ server), **BATCH EXPORT** |
|
||||
|
||||
Bảng hạng để trong 1 file config duy nhất để đổi chính sách mà không sửa UI.
|
||||
|
||||
## Theme (light/dark + nhóm màu)
|
||||
- **Cơ chế**: CSS custom properties trên `:root`, chuyển bằng thuộc tính `data-theme` + `data-accent` trên `<html>`; mọi màu trong UI đi qua token, không hardcode. Đổi theme = set 2 attribute, không re-render React.
|
||||
- **Theme nền**: `light`, `dark` (mặc định theo `prefers-color-scheme`, người dùng chọn được thì ghi đè). Dark dùng nền đen thuần theo `web-design`.
|
||||
- **Nhóm màu accent**: `amber` (mặc định — đúng chất RecipesCam), `blue`, `emerald`, `rose`, `violet`. Accent chỉ đổi token `--accent*`, không đổi layout.
|
||||
- **Lưu**: `localStorage` (`theme`, `accent`); đồng bộ sang `<meta name="theme-color">` cho mobile browser.
|
||||
- **UI chọn**: popover trong header (icon bảng màu) trên cả landing lẫn workspace; landing cũng đổi theme được.
|
||||
- `ponytail:` 2 theme nền + 5 accent là cấu hình dữ liệu trong `web/src/theme/themes.ts`; thêm theme mới = thêm 1 object, không sửa component.
|
||||
|
||||
## Đa ngôn ngữ (i18n)
|
||||
- **Ngôn ngữ v1**: `vi` (mặc định theo `navigator.language` nếu là tiếng Việt) + `en`. Thêm ngôn ngữ = thêm 1 file từ điển.
|
||||
- **Cách làm**: tự viết, **không thêm thư viện** — từ điển `web/src/i18n/vi.ts` / `en.ts` (object lồng nhau, có kiểu từ `vi` để `en` thiếu key là lỗi typecheck), `t(key, params)` + `I18nProvider` giữ `lang` trong `localStorage`. Đổi ngôn ngữ = đổi state, không reload.
|
||||
- **Phạm vi dịch**: toàn bộ nhãn UI (rail tab, chip, slider, tooltip, nút, thông báo lỗi, landing/review/auth form), tiêu đề trang + SEO (`<html lang>`, OG locale).
|
||||
- **Không dịch**: tên film sim (`PROVIPES`, `LC STREETLIFE CLASSIC`…), tên recipe người dùng đặt, nội dung watermark người dùng nhập — là danh từ riêng/thương hiệu, giữ nguyên ở mọi ngôn ngữ.
|
||||
- `ponytail:` từ điển là file TS được bundle; chỉ tách lazy-load theo ngôn ngữ khi bundle vượt ~50KB text.
|
||||
|
||||
## Kiến trúc
|
||||
```
|
||||
docker-compose.yml
|
||||
├── web nginx:alpine 8090:80 static SPA (Vite dist) + proxy /api → api
|
||||
└── api node:20-alpine 3000 Fastify + better-sqlite3, volume ./data
|
||||
```
|
||||
- **Frontend**: Vite + React + TS + react-router (`/`, `/app`, `/login`, `/signup`).
|
||||
- **Engine ảnh**: **CanvasKit (canvaskit-wasm)** trong Web Worker — nhưng **bắt buộc trên surface WebGL** (`MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))`), không phải raster CPU: xem W0. Preview bản thu nhỏ (~2048px), render full-res khi export.
|
||||
- **API**: Fastify + better-sqlite3 (1 file DB, không cần container DB riêng). Session cookie `httpOnly` + `SameSite=Lax`, mật khẩu hash `argon2`. Bảng: `users`, `sessions`, `recipes`, `favorites`.
|
||||
- **Recipe bundle** lấy thẳng từ `src/utils/defaultRecipes.ts` (client-side) — server chỉ lưu recipe người dùng tạo.
|
||||
- Nginx Proxy Manager bên ngoài lo TLS/domain; container chỉ nghe HTTP.
|
||||
|
||||
### Vì sao CanvasKit
|
||||
Tái dùng gần như nguyên vẹn `src/utils/exportEngine.ts`: CanvasKit có `RuntimeEffect` (SkSL y nguyên `TONE_SKSL`, `CINEMA_SKSL`), `ColorFilter.MakeMatrix`, `ImageFilter.MakeBlur`/`MakeCompose`, `BlendMode.Overlay`, `drawImageRect`, `MakeFreeTypeFaceFromData`, `encodeToBytes(JPEG, 95)` → parity cao nhất với app, code ít nhất.
|
||||
Hai chỗ CanvasKit khác Skia JSI (đã xử lý ở W0): encode JPEG cần build `canvaskit-wasm/full`; không có `ImageFilter.MakeMatrixConvolution` → CLARITY/sharpening đi qua `CLARITY_SKSL`.
|
||||
**Không** dùng `react-native-web`/Expo web: app phụ thuộc `react-native-vision-camera` + Skia JSI, web sẽ vỡ.
|
||||
|
||||
## Tái sử dụng (không viết lại)
|
||||
| Nguồn trong repo | Dùng cho web |
|
||||
|---|---|
|
||||
| `src/utils/colorUtils.ts` | `getSkiaColorMatrix`, `applyExposureGain`, `kelvinToRGB` — nguyên văn |
|
||||
| `src/utils/toneShader.ts` | `TONE_SKSL` + `getToneUniforms` |
|
||||
| `src/utils/cinemaShader.ts` | `CINEMA_SKSL` + `getCinemaUniforms` |
|
||||
| `src/utils/frameUtils.ts` | `FRAMES`, `polaroidLayout`, `wallframeLayout`, `drawFrameOnCanvas`, hằng số POLAROID/WALLFRAME |
|
||||
| `src/utils/paramDefs.ts` | `PARAM_DEFS` (slider bounds/label/format) → render chip + slider |
|
||||
| `src/utils/defaultRecipes.ts` | `FILM_SIMS`, `DEFAULT_ADJUSTMENTS`, recipe bundle |
|
||||
| `src/utils/jpegDpi.ts` | `patchJpegDpi(bytes, 300)` — chạy trong worker |
|
||||
| `src/utils/recipeShare.ts` | IMPORT/EXPORT recipe chia sẻ giữa app ↔ web (cùng định dạng) |
|
||||
| `src/types/index.ts` | `ColorAdjustments`, `Recipe`, `FrameId`, `AspectRatio`, `CropRatio`, `CropRect`, `ASPECT_RATIO_W_H`, `CROP_W_H` |
|
||||
| `assets/Cousine-Regular.ttf`, `assets/NotoEmoji-GPS.ttf`, `wallframe.png` | font + artwork frame |
|
||||
|
||||
Viết mới: `web/src/engine/skiaShim.ts` (adapter API Skia trên CanvasKit) + `web/src/engine/exportEngine.ts` (port từ `src/utils/exportEngine.ts`, bỏ `expo-file-system`/MediaLibrary → Blob/download).
|
||||
|
||||
## Bản đồ UI app → web
|
||||
| Khu vực app | Web |
|
||||
|---|---|
|
||||
| `ToolRail` (rail ngang dưới màn hình) | Cột dọc bên trái, 6 tab; mobile: rail ngang dưới đáy |
|
||||
| `AdjustmentPanel` chip row + strip | Cột thứ 2 cạnh rail (~280–320px): chip của tab + strip/slider mở tại chỗ |
|
||||
| `Viewfinder` | Canvas giữa, nhận drag&drop file ảnh |
|
||||
| Drag-on-image để đổi slider | Giữ: kéo dọc trên ảnh = chỉnh param đang mở |
|
||||
| PEEK (giữ để so sánh) | Giữ chuột phải hoặc phím `\` |
|
||||
| `TopBar` | Header: tên recipe, UNDO/RESET, SAVE AS, EXPORT, IMPORT/EXPORT recipe, tài khoản, **đổi theme**, **đổi ngôn ngữ**; ẩn mục camera-only |
|
||||
| `PhotoViewerModal` | Strip thumbnail kết quả export ở đáy |
|
||||
| `SettingsModal` | Popover settings; bỏ mục camera-only; giữ PHOTO RATIO |
|
||||
|
||||
### Danh sách tool đầy đủ theo tab (lấy từ code hiện tại)
|
||||
- **PRESETS**: PHOTO STYLE (`provia`, `velvia`, `classic-chrome`, `classic-neg`, `astia`, `eterna`, `monochrome`, `leica`, `leica-vivid`) · RECIPES (bundle + user, có star) · CREATE · IMPORT.
|
||||
- **FAVORITED**: recipe đã star.
|
||||
- **LIGHT (iq)**: EXPOSURE · EV · CONTRAST · COLOR · VIBRANCE · HIGHLIGHT · SHADOW · D.RANGE (AUTO/DR100/200/400).
|
||||
- **WB**: TEMP (AUTO / DAYLIGHT / DAYLIGHT -3R / CLOUDY / SHADE / TUNGSTEN / FLUOR + slider COLOR TEMP) · TINT · COLOR CHROME · CHROME BLUE.
|
||||
- **FX (filters)**: NOISE REDUCTION · CLARITY · SHARPENING · MONOCHROME GRAIN · HDF EFFECT · VIGNETTING.
|
||||
- **FRAME**: NO FRAME / CLASSIC BORDER / RETRO INSTANT (polaroid) / WALL FRAME (+ WALL LANDSCAPE) · CROP (none/free/1:1/2:3/3:2/3:4/4:3/16:9 + APPLY) · ROTATE (0/90/180/270 + STRAIGHTEN + AUTO STRAIGHTEN) · WATERMARK (GPS WATERMARK ON/OFF, PLACE NAME, TIME, TEXT COLOR/SIZE/FONT/ROTATE, CUSTOM WATERMARK) · PHOTO RATIO (FULL/4:3/3:2).
|
||||
|
||||
### Metadata khi export — phải khớp app (300 ppi + EXIF)
|
||||
Đường export mặc định của app (`src/utils/exportEngine.ts`, tức `EXPO_PUBLIC_NATIVE_EXPORT != 1`) ghi JPEG đúng 3 bước, **theo thứ tự này**:
|
||||
|
||||
1. `encodeToBytes(JPEG, 95)` — Skia re-encode **làm mất toàn bộ EXIF nguồn**.
|
||||
2. `patchJpegDpi(bytes, 300)` — JFIF density `units=1 X=Y=300` (kèm `XResolution`/`YResolution` nếu JPEG có EXIF).
|
||||
3. `writeJpegExif(bytes, stamp, sourceBytes)` — dựng lại APP1/EXIF: **copy từ ảnh nguồn mọi tag app không sở hữu** (hãng máy, model, lens, phơi sáng, ISO…), **bỏ IFD1 thumbnail + MakerNote**, rồi đóng dấu `Software = "RecipesCam 1.2"`, `DateTimeOriginal`, GPS, tên địa điểm (0x889d), blob thiết bị (0x889e), kích thước pixel, dpi.
|
||||
|
||||
Web phải làm y hệt, cùng thứ tự, **giữ nguyên kích thước pixel và 300 ppi**:
|
||||
- `src/utils/exifWrite.ts` và `src/utils/jpegDpi.ts` **không có import nào** → tái dùng/port **nguyên văn**, chạy trên `Uint8Array` trong worker; web đã có sẵn bytes ảnh nguồn nên không cần `readPhotoBytes`.
|
||||
- Chỉ `src/utils/photoMeta.ts` mới dính Expo (`getDeviceIdentity`) → web truyền `device` từ nguồn khác hoặc để `null` (mọi field của `ExifStamp` đều optional).
|
||||
- Trạng thái hiện tại: `web/src/engine/exportEngine.ts` mới có bước 1+2 (đã kiểm chứng: JFIF `x=300 y=300`) — **bước 3 chưa port** → làm ở W4. Nhớ `patchJpegDpi` phải chạy **trước** `writeJpegExif`, và truyền `dpi` vào stamp để `XResolution`/`YResolution` khớp JFIF.
|
||||
|
||||
### Khác biệt web (bắt buộc xử lý)
|
||||
- **GPS watermark**: đọc GPS từ EXIF ảnh nạp vào (`exifr`); ảnh không có GPS → cho nhập tay place name + toạ độ. Reverse-geocode tên địa điểm là tuỳ chọn (gọi Nominatim), mặc định tắt. Cùng GPS đó cũng đi vào EXIF xuất ra (bước 3 ở trên), không chỉ vào watermark.
|
||||
- **Crop/rotate/straighten**: thao tác chuột/chạm trực tiếp trên canvas.
|
||||
- **Export**: CanvasKit `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` → tải `recipescam_<tên>_<timestamp>.jpg`; batch → ZIP (`fflate`, ~8KB) hoặc tải lần lượt. Không re-encode lần hai, không đổi kích thước pixel.
|
||||
- **Trạng thái**: `localStorage` thay `AsyncStorage`; recipe đã lưu đồng bộ server khi đã đăng nhập.
|
||||
|
||||
## Cấu trúc thư mục đề xuất
|
||||
```
|
||||
web/
|
||||
Dockerfile nginx.conf docker-compose.yml
|
||||
package.json vite.config.ts tsconfig.json index.html
|
||||
api/ # Fastify + better-sqlite3 (build riêng, chạy trong container api)
|
||||
server.ts auth.ts recipes.ts db.ts Dockerfile
|
||||
src/
|
||||
main.tsx App.tsx router.tsx config/tiers.ts config/storeLinks.ts
|
||||
content/reviews.json content/features.json
|
||||
theme/ (tokens.css, themes.ts, ThemeProvider.tsx, ThemePopover.tsx)
|
||||
i18n/ (vi.ts, en.ts, I18nProvider.tsx, t.ts)
|
||||
pages/ (Landing, Login, Signup, Workspace)
|
||||
ui/ (ToolRail, ChipColumn, ImageStage, SliderRow, StripRow, ThumbStrip, SettingsPopover, BatchPanel, AccountMenu)
|
||||
engine/ (skiaShim.ts, exportEngine.ts, worker.ts, previewEngine.ts, exifGps.ts)
|
||||
state/ (recipeStore.ts, authStore.ts)
|
||||
assets/ (fonts, wallframe.png — import từ ../assets)
|
||||
```
|
||||
Docker build context = repo root (cần `src/utils`, `assets/`), `-f web/Dockerfile`.
|
||||
|
||||
## Phases
|
||||
|
||||
### W0 — Spike CanvasKit (parity gate) ← đã chạy, còn cổng parity
|
||||
Chuỗi 1 ảnh: decode → crop → matrix → tone → cinema → denoise/clarity → HDF → grain → vignette → frame → watermark → JPEG 95 + DPI 300.
|
||||
Exit: chạy trong Node trên ảnh mẫu; so sánh với ảnh export từ app (meanAbs/gt12 như probe P6); đo thời gian 12MP; xác nhận FreeType font `Cousine` + `NotoEmoji-GPS` render (emoji không ra → fallback Canvas2D overlay).
|
||||
Rủi ro lớn nhất của cả kế hoạch → làm trước.
|
||||
|
||||
#### Kết quả đo được (2026-09-06, spike trong `web/spike/`)
|
||||
Bản port đã chạy hết chuỗi trên ảnh 12MP và ra JPEG; 4 phát hiện làm đổi kế hoạch:
|
||||
|
||||
1. **JPEG encode**: build `canvaskit-wasm` mặc định **không** encode được JPEG (`encodeToBytes(JPEG)` → null; PNG vẫn được). → dùng build `canvaskit-wasm/full` (8.2MB wasm so với 7.3MB). Shim import `canvaskit-wasm/full`.
|
||||
2. **`ImageFilter.MakeMatrixConvolution` không tồn tại** trong CanvasKit (chuỗi wasm có nhưng không có binding JS). → thay bằng `CLARITY_SKSL` (lấy từ `src/utils/toneShader.ts`, vốn đã dùng cho preview RN) chạy như shader pass, `px=(1,1)` ở tỉ lệ 1:1. Không phải viết thuật toán mới.
|
||||
3. **Font ✓**: `Typeface.MakeFreeTypeFaceFromData` + `Canvas.drawText` chạy. `Cousine-Regular.ttf` **thiếu** 📍/📷 (glyphID 0) nhưng `NotoEmoji-GPS.ttf` có (glyphID 1,2) và vẽ ra 1731 pixel → **không cần fallback Canvas2D emoji**.
|
||||
4. **Raster CPU không dùng được, buộc phải render trên surface WebGL**. Đo trên 4000×3000 (`spike/bench.ts`, SkVM interpreter):
|
||||
|
||||
| Bước | 4000×3000 | 2048 |
|
||||
|---|---|---|
|
||||
| draw | 5ms | — |
|
||||
| tone SkSL pass | **3205ms** | 836ms |
|
||||
| clarity SkSL 5-tap | **13745ms** | 3581ms |
|
||||
| blur σ=40 | **3024ms** | — |
|
||||
| JPEG q95 | 246ms | 54ms |
|
||||
| decode | 233ms | — |
|
||||
|
||||
Cùng pipeline trên surface WebGL (`spike/gpu.html`, headless Chromium, ANGLE/SwiftShader — **software GL**, GPU thật sẽ nhanh hơn hoặc bằng):
|
||||
- `MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))` OK, tạo surface 15–22ms.
|
||||
- tone / clarity / blur pass: **0.1–1ms** (submit bất đồng bộ).
|
||||
- **JPEG q95: 205ms @12MP, 54ms @2048, 14ms @1024**; decode 12MP 280ms.
|
||||
|
||||
Đầu ra kiểm chứng trên ảnh 12MP (raster CPU, `spike/w0.ts`): 3 file JPEG 4.1–5.1MB, JFIF `units=1 x=300 y=300` (DPI 300 đúng), byte-mean ~125 → **ảnh không trắng, pipeline chạy hết chuỗi**.
|
||||
|
||||
→ Chốt: engine **bắt buộc** chạy trên WebGL surface (`MakeWebGLCanvasSurface`), raster chỉ là fallback cho test Node. Preview ~2048px, full-res khi export.
|
||||
|
||||
Các API khác đã xác nhận có trong CanvasKit: `MakeCompose`, `MakeBlur`, `MakeRadialGradient`, `RuntimeEffect.Make`, `makeShaderWithChildren`, `MakeSurface`, `Font`, `TypefaceFontProvider`, `MakeOffscreen`, `MakeWebGLCanvasSurface`.
|
||||
Gotcha khi viết shim: `new CK.Paint()` (không phải `CK.Paint()`); `img.readPixels(srcX, srcY, imageInfo)`; `CK.ImageFormat` là enum object; phải `flush(surface)` trước mỗi snapshot.
|
||||
|
||||
**Còn lại của W0 (chặn parity)**: cần **1 cặp ảnh mẫu từ người dùng** — ảnh gốc + đúng ảnh app đã export từ ảnh đó — để chạy diff meanAbs/gt12 như P6. Cặp `app.jpg`/`native.jpg` đang có trong scratchpad không rõ nguồn nên không dùng làm chuẩn được. Sau đó chạy lại 1 lần end-to-end trên WebGL để lấy số 12MP thật trong browser.
|
||||
|
||||
### W1 — Scaffold + layout + theme + i18n + nạp ảnh + responsive
|
||||
Vite/React/TS/router; 3 vùng desktop / 1 cột mobile; rail 6 tab; chip column rỗng; canvas giữa; nạp ảnh bằng drop/chọn file/paste; preview downscale 2048px (worker); zoom/pan.
|
||||
Theme: `tokens.css` (light/dark + 5 accent), `ThemeProvider`, popover chọn theme; i18n: `I18nProvider` + từ điển vi/en + nút đổi ngôn ngữ, mọi nhãn đi qua `t()`.
|
||||
Exit: thả ảnh từ desktop → hiển thị; đổi theme/ngôn ngữ tức thì và giữ sau reload; `npm run build` + typecheck sạch; mở đúng trên khổ mobile.
|
||||
|
||||
### W2 — Tool phi cấu trúc ảnh
|
||||
PRESETS, FAVORITED, LIGHT, WB, FX; slider/chip/strip (một row mở tại thời điểm); undo/reset; PEEK; lưu localStorage. Từ điển vi/en phủ hết nhãn của các tab này.
|
||||
Exit: mọi tham số đổi tức thì trên preview; reload giữ trạng thái; không còn chuỗi tiếng Anh hardcode ngoài tên film sim.
|
||||
|
||||
### W3 — FRAME / CROP / ROTATE / WATERMARK / RATIO + EXIF GPS
|
||||
4 frame + wall landscape; free crop kéo rect; rotate/straighten/auto; watermark GPS (EXIF + nhập tay) + custom text; photo ratio.
|
||||
Exit: preview đúng layout từng frame; ảnh có GPS tự điền, ảnh không GPS cho nhập tay.
|
||||
|
||||
### W4 — EXPORT / RENDER full-res
|
||||
Worker render full-res + tiến trình + hủy; `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` (port `src/utils/exifWrite.ts`, giữ tag ảnh nguồn, bỏ IFD1/MakerNote, stamp software/date/GPS/dims/dpi); download; thumbnail strip.
|
||||
Exit: file tải về đúng **300 ppi** (`JFIF units=1 X=Y=300`) và **giữ EXIF như app** (camera/lens/ISO của ảnh nguồn + tag app tự ghi); kích thước pixel khớp app với cùng input; cùng ảnh + cùng recipe → EXIF web và app giống nhau từng tag app sở hữu.
|
||||
|
||||
### W5 — API + auth
|
||||
Fastify + SQLite: `POST /api/auth/signup|login|logout`, `GET /api/auth/me`, CRUD `/api/recipes`; session cookie; trang Login/Signup; đồng bộ recipe + favorite; chế độ **demo** (không cần tài khoản, giới hạn theo `tiers.ts`).
|
||||
Exit: đăng ký → đăng nhập → lưu recipe → đăng nhập lại ở máy khác thấy recipe; demo chạy không cần tài khoản và bị giới hạn đúng cấu hình.
|
||||
|
||||
### W6 — Landing page
|
||||
Hero, tính năng, before/after, review (`reviews.json`), badge Google Play/App Store (`storeLinks.ts`), CTA demo + đăng ký; responsive; SEO cơ bản (`<title>`, OG tags, `<html lang>` theo ngôn ngữ đang chọn); landing cũng đổi được theme + ngôn ngữ.
|
||||
Exit: trang mở đẹp ở cả 360px và 1440px ở cả 2 theme; link store + CTA đúng đích; nội dung landing có đủ vi/en.
|
||||
|
||||
### W7 — BATCH EXPORT (add-on)
|
||||
Panel riêng: nạp nhiều ảnh, chọn recipe (bundle hoặc của người dùng), hàng đợi render tuần tự có tiến trình, tải từng file hoặc ZIP. Yêu cầu đăng nhập.
|
||||
Exit: 10 ảnh + 1 recipe → 10 file cùng tông màu, không tràn RAM.
|
||||
|
||||
### W8 — Parity & perf
|
||||
Chạy cùng bộ ảnh trên app (native/Skia) và web; so diff + mắt; tối ưu (worker pool, preview cache, dùng lại bitmap khi export).
|
||||
Exit: diff trong ngưỡng đã chấp nhận ở P6; render 12MP web < ~3s.
|
||||
|
||||
### W9 — Docker + deploy + docs
|
||||
`docker compose up -d --build`; kiểm tra qua `IP:8090`; hướng dẫn gắn Nginx Proxy Manager (domain, TLS, `X-Forwarded-*`); `docker/README.md` (RUNBOOK: port, domain, backup `./data`, đổi `TIER` config); commit + push.
|
||||
Exit: mở webUI từ máy khác trong LAN; đăng ký/đăng nhập/demo/export chạy; container tự lên lại sau reboot.
|
||||
|
||||
**Trạng thái**: `docker/` đã tự chứa và chạy được — `docker/docker-compose.yml` (service `frontend` + `api`), `docker/backend/` (Fastify + SQLite), `docker/frontend/` (Vite + CanvasKit + nginx). Chạy `docker compose up -d --build` trong `docker/`, UI ở `:8090`; đã kiểm chứng health/signup/login/recipes và export ảnh thật end-to-end trong Chromium headless. Còn lại: gắn domain/TLS qua Nginx Proxy Manager, cập nhật `README.md` gốc.
|
||||
|
||||
## Rủi ro
|
||||
- ~~**CanvasKit không có emoji màu**~~ → **đã loại ở W0**: `NotoEmoji-GPS.ttf` cung cấp đủ 📍/📷, không cần fallback Canvas2D.
|
||||
- ~~**Skia API shim** (`MakeMatrixConvolution` khác chữ ký)~~ → **đã loại ở W0**: không tồn tại trong CanvasKit, thay bằng `CLARITY_SKSL`.
|
||||
- ~~**Hiệu năng**: raster CPU chậm gấp ~1000 lần WebGL~~ → **đã chốt**: engine dùng GPU surface, nhưng **một `GrDirectContext` dùng chung** + `MakeRenderTarget` cho mọi surface. `MakeWebGLCanvasSurface` gọi nhiều lần tạo nhiều WebGL context riêng → snapshot của surface này vẽ sang surface kia ra **đen thui** (đo được: 12MP JPEG + output sharpen, GL mean 0.0 vs raster 107.0). Không có WebGL thì fallback raster `MakeSurface` — đúng pipeline, chỉ chậm hơn.
|
||||
- **wasm ~8MB** (build `full`, gzip ~3MB): lazy-load, nginx cache dài hạn.
|
||||
- **RAM 12MP** trong browser (bitmap ~48MB + surface) — giới hạn worker pool 1–2, giải phóng bitmap sau export; batch render tuần tự, không song song.
|
||||
- **HDF/vignette** dùng gradient + blend — kiểm tra tương đương CanvasKit.
|
||||
- Không xoá/đổi code app RN: web chỉ **đọc** `src/utils/*` (nếu phải sửa util để dùng chung → giữ tương thích app, typecheck lại).
|
||||
- Import TS ngoài root `web/`: cấu hình `server.fs.allow` + alias; Docker context phải là repo root.
|
||||
- **Parity chưa chốt**: thiếu cặp ảnh gốc + ảnh app export cùng nguồn (xem cuối W0) → rủi ro phát hiện lệch màu muộn ở W8.
|
||||
- **Metadata**: engine web **phải** giữ đúng 300 ppi và dựng lại EXIF như app; quên bước `writeJpegExif` thì ảnh vẫn ra nhưng mất camera/lens/ISO/ngày chụp.
|
||||
- **Hai đường export của app không đồng nhất**: đường Kotlin native (`RecipescamExportModule.kt`, `Bitmap.compress(JPEG)`) **chỉ** gọi `patchJpegDpi`, **không** dựng lại EXIF → nếu P6 flip default sang native thì app cũng sẽ mất metadata. Cần chốt: port `writeJpegExif` sang Kotlin, hoặc giữ đường Skia làm mặc định. Không ảnh hưởng web, nhưng ảnh hưởng mốc "khớp app".
|
||||
- **Giá trị cần xác nhận khi làm W6**: URL Google Play / App Store thật (đang để placeholder trong `storeLinks.ts`), tên miền cuối cùng gắn qua Nginx Proxy Manager.
|
||||
|
||||
## Checklist
|
||||
- [x] Tạo branch `recipes-web`
|
||||
- [x] Chốt yêu cầu (port, auth, batch, responsive, EXIF GPS)
|
||||
- [x] Bổ sung theme (light/dark + accent) + i18n (vi/en) vào kế hoạch
|
||||
- [x] Chốt metadata export: 300 ppi (`patchJpegDpi`) + dựng lại EXIF (`writeJpegExif`), giữ nguyên kích thước pixel
|
||||
- [ ] W0 spike CanvasKit — pipeline ✓, font ✓, build `full` ✓, WebGL ✓; **còn**: cặp ảnh parity từ người dùng + số end-to-end trên WebGL
|
||||
- [ ] W1 scaffold + drag&drop + responsive
|
||||
- [ ] W2 tool phi cấu trúc ảnh
|
||||
- [ ] W3 frame/crop/rotate/watermark/ratio + EXIF GPS
|
||||
- [ ] W4 export/render full-res
|
||||
- [ ] W5 API + auth + demo
|
||||
- [ ] W6 landing page
|
||||
- [ ] W7 batch export
|
||||
- [ ] W8 parity & perf
|
||||
- [x] `docker/` tự chứa + chạy được (code FE/BE nằm trong `docker/`, compose + Dockerfile + `.env.example` + README) — đã verify end-to-end
|
||||
- [ ] W9 Docker + deploy + docs
|
||||
@@ -92,8 +92,8 @@ android {
|
||||
applicationId 'com.locphamtran.recipescamera'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 9
|
||||
versionName "1.2.7"
|
||||
versionCode 5
|
||||
versionName "1.2.3"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "RecipesCam",
|
||||
"slug": "recipescamera",
|
||||
"version": "1.2.7",
|
||||
"version": "1.2.3",
|
||||
"orientation": "portrait",
|
||||
"icon": "./RecipesCamIcon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
@@ -49,12 +49,6 @@
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
|
||||
}
|
||||
],
|
||||
"expo-sharing"
|
||||
],
|
||||
"extra": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Host port the web UI is published on. Everything else is internal: the API is
|
||||
# only reachable through nginx at /api/.
|
||||
WEB_PORT=8090
|
||||
|
||||
# Comma-separated emails allowed to moderate the landing strip (/admin).
|
||||
# Leave empty to make nobody an admin.
|
||||
ADMIN_EMAILS=
|
||||
|
||||
# The mail relay that sends the address-verification mail — the 6-digit code
|
||||
# plus the link. A new account is a guest until it proves the address with one
|
||||
# of the two, so a deployment without a relay can only ever hand out guest
|
||||
# access.
|
||||
#
|
||||
# Leave SMTP_HOST empty and both the code and the link are written to the api
|
||||
# container's log instead (`docker compose logs api`), which is enough for local
|
||||
# work.
|
||||
SMTP_HOST=
|
||||
# 587 upgrades to TLS (STARTTLS); 465 is TLS from the first byte. Set
|
||||
# SMTP_SECURE=true to force the latter on an unusual port.
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
# What the mail says it is from. Defaults to SMTP_USER, then a noreply address.
|
||||
SMTP_FROM=
|
||||
SMTP_SECURE=
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
data/
|
||||
@@ -0,0 +1,91 @@
|
||||
# RecipesCam web — self-contained stack
|
||||
|
||||
A Docker-hosted web build of RecipesCam. Everything it needs is in this folder:
|
||||
move it to another machine, run two commands, and the app is up. It does **not**
|
||||
need the React Native project around it.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
# → http://localhost:8090
|
||||
```
|
||||
|
||||
## What runs where
|
||||
|
||||
| Service | Image | Role |
|
||||
| --- | --- | --- |
|
||||
| `frontend` | `nginx:1.27-alpine` (built by `frontend/Dockerfile`) | Static SPA + `/api/` reverse proxy |
|
||||
| `api` | `node:22-slim` (built by `backend/Dockerfile`) | Accounts + saved recipes, SQLite on `./data` |
|
||||
|
||||
`frontend` resolves `api` through Docker's embedded DNS and proxies `/api/*` to
|
||||
it — that is why the API container is named `api` and why it is not published on
|
||||
the host. Only `${WEB_PORT:-8090}` is exposed.
|
||||
|
||||
Photos never leave the browser. The CanvasKit render pipeline (grade, frame,
|
||||
watermarks, JPEG encode) runs in the visitor's tab; the API only stores recipes
|
||||
as JSON.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
docker-compose.yml the stack
|
||||
.env.example WEB_PORT
|
||||
data/ SQLite (created on first run, gitignored)
|
||||
backend/ Fastify + better-sqlite3 API, own Dockerfile
|
||||
frontend/ Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf
|
||||
shared/ vendored copies of the app's types + utils (see below)
|
||||
src/engine/ skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline)
|
||||
+ session.ts (localStorage/IndexedDB studio persistence)
|
||||
```
|
||||
|
||||
### Vendored files
|
||||
|
||||
`frontend/shared/{types/index.ts,utils/*.ts}` are byte-identical copies of
|
||||
`src/types/index.ts` and ten `src/utils/*.ts` files from the React Native
|
||||
project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in
|
||||
`vite.config.ts` + `tsconfig.json`, so those files compile unchanged):
|
||||
|
||||
`cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi`
|
||||
`paramDefs` `recipeShare` `skiaImage` `toneShader`.
|
||||
|
||||
The landing page needs no CDN: `frontend/public/assets/fonts/*.woff2` are the
|
||||
seven self-hosted faces behind the three font groups the Themes menu offers
|
||||
(Plus Jakarta Sans / Inter / JetBrains Mono, Fraunces / Be Vietnam Pro /
|
||||
Courier Prime, Be Vietnam Pro / Space Mono — all SIL OFL, pulled from Google
|
||||
Fonts, vietnamese + latin + latin-ext subsets), and
|
||||
`frontend/public/assets/samples/s*.jpg` are the six placeholder negatives the
|
||||
film strip, preset tester and QR card show (swap them for real graded stills
|
||||
whenever we have them). Its one foreign request is the QR image from
|
||||
`api.qrserver.com`, which degrades to an empty slot offline.
|
||||
|
||||
When the app changes one of them, copy it back in — the renderer is only
|
||||
"parity" for as long as these stay in sync:
|
||||
|
||||
```bash
|
||||
cd <repo>/docker/frontend/shared/utils
|
||||
cp <repo>/src/utils/<name>.ts .
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
```bash
|
||||
docker compose logs -f api # API log
|
||||
docker compose restart api # after backend/src changes (rebuild: --build)
|
||||
docker compose down # stop; ./data survives
|
||||
```
|
||||
|
||||
Backup is the `./data` folder — that is the whole database.
|
||||
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8090/api/health # {"ok":true}
|
||||
curl -sI http://localhost:8090/ # 200, index.html
|
||||
```
|
||||
|
||||
Then open the UI, drop a photo in, and confirm the preview shows the picture and
|
||||
EXPORT downloads a JPEG that opens. The preview going solid black while the
|
||||
export still reports a plausible size is the one failure mode worth knowing: it
|
||||
means the CanvasKit GPU surfaces lost their shared `GrDirectContext` (see
|
||||
`frontend/src/engine/skiaShim.ts`), and with no GPU the raster fallback renders
|
||||
the same pipeline correctly, just slower.
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,20 @@
|
||||
# RecipesCam API — self-contained build (context = this folder).
|
||||
# Builder compiles TypeScript; runtime carries production deps only.
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:22-slim
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
DATA_DIR=/data
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY package.json ./
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/server.js"]
|
||||
Generated
+1674
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "recipescam-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "RecipesCam web API - accounts + user recipes (server never receives photos)",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"test": "node test/security.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"fastify": "^5.12.5",
|
||||
"nodemailer": "^7.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
|
||||
// The API sends exactly one kind of mail: the link that proves an address. The
|
||||
// relay is declared in the deployment's .env, because a mail server is
|
||||
// infrastructure, not a constant.
|
||||
//
|
||||
// With no SMTP_HOST there is nothing to connect to, so the link is written to
|
||||
// the log instead. That keeps a dev box — or this repo's own test suite — able
|
||||
// to finish a signup without a mail server, and whoever reads the log is
|
||||
// already the person running the database.
|
||||
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim();
|
||||
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
|
||||
const SMTP_USER = (process.env.SMTP_USER ?? '').trim();
|
||||
const SMTP_PASS = process.env.SMTP_PASS ?? '';
|
||||
const SMTP_FROM = (process.env.SMTP_FROM ?? '').trim() || SMTP_USER || 'no-reply@recipescam.local';
|
||||
// 465 is TLS from the first byte; 587 starts in the clear and upgrades. Only a
|
||||
// port the operator actually chose should be second-guessed.
|
||||
const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465;
|
||||
|
||||
export const mailConfigured = SMTP_HOST !== '';
|
||||
|
||||
const transporter: Transporter | null = mailConfigured
|
||||
? nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_SECURE,
|
||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||||
// A relay that never answers must not hold a request open.
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
socketTimeout: 20_000,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Both languages, because the account's language is not known before it exists.
|
||||
// The code comes first and the link second: the code is the one that works
|
||||
// without leaving the page the visitor signed up on, so it is what most of them
|
||||
// will use — but the link stays, for the phone that owns the address, and
|
||||
// because a letter that only carries digits is a letter a spam filter can
|
||||
// decide is a phishing attempt.
|
||||
const body = (url: string, code: string) =>
|
||||
[
|
||||
'RecipesCam — xác thực địa chỉ email / verify your email address',
|
||||
'',
|
||||
`Mã xác thực của bạn / your verification code: ${code}`,
|
||||
'',
|
||||
'Mã có hiệu lực 15 phút. Nhập mã vào trang đăng ký để bật các tính năng PRO.',
|
||||
'The code is valid for 15 minutes. Type it into the signup page to unlock PRO.',
|
||||
'',
|
||||
'Hoặc mở liên kết này / or open this link (hiệu lực 24 giờ / valid 24 hours):',
|
||||
url,
|
||||
'',
|
||||
'Nếu bạn không đăng ký, hãy bỏ qua thư này. / If you did not sign up, ignore this mail.',
|
||||
].join('\r\n');
|
||||
|
||||
// Fire and forget: the account already exists, so a relay that is slow, out of
|
||||
// quota or misconfigured may not fail the signup that asked for it. The owner
|
||||
// can ask for another link from the studio; the operator sees the error here.
|
||||
export function sendVerificationMail(to: string, url: string, code: string, log: (msg: string) => void): void {
|
||||
if (!transporter) {
|
||||
log(`[verify] SMTP not configured — code ${code} and verification link for ${to}: ${url}`);
|
||||
return;
|
||||
}
|
||||
transporter
|
||||
.sendMail({ from: SMTP_FROM, to, subject: `RecipesCam — ${code} is your verification code`, text: body(url, code) })
|
||||
.then(() => log(`[verify] code and link sent to ${to}`))
|
||||
.catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Place names for a coordinate. The phone app asks the OS geocoder, and a
|
||||
// browser has no twin of that, so the lookup lives here: one HTTP call behind a
|
||||
// cache, and the same naming rule the app uses, so a stamp reads the same on
|
||||
// both.
|
||||
import { findPlace, savePlace } from './db';
|
||||
|
||||
// ~11m at the equator: fixes from the same spot share a cache row.
|
||||
const key = (lat: number, lng: number) => `${lat.toFixed(4)},${lng.toFixed(4)}`;
|
||||
|
||||
// Nominatim (OpenStreetMap). Its usage policy asks for one request per second,
|
||||
// a caller that identifies itself, and answers that are kept — hence the queue
|
||||
// below and the SQLite row every answer leaves behind.
|
||||
const ENDPOINT = 'https://nominatim.openstreetmap.org/reverse';
|
||||
const AGENT = 'RecipesCam/1.2 (https://recipescam.labz.io.vn)';
|
||||
const GAP_MS = 1100;
|
||||
const TIMEOUT_MS = 8000;
|
||||
|
||||
// One lookup at a time, never closer together than GAP_MS.
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
// Android answers with the commune in `subregion` and the city in `region`;
|
||||
// Nominatim says the same thing with `suburb` and `city`. Same rule as the app's
|
||||
// localityName: "COMMUNE, CITY", and a name that fills both slots prints once.
|
||||
export function nameOf(address: Record<string, string | undefined>): string | null {
|
||||
const district =
|
||||
address.suburb || address.city_district || address.district || address.quarter || address.neighbourhood || address.county;
|
||||
const city = address.city || address.town || address.municipality || address.village || address.state;
|
||||
const parts = district && district !== city ? [district, city] : [district || city];
|
||||
const name = parts.filter(Boolean).join(', ');
|
||||
return name ? name.toUpperCase() : null;
|
||||
}
|
||||
|
||||
export async function placeName(lat: number, lng: number): Promise<string | null> {
|
||||
const cached = findPlace(key(lat, lng));
|
||||
if (cached !== undefined) return cached || null;
|
||||
|
||||
const call = queue.then(async () => {
|
||||
await new Promise((r) => setTimeout(r, GAP_MS));
|
||||
const url = `${ENDPOINT}?format=jsonv2&zoom=14&addressdetails=1&lat=${lat}&lon=${lng}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'user-agent': AGENT, accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`geocoder HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { address?: Record<string, string> };
|
||||
return body.address ? nameOf(body.address) : null;
|
||||
});
|
||||
// The queue must not inherit a rejection, or every later lookup would fail
|
||||
// with it; the caller gets the failure through `call` itself.
|
||||
queue = call.catch(() => undefined);
|
||||
|
||||
try {
|
||||
const name = await call;
|
||||
// Only an answer that came back is worth keeping — a timeout or an outage
|
||||
// must not pin "no name here" on the coordinate for good.
|
||||
savePlace(key(lat, lng), name ?? '');
|
||||
return name;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// The `.recipe` file the app writes on EXPORT and reads back on IMPORT. It is a
|
||||
// small XML envelope around a scrambled hex payload, and the landing page's QR
|
||||
// card hands a photo's look back as exactly this file, so the server has to
|
||||
// write the app's own format. The source of truth is the web app's
|
||||
// `shared/utils/recipeShare.ts`; this module and the phone app's
|
||||
// `src/utils/recipeShare.ts` are its copies, and `test/security.mjs` pins the
|
||||
// envelope's shape. Change all three together, or the QR stops importing.
|
||||
//
|
||||
// ponytail: obfuscation, not cryptography — the key ships inside the app. Same
|
||||
// caveat as the app's copy: swap in a real cipher at these two functions if the
|
||||
// envelope ever has to resist a determined reader.
|
||||
const KEY = 'RecipesCam::recipe-share::v1';
|
||||
const ALGORITHM = 'xor16-v1';
|
||||
|
||||
const hash = (s: string): number => {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
};
|
||||
|
||||
// xorshift32: one 16-bit word of keystream per call.
|
||||
const stream = (seed: number) => {
|
||||
let s = seed >>> 0 || 0x9e3779b9;
|
||||
return () => {
|
||||
s ^= s << 13;
|
||||
s >>>= 0;
|
||||
s ^= s >>> 17;
|
||||
s ^= s << 5;
|
||||
s >>>= 0;
|
||||
return s & 0xffff;
|
||||
};
|
||||
};
|
||||
|
||||
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
|
||||
// with Vietnamese diacritics included) survives the round trip.
|
||||
const scramble = (text: string, salt: number): string => {
|
||||
const k = stream(hash(`${KEY}|${salt}`));
|
||||
let out = '';
|
||||
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
|
||||
return out;
|
||||
};
|
||||
|
||||
// The recipe as the app stored it, wrapped in the envelope. The payload is the
|
||||
// stored JSON whole: the parser on the other side keeps the fields it knows and
|
||||
// drops the rest, so this side needs no opinion about what a recipe contains.
|
||||
export function recipeFile(recipeJson: string): string {
|
||||
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
|
||||
` <payload>${scramble(recipeJson, salt)}</payload>`,
|
||||
'</recipescam-recipe>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,740 @@
|
||||
// Security self-check for the API. Boots the real server against a throwaway
|
||||
// DATA_DIR and exercises the boundaries that matter: who may write, what may be
|
||||
// written, who may read a photo back, and who may moderate the strip.
|
||||
//
|
||||
// npm test (from docker/backend/)
|
||||
//
|
||||
// Node only — no test framework, no network beyond loopback.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PORT = Number(process.env.TEST_PORT || 3411);
|
||||
const BASE = `http://127.0.0.1:${PORT}/api`;
|
||||
const ADMIN_EMAIL = 'admin@test.local';
|
||||
const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-sec-'));
|
||||
|
||||
// A 1x1 PNG, and the first bytes of a JPEG (all the sniffer looks at).
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const JPEG_HEAD = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64)]);
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(`PASS ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(`FAIL ${name}${detail ? ` :: ${detail}` : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
// One cookie jar per actor, so "signed in as A" cannot leak into B.
|
||||
function actor() {
|
||||
let cookie = '';
|
||||
return {
|
||||
get cookie() {
|
||||
return cookie;
|
||||
},
|
||||
async req(path, init = {}) {
|
||||
const headers = { ...(init.headers ?? {}) };
|
||||
if (cookie) headers.cookie = cookie;
|
||||
const res = await fetch(BASE + path, { ...init, headers });
|
||||
const set = res.headers.getSetCookie?.() ?? (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||
for (const line of set) {
|
||||
const value = line.split(';')[0];
|
||||
if (value.startsWith('rc_session=')) cookie = value.endsWith('=') ? '' : value;
|
||||
}
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
return { status: res.status, headers: res.headers, setCookie: set.join(' | '), body };
|
||||
},
|
||||
signup(email, password = 'supersecret1') {
|
||||
return this.req('/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
},
|
||||
upload(bytes, type) {
|
||||
return this.req('/photos', { method: 'POST', headers: { 'content-type': type }, body: bytes });
|
||||
},
|
||||
avatar(bytes, type) {
|
||||
return this.req('/auth/avatar', { method: 'POST', headers: { 'content-type': type }, body: bytes });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A fresh signup proves nothing until the address it gave is confirmed: an
|
||||
// unverified account is served at the guest tier (see the PRO gate below). The
|
||||
// suite cannot read the mail, but the token is in the throwaway database and
|
||||
// the link is the API's own route, so it is followed here for the accounts that
|
||||
// are exercising something other than the gate.
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
function tokenFor(email) {
|
||||
const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT token FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)')
|
||||
.get(email);
|
||||
db.close();
|
||||
return row?.token;
|
||||
}
|
||||
async function followVerifyLink(email) {
|
||||
const res = await fetch(`${BASE}/auth/verify?token=${tokenFor(email)}`, { redirect: 'manual' });
|
||||
if (res.status !== 303) throw new Error(`verify link for ${email} answered ${res.status}`);
|
||||
}
|
||||
async function activeSignup(a, email) {
|
||||
const res = await a.signup(email);
|
||||
await followVerifyLink(email);
|
||||
return res;
|
||||
}
|
||||
// The same letter carries a code, and the suite reaches it the same way — the
|
||||
// database is the only reader of the mail here.
|
||||
function codeFor(email) {
|
||||
const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT code FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)')
|
||||
.get(email);
|
||||
db.close();
|
||||
return row?.code;
|
||||
}
|
||||
// The code's own clock starts at `created_at`, so an old one is made here
|
||||
// rather than waited for.
|
||||
function ageCode(email, minutes) {
|
||||
const db = new Database(join(DATA_DIR, 'recipescam.db'));
|
||||
db.prepare(
|
||||
'UPDATE email_verifications SET created_at = ? WHERE user_id = (SELECT id FROM users WHERE email = ?)',
|
||||
).run(new Date(Date.now() - minutes * 60_000).toISOString(), email);
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Run the sources, not a possibly stale build: the point of this suite is to
|
||||
// test the code as written.
|
||||
const tsx = join(ROOT, 'node_modules/.bin/tsx');
|
||||
const entry = existsSync(tsx) ? [tsx, 'src/server.ts'] : ['dist/server.js'];
|
||||
const server = spawn(process.execPath, entry, {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, PORT: String(PORT), DATA_DIR, ADMIN_EMAILS: ADMIN_EMAIL, NODE_ENV: 'test' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let serverLog = '';
|
||||
server.stdout.on('data', (d) => (serverLog += d));
|
||||
server.stderr.on('data', (d) => (serverLog += d));
|
||||
|
||||
async function waitForServer() {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/health`);
|
||||
if (res.ok) return true;
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`);
|
||||
|
||||
const stamp = Date.now();
|
||||
const admin = actor();
|
||||
const user = actor();
|
||||
const other = actor();
|
||||
|
||||
// ---- accounts -----------------------------------------------------------
|
||||
check('health responds', (await fetch(`${BASE}/health`)).ok);
|
||||
|
||||
const badEmail = await user.signup('not-an-email');
|
||||
check('signup rejects a malformed email', badEmail.status === 400, `got ${badEmail.status}`);
|
||||
const badPw = await user.signup(`short${stamp}@test.local`, 'short');
|
||||
check('signup rejects a short password', badPw.status === 400, `got ${badPw.status}`);
|
||||
|
||||
const adminSignup = await admin.signup(ADMIN_EMAIL);
|
||||
check('admin account signs up', adminSignup.status === 201, `got ${adminSignup.status}`);
|
||||
const userSignup = await activeSignup(user, `contributor${stamp}@test.local`);
|
||||
check('contributor account signs up', userSignup.status === 201, `got ${userSignup.status}`);
|
||||
await activeSignup(other, `other${stamp}@test.local`);
|
||||
|
||||
const cookie = userSignup.setCookie;
|
||||
check('session cookie is HttpOnly', /HttpOnly/i.test(cookie), cookie);
|
||||
check('session cookie is SameSite', /SameSite=Lax/i.test(cookie), cookie);
|
||||
check('session cookie is not Secure over plain http', !/;\s*Secure/i.test(cookie), cookie);
|
||||
check('session token is 256 bits of hex', /rc_session=[0-9a-f]{64}/.test(cookie), cookie);
|
||||
|
||||
// The proxy hands nginx the public scheme; a TLS visitor must get Secure.
|
||||
const tls = actor();
|
||||
const tlsSignup = await tls.req('/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' },
|
||||
body: JSON.stringify({ email: `tls${stamp}@test.local`, password: 'supersecret1' }),
|
||||
});
|
||||
check('session cookie is Secure behind https', /;\s*Secure/i.test(tlsSignup.setCookie), tlsSignup.setCookie);
|
||||
|
||||
// Documented, not fixed: the 409 is a deliberate UX choice and doubles as an
|
||||
// account-existence oracle.
|
||||
const dup = await actor().signup(ADMIN_EMAIL);
|
||||
check('duplicate signup is a 409 (known user-enumeration oracle)', dup.status === 409, `got ${dup.status}`);
|
||||
|
||||
const noUser = await actor().req('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: `ghost${stamp}@test.local`, password: 'supersecret1' }),
|
||||
});
|
||||
check('unknown email and wrong password look identical', noUser.status === 401, `got ${noUser.status}`);
|
||||
|
||||
const anonMe = await actor().req('/auth/me');
|
||||
check('signed out is a 200 with no user', anonMe.status === 200 && anonMe.body?.user === null, JSON.stringify(anonMe.body));
|
||||
const ownMe = await user.req('/auth/me');
|
||||
check('/auth/me reports the signed-in account', ownMe.body?.user?.email === `contributor${stamp}@test.local`, JSON.stringify(ownMe.body));
|
||||
|
||||
// ---- the PRO gate: an unproven address is a guest -----------------------
|
||||
// Signing up is not what earns the tier — the address is. Until its link is
|
||||
// followed the account is a guest with a name: every write and every personal
|
||||
// listing answers 403, which is what tells the studio to ask for the mail
|
||||
// rather than for a password.
|
||||
const jsonHdr = { 'content-type': 'application/json' };
|
||||
const unproven = actor();
|
||||
const unprovenEmail = `unproven${stamp}@test.local`;
|
||||
const unprovenSignup = await unproven.signup(unprovenEmail);
|
||||
check('a fresh signup is unverified', unprovenSignup.body?.user?.verified === false, JSON.stringify(unprovenSignup.body));
|
||||
check(
|
||||
'an unverified account may still ask for its link',
|
||||
(await unproven.req('/auth/resend-verification', { method: 'POST' })).status === 200,
|
||||
);
|
||||
check('an unverified account cannot upload', (await unproven.upload(PNG, 'image/png')).status === 403);
|
||||
check('an unverified account cannot list a folder', (await unproven.req('/photos/mine')).status === 403);
|
||||
check(
|
||||
'an unverified account cannot save a recipe',
|
||||
(await unproven.req('/recipes', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ name: 'x', recipe: {} }) })).status === 403,
|
||||
);
|
||||
check('an unverified account cannot wear an avatar', (await unproven.avatar(PNG, 'image/png')).status === 403);
|
||||
check('a signed-out caller still gets a 401, not a 403', (await actor().req('/photos/mine')).status === 401);
|
||||
|
||||
const unknownLink = await fetch(`${BASE}/auth/verify?token=${'0'.repeat(64)}`, { redirect: 'manual' });
|
||||
check(
|
||||
'an unknown link verifies nothing',
|
||||
unknownLink.status === 303 && unknownLink.headers.get('location')?.endsWith('/?verified=0'),
|
||||
String(unknownLink.headers.get('location')),
|
||||
);
|
||||
|
||||
const link = tokenFor(unprovenEmail);
|
||||
check('signup leaves one verification link in the database', typeof link === 'string' && link.length === 64, String(link));
|
||||
const followed = await fetch(`${BASE}/auth/verify?token=${link}`, { redirect: 'manual' });
|
||||
check(
|
||||
'the mailed link verifies the account',
|
||||
followed.status === 303 && followed.headers.get('location')?.endsWith('/?verified=1'),
|
||||
String(followed.headers.get('location')),
|
||||
);
|
||||
check('the account is verified from then on', (await unproven.req('/auth/me')).body?.user?.verified === true);
|
||||
check('a verified account may upload', (await unproven.upload(PNG, 'image/png')).status === 201);
|
||||
const replay = await fetch(`${BASE}/auth/verify?token=${link}`, { redirect: 'manual' });
|
||||
check('a spent link cannot be followed twice', replay.headers.get('location')?.endsWith('/?verified=0'), String(replay.headers.get('location')));
|
||||
check('the allowlisted admin needs no letter', (await admin.req('/auth/me')).body?.user?.verified === true);
|
||||
|
||||
// ---- the code in the same letter ----------------------------------------
|
||||
// The link is not the only way to prove an address any more: the letter also
|
||||
// carries six digits the visitor types into the dialog they signed up on. The
|
||||
// code is the shorter-lived of the two proofs and the one worth guessing, so
|
||||
// the checks below are mostly about what a wrong guess costs.
|
||||
const typed = actor();
|
||||
const typedEmail = `typed${stamp}@test.local`;
|
||||
await typed.signup(typedEmail);
|
||||
const code = codeFor(typedEmail);
|
||||
check('signup leaves a six-digit code beside the link', /^\d{6}$/.test(String(code)), String(code));
|
||||
const wrongCode = code === '000000' ? '111111' : '000000';
|
||||
const type = (a, value) =>
|
||||
a.req('/auth/verify-code', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ code: value }) });
|
||||
check('a wrong code verifies nothing', (await type(typed, wrongCode)).status === 400);
|
||||
check('and leaves the account unproven', (await typed.req('/auth/me')).body?.user?.verified === false);
|
||||
check('a code of the wrong shape is refused', (await type(typed, '12345')).status === 400);
|
||||
check('a signed-out caller cannot type a code', (await type(actor(), code)).status === 401);
|
||||
check('the mailed code verifies the account', (await type(typed, code)).status === 200);
|
||||
check('the account is verified from then on', (await typed.req('/auth/me')).body?.user?.verified === true);
|
||||
check('the code being spent spends the link with it', tokenFor(typedEmail) === undefined, String(tokenFor(typedEmail)));
|
||||
|
||||
const locked = actor();
|
||||
const lockedEmail = `locked${stamp}@test.local`;
|
||||
await locked.signup(lockedEmail);
|
||||
const lockedCode = codeFor(lockedEmail);
|
||||
const otherCode = lockedCode === '000000' ? '111111' : '000000';
|
||||
for (let i = 0; i < 5; i++) await type(locked, otherCode);
|
||||
const afterLock = await type(locked, lockedCode);
|
||||
check('five wrong guesses lock the code out', afterLock.status === 429, `got ${afterLock.status}`);
|
||||
check('and the right code no longer helps', (await locked.req('/auth/me')).body?.user?.verified === false);
|
||||
await locked.req('/auth/resend-verification', { method: 'POST' });
|
||||
const freshCode = codeFor(lockedEmail);
|
||||
check('a resent letter hands out a fresh code', freshCode !== lockedCode && /^\d{6}$/.test(String(freshCode)));
|
||||
check('that fresh code is not locked out by the old guesses', (await type(locked, freshCode)).status === 200);
|
||||
|
||||
const stale = actor();
|
||||
const staleEmail = `stale${stamp}@test.local`;
|
||||
await stale.signup(staleEmail);
|
||||
const staleCode = codeFor(staleEmail);
|
||||
ageCode(staleEmail, 16); // past CODE_TTL_S
|
||||
check('a code older than its quarter hour is refused', (await type(stale, staleCode)).status === 400);
|
||||
check('and leaves the account unproven', (await stale.req('/auth/me')).body?.user?.verified === false);
|
||||
|
||||
// ---- rate limiting ------------------------------------------------------
|
||||
const brute = actor();
|
||||
const bruteEmail = `brute${stamp}@test.local`;
|
||||
let limited = 0;
|
||||
for (let i = 0; i < 21; i++) {
|
||||
const res = await brute.req('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: bruteEmail, password: 'wrong-password' }),
|
||||
});
|
||||
if (res.status === 429) limited++;
|
||||
}
|
||||
check('login attempts are rate limited', limited === 1, `429s: ${limited}`);
|
||||
|
||||
// ---- upload boundary ----------------------------------------------------
|
||||
check('guest cannot upload', (await actor().upload(PNG, 'image/png')).status === 401);
|
||||
check(
|
||||
'non-image content-type is refused',
|
||||
(await user.upload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'), 'image/svg+xml')).status === 415,
|
||||
);
|
||||
check(
|
||||
'svg bytes under an image content-type is refused',
|
||||
(await user.upload(Buffer.from('<svg onload="alert(1)"/>'), 'image/jpeg')).status === 415,
|
||||
);
|
||||
check(
|
||||
'a declared type that disagrees with the bytes is refused',
|
||||
(await user.upload(JPEG_HEAD, 'image/png')).status === 415,
|
||||
);
|
||||
check('empty body is refused', (await user.upload(Buffer.alloc(0), 'image/png')).status === 400);
|
||||
|
||||
const tooBig = Buffer.concat([PNG, Buffer.alloc(13 * 1024 * 1024)]);
|
||||
check('an over-limit body is refused', (await user.upload(tooBig, 'image/png')).status === 413);
|
||||
|
||||
const created = await user.upload(PNG, 'image/png');
|
||||
check('a signed-in member can upload a real PNG', created.status === 201, JSON.stringify(created.body));
|
||||
|
||||
// ---- what the public may read ------------------------------------------
|
||||
const list = await actor().req('/photos');
|
||||
const listed = list.body?.photos ?? [];
|
||||
check('the strip is readable anonymously', list.status === 200);
|
||||
check(
|
||||
'the public strip leaks no owner',
|
||||
listed.length > 0 && !('userId' in listed[0]) && !('email' in listed[0]) && !('file' in listed[0]),
|
||||
JSON.stringify(listed[0]),
|
||||
);
|
||||
|
||||
const id = created.body?.photo?.id;
|
||||
const served = await fetch(`${BASE}/photos/${id}/file`);
|
||||
check('a contributed photo is served', served.status === 200);
|
||||
check('the served photo keeps its sniffed type', served.headers.get('content-type') === 'image/png');
|
||||
check('the served photo is nosniff', served.headers.get('x-content-type-options') === 'nosniff');
|
||||
check('the served photo is sandboxed', (served.headers.get('content-security-policy') ?? '').includes('sandbox'));
|
||||
check('the bytes round-trip intact', Buffer.from(await served.arrayBuffer()).equals(PNG));
|
||||
|
||||
check('an unknown id is a 404', (await fetch(`${BASE}/photos/999999/file`)).status === 404);
|
||||
check('a non-numeric id is a 404', (await fetch(`${BASE}/photos/abc/file`)).status === 404);
|
||||
check(
|
||||
'an id cannot escape the uploads directory',
|
||||
(await fetch(`${BASE}/photos/..%2f..%2fetc%2fpasswd/file`)).status === 404,
|
||||
);
|
||||
|
||||
// ---- the member's own folder --------------------------------------------
|
||||
check('a guest has no folder', (await actor().req('/photos/mine')).status === 401);
|
||||
const folder = await user.req('/photos/mine');
|
||||
check(
|
||||
'a member lists their own photos',
|
||||
folder.status === 200 && (folder.body?.photos ?? []).some((p) => p.id === id),
|
||||
JSON.stringify(folder.body).slice(0, 120),
|
||||
);
|
||||
|
||||
const stranger = actor();
|
||||
await activeSignup(stranger, `stranger${stamp}@test.local`);
|
||||
check("a fresh account's folder is empty", ((await stranger.req('/photos/mine')).body?.photos ?? []).length === 0);
|
||||
|
||||
// The strip's own labels ride the query string: the body is the image.
|
||||
const labels = { tag: '#KODAK_PORTRA_400', title: 'Golden Hour Portrait', meta: 'ISO 400 · GRAIN 35 · WARMTH +18' };
|
||||
const labelled = await user.req(
|
||||
`/photos?tag=${encodeURIComponent(labels.tag)}&title=${encodeURIComponent(labels.title)}&meta=${encodeURIComponent(labels.meta)}`,
|
||||
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG },
|
||||
);
|
||||
check(
|
||||
'an upload carries its strip labels',
|
||||
labelled.status === 201 &&
|
||||
labelled.body?.photo?.tag === labels.tag &&
|
||||
labelled.body?.photo?.title === labels.title &&
|
||||
labelled.body?.photo?.meta === labels.meta,
|
||||
JSON.stringify(labelled.body),
|
||||
);
|
||||
const labelledId = labelled.body?.photo?.id;
|
||||
const publicRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === labelledId);
|
||||
check('the labels reach the public strip', publicRow?.tag === labels.tag && publicRow?.meta === labels.meta);
|
||||
|
||||
const capped = await user.req(
|
||||
`/photos?tag=${'x'.repeat(200)}&meta=${encodeURIComponent('bad\u0007line')}`,
|
||||
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG },
|
||||
);
|
||||
check('a label is length-capped', capped.body?.photo?.tag?.length === 64, `len ${capped.body?.photo?.tag?.length}`);
|
||||
check('a label is control-stripped', capped.body?.photo?.meta === 'bad line', JSON.stringify(capped.body?.photo?.meta));
|
||||
|
||||
check(
|
||||
'a member cannot delete a photo they do not own',
|
||||
(await stranger.req(`/photos/${labelledId}`, { method: 'DELETE' })).status === 404,
|
||||
);
|
||||
check('the stranger’s delete leaves the file alone', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 200);
|
||||
const ownDelete = await user.req(`/photos/${labelledId}`, { method: 'DELETE' });
|
||||
check('a member deletes their own photo', ownDelete.status === 204, `got ${ownDelete.status}`);
|
||||
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 404);
|
||||
check(
|
||||
'the row leaves the folder',
|
||||
!((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === labelledId),
|
||||
);
|
||||
|
||||
// The curator removes anyone's through the same route — the moderation screen
|
||||
// keeps its own two admin endpoints, this one just shares the job.
|
||||
const spare = (await user.upload(PNG, 'image/png')).body?.photo;
|
||||
check('an admin deletes through the member route too', (await admin.req(`/photos/${spare?.id}`, { method: 'DELETE' })).status === 204);
|
||||
check('a deleted photo leaves the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === spare?.id));
|
||||
|
||||
// ---- moderation ---------------------------------------------------------
|
||||
check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401);
|
||||
const forbidden = await user.req('/admin/photos');
|
||||
check('a plain member is 403, not 200', forbidden.status === 403, `got ${forbidden.status}`);
|
||||
|
||||
const adminList = await admin.req('/admin/photos');
|
||||
const rows = adminList.body?.photos ?? [];
|
||||
check('an admin lists contributions', adminList.status === 200 && rows.length > 0);
|
||||
check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? '')));
|
||||
check(
|
||||
'a fresh upload lands in the strip section',
|
||||
JSON.stringify(rows.find((r) => r.id === id)?.slots) === JSON.stringify(['strip']),
|
||||
);
|
||||
check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403);
|
||||
|
||||
// ---- placement ----------------------------------------------------------
|
||||
const patch = (path, body) =>
|
||||
admin.req(path, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
check(
|
||||
'a plain member cannot place a photo',
|
||||
(await user.req(`/admin/photos/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slots: ['qr'] }),
|
||||
})).status === 403,
|
||||
);
|
||||
const placed = await patch(`/admin/photos/${id}`, { slots: ['tester', 'creator'] });
|
||||
check(
|
||||
'an admin puts a photo in several sections at once',
|
||||
placed.status === 200 && JSON.stringify(placed.body?.slots) === JSON.stringify(['tester', 'creator']),
|
||||
JSON.stringify(placed.body),
|
||||
);
|
||||
check(
|
||||
'the sections are public, the owner is not',
|
||||
JSON.stringify(((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots) ===
|
||||
JSON.stringify(['tester', 'creator']),
|
||||
);
|
||||
const one = await patch(`/admin/photos/${id}`, { slots: ['strip', 'strip', 'qr'] });
|
||||
check(
|
||||
'a repeated section is stored once',
|
||||
JSON.stringify(one.body?.slots) === JSON.stringify(['strip', 'qr']),
|
||||
JSON.stringify(one.body),
|
||||
);
|
||||
// An empty set is the curator's removal: no landing section draws the row,
|
||||
// but the uploader keeps it in their folder.
|
||||
const off = await patch(`/admin/photos/${id}`, { slots: [] });
|
||||
check('an admin takes a photo off the landing', off.status === 200 && off.body?.slots?.length === 0, JSON.stringify(off.body));
|
||||
check(
|
||||
'an off photo is on no landing section',
|
||||
((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots?.length === 0,
|
||||
);
|
||||
check('its owner still has it in the folder', ((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === id));
|
||||
check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slots: ['nope'] })).status === 400);
|
||||
check('a bare slot string is refused', (await patch(`/admin/photos/${id}`, { slots: 'strip' })).status === 400);
|
||||
check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slots: ['qr'] })).status === 404);
|
||||
|
||||
// ---- preset link --------------------------------------------------------
|
||||
// The look a photo was uploaded with is the landing QR card's payload: the
|
||||
// `.recipe` file the app reads back on IMPORT. The curator's `qr` tick is the
|
||||
// whole permission — nothing else is a link, and the listing exposes only
|
||||
// whether a look exists, never the look.
|
||||
const look = { name: 'QR LOOK', baseFilter: 'velvia', adjustments: { contrast: 7 }, frameId: 'none' };
|
||||
const withLook = await user.req(`/photos?recipe=${encodeURIComponent(JSON.stringify(look))}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'image/png' },
|
||||
body: PNG,
|
||||
});
|
||||
const presetId = withLook.body?.photo?.id;
|
||||
check('an upload that carried a look says so', withLook.body?.photo?.hasPreset === true);
|
||||
check('a photo with no look says no', rows.find((r) => r.id === id)?.hasPreset === false);
|
||||
const listedRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === presetId);
|
||||
check('the public listing counts looks, never ships them', listedRow?.hasPreset === true && !('recipe' in listedRow));
|
||||
check('an un-curated photo is not a link', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
|
||||
check('a lookless photo is not a link either', (await actor().req(`/photos/${id}/preset.recipe`)).status === 404);
|
||||
check('an unknown row is a 404', (await actor().req('/photos/999999/preset.recipe')).status === 404);
|
||||
check('a nonsense id is a 404, not a crash', (await actor().req('/photos/abc/preset.recipe')).status === 404);
|
||||
|
||||
await patch(`/admin/photos/${presetId}`, { slots: ['qr'] });
|
||||
const presetFile = await fetch(`${BASE}/photos/${presetId}/preset.recipe`);
|
||||
const presetXml = await presetFile.text();
|
||||
check(
|
||||
'a curated photo serves its look as a download',
|
||||
presetFile.status === 200 && /^application\/xml/.test(presetFile.headers.get('content-type') ?? ''),
|
||||
`got ${presetFile.status}`,
|
||||
);
|
||||
check(
|
||||
'the download carries the row it came from',
|
||||
presetFile.headers.get('content-disposition') === `attachment; filename="recipescam-${presetId}.recipe"`,
|
||||
presetFile.headers.get('content-disposition') ?? '',
|
||||
);
|
||||
check(
|
||||
'the file is the app’s own envelope',
|
||||
/^<\?xml version="1\.0" encoding="UTF-8"\?>\n<recipescam-recipe version="1" algorithm="xor16-v1" salt="[0-9a-f]+">\n {2}<payload>[0-9a-f]+<\/payload>\n<\/recipescam-recipe>\n$/.test(
|
||||
presetXml,
|
||||
),
|
||||
);
|
||||
const again = await (await fetch(`${BASE}/photos/${presetId}/preset.recipe`)).text();
|
||||
check(
|
||||
'every download gets its own salt',
|
||||
/salt="([0-9a-f]+)"/.exec(again)?.[1] !== /salt="([0-9a-f]+)"/.exec(presetXml)?.[1],
|
||||
);
|
||||
|
||||
// Taking the photo out of the `qr` section takes the link away with it.
|
||||
await patch(`/admin/photos/${presetId}`, { slots: [] });
|
||||
check('an un-curated photo loses its link again', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
|
||||
await user.req(`/photos/${presetId}`, { method: 'DELETE' });
|
||||
|
||||
const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' });
|
||||
check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`);
|
||||
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404);
|
||||
check('the deleted row is gone from the listing', !((await admin.req('/admin/photos')).body?.photos ?? []).some((r) => r.id === id));
|
||||
|
||||
// ---- quota --------------------------------------------------------------
|
||||
const quota = actor();
|
||||
await activeSignup(quota, `quota${stamp}@test.local`);
|
||||
let last = 0;
|
||||
for (let i = 0; i < 13; i++) last = (await quota.upload(PNG, 'image/png')).status;
|
||||
check('uploads are capped per account', last === 429, `13th upload: ${last}`);
|
||||
|
||||
// The cap is a member fair-use rule; the curator stocks the landing page from
|
||||
// one account, so it must not apply to the allowlist.
|
||||
let adminLast = 0;
|
||||
for (let i = 0; i < 13; i++) adminLast = (await admin.upload(PNG, 'image/png')).status;
|
||||
check('the admin is exempt from the member quota', adminLast === 201, `13th admin upload: ${adminLast}`);
|
||||
|
||||
const cleared = await admin.req('/admin/photos', { method: 'DELETE' });
|
||||
check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body));
|
||||
check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0);
|
||||
|
||||
// ---- profile: an account edits itself ----------------------------------
|
||||
const JSON_HDR = { 'content-type': 'application/json' };
|
||||
const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
|
||||
|
||||
const member = actor();
|
||||
await activeSignup(member, `profile${stamp}@test.local`);
|
||||
const anonEdit = await edit(actor(), { password: 'another-secret-1', currentPassword: 'supersecret1' });
|
||||
check('a profile edit needs a session', anonEdit.status === 401, `got ${anonEdit.status}`);
|
||||
const badCurrent = await edit(member, { password: 'another-secret-1', currentPassword: 'not-the-password' });
|
||||
check('a profile edit needs the current password', badCurrent.status === 403, `got ${badCurrent.status}`);
|
||||
const takenEmail = await edit(member, { email: ADMIN_EMAIL, currentPassword: 'supersecret1' });
|
||||
check('a profile edit refuses a taken email', takenEmail.status === 409, `got ${takenEmail.status}`);
|
||||
const shortNew = await edit(member, { password: 'short', currentPassword: 'supersecret1' });
|
||||
check('a profile edit refuses a short password', shortNew.status === 400, `got ${shortNew.status}`);
|
||||
|
||||
const newEmail = `renamed${stamp}@test.local`;
|
||||
const renamed = await edit(member, { email: newEmail, currentPassword: 'supersecret1' });
|
||||
check('an admin-visible profile edit changes the email', renamed.status === 200 && renamed.body?.user?.email === newEmail, JSON.stringify(renamed.body));
|
||||
// The tier follows the address that earned it: the new one is unproven until
|
||||
// its own letter is followed, so the account drops back to the guest tier.
|
||||
check('a changed address is unproven again', renamed.body?.user?.verified === false, JSON.stringify(renamed.body));
|
||||
check('a changed address loses the writes', (await member.req('/recipes')).status === 403);
|
||||
await followVerifyLink(newEmail);
|
||||
check('following the new letter restores the tier', (await member.req('/auth/me')).body?.user?.verified === true);
|
||||
const login = (email, password) =>
|
||||
actor().req('/auth/login', { method: 'POST', headers: JSON_HDR, body: JSON.stringify({ email, password }) });
|
||||
check('the account logs in under the new email', (await login(newEmail, 'supersecret1')).status === 200);
|
||||
check('the old email no longer logs in', (await login(`profile${stamp}@test.local`, 'supersecret1')).status === 401);
|
||||
|
||||
// The session that made the edit is the same row, so it also changes the password.
|
||||
const newPassword = 'second-secret-1';
|
||||
const rekeyed = await edit(member, { password: newPassword, currentPassword: 'supersecret1' });
|
||||
check('an account changes its own password', rekeyed.status === 200, `got ${rekeyed.status}`);
|
||||
check('the old password stops working', (await login(newEmail, 'supersecret1')).status === 401);
|
||||
check('the new password works', (await login(newEmail, newPassword)).status === 200);
|
||||
|
||||
// ---- admin: the account list -------------------------------------------
|
||||
const anonUsers = await actor().req('/admin/users');
|
||||
check('the user list is not public', anonUsers.status === 401, `got ${anonUsers.status}`);
|
||||
const memberUsers = await member.req('/admin/users');
|
||||
check('a member cannot read the user list', memberUsers.status === 403, `got ${memberUsers.status}`);
|
||||
const adminUsers = await admin.req('/admin/users');
|
||||
const adminRow = (adminUsers.body?.users ?? []).find((u) => u.email === ADMIN_EMAIL);
|
||||
check('an admin reads the user list', adminUsers.status === 200 && Array.isArray(adminUsers.body?.users), `got ${adminUsers.status}`);
|
||||
check('the list flags the allowlisted account', adminRow?.admin === true, JSON.stringify(adminRow));
|
||||
check('the list counts each account’s photos', typeof adminRow?.photos === 'number', JSON.stringify(adminRow));
|
||||
|
||||
// ---- avatar: the picture beside the name -------------------------------
|
||||
const memberId = renamed.body?.user?.id;
|
||||
const noSession = await actor().avatar(PNG, 'image/png');
|
||||
check('an avatar upload needs a session', noSession.status === 401, `got ${noSession.status}`);
|
||||
const badAvatar = await member.avatar(Buffer.from('<svg onload="alert(1)"/>'), 'image/png');
|
||||
check('an avatar upload sniffs the bytes', badAvatar.status === 415, `got ${badAvatar.status}`);
|
||||
|
||||
const gaveAvatar = await member.avatar(PNG, 'image/png');
|
||||
const avatarUrl = gaveAvatar.body?.user?.avatar;
|
||||
check('a member uploads an avatar', gaveAvatar.status === 200 && typeof avatarUrl === 'string', JSON.stringify(gaveAvatar.body));
|
||||
check('the avatar URL points at the account', new RegExp(`^/api/users/${memberId}/avatar\\?v=[0-9a-f]{32}$`).test(String(avatarUrl)), String(avatarUrl));
|
||||
|
||||
const servedAvatar = await fetch(`http://127.0.0.1:${PORT}${avatarUrl}`);
|
||||
check('an avatar is served without a session', servedAvatar.status === 200, `got ${servedAvatar.status}`);
|
||||
check('an avatar carries its image type', servedAvatar.headers.get('content-type') === 'image/png', String(servedAvatar.headers.get('content-type')));
|
||||
check('an avatar is cacheable for a long time', (servedAvatar.headers.get('cache-control') ?? '').includes('immutable'), String(servedAvatar.headers.get('cache-control')));
|
||||
check('the avatar bytes round-trip intact', Buffer.from(await servedAvatar.arrayBuffer()).equals(PNG));
|
||||
|
||||
const replaced = await member.avatar(JPEG_HEAD, 'image/jpeg');
|
||||
const replacedUrl = replaced.body?.user?.avatar;
|
||||
check('a second avatar replaces the first', replaced.status === 200 && replacedUrl !== avatarUrl, JSON.stringify(replaced.body));
|
||||
check('the replaced avatar file is gone', !existsSync(join(DATA_DIR, 'avatars', `${String(avatarUrl).split('?v=')[1]}.png`)));
|
||||
check('the new avatar resolves', (await fetch(`http://127.0.0.1:${PORT}${replacedUrl}`)).status === 200);
|
||||
|
||||
const ghost = await actor().req('/users/999999/avatar');
|
||||
check('an unknown account has no avatar', ghost.status === 404, `got ${ghost.status}`);
|
||||
|
||||
// The moderation list has to show the same face beside the email.
|
||||
const listedUsers = (await admin.req('/admin/users')).body?.users ?? [];
|
||||
const memberRow = listedUsers.find((u) => u.id === memberId);
|
||||
check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow));
|
||||
|
||||
// ---- moderation: block, remove, delete an account -----------------------
|
||||
const target = actor();
|
||||
await activeSignup(target, `moderated${stamp}@test.local`);
|
||||
const targetId = (await target.req('/auth/me')).body?.user?.id;
|
||||
const targetPhoto = (await target.upload(PNG, 'image/png')).body?.photo;
|
||||
const targetPhotoUrl = `http://127.0.0.1:${PORT}/api/photos/${targetPhoto?.id}/file`;
|
||||
const moderate = (a, id, body) => a.req(`/admin/users/${id}`, { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
|
||||
check('a fresh account is moderatable', Number.isInteger(targetId) && Number.isInteger(targetPhoto?.id), `${targetId}/${targetPhoto?.id}`);
|
||||
|
||||
const anonModerate = await moderate(actor(), targetId, { blocked: true });
|
||||
check('moderating needs a session', anonModerate.status === 401, `got ${anonModerate.status}`);
|
||||
const memberModerate = await moderate(member, targetId, { blocked: true });
|
||||
check('a member cannot moderate', memberModerate.status === 403, `got ${memberModerate.status}`);
|
||||
const selfModerate = await moderate(admin, adminRow?.id, { blocked: true });
|
||||
check('an allowlisted account cannot be moderated', selfModerate.status === 403, `got ${selfModerate.status}`);
|
||||
const ghostModerate = await moderate(admin, 999999, { blocked: true });
|
||||
check('an unknown account is a 404', ghostModerate.status === 404, `got ${ghostModerate.status}`);
|
||||
const badModerate = await moderate(admin, targetId, { blocked: 'yes' });
|
||||
check('a moderation body is type-checked', badModerate.status === 400, `got ${badModerate.status}`);
|
||||
|
||||
const blocked = await moderate(admin, targetId, { blocked: true });
|
||||
check('an admin blocks an account', blocked.status === 200 && blocked.body?.user?.blocked === true, JSON.stringify(blocked.body));
|
||||
const blockedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
|
||||
check('a blocked account cannot sign in', blockedLogin.status === 403 && blockedLogin.body?.error === 'account blocked', JSON.stringify(blockedLogin.body));
|
||||
check('a blocked session stops being a user', (await target.req('/auth/me')).body?.user === null, JSON.stringify((await target.req('/auth/me')).body));
|
||||
const blockedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
|
||||
check('the list flags a blocked account', blockedRow?.blocked === true, JSON.stringify(blockedRow));
|
||||
|
||||
const unblocked = await moderate(admin, targetId, { blocked: false });
|
||||
check('an admin unblocks an account', unblocked.status === 200 && unblocked.body?.user?.blocked === false, JSON.stringify(unblocked.body));
|
||||
check('an unblocked account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
|
||||
check('its photo is back on the strip', ((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
|
||||
|
||||
const removed = await moderate(admin, targetId, { removed: true });
|
||||
check('an admin removes an account', removed.status === 200 && removed.body?.user?.removed === true, JSON.stringify(removed.body));
|
||||
const removedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
|
||||
check('a removed account cannot sign in', removedLogin.status === 403 && removedLogin.body?.error === 'account removed', JSON.stringify(removedLogin.body));
|
||||
check('a removed account’s photos leave the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
|
||||
const removedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
|
||||
check('the list flags a removed account', removedRow?.removed === true, JSON.stringify(removedRow));
|
||||
|
||||
const restored = await moderate(admin, targetId, { removed: false });
|
||||
check('an admin restores an account', restored.status === 200 && restored.body?.user?.removed === false, JSON.stringify(restored.body));
|
||||
check('a restored account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
|
||||
|
||||
const hardDelete = await admin.req(`/admin/users/${targetId}`, { method: 'DELETE' });
|
||||
check('an admin deletes an account outright', hardDelete.status === 204, `got ${hardDelete.status}`);
|
||||
check('a deleted account leaves the list', !((await admin.req('/admin/users')).body?.users ?? []).some((u) => u.id === targetId));
|
||||
check('a deleted account cannot sign in', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 401);
|
||||
check('a deleted account’s photo file is unlinked', (await fetch(targetPhotoUrl)).status === 404);
|
||||
|
||||
// ---- film-strip ratings -------------------------------------------------
|
||||
// Public and one-per-visitor: two addresses are two voters, and a second vote
|
||||
// from the same address replaces the first instead of adding to it.
|
||||
const vote = (ip, key, stars) =>
|
||||
fetch(`${BASE}/ratings`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
|
||||
body: JSON.stringify({ key, stars }),
|
||||
});
|
||||
const readRatings = async (ip) =>
|
||||
((await (await fetch(`${BASE}/ratings`, { headers: { 'x-forwarded-for': ip } })).json()).ratings ?? {});
|
||||
|
||||
check('a guest may rate a frame', (await vote('10.9.9.1', 'look:TEST_LOOK', 5)).status === 200);
|
||||
await vote('10.9.9.2', 'look:TEST_LOOK', 3);
|
||||
const tally = (await readRatings('10.9.9.1'))['look:TEST_LOOK'];
|
||||
check('the tally averages every vote', tally?.avg === 4 && tally?.n === 2, JSON.stringify(tally));
|
||||
check('a visitor reads back their own vote', tally?.mine === 5);
|
||||
check('a stranger has no vote of their own', (await readRatings('10.9.9.3'))['look:TEST_LOOK']?.mine === 0);
|
||||
await vote('10.9.9.1', 'look:TEST_LOOK', 1);
|
||||
const changed = (await readRatings('10.9.9.1'))['look:TEST_LOOK'];
|
||||
check('a second vote replaces the first', changed?.avg === 2 && changed?.n === 2 && changed?.mine === 1, JSON.stringify(changed));
|
||||
check('a six-star score is refused', (await vote('10.9.9.1', 'look:TEST_LOOK', 6)).status === 400);
|
||||
check('a zero-star score is refused', (await vote('10.9.9.1', 'look:TEST_LOOK', 0)).status === 400);
|
||||
check('a malformed key is refused', (await vote('10.9.9.1', 'bad key!', 3)).status === 400);
|
||||
check('an empty key is refused', (await vote('10.9.9.1', '', 3)).status === 400);
|
||||
|
||||
const throwaway = await user.upload(PNG, 'image/png');
|
||||
const throwawayId = throwaway.body?.photo?.id;
|
||||
check('the throwaway upload lands', Number.isInteger(throwawayId), JSON.stringify(throwaway.body));
|
||||
await vote('10.9.9.4', `photo:${throwawayId}`, 5);
|
||||
|
||||
// ---- the hero's award windows -------------------------------------------
|
||||
// The landing's award column reads the same votes over a window: what was
|
||||
// cast today, and what was cast this week. A vote cast a moment ago is in
|
||||
// both. A subject that is not a photo — a built-in look — is never a frame.
|
||||
const highlights = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
|
||||
const day = (highlights.day ?? []).find((r) => r.id === throwawayId);
|
||||
const week = (highlights.week ?? []).find((r) => r.id === throwawayId);
|
||||
check('a vote lands in today’s window', day?.avg === 5 && day?.n === 1, JSON.stringify(day));
|
||||
check('and in this week’s window too', week?.avg === 5 && week?.n === 1, JSON.stringify(week));
|
||||
check(
|
||||
'a look that is not a photo is never an award',
|
||||
[...(highlights.day ?? []), ...(highlights.week ?? [])].every((r) => Number.isInteger(r.id)),
|
||||
JSON.stringify(highlights),
|
||||
);
|
||||
|
||||
await user.req(`/photos/${throwawayId}`, { method: 'DELETE' });
|
||||
check(
|
||||
'a deleted photo takes its votes with it',
|
||||
!Object.hasOwn(await readRatings('10.9.9.4'), `photo:${throwawayId}`),
|
||||
);
|
||||
const afterDelete = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
|
||||
check(
|
||||
'and off the award column',
|
||||
![...(afterDelete.day ?? []), ...(afterDelete.week ?? [])].some((r) => r.id === throwawayId),
|
||||
JSON.stringify(afterDelete),
|
||||
);
|
||||
|
||||
// ---- pre-existing guarantees still hold ---------------------------------
|
||||
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
|
||||
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
|
||||
const missing = await actor().req('/nope');
|
||||
check('unknown routes keep the single error shape', missing.status === 404 && missing.body?.error === 'not_found');
|
||||
} catch (err) {
|
||||
fail++;
|
||||
console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`);
|
||||
console.log(serverLog.slice(-2000));
|
||||
} finally {
|
||||
server.kill('SIGTERM');
|
||||
rmSync(DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# RecipesCam web — the whole stack. Copy this folder anywhere, then:
|
||||
# cp .env.example .env
|
||||
# docker compose up -d --build
|
||||
name: recipescam-web
|
||||
|
||||
services:
|
||||
# Accounts + saved recipes. Never sees a photo: every pixel of the render
|
||||
# pipeline runs in the visitor's browser.
|
||||
api:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
# Who may moderate the contributed strip: a comma-separated email
|
||||
# allowlist. Empty means nobody is an admin, which is the safe default.
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
# The relay that mails the "prove this address" link. Leave SMTP_HOST
|
||||
# empty and the link is written to this container's log instead — a dev
|
||||
# box needs no mail server, and the operator can read / copy it.
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-}
|
||||
volumes:
|
||||
# SQLite (WAL) lives on the host so a rebuild never loses accounts.
|
||||
- ./data:/data
|
||||
|
||||
# Static SPA + /api reverse proxy. "api" is the DNS name nginx.conf proxies to,
|
||||
# so this service name is load-bearing.
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:80"
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
public/wasm
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
public/wasm/
|
||||
@@ -0,0 +1,14 @@
|
||||
# Build stage. Node 22 (glibc) so nothing here depends on the host.
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# tsc --noEmit && vite build; the prebuild hook copies canvaskit.wasm into public/
|
||||
RUN npm run build
|
||||
|
||||
# Runtime: static files only. The photo pipeline runs in the visitor's browser.
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="vi" data-theme="dark" data-accent="amber" data-fonts="studio">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="icon" type="image/png" href="/assets/RecipesCamIcon.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/RecipesCamIcon.png" />
|
||||
<!-- The installed app. The manifest is a static file in public/ (see
|
||||
public/manifest.json and scripts/make-icons.mjs for its icons), so a build
|
||||
needs no plugin to ship it. iOS ignores most of the manifest and reads these
|
||||
three metas instead: 'capable' is what makes a home-screen bookmark open
|
||||
without Safari's chrome, 'black-translucent' gives it the app's black bars,
|
||||
and 'title' is the name under the icon. -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="RecipesCam" />
|
||||
<title>RecipesCam — web</title>
|
||||
<meta name="description" content="RecipesCam web — grade photos with the same film recipes as the app." />
|
||||
<link rel="canonical" href="/" />
|
||||
|
||||
<!-- Share card. The URLs are relative on purpose: a crawler resolves them
|
||||
against whatever host serves the page, so the same build previews
|
||||
correctly on any deployment. og:image is the 1200x630 hero shot in
|
||||
public/assets/og-cover.png (see the og-cover script in the repo notes). -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="RecipesCam" />
|
||||
<meta property="og:title" content="RecipesCam — Color Recipes & Film Camera Studio" />
|
||||
<meta property="og:description" content="Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn." />
|
||||
<meta property="og:url" content="/" />
|
||||
<meta property="og:image" content="/assets/og-cover.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="RecipesCam landing page — capture and grade photos with exclusive color recipes" />
|
||||
<meta property="og:locale" content="vi_VN" />
|
||||
<meta property="og:locale:alternate" content="en_US" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="RecipesCam — Color Recipes & Film Camera Studio" />
|
||||
<meta name="twitter:description" content="Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio." />
|
||||
<meta name="twitter:image" content="/assets/og-cover.png" />
|
||||
<meta name="twitter:image:alt" content="RecipesCam landing page — capture and grade photos with exclusive color recipes" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
# Static SPA + API proxy. TLS/domain are terminated outside (Nginx Proxy
|
||||
# Manager), so this container only ever speaks plain HTTP on port 80.
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Static only, plus the API proxy below; a stricter CSP needs a per-app tune
|
||||
# (canvas wasm, the QR image host) and belongs here once it is done.
|
||||
# Cross-origin isolation: super-resolution runs onnxruntime's threaded wasm,
|
||||
# which needs a SharedArrayBuffer, which the browser only hands over when the
|
||||
# page is isolated. Both headers are required, and both must also sit on the
|
||||
# script responses a nested worker fetches — see the two locations below.
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/css application/javascript application/json application/wasm image/svg+xml;
|
||||
|
||||
# nginx's stock mime.types has no .mjs, and the browser refuses to run a module
|
||||
# the server hands over as octet-stream: onnxruntime's glue is one of those.
|
||||
# First match wins among regex locations, so this one stands before the
|
||||
# /wasm/ block that would otherwise swallow the file.
|
||||
# (Both locations below restate the isolation headers from above: an add_header
|
||||
# in a location drops every add_header inherited, and a worker script is fetched
|
||||
# under the embedder's COEP — Chromium blocks it as
|
||||
# `coep-frame-resource-needs-coep-header` without them.)
|
||||
location ~* \.mjs$ {
|
||||
types { application/javascript mjs; }
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# canvas.wasm is ~8MB and the super-resolution model is ~5MB: immutable, hashed
|
||||
# or not, they never change under a given build. Fonts and the wall-frame
|
||||
# artwork are likewise static.
|
||||
location ~* ^/(wasm|models|assets)/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The two files whose names never change but whose contents must: a cached worker
|
||||
# keeps answering with the previous build's shell long after the deploy that
|
||||
# replaced it, and a cached manifest keeps pointing at the icon set it shipped with.
|
||||
# Nothing else would ever flush them, which is exactly why they opt out of the
|
||||
# long-lived caching above. The worker is a script under the embedder's COEP like
|
||||
# the .mjs files, so it restates the two isolation headers for the same reason.
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /manifest.json {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The one route that carries a whole data dir back in (see /api/admin/restore
|
||||
# — who may call it is the API's own admin check, done before it reads a byte).
|
||||
# The upload cap that holds everywhere else would reject it, and unpacking the
|
||||
# archive plus the restart takes longer than the stock read timeout. nginx
|
||||
# takes the longest matching prefix, so this one holds for that route while
|
||||
# /api/ below keeps the rest.
|
||||
location /api/admin/restore {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $api_upstream http://api:3000;
|
||||
proxy_pass $api_upstream$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
client_max_body_size 0;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
# Resolved per request through Docker's embedded DNS, so the frontend can
|
||||
# start before the API container without nginx refusing to boot.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $api_upstream http://api:3000;
|
||||
proxy_pass $api_upstream$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
# $http_host, not $host: keep the port when the visitor hits this container
|
||||
# directly (localhost:8090), since the API builds the verification link from
|
||||
# the forwarded host.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# Hand the API the scheme the visitor actually used (TLS is terminated by
|
||||
# Nginx Proxy Manager in front of this container) so it can mark the session
|
||||
# cookie Secure. Falls back to nothing when that header is absent.
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
# Room for a full-size upload (the API caps photo bodies at 12m itself).
|
||||
client_max_body_size 16m;
|
||||
}
|
||||
|
||||
# SPA fallback: /app, /login and /signup are all index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+2038
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "recipescam-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare-wasm": "node scripts/copy-wasm.mjs",
|
||||
"predev": "npm run prepare-wasm",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run prepare-wasm",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"exifr": "^7.1.3",
|
||||
"libraw-wasm": "^1.6.0",
|
||||
"onnxruntime-web": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>probe: develop a real RAW</title>
|
||||
<body>
|
||||
<pre id="out" style="font:12px monospace">running…</pre>
|
||||
<img id="shot" style="max-width:100%">
|
||||
<script type="module">
|
||||
const log = (...a) => {
|
||||
const s = a.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(' ');
|
||||
document.getElementById('out').textContent += s + '\n';
|
||||
console.log(s);
|
||||
};
|
||||
const b64 = (bytes) => {
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(s);
|
||||
};
|
||||
|
||||
try {
|
||||
const RAW_URL = new URLSearchParams(location.search).get('f') || 'http://127.0.0.1:5300/DSC03453.ARW';
|
||||
const { initSkia } = await import('/src/engine/skiaShim.ts');
|
||||
await initSkia({ locateFile: (file) => `/wasm/${file}` });
|
||||
log('canvaskit up');
|
||||
const { developRaw } = await import('/src/engine/rawDevelop.ts');
|
||||
|
||||
const bytes = new Uint8Array(await (await fetch(RAW_URL)).arrayBuffer());
|
||||
log('raw bytes', bytes.length, RAW_URL);
|
||||
// The develop hands its buffer to LibRaw's worker, which detaches it — one
|
||||
// copy each, since the plane below is read out of a second decode.
|
||||
const forPlane = bytes.slice();
|
||||
|
||||
const t0 = performance.now();
|
||||
const jpeg = await developRaw(bytes);
|
||||
log('develop ms', Math.round(performance.now() - t0), 'jpeg bytes', jpeg.length);
|
||||
window.__dev = b64(jpeg);
|
||||
document.getElementById('shot').src = URL.createObjectURL(new Blob([jpeg], { type: 'image/jpeg' }));
|
||||
|
||||
// The plane and the colour data behind that develop, so the shader can be
|
||||
// replayed offline a stage at a time.
|
||||
const { default: LibRaw } = await import('libraw-wasm');
|
||||
const raw = new LibRaw();
|
||||
// LibRaw hands the buffer to its worker, which detaches it — a copy, since
|
||||
// the develop above already passed the original through.
|
||||
await raw.open(forPlane, {
|
||||
halfSize: true, outputBps: 16, outputColor: 0, noAutoScale: true,
|
||||
useCameraWb: false, noAutoBright: true, gamm: [1, 1], userQual: 3, highlight: 0,
|
||||
});
|
||||
const meta = await raw.metadata(true);
|
||||
const img = await raw.imageData();
|
||||
const cd = meta.color_data;
|
||||
const plane = new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength);
|
||||
log('plane', img.width, 'x', img.height, 'bytes', plane.length);
|
||||
log('black', cd.black, 'maximum', cd.maximum, 'mul', cd.cam_mul, 'model', meta.normalized_model);
|
||||
window.__dump = {
|
||||
b64: b64(plane),
|
||||
width: img.width, height: img.height,
|
||||
black: cd.black, maximum: cd.maximum,
|
||||
cam_mul: cd.cam_mul, rgb_cam: cd.rgb_cam,
|
||||
model: meta.normalized_model, raw_bps: meta.raw_bps ?? null,
|
||||
};
|
||||
const thumb = await raw.thumbnailData().catch(() => null);
|
||||
if (thumb?.data?.length) window.__thumb = b64(new Uint8Array(thumb.data));
|
||||
log('thumb', thumb?.format, thumb?.width, 'x', thumb?.height);
|
||||
raw.dispose();
|
||||
log('DONE');
|
||||
} catch (e) {
|
||||
log('ERROR', e?.message ?? String(e), (e?.stack ?? '').split('\n').slice(0, 6).join(' | '));
|
||||
log('FAILED');
|
||||
}
|
||||
</script>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "RecipesCam — Color Recipes & Film Camera Studio",
|
||||
"short_name": "RecipesCam",
|
||||
"description": "Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn.",
|
||||
"lang": "vi",
|
||||
"start_url": "/app",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Thư viện",
|
||||
"short_name": "Thư viện",
|
||||
"url": "/library"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
// The app shell as a service worker, so an installed RecipesCam opens with no
|
||||
// network at all. Not bundled on purpose: through vite/rollup it would stop being
|
||||
// readable in DevTools, the one place a worker gets debugged. VERSION is the cache
|
||||
// name and the whole update story — bump it when the shell changes, and `activate`
|
||||
// drops every older cache.
|
||||
const VERSION = 'recipescam-v1';
|
||||
|
||||
// nginx answers all three with the same index.html (SPA fallback), so they are one
|
||||
// document under three keys: an offline navigation finds it whichever key it asks.
|
||||
const SHELL = ['/', '/app', '/library'];
|
||||
|
||||
// Cache-first paths: vite hashes every filename in its own bundle, and the wasm,
|
||||
// the models and the icons never change under a given build.
|
||||
const STATIC = /^\/(assets|wasm|models|icons)\//;
|
||||
|
||||
// Only a complete same-origin 200 is worth keeping — an opaque or error body
|
||||
// stored here comes back as a failure on the next visit, and nothing in the worker
|
||||
// can tell the two apart by then.
|
||||
const cachedFetch = (request) =>
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches.open(VERSION).then((cache) => cache.put(request, copy));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
// Offline, the studio the app is for; the landing page for a build without /app.
|
||||
const offlineShell = async () =>
|
||||
(await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error();
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// Best effort, one route at a time: a deploy caught mid-flight must not leave the
|
||||
// worker uninstalled, so each route fails on its own.
|
||||
event.waitUntil(
|
||||
caches.open(VERSION)
|
||||
.then((cache) => Promise.all(SHELL.map((url) => cache.add(url).catch(() => {}))))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
// A worker is a cache, not a proxy: a POST, the API, another origin's font — a
|
||||
// request that is not a plain same-origin read — goes straight out.
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// A navigation is network-first even when it is cached: a shell answered from the
|
||||
// cache while the network holds a newer build pins the visitor to the old one. It
|
||||
// is also the path an installed app opens through with no network.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(fetch(request).catch(offlineShell));
|
||||
return;
|
||||
}
|
||||
|
||||
// ponytail: a navigation is never written back, the precached entry is what
|
||||
// answers offline. Add a put() here once a route must survive on its own.
|
||||
if (STATIC.test(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else — a font, the manifest, an uploaded photo — is network-first,
|
||||
// and answered from the cache only when that fails.
|
||||
event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error()));
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// AUTO's three readings are pure arithmetic on the binned ramp, so they can be
|
||||
// checked here rather than in a browser: hand the functions a histogram whose
|
||||
// answer is known by construction and read it back. The module is TypeScript
|
||||
// (and a .tsx), so it is transpiled on the fly out of the installed compiler and
|
||||
// the React/i18n imports — which only the overlay component needs — are dropped
|
||||
// before it is evaluated (same convention as preview-match-check.mjs).
|
||||
//
|
||||
// node scripts/auto-tone-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const transpile = (path) =>
|
||||
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
}).outputText;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'auto-check-'));
|
||||
// colorUtils only imports types, so the compiler drops that line on its own.
|
||||
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
writeFileSync(
|
||||
join(dir, 'histogram.mjs'),
|
||||
transpile('../src/ui/Histogram.tsx')
|
||||
.replace(
|
||||
/^import .*from ['"]\.\.\/\.\.\/shared\/utils\/colorUtils['"];$/m,
|
||||
'import { kelvinToRGB } from "./colorUtils.mjs";',
|
||||
)
|
||||
// The overlay's own imports: the component is never rendered here, so React
|
||||
// and the i18n provider are dead weight node cannot resolve.
|
||||
.replace(/^import .*from ['"]react[^'"]*['"];$/gm, '')
|
||||
.replace(/^import .*from ['"]\.\.\/i18n\/I18nProvider['"];$/m, ''),
|
||||
);
|
||||
const { autoExposureStops, autoTone, autoWhiteBalance, AUTO_EV_MAX } = await import(
|
||||
pathToFileURL(join(dir, 'histogram.mjs')).href
|
||||
);
|
||||
|
||||
const BINS = 256;
|
||||
// One pixel per bin: the whole 0..255 ramp, evenly, so every quantile of it is
|
||||
// known in closed form (quantile q of a flat ramp is q).
|
||||
const flat = () => new Array(BINS).fill(1);
|
||||
// Every pixel at one level: a frame with a single tone in it, and nothing else.
|
||||
const spike = (v, n = 1000) => {
|
||||
const bins = new Array(BINS).fill(0);
|
||||
bins[v] = n;
|
||||
return bins;
|
||||
};
|
||||
|
||||
// --- Exposure: the mean luma against the 0.48 a grey card lands on, in stops.
|
||||
const stops = (lum) => autoExposureStops(lum);
|
||||
assert.equal(stops(spike(255)), Math.log2(0.48 / 1), 'white is pulled back by log2(0.48)');
|
||||
assert.equal(stops(spike(0)), AUTO_EV_MAX, "black is pushed to the knob own ceiling");
|
||||
// Half a stop down from the target is 0.48 / 2^0.5 = 0.339 -> bin 87.
|
||||
assert.equal(Math.round(stops(spike(87)) * 10) / 10, 0.5, 'a dark frame is asked for +0.5 stop');
|
||||
// The flat ramp averages exactly mid-grey, which is 0.0589 stop under target.
|
||||
assert.ok(Math.abs(stops(flat()) + 0.0589) < 5e-4, 'a flat ramp sits just under mid-grey');
|
||||
assert.equal(stops(spike(20, 0)), AUTO_EV_MAX, 'an empty histogram takes the ceiling, not infinity');
|
||||
|
||||
// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±5.
|
||||
assert.deepEqual(autoTone(spike(255)), { highlight: -5, shadow: 0 }, 'blown frame only pulls highlights');
|
||||
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 5 }, 'black frame only opens shadows');
|
||||
// The flat ramp is the interesting one: its own top 1% really is clipped and its
|
||||
// own bottom 1% really is on the floor, so both ends move. p99 = 253/255 = 0.992
|
||||
// (5 units), p01 = 2/255 = 0.008 (3 units).
|
||||
assert.deepEqual(autoTone(flat()), { highlight: -5, shadow: 3 }, 'an even ramp has both ends in play');
|
||||
// 1% of the frame at white over an otherwise mid ramp: the spec's small blown
|
||||
// window, and the shadow side stays put because the ramp's floor is not black.
|
||||
const blown = flat();
|
||||
blown[255] += 3;
|
||||
assert.deepEqual(autoTone(blown), { highlight: -5, shadow: 3 }, 'a blown 1% is enough to pull the top');
|
||||
// p99 landed just over the threshold: 989 pixels mid-ramp and 11 at bin 243 puts
|
||||
// p99 at 0.953, 0.053 over 0.9 -> 2.65 units -> 3. Proof the pull is graded.
|
||||
const shoulder = spike(100, 989);
|
||||
shoulder[243] = 11;
|
||||
assert.deepEqual(autoTone(shoulder), { highlight: -3, shadow: 0 }, 'p99 just over 0.9 pulls a third of the way');
|
||||
// 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves —
|
||||
// but only 3 units, because the tail is barely under the 0.02 threshold.
|
||||
const crushed = spike(128, 980);
|
||||
crushed[2] = 20;
|
||||
assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 3 }, 'a crushed 2% opens the shadows part way');
|
||||
// 0.4% on the floor is NOT the bottom 1%: the tenth-darkest pixel is the mid
|
||||
// ramp, so the threshold is not crossed and the knob stays put.
|
||||
const speck = spike(128, 996);
|
||||
speck[0] = 4;
|
||||
assert.deepEqual(autoTone(speck), { highlight: 0, shadow: 0 }, 'a speck of black is not a crushed frame');
|
||||
assert.deepEqual(autoTone(spike(0, 0)), { highlight: 0, shadow: 0 }, 'an empty histogram is left alone');
|
||||
|
||||
// --- White Balance: gray-world with G as the anchor, matched against the
|
||||
// engine's own kelvinToRGB, so a neutral frame must come back on 5500K / 0.
|
||||
assert.deepEqual(autoWhiteBalance(flat(), flat(), flat()), { temperature: 5500, tint: 0 }, 'neutral stays put');
|
||||
assert.deepEqual(
|
||||
autoWhiteBalance(spike(0), spike(0), spike(0)),
|
||||
{ temperature: 5500, tint: 0 },
|
||||
'a frame with no light in it has no cast to read',
|
||||
);
|
||||
// A mildly warm frame (R above G above B) is a frame lit too warmly, so the
|
||||
// ruler is asked to cool it — below 5500K. The cast sits on the planckian
|
||||
// locus, so no green/magenta trim is needed to reach it.
|
||||
const warm = autoWhiteBalance(spike(160), spike(150), spike(140));
|
||||
assert.ok(warm.temperature < 5500, `a warm frame cools: got ${warm.temperature}K`);
|
||||
assert.equal(warm.tint, 0, `and stays on the locus: got tint ${warm.tint}`);
|
||||
// A cast far warmer than the Kelvin ruler's travel cannot be cancelled, and the
|
||||
// two axes overlap once the temperature pins at its end — so this only asks for
|
||||
// the direction and for the trim staying small.
|
||||
const veryWarm = autoWhiteBalance(spike(180), spike(150), spike(120));
|
||||
assert.ok(veryWarm.temperature < 3000, `a harder warm cast cools further: got ${veryWarm.temperature}K`);
|
||||
assert.ok(Math.abs(veryWarm.tint) <= 5, `without a wild trim: got ${veryWarm.tint}`);
|
||||
// A frame that is only green is the one the TINT axis exists for: green is
|
||||
// trimmed toward magenta (positive), which no Kelvin move can do.
|
||||
const green = autoWhiteBalance(spike(120), spike(160), spike(120));
|
||||
assert.ok(green.tint > 5, `a green frame is trimmed toward magenta: got ${green.tint}`);
|
||||
|
||||
console.log('auto-tone-check ok');
|
||||
@@ -0,0 +1,31 @@
|
||||
// canvaskit and onnxruntime resolve their wasm at runtime, not through the
|
||||
// bundler, so the binaries have to be served assets. 20MB of wasm in the repo is
|
||||
// worse than 20MB copied out of node_modules at build time (the Docker build
|
||||
// stage does the same).
|
||||
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const copies = [
|
||||
['node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', 'public/wasm/canvaskit.wasm'],
|
||||
// onnxruntime-web's default entry (its own bundled glue) looks for the jsep
|
||||
// build of the runtime in `env.wasm.wasmPaths`; that build carries both the
|
||||
// WebGPU and the plain WebAssembly execution provider, so one file is enough.
|
||||
[
|
||||
'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm',
|
||||
'public/wasm/ort/ort-wasm-simd-threaded.jsep.wasm',
|
||||
],
|
||||
[
|
||||
'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs',
|
||||
'public/wasm/ort/ort-wasm-simd-threaded.jsep.mjs',
|
||||
],
|
||||
];
|
||||
|
||||
for (const [from, to] of copies) {
|
||||
if (!existsSync(from)) {
|
||||
console.error(`missing ${from} — run npm install first`);
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(`copied ${from} -> ${to}`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// The RAW develop hands its band plane to Skia as half (src/engine/halfFloat.ts)
|
||||
// because the GPU backend quantises an F32 image to the 1/255 grid — which is
|
||||
// exactly the grid the shadows live under. This checks the conversion itself:
|
||||
// the named encodings, and that no plane value in the sensor's range moves more
|
||||
// than the 16-bit plane step it came from.
|
||||
//
|
||||
// node scripts/half-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const src = readFileSync(new URL('../src/engine/halfFloat.ts', import.meta.url), 'utf8');
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const { f32ToF16 } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
|
||||
|
||||
function encode(...values) {
|
||||
const src32 = Float32Array.from(values);
|
||||
const out = new Uint16Array(values.length);
|
||||
f32ToF16(src32, out, values.length);
|
||||
return out;
|
||||
}
|
||||
// Independent decoder, from the IEEE 754 binary16 layout.
|
||||
function decode(h) {
|
||||
const sign = h & 0x8000 ? -1 : 1;
|
||||
const exp = (h >> 10) & 0x1f;
|
||||
const man = h & 0x3ff;
|
||||
if (exp === 0x1f) return sign * Infinity;
|
||||
if (exp === 0) return sign * man * 2 ** -24;
|
||||
return sign * (1 + man / 1024) * 2 ** (exp - 15);
|
||||
}
|
||||
|
||||
const named = [
|
||||
[0, 0x0000], [1, 0x3c00], [0.5, 0x3800], [2, 0x4000], [-1, 0xbc00],
|
||||
[2 ** -24, 0x0001], [1e-9, 0x0000], [70000, 0x7c00], [65504, 0x7bff],
|
||||
];
|
||||
for (const [v, bits] of named) {
|
||||
assert.equal(encode(v)[0], bits, `${v} -> expected ${bits.toString(16)}`);
|
||||
}
|
||||
|
||||
// Every value the plane can carry — a 14-bit sensor from its black floor to its
|
||||
// white level — has to come back within half precision's 2^-11 relative error,
|
||||
// and the shadows within a far finer absolute one, since that is where the
|
||||
// develop's black level lives. 1e-3 relative is a tenth of an output level at
|
||||
// white; the absolute bound covers the values whose relative error says nothing.
|
||||
let worstRel = 0, worstAbs = 0;
|
||||
for (let n = 0; n <= 16312; n++) {
|
||||
const v = n / 65535;
|
||||
const back = decode(encode(v)[0]);
|
||||
worstAbs = Math.max(worstAbs, Math.abs(back - v));
|
||||
if (v > 0) worstRel = Math.max(worstRel, Math.abs(back - v) / v);
|
||||
}
|
||||
assert.ok(worstRel < 1e-3, `worst relative error ${worstRel}`);
|
||||
assert.ok(worstAbs < 1e-4, `worst absolute error ${worstAbs} at raw ${Math.round(worstAbs * 65535)}`);
|
||||
console.log(`plane 0..16312: worst ${worstRel.toExponential(2)} relative, ${worstAbs.toExponential(2)} absolute`);
|
||||
|
||||
console.log('half-check ok');
|
||||
@@ -0,0 +1,86 @@
|
||||
// Highlight roll-off, both ends of the pipeline, as one soft knee:
|
||||
//
|
||||
// L' = L , L < T
|
||||
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
|
||||
//
|
||||
// The develop draws it on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)),
|
||||
// which puts the asymptote on 1.0) so the two stops the sensor holds above its
|
||||
// white level are COMPRESSED into the frame instead of being thrown away by the
|
||||
// old fade-to-white; the tone pass draws the same curve in linear light on the
|
||||
// value the develop and the camera match left, where -HL is the knob (T = 0.5,
|
||||
// S = |hl|). Before this, a blown sky left the develop on exactly 1.0 in all
|
||||
// three channels and HL had a flat white to pull on: measured on DSC03453.ARW,
|
||||
// where the camera's own preview is clipped the develop's luma was 253.4 with a
|
||||
// standard deviation of 2.4, against 251.2 / 10.0 through the knee.
|
||||
//
|
||||
// Both are SkSL, so the shape is pinned on the source; the curve itself is
|
||||
// checked as arithmetic, with the constants the source is asserted to carry.
|
||||
//
|
||||
// node scripts/highlight-knee-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
|
||||
const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8');
|
||||
|
||||
// The develop: knee on the sensor's max channel, the channel ratios kept, so the
|
||||
// hue and the saturation of a blown area survive the pull-down.
|
||||
const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0];
|
||||
assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again');
|
||||
assert.match(dev, /float over = mx - 0\.7;/);
|
||||
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
|
||||
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
|
||||
|
||||
// The tone pass: the knee runs in LINEAR light and before the luma is read, the
|
||||
// lift keeps its headroom weight — and the recovery must not also ride the
|
||||
// additive term, which would darken the white the knee protects.
|
||||
assert.match(tone, /if \(hl < 0\.0\) \{/);
|
||||
assert.match(tone, /vec3 lin = toLinear\(rgb\);/);
|
||||
assert.match(tone, /float l0 = dot\(lin, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
|
||||
assert.match(tone, /lin \*= \(0\.5 \+ over \/ \(1\.0 \+ S \* over \* 2\.0\)\) \/ l0;/);
|
||||
assert.match(tone, /rgb = clamp\(toEncoded\(lin\), 0\.0, 1\.0\);/);
|
||||
assert.match(tone, /float o = t \+ max\(hl, 0\.0\) \* hlMask \* \(1\.0 - t\) \+ sh \* 0\.34 \* shMask;/);
|
||||
// The transfer pair has to be the accurate one, or the knee is drawn in a space
|
||||
// that is not linear at all.
|
||||
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
|
||||
|
||||
// The arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is the develop's pair (S is what
|
||||
// puts the asymptote on 1.0: T + 1/(2S) = 1); T = 0.5 with S = 1 is the top of
|
||||
// the tone pass's knob.
|
||||
const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T)));
|
||||
|
||||
for (const [T, S] of [
|
||||
[0.7, 1 / (2 * (1 - 0.7))],
|
||||
[0.5, 0.25],
|
||||
[0.5, 1],
|
||||
]) {
|
||||
// Below the knee the frame is untouched, and the curve is continuous and C1 at
|
||||
// T — slope 1 on both sides — so there is no seam for a later pass to mask.
|
||||
assert.equal(knee(T - 0.2, T, S), T - 0.2);
|
||||
assert.equal(knee(T, T, S), T);
|
||||
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
|
||||
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
|
||||
// Monotone, and never a brightening: a recovery slider that lifted a highlight
|
||||
// would be a lift in disguise, and an inverted pair of pixels is a visible edge.
|
||||
let prev = -Infinity;
|
||||
for (let l = 0; l <= 2; l += 1 / 512) {
|
||||
assert.ok(slope(l) > 0, `inverted at ${l} (T=${T}, S=${S})`);
|
||||
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
|
||||
assert.ok(knee(l, T, S) >= prev);
|
||||
prev = knee(l, T, S);
|
||||
}
|
||||
// The asymptote: everything the sensor held above the knee lands under it.
|
||||
// The develop's pair puts it exactly on 1.0, the tone pass's S = 1 on 0.75.
|
||||
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
|
||||
}
|
||||
assert.ok(Math.abs(0.7 + 1 / (2 * (1 / (2 * (1 - 0.7)))) - 1) < 1e-9, 'the develop plateau left 1.0');
|
||||
// ...and the same pair in the encoded domain, which is the domain the develop
|
||||
// hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau
|
||||
// (1.93, see the gain above) on 248 — a ramp of a dozen code values where the
|
||||
// old fade-to-white left nothing above 250 at all.
|
||||
const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055);
|
||||
const DEV_S = 1 / (2 * (1 - 0.7));
|
||||
assert.equal(Math.round(enc(knee(1.0, 0.7, DEV_S)) * 255), 237);
|
||||
assert.equal(Math.round(enc(knee(1.93, 0.7, DEV_S)) * 255), 248);
|
||||
|
||||
console.log('highlight-knee-check ok');
|
||||
@@ -0,0 +1,226 @@
|
||||
// The catalogue, checked where it actually lives: in a browser. A directory
|
||||
// handle, IndexedDB, a grid of tiles and a handover into the studio have no
|
||||
// meaning outside one, so this is a Playwright run rather than a pure-node
|
||||
// assert.
|
||||
//
|
||||
// Only Chromium can hand a page a folder, and only a person can answer the
|
||||
// picker. The check stands in for both: it serves two real frames (one JPEG, one
|
||||
// RAW) off a local server and answers `showDirectoryPicker` with a folder made of
|
||||
// them. The stand-in handles carry their methods as *non-enumerable* properties,
|
||||
// which is what lets them go through IndexedDB's structured clone at all (a
|
||||
// clone copies enumerable own keys and drops the rest, and a function anywhere
|
||||
// else is a DataCloneError); the methods are put back where the catalogue reads
|
||||
// them, because a cloned handle is a plain object with no `getFile`.
|
||||
//
|
||||
// npm run build && node scripts/library-check.mjs
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/library-check.mjs
|
||||
//
|
||||
// Playwright is not a dependency of this package (a browser run needs it, a
|
||||
// build does not), so a run without it says SKIP and exits 0.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
|
||||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
|
||||
const JPG = 'P1010256.JPG';
|
||||
const RAW = 'P1010256.RW2';
|
||||
|
||||
let failures = 0;
|
||||
function step(name, ok, detail = '') {
|
||||
if (!ok) failures++;
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
|
||||
if (!playwright) {
|
||||
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- the two frames, off a local server (the page fetches them by CORS) ------
|
||||
const sampleServer = createServer(async (req, res) => {
|
||||
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
|
||||
if (name !== JPG && name !== RAW) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const bytes = await readFile(path.join(SAMPLES, name));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': bytes.length,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.end(bytes);
|
||||
});
|
||||
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
|
||||
|
||||
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
|
||||
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
|
||||
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||||
cwd: FRONTEND,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const base = `http://127.0.0.1:${APP_PORT}`;
|
||||
let up = false;
|
||||
for (let i = 0; i < 60 && !up; i++) {
|
||||
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
|
||||
if (!up) await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!up) {
|
||||
app.kill('SIGTERM');
|
||||
sampleServer.close();
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- the browser ------------------------------------------------------------
|
||||
const browser = await playwright.chromium.launch({
|
||||
// Playwright's own Chromium when it has one; CHROME points at any other.
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
||||
// The folder the picker would have returned, plus the revival of its handles on
|
||||
// the way back out of IndexedDB.
|
||||
await context.addInitScript(
|
||||
({ origin, names, folder }) => {
|
||||
const bytes = new Map();
|
||||
const load = async (name) => {
|
||||
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
|
||||
return bytes.get(name);
|
||||
};
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }),
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = () => {
|
||||
const handle = { kind: 'directory', name: folder };
|
||||
Object.defineProperties(handle, {
|
||||
values: { value: () => names.map(fileHandle)[Symbol.iterator]() },
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
window.showDirectoryPicker = async () => dirHandle();
|
||||
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
row.handle = row.handle.kind === 'directory' ? dirHandle() : fileHandle(row.handle.name);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
for (const method of ['get', 'getAll']) {
|
||||
const original = IDBObjectStore.prototype[method];
|
||||
IDBObjectStore.prototype[method] = function (...args) {
|
||||
const request = original.apply(this, args);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
|
||||
Object.defineProperty(request, 'result', {
|
||||
configurable: true,
|
||||
get() {
|
||||
const value = descriptor.get.call(this);
|
||||
return Array.isArray(value) ? value.map(revive) : revive(value);
|
||||
},
|
||||
});
|
||||
return request;
|
||||
};
|
||||
}
|
||||
},
|
||||
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG, RAW], folder: 'CheckRoll' }
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
const catalogue = () =>
|
||||
page.evaluate(async () => {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const all = (store) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = db.transaction(store).objectStore(store).getAll();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const [photos, edits] = await Promise.all([all('photos'), all('edits')]);
|
||||
const thumbs = [];
|
||||
for (const photo of photos) {
|
||||
const head = photo.thumb ? new Uint8Array(await photo.thumb.slice(0, 2).arrayBuffer()) : null;
|
||||
thumbs.push({
|
||||
id: photo.id,
|
||||
bytes: photo.thumb?.size ?? 0,
|
||||
jpeg: head ? head[0] === 0xff && head[1] === 0xd8 : false,
|
||||
});
|
||||
}
|
||||
return { thumbs, edits: edits.map((e) => ({ id: e.photoId, filter: e.recipe?.baseFilter, knobs: Object.keys(e.recipe?.adjustments ?? {}).length })) };
|
||||
});
|
||||
|
||||
// --- the run ----------------------------------------------------------------
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
const tiles = '[data-key^="lib-photo-"]';
|
||||
await page.waitForSelector(tiles, { timeout: 120_000 });
|
||||
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, tiles, { timeout: 120_000 });
|
||||
step('both frames indexed as tiles', true, `2 tiles from ${JPG} + ${RAW}`);
|
||||
|
||||
const indexed = await catalogue();
|
||||
for (const name of [JPG, RAW]) {
|
||||
const row = indexed.thumbs.find((t) => t.id.endsWith(`/${name}`));
|
||||
step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`);
|
||||
}
|
||||
|
||||
// One tap on a tile hands the frame to the studio. The RAW is the harder half —
|
||||
// it has to be read back off the (stand-in) handle and developed — so it is the
|
||||
// one that gets opened.
|
||||
const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id;
|
||||
await page.click(`[data-key="lib-open-${rawId}"]`);
|
||||
await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 });
|
||||
await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 });
|
||||
step('studio developed the frame from its handle', true, 'stage no longer on the drop zone');
|
||||
|
||||
// The handover is an address, not a state: the studio puts the frame into its
|
||||
// own store and drops the query, so a reload reopens it like any other photo.
|
||||
step('the address was handed back', !page.url().includes('lib='), `url=${page.url()}`);
|
||||
|
||||
const editsIn = () =>
|
||||
page.evaluate(async () => {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return await new Promise((resolve) => {
|
||||
const req = db.transaction('edits').objectStore('edits').getAll();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
});
|
||||
});
|
||||
// The recipe is written 600ms after the last change (a slider drag fires it per
|
||||
// frame), so the row arrives a beat after the frame does.
|
||||
let filed = [];
|
||||
for (let i = 0; i < 40 && !filed.length; i++) {
|
||||
filed = await editsIn();
|
||||
if (!filed.length) await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed));
|
||||
|
||||
await page.goto(`${base}/library`);
|
||||
await page.waitForSelector(`[data-key="lib-photo-${rawId}"]`);
|
||||
const meta = await page.locator(`[data-key="lib-photo-${rawId}"] .adm-meta`).innerText();
|
||||
step('the tile says the frame is edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · '));
|
||||
|
||||
await browser.close();
|
||||
sampleServer.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,71 @@
|
||||
// The manifest needs its icons at fixed sizes, and the only artwork there is ships
|
||||
// at 256x256 (public/assets/RecipesCamIcon.png). Skia is already a dependency — the
|
||||
// engine grades through it — so it does the resize here rather than a new image
|
||||
// tool or a runtime fetch of an oversized PNG. One-off, run by hand: the output is
|
||||
// committed, so a build never has to decode and rescale anything.
|
||||
//
|
||||
// node scripts/make-icons.mjs
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
||||
|
||||
const SOURCE = 'public/assets/RecipesCamIcon.png';
|
||||
const OUT = 'public/icons';
|
||||
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () =>
|
||||
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
|
||||
// Buffers are typed arrays already, but Skia's binding wants one it can read as a
|
||||
// plain byte view, not node's pooled slab.
|
||||
const art = CanvasKit.MakeImageFromEncoded(new Uint8Array(readFileSync(SOURCE)));
|
||||
if (!art) throw new Error(`${SOURCE} is not an image Skia can decode`);
|
||||
|
||||
// `scale` is the art's share of the square. The plain icons bleed to the edge,
|
||||
// exactly as the source does. The maskable one cannot: the launcher supplies the
|
||||
// mask, and most of them are circles, so the art has to stay inside the 80%
|
||||
// safe zone the spec draws — the background around it is the app's own #0a0a0a
|
||||
// rather than nothing at all, because a masked icon with transparent corners
|
||||
// shows whatever the launcher paints behind it.
|
||||
const icons = [
|
||||
{ file: 'icon-192.png', size: 192, scale: 1, background: false },
|
||||
{ file: 'icon-512.png', size: 512, scale: 1, background: false },
|
||||
{ file: 'maskable-512.png', size: 512, scale: 0.8, background: true },
|
||||
];
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
for (const { file, size, scale, background } of icons) {
|
||||
const surface = CanvasKit.MakeSurface(size, size);
|
||||
if (!surface) throw new Error(`could not make a ${size}x${size} surface`);
|
||||
const canvas = surface.getCanvas();
|
||||
|
||||
if (background) {
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setColor(CanvasKit.Color(10, 10, 10));
|
||||
canvas.drawPaint(paint);
|
||||
paint.delete();
|
||||
}
|
||||
|
||||
const side = size * scale;
|
||||
const offset = (size - side) / 2;
|
||||
// Mitchell cubic (B = C = 1/3): the 256 -> 192 step is a downscale, where
|
||||
// nearest or bilinear would drop detail off the camera's thin outlines.
|
||||
canvas.drawImageRectCubic(
|
||||
art,
|
||||
CanvasKit.XYWHRect(0, 0, art.width(), art.height()),
|
||||
CanvasKit.XYWHRect(offset, offset, side, side),
|
||||
1 / 3,
|
||||
1 / 3,
|
||||
null,
|
||||
);
|
||||
surface.flush();
|
||||
|
||||
const png = surface.makeImageSnapshot().encodeToBytes();
|
||||
surface.dispose();
|
||||
if (!png) throw new Error(`could not encode ${file}`);
|
||||
writeFileSync(`${OUT}/${file}`, png);
|
||||
console.log(`${OUT}/${file} ${size}x${size} ${png.length} bytes`);
|
||||
}
|
||||
|
||||
art.delete();
|
||||
@@ -0,0 +1,102 @@
|
||||
// The per-file colour fit is pure arithmetic, so it can be checked here rather
|
||||
// than in a browser: hand fitMatch a grid and the same grid through a known 3x3,
|
||||
// and it has to hand that 3x3 back. Done that way because the function only ever
|
||||
// runs inside the RAW develop, which needs LibRaw and CanvasKit — neither of
|
||||
// which a node check has. The module is TypeScript, so it is transpiled on the
|
||||
// fly out of the installed compiler (the repo's convention for checks: see
|
||||
// raw-develop-check.mjs, which reads the shader source instead).
|
||||
//
|
||||
// node scripts/preview-match-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const file = new URL('../src/engine/previewMatch.ts', import.meta.url);
|
||||
const js = ts.transpileModule(readFileSync(file, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const { fitMatch, MATCH_GRID } = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`
|
||||
);
|
||||
|
||||
// A grid with colour in it: three independent ramps, none of them clipped and
|
||||
// none at the floor, so every block carries something to fit. Independent on
|
||||
// purpose — three correlated channels leave the 3x3 ill-conditioned, and a fit
|
||||
// that comes back a percent off on such a scene says nothing about the code.
|
||||
function scene() {
|
||||
let s = 1;
|
||||
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
|
||||
const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
|
||||
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
|
||||
for (let c = 0; c < 3; c++) g[i * 4 + c] = 25 + Math.round(rnd() * 190);
|
||||
g[i * 4 + 3] = 255;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
const M = [1.06, -0.05, 0.01, 0.02, 0.98, 0.03, -0.04, 0.06, 1.02];
|
||||
const dev = scene();
|
||||
const ref = new Uint8Array(dev.length);
|
||||
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
|
||||
const e = [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]];
|
||||
for (let r = 0; r < 3; r++) {
|
||||
const v = M[r * 3] * e[0] + M[r * 3 + 1] * e[1] + M[r * 3 + 2] * e[2];
|
||||
ref[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
|
||||
}
|
||||
ref[i * 4 + 3] = 255;
|
||||
}
|
||||
|
||||
const fit = fitMatch(dev, ref);
|
||||
assert.ok(fit, 'a fitted grid has to fit');
|
||||
for (let r = 0; r < 3; r++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
assert.ok(Math.abs(fit.m[r * 3 + c] - M[r * 3 + c]) < 0.01, `recovered ${fit.m[r * 3 + c]} for ${M[r * 3 + c]}`);
|
||||
}
|
||||
}
|
||||
// This scene was made by a 3x3 and nothing else, so the curve fitted on what the
|
||||
// 3x3 leaves has to come back flat — the identity, up to the rounding the scene
|
||||
// carries.
|
||||
assert.equal(fit.tone.length, 12);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const flat = i % 4 === 1 ? 1 : 0;
|
||||
assert.ok(Math.abs(fit.tone[i] - flat) < 0.15, `tone ${i} came back ${fit.tone[i]}, not ${flat}`);
|
||||
}
|
||||
|
||||
// Nothing to fit against: the camera clipped the whole frame, so no block says
|
||||
// anything about its rendering, and the caller develops as the sensor left it.
|
||||
assert.equal(fitMatch(dev, new Uint8Array(dev.length).fill(255)), null);
|
||||
|
||||
// A near-neutral frame through a matrix whose rows carry a negative element — what
|
||||
// the bodies the fit used to refuse looked like (X-T3 -0.04, X100V -1.67 on rows
|
||||
// that also carry +1.05 and +1.63). Three channels that share nearly all their
|
||||
// content leave the normal equations near-singular, and the plain solve came back
|
||||
// with a diagonal element at or below zero, which the caller read as a degeneracy
|
||||
// and dropped the whole fit for. It is a colour: the ridge lifts the system back
|
||||
// to definite and the fit has to come back a matrix.
|
||||
function neutral() {
|
||||
let s = 7;
|
||||
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
|
||||
const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
|
||||
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
|
||||
const x = 40 + Math.round(rnd() * 180);
|
||||
for (let c = 0; c < 3; c++) g[i * 4 + c] = Math.max(0, Math.min(255, x + Math.round((rnd() - 0.5) * 8)));
|
||||
g[i * 4 + 3] = 255;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
const N = [1.05, -1.67, 1.63, 0.02, 0.95, 0.03, -0.04, 0.06, 1.02];
|
||||
const nDev = neutral();
|
||||
const nRef = new Uint8Array(nDev.length);
|
||||
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
|
||||
const e = [nDev[i * 4], nDev[i * 4 + 1], nDev[i * 4 + 2]];
|
||||
for (let r = 0; r < 3; r++) {
|
||||
const v = N[r * 3] * e[0] + N[r * 3 + 1] * e[1] + N[r * 3 + 2] * e[2];
|
||||
nRef[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
|
||||
}
|
||||
nRef[i * 4 + 3] = 255;
|
||||
}
|
||||
const nFit = fitMatch(nDev, nRef);
|
||||
assert.ok(nFit, 'a near-neutral frame has to fit, not come back null');
|
||||
for (const v of nFit.m) assert.ok(Number.isFinite(v) && Math.abs(v) <= 4, `degenerate fit ${v}`);
|
||||
|
||||
console.log('preview-match-check ok');
|
||||
@@ -0,0 +1,86 @@
|
||||
// Install support is not visible in the source. It is the manifest the browser
|
||||
// reads, the worker it registers, and what that worker still holds once the network
|
||||
// is gone — so unlike the other checks here, which read a module and assert on it,
|
||||
// this one has to drive a real browser at a real build. Four assertions, one line
|
||||
// each, against a preview server:
|
||||
//
|
||||
// npx vite build && npx vite preview --port 4183 &
|
||||
// node scripts/pwa-check.mjs
|
||||
//
|
||||
// The browser is Playwright's own, installed by whoever runs this — there is no
|
||||
// test runner in this repo, and software GL is what the studio needs to paint at
|
||||
// all. PLAYWRIGHT_CORE points at another copy, CHROME at another browser.
|
||||
const BASE = process.argv[2] ?? 'http://localhost:4183';
|
||||
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
|
||||
if (!playwright) {
|
||||
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
|
||||
process.exit(0);
|
||||
}
|
||||
const { chromium } = playwright;
|
||||
|
||||
const browser = await chromium.launch({
|
||||
// Playwright's own Chromium when it has one; CHROME points at any other.
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
||||
// Every assertion prints, rather than throwing on the first one: which of the four
|
||||
// works and which does not is the whole answer.
|
||||
let failed = 0;
|
||||
const check = (ok, label) => {
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`);
|
||||
if (!ok) failed++;
|
||||
};
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${BASE}/app`);
|
||||
|
||||
// (a) The manifest, as the browser fetches it before it offers anything.
|
||||
const manifest = await page.evaluate(async () => await (await fetch('/manifest.json')).json());
|
||||
check(Array.isArray(manifest.icons) && manifest.icons.length === 3, `/manifest.json parses with ${manifest.icons?.length} icons`);
|
||||
|
||||
// (b) The worker registration main.tsx makes on load. `ready` only ever resolves
|
||||
// with a registration that has an `active` worker; that worker's own state string
|
||||
// is 'activated' (the ServiceWorkerState enum has no 'active' in it).
|
||||
const registration = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
return { scope: reg.scope, state: reg.active?.state ?? null };
|
||||
});
|
||||
check(registration.state === 'activated', `serviceWorker.ready resolves — scope ${registration.scope}, active.state '${registration.state}'`);
|
||||
|
||||
// (c) What it put away. Read after a reload, because the first load is the one that
|
||||
// installs it and the reload is the first one it is in control of.
|
||||
await page.waitForFunction(async () => (await caches.match(new URL('/app', location.origin).href)) !== undefined);
|
||||
await page.reload();
|
||||
const cached = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
for (const name of await caches.keys()) {
|
||||
out[name] = (await (await caches.open(name)).keys()).map((r) => new URL(r.url).pathname);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const names = Object.keys(cached);
|
||||
const urls = Object.values(cached).flat().sort();
|
||||
check(
|
||||
urls.includes('/') && urls.includes('/app') && urls.some((u) => /^\/assets\/index-/.test(u)),
|
||||
`worker cache ${names.join(', ')} after reload: ${urls.join(' ')}`,
|
||||
);
|
||||
|
||||
// (d) The point of all of it: the network gone, the shell still paints.
|
||||
await context.setOffline(true);
|
||||
let brand = 'nothing';
|
||||
try {
|
||||
await page.reload();
|
||||
brand = await page.evaluate(() => document.querySelector('header.header .brand')?.textContent ?? null);
|
||||
} catch (err) {
|
||||
brand = `reload failed: ${err.message.split('\n')[0]}`;
|
||||
}
|
||||
check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`);
|
||||
|
||||
await browser.close();
|
||||
if (failed) {
|
||||
console.error(`pwa-check: ${failed} of 4 failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('pwa-check ok');
|
||||
@@ -0,0 +1,122 @@
|
||||
// LibRaw subtracts the black level itself, whatever `noAutoScale` says, so the
|
||||
// develop must not do it again: a second subtraction drained red and blue — the
|
||||
// channels `cam_mul` lifts most — and turned every Sony ARW green. This pins that
|
||||
// down on the source, since the develop itself only runs in a browser (LibRaw
|
||||
// worker + CanvasKit).
|
||||
//
|
||||
// node scripts/raw-develop-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const src = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
|
||||
const sksl = src.match(/const RAW_DEVELOP_SKSL = `([\s\S]*?)`;/)?.[1];
|
||||
assert.ok(sksl, 'RAW_DEVELOP_SKSL not found');
|
||||
// The prose above the code talks about the black level, so drop the comments.
|
||||
const code = sksl.replace(/\/\/[^\n]*/g, '');
|
||||
|
||||
// The plane comes in already black-subtracted, so its only scale is the white
|
||||
// level less the black level, handed over as one inverse.
|
||||
assert.match(code, /uniform float gain;/);
|
||||
assert.doesNotMatch(code, /uniform float4 black;/);
|
||||
assert.doesNotMatch(code, /-\s*black/, 'the shader subtracts the black level again');
|
||||
assert.match(code, /float3 n = max\(p\.rgb \* gain, 0\.0\);/);
|
||||
assert.match(code, /float3 lin = n \* mul\.rgb;/);
|
||||
|
||||
// The WB gains, applied where the sensor has already clipped, are what leaves the
|
||||
// blown areas magenta, so the shader has to read the sensor's own levels — before
|
||||
// the gains — and desaturate the pixel towards its own value as the clip is
|
||||
// approached. Measured on the FX30 ARW: the camera's own JPEG has an all-white top
|
||||
// percentile (R/G 0.995, B/G 0.999), the develop without this line came back at
|
||||
// R/G 1.017, B/G 0.862 — a warm tint on every blown area.
|
||||
//
|
||||
// The clip to read is the one the gains make as well as the sensor's own: on a body
|
||||
// whose gains lift red and blue (the Ricoh GR: `cam_mul` [2.64, 1, 1.73]) a blown
|
||||
// sky crosses the white level at 0.38 of the raw range in red and 0.58 in blue while
|
||||
// green only crosses at 1.0, so a gate read on the sensor's levels alone stayed shut
|
||||
// across the whole sky. Measured on that frame in the app against the camera's own
|
||||
// JPEG: mean dRGB +1.2, -5.9, -6.1 with the sensor's clip alone, +0.8, +1.0, +1.1
|
||||
// with both, mean |dL| 21.5 against 11.1.
|
||||
assert.match(
|
||||
code,
|
||||
/float hi = max\(max\(n\.r, n\.g\), n\.b\);\s*hi = max\(hi, max\(max\(lin\.r, lin\.g\), lin\.b\)\);\s*rgb = mix\(rgb, float3\(mx\), smoothstep\(0\.95, 1\.0, hi\)\);/
|
||||
);
|
||||
|
||||
// The inverse is built on the CPU side and has to be the first uniform of the
|
||||
// buffer the shader reads as `gain`, and its divisor has to be the white level
|
||||
// the frame itself ran out at — `maximum` alone left every frame a stop bright,
|
||||
// and a fixed factor two only fitted the two bodies it was measured on. The
|
||||
// probe and its own check live in src/engine/sensorWhite.ts.
|
||||
assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ sensorWhite\(data, cd\.maximum, cd\.black\);$/m);
|
||||
assert.match(src, /^import \{ sensorWhite \} from '\.\/sensorWhite';$/m);
|
||||
|
||||
// On the FX30 that inverse is 2.065 (65535 / (2 (16380 - 512))), against the
|
||||
// 4.13 the white level alone gives. The camera's own JPEG lands on the 2.065:
|
||||
// 0.05% of its pixels at pure white against the develop's 0.76% at 4.13 — which
|
||||
// is also what the probe has to fall back to on a frame with nothing blown.
|
||||
assert.ok(65535 / (2 * (16380 - 512)) - 2.065 < 0.001, 'the white level factor dropped out');
|
||||
const probe = readFileSync(new URL('../src/engine/sensorWhite.ts', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
probe,
|
||||
/if \(top < maximum - black\) return legacy;/,
|
||||
'a frame with nothing blown no longer falls back to the fixed factor'
|
||||
);
|
||||
|
||||
// What a RAW opens as is the develop of its own sensor data, fitted to the
|
||||
// preview the camera wrote into the file — so the file opens at the colour the
|
||||
// body chose and at the resolution its sensor has. The preview is the reference
|
||||
// and the fallback, never the frame: a file with no preview of its own is not
|
||||
// fitted, and there is no body table behind the fit any more (a table fitted on
|
||||
// one develop stops matching when the develop changes under it, and it did: the
|
||||
// highlight knee left every body that had one with a cast).
|
||||
assert.match(src, /preview = await cameraPreview\(raw\);/);
|
||||
assert.match(src, /const match = blocks && ref \? fitMatch\(blocks, ref\) : null;/);
|
||||
assert.doesNotMatch(src, /cameraMatch/, 'the body table is back');
|
||||
assert.doesNotMatch(code, /uniform float4 w0;/, 'the body table is back in the shader');
|
||||
// The fit is drawn by the develop that was fitted, so the frame goes through it
|
||||
// twice: once on the sensor alone, to fit against the preview, and then again
|
||||
// with the fit in the shader.
|
||||
assert.match(src, /const first = develop\(null\);/);
|
||||
assert.match(src, /const matched = match \? develop\(match\) : null;/);
|
||||
assert.match(src, /const jpeg = \(matched \?\? first\)\.encodeToBytes/);
|
||||
assert.match(src, /if \(preview\) return preview;/);
|
||||
assert.match(src, /if \(thumb\?\.format !== 'jpeg' \|\| !thumb\.data\?\.length\) return null;/);
|
||||
|
||||
// The fit has to land on the encoded value in float, not as an 8-bit colour filter
|
||||
// painted over the frame afterwards: the fit carries an exposure, and a channel it
|
||||
// lifted past the white level was cut where it stood — blue first, the channel the
|
||||
// WB gains lift most — which is what left the bright end of the frame short of
|
||||
// blue. Measured on the A5100 frame, pixels at 255: 5.25% blue, 2.10% of them blue
|
||||
// alone through the filter, against 2.46% / 2.44% through this path.
|
||||
//
|
||||
// No rolloff after the fit. The fit is luma-preserving, not white-preserving —
|
||||
// its rows sum to ~0.79 / 1.07 / 0.92, so white leaves it past 1.0 in green only —
|
||||
// and dividing by that max pulled red and blue down with it: highlights came out
|
||||
// cyan and not one cell of the frame reached white on all three channels (0.0%)
|
||||
// against 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6.
|
||||
assert.match(code, /uniform float4 f0;/);
|
||||
assert.doesNotMatch(src, /drawMatched|colorMatrix/, 'the fit is painted through a colour filter again');
|
||||
assert.match(code, /float3 q = clamp\(float3\(dot\(f0\.xyz, e\), dot\(f1\.xyz, e\), dot\(f2\.xyz, e\)\), 0\.0, 1\.0\);/);
|
||||
assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back');
|
||||
|
||||
// A 3x3 can only scale a channel and the gap to the camera is mostly a shape, so
|
||||
// the frame leaves through the per-channel curve the fit carries as well. Without
|
||||
// it the shadows stayed bright and green: measured on the A5100 frame, dE00 6.6
|
||||
// against 4.4, and at the bottom of the lightness range dL +13.8 with green
|
||||
// +0.128 against +4.5 and +0.017 through the curve (both against the camera's own
|
||||
// JPEG).
|
||||
assert.match(code, /float tone\(float4 w, float x\) \{/);
|
||||
assert.match(code, /return half4\(half3\(tone\(t0, q\.r\), tone\(t1, q\.g\), tone\(t2, q\.b\)\), 1\.0\);/);
|
||||
assert.match(code, /uniform float4 t0;/);
|
||||
// ...and it is the tail of the uniform buffer, one float4 per channel, which the
|
||||
// second develop overwrites on the buffer it already built.
|
||||
assert.match(src, /^ *uniforms\.set\(fit\?\.tone \?\? FLAT_TONE, 33\);$/m);
|
||||
assert.match(src, /const uniforms = new Float32Array\(45\);/);
|
||||
assert.match(src, /^ *uniforms\[21\] = f\[0\]; uniforms\[22\] = f\[1\]; uniforms\[23\] = f\[2\]; uniforms\[24\] = 0;$/m);
|
||||
|
||||
// A RAW opens at the sensor's own resolution, not at the quarter the half-size
|
||||
// demosaic reports: the GR's DNG came back 3010x2012 against the 6000x4000 of the
|
||||
// camera's own JPEG beside it. With the flag off the same develop returns
|
||||
// 6020x4024, and every one of the eight bodies checked doubled its frame.
|
||||
assert.match(src, /^ *halfSize: false,$/m, 'the develop is half-size again');
|
||||
|
||||
console.log('raw-develop-check ok');
|
||||
@@ -0,0 +1,85 @@
|
||||
# The GPU path runs the upscaler in half precision. The chip is given 2.3MB of
|
||||
# weights instead of 4.9MB, and where its shaders can multiply in fp16 (the
|
||||
# adapter's `shader-f16`) it does twice the work per pass.
|
||||
#
|
||||
# The export itself is unchanged: the tensor the app hands over is still float32,
|
||||
# and the two Cast nodes below are the model's own edge, so nothing in
|
||||
# superRes.ts or App.tsx has to know which copy it got.
|
||||
#
|
||||
# python3 realesr-fp16.py ../public/models/realesr-general-x4v3.onnx \
|
||||
# ../public/models/realesr-general-x4v3-f16.onnx
|
||||
#
|
||||
# The source is the PReLU-rewritten model realesr-gpu.py writes — this runs
|
||||
# after that one, never instead of it.
|
||||
#
|
||||
# onnxconverter-common's `keep_io_types` rewrites the input's own consumers and
|
||||
# misses the one that does not go through the network: this model adds a Resize
|
||||
# of the ORIGINAL photo to the upsampler's output, that Resize reads the graph
|
||||
# input directly, and the runtime refuses a graph whose final Add mixes the two.
|
||||
# The consumer is rewired onto the cast here, which is all `keep_io_types` should
|
||||
# have done.
|
||||
#
|
||||
# Its second miss is Resize's `scales`, which ONNX defines as a float input
|
||||
# whatever the graph around it is doing, and which came out half precision. A
|
||||
# runtime that loads the file at all rejects the whole graph for it — "Type
|
||||
# 'tensor(float16)' of input parameter (/Constant_output_0) of operator (Resize)
|
||||
# is invalid" — on the GPU as much as on the processor. Both are put right here.
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import onnx
|
||||
from onnx import numpy_helper
|
||||
from onnxconverter_common import float16
|
||||
|
||||
# Resize's own float inputs: the region of interest and the scales. Neither is
|
||||
# part of the picture, so neither has any business in half precision.
|
||||
FLOAT_OPERAND = {("Resize", 1), ("Resize", 2)}
|
||||
|
||||
|
||||
def convert(src, dst):
|
||||
model = onnx.load(src)
|
||||
half = float16.convert_float_to_float16(model, keep_io_types=True, disable_shape_infer=True)
|
||||
graph = half.graph
|
||||
|
||||
io = graph.input[0].name
|
||||
cast = next(n for n in graph.node if n.op_type == "Cast" and list(n.input) == [io])
|
||||
rewired = 0
|
||||
for node in graph.node:
|
||||
for i, name in enumerate(node.input):
|
||||
if name == io and node is not cast:
|
||||
node.input[i] = cast.output[0]
|
||||
rewired += 1
|
||||
|
||||
widened = 0
|
||||
producer = {o: n for n in graph.node for o in n.output}
|
||||
for node in graph.node:
|
||||
for i, name in enumerate(node.input):
|
||||
if (node.op_type, i) not in FLOAT_OPERAND or not name:
|
||||
continue
|
||||
src_node = producer.get(name)
|
||||
if src_node is None or src_node.op_type != "Constant":
|
||||
continue
|
||||
t = src_node.attribute[0].t
|
||||
if t.data_type != onnx.TensorProto.FLOAT16:
|
||||
continue
|
||||
arr = numpy_helper.to_array(t).astype(np.float32)
|
||||
src_node.attribute[0].t.CopyFrom(numpy_helper.from_array(arr, t.name))
|
||||
widened += 1
|
||||
|
||||
# The tensor the app builds is float32 and has to stay that way: a model whose
|
||||
# input is fp16 would need the pixels converted in JavaScript, where there is
|
||||
# no half-float array to convert them into.
|
||||
assert graph.input[0].type.tensor_type.elem_type == onnx.TensorProto.FLOAT
|
||||
assert graph.output[0].type.tensor_type.elem_type == onnx.TensorProto.FLOAT
|
||||
assert rewired >= 1, "the input's direct consumer is gone: is this still the same model?"
|
||||
assert widened >= 1, "Resize's scales are still fp16: the runtime will reject the graph"
|
||||
ons = sum(1 for i in graph.initializer if i.data_type == onnx.TensorProto.FLOAT16)
|
||||
assert ons == len(graph.initializer), f"{ons}/{len(graph.initializer)} weights are fp16"
|
||||
|
||||
onnx.save(half, dst)
|
||||
print(f"{len(graph.node)} nodes, {rewired} graph input consumer(s) rewired, {widened} Resize operand(s) widened back to float, {ons} fp16 weights -> {dst}")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
convert(sys.argv[1], sys.argv[2])
|
||||
@@ -0,0 +1,73 @@
|
||||
# The WebGPU execution provider has no PReLU kernel. The upscaler is 34 convolutions
|
||||
# with a PReLU after every one of them, so the runtime splits the graph 33 times:
|
||||
# each activation travels off the GPU to the processor and the result travels back,
|
||||
# a 64-channel map in both directions, per tile. A machine with a good graphics chip
|
||||
# was therefore not exporting any faster for having it.
|
||||
#
|
||||
# Relu, Neg, Mul and Sub it does have, and
|
||||
# PRelu(x) = Relu(x) - slope * Relu(-x)
|
||||
# holds exactly, negative slopes included, so this rewrites the activations into
|
||||
# those four and leaves the weights untouched.
|
||||
#
|
||||
# python3 realesr-gpu.py <downloaded>.onnx public/models/realesr-general-x4v3.onnx
|
||||
#
|
||||
# The source is the x4v3 model of https://github.com/xinntao/Real-ESRGAN
|
||||
# (realesr-general-x4v3.pth, converted to ONNX at opset 17).
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import onnx
|
||||
from onnx import TensorProto, helper, numpy_helper
|
||||
|
||||
|
||||
def rewrite(src, dst):
|
||||
model = onnx.load(src)
|
||||
graph = model.graph
|
||||
rank = len(graph.input[0].type.tensor_type.shape.dim)
|
||||
by_name = {i.name: i for i in graph.initializer}
|
||||
|
||||
nodes, made = [], 0
|
||||
for node in graph.node:
|
||||
if node.op_type != "PRelu":
|
||||
nodes.append(node)
|
||||
continue
|
||||
x, slope = node.input
|
||||
# The slope comes out of PyTorch as (C,1,1) and has to line up with a
|
||||
# (1,C,H,W) activation, so give it the batch axis it is missing.
|
||||
s = numpy_helper.to_array(by_name[slope])
|
||||
if s.shape != (1,) + tuple(s.shape):
|
||||
s = s.reshape((1,) + tuple(s.shape))
|
||||
name = f"{slope}__broadcast"
|
||||
if name not in by_name:
|
||||
graph.initializer.append(numpy_helper.from_array(s, name))
|
||||
by_name[name] = graph.initializer[-1]
|
||||
slope = name
|
||||
|
||||
p, n, q, scaled = f"{node.output[0]}.pos", f"{node.output[0]}.neg", f"{node.output[0]}.relu", f"{node.output[0]}.scaled"
|
||||
nodes += [
|
||||
helper.make_node("Relu", [x], [p], name=p),
|
||||
helper.make_node("Neg", [x], [n], name=n),
|
||||
helper.make_node("Relu", [n], [q], name=q),
|
||||
helper.make_node("Mul", [q, slope], [scaled], name=scaled),
|
||||
helper.make_node("Sub", [p, scaled], [node.output[0]], name=node.name),
|
||||
]
|
||||
made += 1
|
||||
|
||||
del graph.node[:]
|
||||
graph.node.extend(nodes)
|
||||
# The slopes the PReLU nodes used to read are now unreachable, and the runtime
|
||||
# warns about every one of them while it loads unless they go.
|
||||
used = {name for node in graph.node for name in node.input}
|
||||
kept = [i for i in graph.initializer if i.name in used]
|
||||
print(f"dropping {len(graph.initializer) - len(kept)} unused initializers")
|
||||
del graph.initializer[:]
|
||||
graph.initializer.extend(kept)
|
||||
onnx.checker.check_model(model)
|
||||
onnx.save(model, dst)
|
||||
print(f"rewrote {made} PReLU nodes -> {dst}")
|
||||
assert made, "no PReLU node found: wrong model?"
|
||||
assert rank >= 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rewrite(sys.argv[1], sys.argv[2])
|
||||
@@ -0,0 +1,80 @@
|
||||
// Where the develop's white level comes from (src/engine/sensorWhite.ts). The
|
||||
// probe reads the frame instead of trusting a constant: the plane's ceiling is
|
||||
// 1.89-2.00x `maximum - black` and which one depends on the body, and a frame
|
||||
// that sat below the desaturation's 0.95 start came out tinted (Fuji X-Trans,
|
||||
// measured 1.892). These pin the two states it has to tell apart — a blown
|
||||
// plateau (spike plus gap) and a bright smooth sky (a top bin no bigger than the
|
||||
// ones under it) — and the fall-backs on frames with nothing to measure.
|
||||
//
|
||||
// node scripts/white-level-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const src = readFileSync(new URL('../src/engine/sensorWhite.ts', import.meta.url), 'utf8');
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const { sensorWhite } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
|
||||
|
||||
const N = 1_000_000;
|
||||
const MAXIMUM = 16383;
|
||||
const BLACK = 512;
|
||||
const LEGACY = 2 * (MAXIMUM - BLACK);
|
||||
const FLOOR = 8192; // an unremarkable mid-tone the frame is mostly made of
|
||||
|
||||
// A frame of `N` mid-tone samples plus `counts` of them sitting in the nine bins
|
||||
// from top-8 up to top: counts[8] is the top bin, counts[0] the one eight below.
|
||||
function plane(top, counts) {
|
||||
const data = new Uint16Array(N);
|
||||
data.fill(FLOOR);
|
||||
let i = 0;
|
||||
for (let d = 0; d < 9; d++) for (let k = 0; k < counts[d]; k++) data[i++] = top - 8 + d;
|
||||
return data;
|
||||
}
|
||||
|
||||
// The sensor's own plateau: a pile at the top with nine near-empty bins under it.
|
||||
assert.equal(sensorWhite(plane(30995, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), 30995, 'X-Trans plateau missed');
|
||||
assert.equal(sensorWhite(plane(31779, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), 31779, 'Sony plateau missed');
|
||||
// ...and the body that really does sit at twice the white level is read as such,
|
||||
// not as the constant it used to be. Same answer either way, which is the point:
|
||||
// the probe does not need to know which body it has.
|
||||
assert.equal(sensorWhite(plane(LEGACY, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), LEGACY);
|
||||
|
||||
// A smooth sky fills the bins around the top far more than a clipped one does,
|
||||
// and its top bin is a tail, not a step: no gap, no clip, no move of the white.
|
||||
assert.equal(
|
||||
sensorWhite(plane(30995, [400, 900, 1500, 2000, 2400, 2600, 2600, 2400, 2000]), MAXIMUM, BLACK),
|
||||
LEGACY,
|
||||
'a smooth top read as a clip'
|
||||
);
|
||||
// A bright flat wall sits exactly on one value with a big pile — but the bins
|
||||
// under it are empty too, so that one IS a clip and is meant to move the white.
|
||||
assert.equal(sensorWhite(plane(30995, [0, 0, 0, 0, 0, 0, 0, 0, 5000]), MAXIMUM, BLACK), 30995);
|
||||
|
||||
// Nothing to measure: a frame that never reached the plane's ceiling, and one
|
||||
// whose only pile is a handful of hot pixels, both stay on the fixed factor two.
|
||||
assert.equal(sensorWhite(plane(9000, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), LEGACY, 'an unblown frame moved the white');
|
||||
assert.equal(sensorWhite(plane(30995, [0, 0, 0, 0, 0, 0, 0, 0, 5]), MAXIMUM, BLACK), LEGACY, 'hot pixels moved the white');
|
||||
|
||||
// A hot pixel above the plateau is not the ceiling: the window is hung off the
|
||||
// pile, not off the frame's largest sample. Measured on a Panasonic DMC-LX10 RW2
|
||||
// — a pile of 3.1M at 13855 with one sample at 15993 over it — reading the stray
|
||||
// as the top left the nine bins under it empty, the frame fell back on the factor
|
||||
// two (7908 against its own 13855) and opened 0.8 of a stop bright with its
|
||||
// highlights clipped flat.
|
||||
const strayed = new Uint16Array(N);
|
||||
strayed.fill(FLOOR);
|
||||
for (let i = 0; i < 400_000; i++) strayed[i] = 30995;
|
||||
strayed[600_000] = 34000;
|
||||
strayed[700_000] = 32400;
|
||||
assert.equal(sensorWhite(strayed, MAXIMUM, BLACK), 30995, 'a stray sample above the plateau moved the white');
|
||||
|
||||
// The pile is a fraction of the plane, not a fixed count, so a small sensor with
|
||||
// the same clipping is read the same way as a large one.
|
||||
const small = new Uint16Array(100_000);
|
||||
small.fill(FLOOR);
|
||||
for (let i = 0; i < 40; i++) small[i] = 30995;
|
||||
assert.equal(sensorWhite(small, MAXIMUM, BLACK), 30995, 'a small plane moved the threshold');
|
||||
|
||||
console.log('white-level-check ok');
|
||||
@@ -0,0 +1,279 @@
|
||||
export type FrameId =
|
||||
| 'none'
|
||||
| 'polaroid'
|
||||
| 'classic-white'
|
||||
| 'cinematic'
|
||||
| 'wallframe' // WALL FRAME PORTRAIT — the artwork hung 90° CW
|
||||
| 'wallframe-landscape'; // the same artwork upright: WALL FRAME LANDSCAPE
|
||||
|
||||
// Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT
|
||||
// (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing
|
||||
// 2:3 photo (w/h = 2/3). "full" = current fullscreen behaviour (no ratio crop).
|
||||
// Wall Frame ignores the ratio (frame + export keep fullscreen as before).
|
||||
export type AspectRatio = 'full' | '4:3' | '3:2';
|
||||
// Full-frame metering behaviour. Evaluative = the camera's default whole-scene
|
||||
// metering (offset 0). Highlight-weighted protects bright areas by underexposing
|
||||
// (~ -0.7EV) — the camera lib exposes no real metering-mode API, so this is the
|
||||
// closest honest approximation via the existing EV path.
|
||||
export type MeterMode = 'evaluative' | 'highlight';
|
||||
// Camera shutter sound. OS policy may force the click on in some regions;
|
||||
// there is no native volume control (OFF/ON only).
|
||||
export type ShutterSound = 'on' | 'off';
|
||||
// LED flash for photo capture (never a live torch): 'auto' lets the capture
|
||||
// pipeline decide from the metered scene, 'on' always fires, 'off' never does.
|
||||
export type FlashMode = 'off' | 'auto' | 'on';
|
||||
export type StartupMode = 'default' | 'last';
|
||||
// One photo in the in-app session history (recent captures + library exports).
|
||||
// uri = app-cache JPEG; assetId (when savedToLibrary) = the MediaLibrary asset
|
||||
// so the trash button can remove the gallery copy too.
|
||||
export interface PhotoEntry {
|
||||
id: string;
|
||||
uri: string;
|
||||
recipeName: string;
|
||||
savedToLibrary: boolean;
|
||||
assetId?: string | null;
|
||||
}
|
||||
// w/h of the final upright photo for each non-full choice.
|
||||
export const ASPECT_RATIO_W_H: Record<Exclude<AspectRatio, 'full'>, number> = {
|
||||
'4:3': 3 / 4,
|
||||
'3:2': 2 / 3,
|
||||
};
|
||||
|
||||
// FRAME-tab CROP. Ratio convention is w:h (so 2:3 is an upright crop, 16:9 a
|
||||
// wide one); 'free' is a user-dragged rectangle instead of a fixed shape.
|
||||
export type CropRatio = 'none' | 'free' | '1:1' | '2:3' | '3:2' | '3:4' | '4:3' | '16:9';
|
||||
// Keep-rectangle as 0..1 fractions of the (post-rotation) source photo.
|
||||
export interface CropRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
// w/h of the kept area for each fixed crop choice.
|
||||
export const CROP_W_H: Record<Exclude<CropRatio, 'none' | 'free'>, number> = {
|
||||
'1:1': 1,
|
||||
'2:3': 2 / 3,
|
||||
'3:2': 3 / 2,
|
||||
'3:4': 3 / 4,
|
||||
'4:3': 4 / 3,
|
||||
'16:9': 16 / 9,
|
||||
};
|
||||
// FREE crop starts as a visible inset rectangle the user can immediately grab.
|
||||
export const DEFAULT_CROP_RECT: CropRect = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 };
|
||||
// Smallest FREE-crop side, in source fractions.
|
||||
export const MIN_CROP_FRAC = 0.08;
|
||||
|
||||
// Cinematic seasonal atmosphere grade. Not a fake re-light: it reads the
|
||||
// photo's EXISTING exposure — warm tint + soft glow on areas already lit,
|
||||
// cool tint in shade, plus a seasonal atmospheric haze/fog veil.
|
||||
export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
|
||||
|
||||
// `monochrome` is Acros' grey ramp; `mono-high-contrast` is the B&W HIGH
|
||||
// CONTRAST stock's own — both are black-and-white, so anything that asks "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';
|
||||
|
||||
// The eight hue bands of the selective-colour mixer, in the order they wrap
|
||||
// around the wheel. HSL_BANDS (shared/utils/colorUtils.ts) carries the anchor
|
||||
// hue each id stands for; the eyedropper picks one from the sampled colour.
|
||||
export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta';
|
||||
|
||||
// One band's edit: [hue, saturation, lightness], each -10..+10 like every other
|
||||
// knob. A band that is all zero is dropped rather than stored, so the object
|
||||
// stays empty until the mixer is actually used.
|
||||
export type HslBand = [number, number, number];
|
||||
|
||||
// LIGHT tab > TONE CURVE. One editable curve per channel, each a list of
|
||||
// control points as [input, output] pairs in 0..1 — the first pinned to x=0 and
|
||||
// the last to x=1, so every curve answers for black and for white. The composite
|
||||
// ('rgb') curve is applied AFTER the three channels (Photoshop's order), which is
|
||||
// what makes the same points print the same numbers in every renderer.
|
||||
//
|
||||
// Only the channels the user actually moved are stored, so an untouched curve
|
||||
// costs nothing, an old recipe stays valid, and the recipe JSON the API keeps
|
||||
// carries it without a migration.
|
||||
export type ToneCurvePoint = [number, number];
|
||||
export type ToneCurveChannel = 'rgb' | 'r' | 'g' | 'b';
|
||||
export type ToneCurve = Partial<Record<ToneCurveChannel, ToneCurvePoint[]>>;
|
||||
|
||||
// FX tab > HEAL. One repaired spot: the circle the brush painted on the photo
|
||||
// and the patch it borrowed, both as fractions of the rendered photo — x/y/sx/sy
|
||||
// of its width and height, r of its width. Fractions rather than pixels because
|
||||
// the same spot has to land on the preview and on the full-size export, and a
|
||||
// fraction of the width keeps the circle round on any photo shape.
|
||||
export interface HealSpot {
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
}
|
||||
|
||||
// FX tab > MOSAIC. One hidden spot: the circle the brush painted on the photo,
|
||||
// as fractions of the rendered photo — x/y of its width and height, r of its
|
||||
// width. The same measurement HEAL stores, minus the patch: a mosaic shows
|
||||
// nothing of what is under it, so there is nothing borrowed to record.
|
||||
export interface MosaicSpot {
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
}
|
||||
|
||||
// FX tab > LINEAR / RADIAL GRADIENT. One local adjustment: a shape the user
|
||||
// dragged on the photo, the three knobs it carries, and the alpha the render
|
||||
// reads them at — Lightroom's gradient masks, with the maths of the GLSL spec
|
||||
// they were asked for (scratchpad gradient_mask.md) ported to SkSL.
|
||||
//
|
||||
// Everything positional is a fraction of the render, the same measurement HEAL
|
||||
// and MOSAIC store and for the same reason: the preview and the export render
|
||||
// the same photo at two sizes, and only a fraction lands a shape on both. The
|
||||
// semi-axes are fractions of the WIDTH, so an ellipse is the same ellipse on a
|
||||
// portrait photo as on a landscape one, exactly as HEAL's circle is.
|
||||
//
|
||||
// LINEAR: (x, y) and (ex, ey) are the two ends of the drag, and the alpha runs
|
||||
// from 0 at the start to 1 at the end — the drag IS the feather, which is what
|
||||
// makes the same gesture a wide fade or a hard edge. RADIAL: (x, y) is the
|
||||
// centre, (rx, ry) the semi-axes, `angle` the ellipse's rotation in radians and
|
||||
// `feather` the fraction of the axis the alpha fades over, inward from the rim.
|
||||
export interface GradientMask {
|
||||
kind: 'linear' | 'radial';
|
||||
x: number;
|
||||
y: number;
|
||||
ex: number;
|
||||
ey: number;
|
||||
rx: number;
|
||||
ry: number;
|
||||
angle: number;
|
||||
feather: number;
|
||||
// The three local knobs. Exposure is in EV, the -5..+5 the spec's blend shader
|
||||
// is written for (its own `pow(2.0, e)`); contrast and saturation are the
|
||||
// spec's -1..+1 as the app's own -10..+10 knobs, so 0 is every one of them and
|
||||
// nothing moves until the user moves it.
|
||||
exposure: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
// The spec's section 4 knobs, the frame-wide ones the mask can restrict to
|
||||
// its own area: HIGHLIGHT and SHADOW through its smoothstep soft masks, WHITE
|
||||
// and BLACK at the two ends of the histogram, and the two spatial moves —
|
||||
// CLARITY against the frame's own blurred reference and DEHAZE through the dark
|
||||
// channel, both signed. The same -10..+10 as the app's own rulers, and the same
|
||||
// moves; absent on a mask stored before they existed, and then the shader reads
|
||||
// 0 for it.
|
||||
highlights?: number;
|
||||
shadows?: number;
|
||||
whites?: number;
|
||||
blacks?: number;
|
||||
clarity?: number;
|
||||
dehaze?: number;
|
||||
}
|
||||
|
||||
export interface ColorAdjustments {
|
||||
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
|
||||
contrast: number; // -10 to +10
|
||||
saturation: number; // -10 to +10
|
||||
temperature: number; // 2500 to 10000 (Kelvin)
|
||||
tint: number; // -10 to +10
|
||||
wbRed?: number; // Fuji WB fine-tune, Red axis (-9..9) — extra red-channel gain
|
||||
wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain
|
||||
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
|
||||
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
|
||||
whites?: number; // -10 to +10 (WB white point: per channel, the shoulder)
|
||||
blacks?: number; // -10 to +10 (WB black point: per channel, the toe)
|
||||
denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back)
|
||||
clarity: number; // -10 to +10 (mapped to matrix convolution / bloom)
|
||||
// FX tab > DEHAZE (raw_parameter_processing_gradient_mask_algorithms.md §3.2):
|
||||
// -10 to +10, the Dark Channel Prior pass — the scattered light the haze puts
|
||||
// in front of the subject, taken back out above zero and put back below it.
|
||||
// Absent on an older recipe = 0.
|
||||
dehaze?: number;
|
||||
grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale)
|
||||
grainSize?: number; // 50 to 200 (percent of the stock's own grain cell; 100 = the stock's own)
|
||||
colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect
|
||||
colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue)
|
||||
dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve)
|
||||
sharpening: number; // -10 to +10 (+ = screen sharpen; - = soften the frame)
|
||||
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 by hue band. Only the bands the user actually moved are
|
||||
// present, so an untouched mixer costs nothing and an old recipe stays valid.
|
||||
hslBands?: Partial<Record<HslBandId, HslBand>>;
|
||||
// The mixer's overall move: one hue turn, one saturation scale and one
|
||||
// lightness offset for the WHOLE image, applied to every hue before the band
|
||||
// edits above. -10..+10 like every other knob; absent/0 = no move at all.
|
||||
hslHue?: number;
|
||||
hslSat?: number;
|
||||
hslLum?: number;
|
||||
// TONE CURVE, the last word on tone: applied after everything above. Absent or
|
||||
// flat = no move at all.
|
||||
toneCurve?: ToneCurve;
|
||||
// FX tab > HEAL. The spots the dust brush repaired, in the order they were
|
||||
// laid down. Absent = nothing was healed, and the pass is not built at all.
|
||||
heal?: HealSpot[];
|
||||
// FX tab > MOSAIC. The spots the hiding brush covered, in the order they were
|
||||
// laid down. Absent = nothing was hidden, and the pass is not built at all.
|
||||
mosaic?: MosaicSpot[];
|
||||
// FX tab > LINEAR / RADIAL GRADIENT. The masks the user drew, in the order
|
||||
// they were laid down: each is applied to what the one before it left, the
|
||||
// way a stack of local adjustments reads in Lightroom. Absent = no local
|
||||
// adjustments at all, and no pass is built.
|
||||
masks?: GradientMask[];
|
||||
// -3 to +3 EV. Camera: AE bias (hardware, so the render adds none).
|
||||
// Library: a real 2^EV gain on LINEAR light (EXPOSURE_SKSL) — not a matrix row.
|
||||
exposureCompensation: number;
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
isCustom?: boolean;
|
||||
baseFilter: BaseFilter;
|
||||
adjustments: ColorAdjustments;
|
||||
frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'
|
||||
cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional)
|
||||
useGeotag: boolean;
|
||||
}
|
||||
|
||||
export interface GPSInfo {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locality?: string;
|
||||
country?: string;
|
||||
timestamp: number;
|
||||
source?: 'exif' | 'mediaLibrary' | 'device'; // where the GPS data came from
|
||||
}
|
||||
|
||||
// Custom text watermark (WATERMARK tab -> CUSTOM WATERMARK chip): enabled flag,
|
||||
// the typed text, and its anchor position as 0..1 fractions of the photo area
|
||||
// (0.5/0.5 = centered). The anchor is the text's BASELINE plus a small 0.35em
|
||||
// drop in both renderers — keep the formula in sync across Viewfinder +
|
||||
// exportEngine.
|
||||
export interface CustomWatermark {
|
||||
enabled: boolean;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
// Styling set by the COLOR / SIZE / FONT chips. Absent on marks saved before
|
||||
// those controls existed — the renderers then fall back to amber, 1x and the
|
||||
// bundled Cousine face, i.e. exactly the previous look.
|
||||
color?: string;
|
||||
size?: number; // multiplier on the base 3.2%-of-width text size
|
||||
font?: string | null; // OS font family; null/absent = bundled font
|
||||
}
|
||||
|
||||
// GPS watermark placement (WATERMARK tab drag + pinch): x/y are 0..1 fractions
|
||||
// of the SAME area the custom mark uses (frame card / photo rect / screen), and
|
||||
// the anchor is the top-left corner of the two-line block. size multiplies the
|
||||
// base 3.2%-of-width size. Defaults reproduce the old fixed top-left look.
|
||||
export interface GpsWatermark {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
// Styling set by the WATERMARK tab's PLACE NAME / TIME / COLOR chips. Absent
|
||||
// on marks saved before those controls existed — the renderers then fall back
|
||||
// to amber text with both lines shown, i.e. exactly the previous look.
|
||||
color?: string;
|
||||
showName?: boolean;
|
||||
showTime?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// The brush's own physics, shared by FX's two tools: HEAL copies a patch of the
|
||||
// photo over what it paints, MOSAIC hides what it paints, and both lay circular
|
||||
// spots from the same gesture — a circle that rides the cursor, a stroke laid
|
||||
// down as the pointer travels, and a wheel that sizes it. What a spot MEANS is
|
||||
// each tool's own business (heal.ts, mosaic.ts); how big it is and how far apart
|
||||
// a stroke lays them is this file's.
|
||||
//
|
||||
// The bounds are fractions of the photo's width. Past BRUSH_MAX_R a spot is a
|
||||
// quarter of the frame — a repair would be borrowing a patch from off the
|
||||
// photo, and a mosaic block would be enormous — and below BRUSH_MIN_R the circle
|
||||
// is finer than the pixels it is drawn on at preview scale, which is a size the
|
||||
// user cannot see well enough to aim.
|
||||
export const BRUSH_MIN_R = 0.003;
|
||||
export const BRUSH_MAX_R = 0.25;
|
||||
// How far apart a drag lays its spots, in radii: any closer and the run is the
|
||||
// same spot written twice, which is work for nothing.
|
||||
export const BRUSH_SPACING = 0.6;
|
||||
|
||||
// The brush radius after a wheel notch. Multiplicative, so the brush grows by
|
||||
// the same proportion wherever it starts, and bounded as above — a trackpad's
|
||||
// small deltas and a mouse's 100px notch are then the same gesture at two
|
||||
// speeds.
|
||||
export function wheelBrushR(r: number, deltaY: number): number {
|
||||
const next = r * Math.exp(-deltaY * 0.0015);
|
||||
return next < BRUSH_MIN_R ? BRUSH_MIN_R : next > BRUSH_MAX_R ? BRUSH_MAX_R : next;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { CinemaSeason } from '../types';
|
||||
|
||||
// Cinematic seasonal atmosphere grade. Deliberately NOT a fake re-light: a
|
||||
// still photo has no depth, so "moving the sun" would look plastic. Instead
|
||||
// this reads the photo's EXISTING exposure and pushes it a certain way:
|
||||
// - lit areas get the season's warm/golden tint + a soft sun glow (halation)
|
||||
// - shadow side gets a complementary cool tint
|
||||
// - a seasonal haze/fog veil blends toward a fog color (strongest in winter)
|
||||
// That reads as "sunlight / morning mist" without inventing new light.
|
||||
//
|
||||
// Uniforms are 16 flat floats (scalar form keeps the JSX <Shader> uniforms
|
||||
// object and the worklet/export flat arrays in the exact same order):
|
||||
// strength, contrast, saturation, haze,
|
||||
// warmR, warmG, warmB, warmAmt,
|
||||
// coolR, coolG, coolB, coolAmt,
|
||||
// fogR, fogG, fogB, fogAmt
|
||||
export const CINEMA_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float strength;
|
||||
uniform float contrast;
|
||||
uniform float saturation;
|
||||
uniform float haze;
|
||||
uniform float warmR;
|
||||
uniform float warmG;
|
||||
uniform float warmB;
|
||||
uniform float warmAmt;
|
||||
uniform float coolR;
|
||||
uniform float coolG;
|
||||
uniform float coolB;
|
||||
uniform float coolAmt;
|
||||
uniform float fogR;
|
||||
uniform float fogG;
|
||||
uniform float fogB;
|
||||
uniform float fogAmt;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
||||
vec3 r = c.rgb;
|
||||
// Filmic-ish gentle contrast on the luma (keeps hue).
|
||||
r = mix(r, clamp((r - 0.5) * (1.0 + contrast * strength) + 0.5, 0.0, 1.0), strength);
|
||||
// Saturation.
|
||||
float l = dot(r, vec3(0.2126, 0.7152, 0.0722));
|
||||
r = mix(vec3(l), r, mix(1.0, saturation, strength));
|
||||
// Season tints follow the EXISTING light: warm on lit areas, cool in shade.
|
||||
float lit = smoothstep(0.30, 1.0, t);
|
||||
float shad = 1.0 - smoothstep(0.0, 0.45, t);
|
||||
vec3 warm = vec3(warmR, warmG, warmB);
|
||||
vec3 cool = vec3(coolR, coolG, coolB);
|
||||
r *= 1.0 + (warm - 1.0) * lit * warmAmt * strength;
|
||||
r *= 1.0 + (cool - 1.0) * shad * coolAmt * strength;
|
||||
// Soft sun glow at the brightest points (halation of existing highlights).
|
||||
float glow = smoothstep(0.65, 1.0, t);
|
||||
r += warm * glow * warmAmt * 0.45 * strength;
|
||||
// Atmospheric haze/fog veil (lightest shadow side => misty air).
|
||||
vec3 fog = vec3(fogR, fogG, fogB);
|
||||
r = mix(r, fog, fogAmt * strength * (0.3 + 0.7 * shad));
|
||||
return vec4(clamp(r, 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
export interface CinemaUniformSet {
|
||||
season: CinemaSeason;
|
||||
// 16 flat floats — order matches CINEMA_SKSL declaration order above.
|
||||
flat: number[];
|
||||
// Name-keyed, for the JSX <Shader uniforms={...}> form.
|
||||
uniforms: Record<string, number>;
|
||||
}
|
||||
|
||||
const ZERO: Record<string, number> = {};
|
||||
|
||||
const SEASONS: Record<CinemaSeason, { name: string; values: number[] }> = {
|
||||
// Soft fresh light, gentle warm haze, barely-there cool in shade.
|
||||
spring: {
|
||||
name: 'CINEMA SPRING',
|
||||
values: [
|
||||
0.6, 0.08, 1.04, 0.18,
|
||||
1.02, 0.998, 0.95, 0.3,
|
||||
0.97, 0.995, 1.03, 0.12,
|
||||
0.95, 0.96, 0.98, 0.16,
|
||||
],
|
||||
},
|
||||
// Golden hour-ish high sun: strong warm on lit, bluish shade, crisp.
|
||||
summer: {
|
||||
name: 'CINEMA SUMMER',
|
||||
values: [
|
||||
0.75, 0.2, 1.12, 0.1,
|
||||
1.08, 1.04, 0.88, 0.4,
|
||||
0.93, 0.97, 1.06, 0.2,
|
||||
1.0, 0.97, 0.88, 0.05,
|
||||
],
|
||||
},
|
||||
// Amber low sun + warm atmospheric haze.
|
||||
autumn: {
|
||||
name: 'CINEMA AUTUMN',
|
||||
values: [
|
||||
0.8, 0.16, 1.1, 0.24,
|
||||
1.12, 1.02, 0.78, 0.42,
|
||||
1.02, 0.98, 0.88, 0.16,
|
||||
1.0, 0.93, 0.75, 0.18,
|
||||
],
|
||||
},
|
||||
// Cold winter fog: desaturated, blue-grey, heavy mist, no warm sun.
|
||||
winter: {
|
||||
name: 'CINEMA WINTER',
|
||||
values: [
|
||||
0.65, -0.02, 0.88, 0.38,
|
||||
0.99, 0.995, 1.02, 0.06,
|
||||
0.94, 0.97, 1.08, 0.3,
|
||||
0.9, 0.93, 0.97, 0.45,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const KEYS = [
|
||||
'strength', 'contrast', 'saturation', 'haze',
|
||||
'warmR', 'warmG', 'warmB', 'warmAmt',
|
||||
'coolR', 'coolG', 'coolB', 'coolAmt',
|
||||
'fogR', 'fogG', 'fogB', 'fogAmt',
|
||||
] as const;
|
||||
|
||||
export function getCinemaUniforms(season: CinemaSeason | null | undefined): CinemaUniformSet | null {
|
||||
if (!season) return null;
|
||||
const s = SEASONS[season];
|
||||
if (!s) return null;
|
||||
const uniforms: Record<string, number> = {};
|
||||
KEYS.forEach((k, i) => (uniforms[k] = s.values[i]));
|
||||
return { season, flat: s.values, uniforms };
|
||||
}
|
||||
|
||||
// Zeroed 16-float block — used to turn the cinema pass OFF in the worklet
|
||||
// synchronizable (strength === 0 means inactive there).
|
||||
export function cinemaOffFlat(): number[] {
|
||||
return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
}
|
||||
|
||||
export function cinemaIsActive(flat: number[] | null | undefined): boolean {
|
||||
return !!flat && flat.length === 16 && flat[0] > 0;
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
|
||||
|
||||
// The eight bands of the selective-colour mixer, and the anchor hue each one
|
||||
// stands for (degrees on the HSL wheel). The anchors are unevenly spaced on
|
||||
// purpose: red/orange/yellow sit 30° apart because the eye separates them
|
||||
// finely, while the blue side is one 60° stretch. TONE_SKSL builds the same
|
||||
// eight weights from this table, so the anchor a band is edited at and the
|
||||
// anchor the shader masks at can never drift apart.
|
||||
//
|
||||
// `label` is what the chip prints — the phone's own strings there too, so it is
|
||||
// not in the i18n dictionary.
|
||||
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 (degrees) the two neighbours of each band sit at, as TONE_SKSL needs
|
||||
// them. Derived here so the two tables cannot disagree: the tent weight of a
|
||||
// band falls to 0 at its neighbours' anchors, which makes the eight weights a
|
||||
// partition of unity on the wheel (they sum to 1 at every hue, so a pixel is
|
||||
// never counted twice and never falls in a gap).
|
||||
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°) wraps: its left neighbour is magenta at 320°, 40° back.
|
||||
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
|
||||
});
|
||||
}
|
||||
|
||||
// 0..255 sRGB to HSL. Hue in degrees, saturation and lightness 0..1 — the
|
||||
// reverse of hslToRgb below, 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 };
|
||||
}
|
||||
|
||||
// The band a sampled hue belongs to: the nearest anchor. Bands overlap in the
|
||||
// shader, so this only decides which one the ruler is pointed 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;
|
||||
}
|
||||
|
||||
// A band triple as stored: integers in -10..10, and an all-zero band is dropped
|
||||
// so the recipe only carries what the user 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
|
||||
// the INVERSE of the Planckian (blackbody) colour at K: set 10000K under a
|
||||
// daylight scene (5500K) and the camera thinks the light is bluer than it is
|
||||
// and warms the picture up; set 2500K and it goes cool/blue. Pinned to unity at
|
||||
// 5500K, the app's neutral reference.
|
||||
//
|
||||
// Two things a hand-drawn linear ramp gets wrong, both of them visible on skin:
|
||||
// - moving the three channels independently (the old one lifted green 20% at
|
||||
// 10000K) shifts LUMINANCE as well as colour, so a warm shot went yellow-
|
||||
// green rather than orange. Here every gain is divided by its own Rec.709
|
||||
// luma, so a neutral pixel keeps its level and only the cast moves.
|
||||
// - the excursion. It was the ratio of the sRGB-ENCODED blackbody colours,
|
||||
// square-rooted, which measured R x1.06 / B x0.89 from 5500K to 10000K: a
|
||||
// shift a swatch shows and a sunset does not. White balance is a gain on
|
||||
// LIGHT, so the ratio is taken in linear light now (planckianLinear) and
|
||||
// tamed by KELVIN_TAME: the same 10000K moves the frame R x1.16 / B x0.76,
|
||||
// and 2500K its mirror, the way a camera does when its WB is set 4500K off
|
||||
// the scene. One constant scales the whole ruler, and both platforms carry
|
||||
// the same one, so a Kelvin picked on either lands the same cast.
|
||||
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
|
||||
const k = Math.min(10000, Math.max(2500, kelvin));
|
||||
const light = planckianLinear(k);
|
||||
const ref = planckianLinear(5500);
|
||||
return normalizeGainLuma([
|
||||
Math.pow(ref.r / Math.max(light.r, 1e-4), KELVIN_TAME),
|
||||
Math.pow(ref.g / Math.max(light.g, 1e-4), KELVIN_TAME),
|
||||
Math.pow(ref.b / Math.max(light.b, 1e-4), KELVIN_TAME),
|
||||
]);
|
||||
}
|
||||
|
||||
// How hard that excursion is allowed to bite. The physical ratio is brutal (the
|
||||
// blue channel carries 12x at 2500K) and it lands on sRGB-encoded, already
|
||||
// tone-mapped pixels, where the raw value clips the ends flat. 0.5 is the
|
||||
// square root of it — a cast that survives a sunset and a print. Toward 1 is a
|
||||
// more violent ruler, toward 0.33 a gentler one; change it in BOTH copies
|
||||
// (src/utils/colorUtils.ts and docker/frontend/shared/utils/colorUtils.ts).
|
||||
const KELVIN_TAME = 0.5;
|
||||
|
||||
// sRGB-encoded colour of a blackbody radiator at K, 0..1 (Tanner Helland's fit
|
||||
// of the Planckian locus; exact enough from 1000K up, and exactly neutral where
|
||||
// the app pins unity). Blue collapses to 0 below ~1900K, hence the guard in the
|
||||
// caller.
|
||||
function planckianSRGB(kelvin: number): { r: number; g: number; b: number } {
|
||||
const t = kelvin / 100;
|
||||
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
|
||||
const g =
|
||||
t <= 66
|
||||
? 99.4708025861 * Math.log(t) - 161.1195681661
|
||||
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
|
||||
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
|
||||
const norm = (v: number) => Math.min(1, Math.max(0, v / 255));
|
||||
return { r: norm(r), g: norm(g), b: norm(b) };
|
||||
}
|
||||
|
||||
// The same colour in LINEAR light, which is the domain a white-balance gain
|
||||
// belongs in: a camera's WB is a multiplier on the light, not on the encoded
|
||||
// pixel, so the ratio has to be taken before the sRGB transfer curve.
|
||||
function planckianLinear(kelvin: number): { r: number; g: number; b: number } {
|
||||
const srgb = planckianSRGB(kelvin);
|
||||
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
||||
return { r: toLinear(srgb.r), g: toLinear(srgb.g), b: toLinear(srgb.b) };
|
||||
}
|
||||
|
||||
// Divide a set of channel gains by their own luma: the CAST moves, the exposure
|
||||
// does not. Channels still clip where they must — this only keeps a neutral
|
||||
// pixel sitting at its neutral level.
|
||||
function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } {
|
||||
const luma = 0.2126 * gain[0] + 0.7152 * gain[1] + 0.0722 * gain[2];
|
||||
const s = luma > 0 ? 1 / luma : 1;
|
||||
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
|
||||
}
|
||||
|
||||
// Sim-owned exposure bias: brightness a stock needs that is part of the stock
|
||||
// itself, never a user knob. Kept beside the matrices it compensates so the
|
||||
// values are one hop from the rows they lift. Only LC STREETLIFE VIVID carries
|
||||
// one — its matrix is a pure separation with no lift, and without this it reads
|
||||
// a full step darker than the other sims.
|
||||
//
|
||||
// In STOPS (EV), on the same scale as exposureStops below, because that is the
|
||||
// domain the gain is applied in now. The old entry was +2 Exposure units, whose
|
||||
// 1.04x scale + 0.03 offset measured out at roughly this much light.
|
||||
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = {
|
||||
'leica-vivid': 0.25,
|
||||
};
|
||||
|
||||
// What one unit of the EXPOSURE knob is worth, in stops. Lightroom's slider is
|
||||
// ±5 EV; this one is ±10 units, so ±2.5 EV at the ends — the same reach AUTO is
|
||||
// allowed to ask for (AUTO_EV_MAX), so the knob can always express what the
|
||||
// button decides. Raise it toward 0.5 for Lightroom's full travel, and accept
|
||||
// that every recipe already saved with a non-zero EXPOSURE gets brighter with
|
||||
// it.
|
||||
const EV_PER_UNIT = 0.25;
|
||||
|
||||
// The EXPOSURE knob and the sim bias, in stops, for the linear-light pass in
|
||||
// exportEngine. Separate from the colour matrix on purpose: the matrix works on
|
||||
// sRGB-ENCODED values, where multiplying by 2 is a gamma-space move that lifts a
|
||||
// mid-grey to a blown 1.0 instead of the 0.73 a real stop of light gives.
|
||||
export function exposureStops(adj: ColorAdjustments, baseFilter?: BaseFilter): number {
|
||||
const units = adj.exposure ?? 0;
|
||||
return units * EV_PER_UNIT + (SIM_EXPOSURE_BIAS_EV[(baseFilter ?? 'none') as BaseFilter] ?? 0);
|
||||
}
|
||||
|
||||
// A second brightness a stock owns, in Contrast-slider units. The knob itself
|
||||
// stays at 0 (a sim is colour only), so the extra slope is added here, where
|
||||
// the knob's own gain/offset mapping already lives.
|
||||
const SIM_CONTRAST_BIAS: Partial<Record<BaseFilter, number>> = {
|
||||
'mono-high-contrast': 4,
|
||||
};
|
||||
|
||||
// Black-and-white stocks. Their grey ramp is the whole look, so every
|
||||
// colour-only stage below — saturation, white balance, the R/B fine-tune — is
|
||||
// skipped for them, and the tone pass leaves the hue mixer alone. 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. EV, the EXPOSURE knob and the sim exposure bias are all
|
||||
// intentionally NOT part of the matrix: a stop of light is a gain on LIGHT, and
|
||||
// a matrix runs on sRGB-encoded values, where the same multiply is a gamma-space
|
||||
// move (a mid-grey at +1 EV would clip instead of landing at 0.73). They go
|
||||
// through EXPOSURE_SKSL in exportEngine instead. dynamicRange/highlight/shadow
|
||||
// are in the same place for the same kind of reason — see toneShader.ts.
|
||||
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
|
||||
const contrast = adj.contrast + (SIM_CONTRAST_BIAS[baseFilter] ?? 0);
|
||||
const saturation = adj.saturation;
|
||||
const temperature = adj.temperature;
|
||||
const tint = adj.tint;
|
||||
// colorChrome/colorChromeBlue are deliberately unused here — the chrome
|
||||
// effects run in the tone shader (per-pixel chroma), not the matrix.
|
||||
|
||||
// 1. Start with Identity Matrix
|
||||
let matrix = [
|
||||
1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
0, 0, 1, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
|
||||
// 2. Base Filters
|
||||
if (baseFilter === 'classic-neg') {
|
||||
// Classic Neg (Superia-like): restrained colour, higher contrast around
|
||||
// mid-grey, reds a touch deeper. Its signature split tone — green/cyan
|
||||
// darks drifting to warm brights — cannot live in a linear matrix (one cast
|
||||
// would hit both ends), so it ships in FILM_TONE (toneShader.ts) instead.
|
||||
matrix = [
|
||||
0.9961, 0.0773, 0.0078, 0, -0.0406,
|
||||
0.0226, 1.0298, 0.0076, 0, -0.0300,
|
||||
0.0224, 0.0750, 0.9520, 0, -0.0247,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'provia') {
|
||||
// Provia (Standard): the reference stock — balanced, faithful, easy on the
|
||||
// eye. Mild saturation and a shallow contrast lift around mid-grey, with no
|
||||
// channel cast, so neutrals stay neutral and only the chroma deepens.
|
||||
matrix = [
|
||||
1.1492, -0.0901, -0.0091, 0, -0.0250,
|
||||
-0.0268, 1.0859, -0.0091, 0, -0.0250,
|
||||
-0.0268, -0.0901, 1.1669, 0, -0.0250,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'velvia') {
|
||||
// Velvia (Vivid): the loud one — saturation pushed very hard onto green,
|
||||
// blue and red alike, on top of strong contrast with deep, dense darks.
|
||||
// Saturated reds, skies and greens are meant to ride the clip; that hard
|
||||
// edge is the look.
|
||||
matrix = [
|
||||
1.3895, -0.2540, -0.0256, 0, -0.0550,
|
||||
-0.0772, 1.2355, -0.0261, 0, -0.0661,
|
||||
-0.0775, -0.2603, 1.4756, 0, -0.0689,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'classic-chrome') {
|
||||
// Classic Chrome: magazine-reportage look. Saturation is pulled well down
|
||||
// while the G/B rows are crossed so blue skies lean teal/cyan (green comes
|
||||
// up towards blue, with blue kept high). The heavy shadow crush that goes
|
||||
// with it is the `sh` entry in FILM_TONE — a matrix alone cannot deepen one
|
||||
// end of the curve without dragging the rest.
|
||||
matrix = [
|
||||
0.7792, 0.2188, 0.0220, 0, -0.0100,
|
||||
0.0652, 0.7186, 0.2362, 0, -0.0100,
|
||||
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. Two stocks spliced at the blue
|
||||
// row, which is exactly the point: quiet skies over saturated colour.
|
||||
matrix = [
|
||||
1.3895, -0.2540, -0.0256, 0, -0.0550,
|
||||
-0.0772, 1.2355, -0.0261, 0, -0.0661,
|
||||
0.0652, 0.3973, 0.5575, 0, -0.0100,
|
||||
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
|
||||
// bias that flatters skin. The outdoor-portrait stock.
|
||||
matrix = [
|
||||
0.9162, 0.0201, 0.0020, 0, 0.0428,
|
||||
0.0058, 0.9030, 0.0020, 0, 0.0386,
|
||||
0.0059, 0.0197, 0.8944, 0, 0.0400,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'eterna') {
|
||||
// Eterna: cinema — flat (low contrast), muted color, gentle highlight rolloff.
|
||||
matrix = [
|
||||
0.94, 0.02, 0.02, 0, 0.02,
|
||||
0.02, 0.96, 0.02, 0, 0.02,
|
||||
0.02, 0.02, 0.94, 0, 0.02,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'leica') {
|
||||
// LC STREETLIFE CLASSIC — the rangefinder character rather than a saturation
|
||||
// push. What makes this file read as one is the separation BETWEEN the
|
||||
// hues, so the deepening lives in the off-diagonal terms, not in a global
|
||||
// chroma gain:
|
||||
// - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and
|
||||
// every saturated colour gains ~5% through channel separation; Velvia
|
||||
// pushes 39%, this stays quiet ("dậm đà, không gắt").
|
||||
// - a small uniform offset deepens the darks: solid shadows that keep their
|
||||
// texture, no lifted black and no crushed toe.
|
||||
// - red up / blue down with green held at 1.00 is the warm European cast:
|
||||
// it lands on skin as warmth, never as the yellow-green a warm gain on
|
||||
// all three channels would give.
|
||||
matrix = [
|
||||
1.060, -0.045, -0.010, 0, -0.010,
|
||||
-0.030, 1.055, -0.025, 0, -0.008,
|
||||
-0.025, -0.020, 1.040, 0, -0.005,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'leica-vivid') {
|
||||
// LC STREETLIFE VIVID — the same slanted channel separation pushed into the
|
||||
// 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 in the rows themselves, so the matrix stays inside the
|
||||
// 0..1 heads: the brightness this stock needs rides SIM_EXPOSURE_BIAS above
|
||||
// (+2), which keeps white out of a hard clip and keeps the sim's
|
||||
// `adjustments` neutral so the Exposure knob still reads 0.
|
||||
matrix = [
|
||||
1.125, -0.100, -0.010, 0, -0.015,
|
||||
-0.055, 1.110, -0.055, 0, -0.005,
|
||||
-0.050, -0.070, 1.100, 0, -0.005,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'monochrome') {
|
||||
// Acros: pure BT.709 grayscale, zero colour leakage, and NO gain or offset.
|
||||
// The weights sum to 1.0 and the offset is 0, so this matrix is a straight
|
||||
// luminance readout: nothing clips at either end and every grey step the
|
||||
// sensor recorded survives. (An earlier 1.10x / -0.05 version plugged every
|
||||
// black below ~5% and blew every white above ~95% — exactly the fine
|
||||
// graduation this stock is prized for.) The deep black and the highlight
|
||||
// shoulder live in FILM_TONE (toneShader.ts), where the curve can be shaped
|
||||
// one end at a time without touching the midtones. (Grain/sharpness stay
|
||||
// the user's call — a film sim here is the colour grade only.)
|
||||
const r = 0.2126;
|
||||
const g = 0.7152;
|
||||
const b = 0.0722;
|
||||
matrix = [
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
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 the
|
||||
// ends (the push itself lives in FILM_TONE, and its midtone slope in
|
||||
// SIM_CONTRAST_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, orange and yellow hold or gain slightly
|
||||
// (their rows carry the red weight plus a green one), while blue and green
|
||||
// fall — 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 (the mixer block already
|
||||
// has the tent weights to do it), 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 (!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;
|
||||
const g = 0.715 * invS;
|
||||
const b = 0.072 * invS;
|
||||
|
||||
const satMat = [
|
||||
r + s, g, b, 0, 0,
|
||||
r, g + s, b, 0, 0,
|
||||
r, g, b + s, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
|
||||
matrix = multiplyMatrices(satMat, matrix);
|
||||
}
|
||||
|
||||
// 4. Color Chrome / Color Chrome FX Blue are NOT here: both deepen only the
|
||||
// already-chromatic pixels, which needs per-pixel chroma (see TONE_SKSL) — a
|
||||
// matrix gain on R/B is a global saturation push that also moves greys, and a
|
||||
// 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 (!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
|
||||
// down). The old version only ever ADDED a channel (magenta lifted R+B,
|
||||
// green lifted G), so the knob brightened the frame instead of tinting it.
|
||||
// Normalising the product keeps that at a tint shift, not an exposure one.
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
const wbGain = normalizeGainLuma([
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
|
||||
const wbMat = [
|
||||
wbGain.r, 0, 0, 0, 0,
|
||||
0, wbGain.g, 0, 0, 0,
|
||||
0, 0, wbGain.b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(wbMat, matrix);
|
||||
}
|
||||
|
||||
// 5b. Fuji WB fine-tune Red/Blue axis (the CREATE modal's R/B offsets). These
|
||||
// axes are independent channel gains on top of the Kelvin/tint preset: each
|
||||
// step nudges its channel ~1.1% toward/away from neutral, matching the
|
||||
// perceptible size of one Fuji fine-tune step (-9..+9).
|
||||
const wbRed = adj.wbRed ?? 0;
|
||||
const wbBlue = adj.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,
|
||||
0, 0, 1 + (wbBlue / 9) * 0.1, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(shiftMat, matrix);
|
||||
}
|
||||
|
||||
// 6. Contrast — applied to ALL base filters (monochrome included) so both keep
|
||||
// affecting mono shots. Contrast scales around mid-grey 0.5, in the encoded
|
||||
// domain, which is where a contrast move belongs (it is a shape on the printed
|
||||
// ramp, not a gain on light).
|
||||
if (contrast !== 0) {
|
||||
const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x
|
||||
const conOffset = 0.5 * (1 - conScale);
|
||||
const adjMat = [
|
||||
conScale, 0, 0, 0, conOffset,
|
||||
0, conScale, 0, 0, conOffset,
|
||||
0, 0, conScale, 0, conOffset,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(adjMat, matrix);
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
// 4x5 Matrix multiplication utility: A * B
|
||||
function multiplyMatrices(a: number[], b: number[]): number[] {
|
||||
const result = new Array(20).fill(0);
|
||||
for (let r = 0; r < 4; r++) {
|
||||
for (let c = 0; c < 4; c++) {
|
||||
result[r * 5 + c] =
|
||||
a[r * 5 + 0] * b[0 * 5 + c] +
|
||||
a[r * 5 + 1] * b[1 * 5 + c] +
|
||||
a[r * 5 + 2] * b[2 * 5 + c] +
|
||||
a[r * 5 + 3] * b[3 * 5 + c];
|
||||
}
|
||||
// Handle the 5th column translation
|
||||
result[r * 5 + 4] =
|
||||
a[r * 5 + 0] * b[0 * 5 + 4] +
|
||||
a[r * 5 + 1] * b[1 * 5 + 4] +
|
||||
a[r * 5 + 2] * b[2 * 5 + 4] +
|
||||
a[r * 5 + 3] * b[3 * 5 + 4] +
|
||||
a[r * 5 + 4];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Recipe, ColorAdjustments } from '../types';
|
||||
|
||||
// Fallback values for every adjustable knob. Old recipes/sessions persisted in
|
||||
// AsyncStorage lack the newer fields — merge this under stored JSON to keep
|
||||
// every adjustment well-defined (see storageUtils.getAllRecipes / App session).
|
||||
export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
|
||||
exposure: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 5500,
|
||||
tint: 0,
|
||||
wbRed: 0,
|
||||
wbBlue: 0,
|
||||
highlight: 0,
|
||||
shadow: 0,
|
||||
denoise: 0,
|
||||
clarity: 0,
|
||||
grain: 0,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
hslHue: 0,
|
||||
hslSat: 0,
|
||||
hslLum: 0,
|
||||
exposureCompensation: 0,
|
||||
};
|
||||
|
||||
// Film simulations. These are the fixed "look" presets at the front of the
|
||||
// PRESETS chip row, and each one is COLOUR AND TONE ONLY.
|
||||
//
|
||||
// A sim carries exactly two things: its `baseFilter` colour matrix
|
||||
// (colorUtils.ts) and, where the stock needs to shape one end of the curve, its
|
||||
// FILM_TONE entry (toneShader.ts). Everything the sim does to the picture is in
|
||||
// those two places — including the brightness a stock owns: that is folded into
|
||||
// the matrix through SIM_EXPOSURE_BIAS (colorUtils.ts), never into
|
||||
// `adjustments`. `adjustments` is therefore the neutral default for every sim —
|
||||
// no grain, no clarity, no saturation push, no exposure/highlight/shadow move —
|
||||
// because those are the user's knobs, not the stock's. A sim is a clean starting
|
||||
// point, never a baked recipe; any further look is added afterwards.
|
||||
//
|
||||
// Name → the stock's colour and tone it must stand for. This table is the
|
||||
// REFERENCE ONLY: the string at the left is just the app's label for the chip
|
||||
// (the legacy 'PES' spelling), and what matters is that the sim behind it grades
|
||||
// the picture the way the stock at the right does. So a change to a label must
|
||||
// never touch the grading, and a change to a grading never renames the chip:
|
||||
// PROVIPES Provia / Standard
|
||||
// VELVIPES Velvia / Vivid
|
||||
// CLASSIC CHRIPES Classic Chrome
|
||||
// CLASSIC VIVIDIPES Velvia spliced with Classic Chrome at the blue row
|
||||
// CLASSIC NEGIPES Classic Negative (Superia-like)
|
||||
// ASTIPES Astia / Soft
|
||||
// ETERNIPES Eterna (cinema)
|
||||
// ACRIPES Acros (monochrome)
|
||||
// B&W HIGH CONTRAST Acros through a filter, ends pushed hard
|
||||
// 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;
|
||||
baseFilter: Recipe['baseFilter'];
|
||||
adjustments: ColorAdjustments;
|
||||
}
|
||||
|
||||
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 } },
|
||||
{ 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. Both are still colour and
|
||||
// tone only: CLASSIC is Leica Authentic (the matrix carries the channel
|
||||
// separation and the warm European cast, nothing else), VIVID is Leica Vivid
|
||||
// (the same separation opened up — again in the matrix, with the one
|
||||
// brightness step the stock owns folded in through SIM_EXPOSURE_BIAS in
|
||||
// colorUtils, never as an `adjustments` value).
|
||||
{ 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).
|
||||
export function filmSimToRecipe(sim: FilmSim): Recipe {
|
||||
return {
|
||||
id: sim.id,
|
||||
name: sim.name,
|
||||
baseFilter: sim.baseFilter,
|
||||
adjustments: { ...sim.adjustments },
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_RECIPES: Recipe[] = [
|
||||
{
|
||||
id: 'classic-neg-default',
|
||||
name: 'CLASSIC NEGIPES.',
|
||||
baseFilter: 'classic-neg',
|
||||
adjustments: {
|
||||
exposure: 2,
|
||||
contrast: 1,
|
||||
saturation: -1,
|
||||
temperature: 6300,
|
||||
tint: 2,
|
||||
highlight: -2,
|
||||
shadow: 1,
|
||||
denoise: 1,
|
||||
clarity: 2,
|
||||
grain: 4,
|
||||
colorChrome: 'weak',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'velvia-default',
|
||||
name: 'VELVIPES VIVID',
|
||||
baseFilter: 'velvia',
|
||||
adjustments: {
|
||||
exposure: 0,
|
||||
contrast: 2,
|
||||
saturation: 3,
|
||||
temperature: 5500,
|
||||
tint: 0,
|
||||
highlight: 1,
|
||||
shadow: -1,
|
||||
denoise: 2,
|
||||
clarity: 4,
|
||||
grain: 1,
|
||||
colorChrome: 'strong',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'provia-default',
|
||||
name: 'PROVIPES STD',
|
||||
baseFilter: 'provia',
|
||||
adjustments: {
|
||||
exposure: 1,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 5000,
|
||||
tint: 0,
|
||||
highlight: 0,
|
||||
shadow: 0,
|
||||
denoise: 2,
|
||||
clarity: 1,
|
||||
grain: 2,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'acros-default',
|
||||
name: 'ACRIPES MONO',
|
||||
baseFilter: 'monochrome',
|
||||
adjustments: {
|
||||
exposure: 0,
|
||||
// Low: the deep black comes from FILM_TONE's toe and the micro-contrast
|
||||
// from CLARITY, both of which add separation WITHOUT flattening the grey
|
||||
// ramp. The old 4 / highlight 3 / shadow -2 stack was three global
|
||||
// contrast moves on top of a matrix that was already clipping — it
|
||||
// bought its deep black by throwing away every step below 9% and every
|
||||
// one above 91%.
|
||||
contrast: 2,
|
||||
saturation: 0,
|
||||
temperature: 5000,
|
||||
tint: 0,
|
||||
highlight: 0,
|
||||
shadow: 0,
|
||||
denoise: 0,
|
||||
clarity: 3,
|
||||
grain: 5,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'retro-amber-default',
|
||||
name: 'RETRO AMBER 🏮',
|
||||
baseFilter: 'classic-neg',
|
||||
adjustments: {
|
||||
exposure: 3,
|
||||
contrast: 2,
|
||||
saturation: 1,
|
||||
temperature: 7500,
|
||||
tint: -2,
|
||||
highlight: -3,
|
||||
shadow: 2,
|
||||
denoise: 3,
|
||||
clarity: -2,
|
||||
grain: 5,
|
||||
colorChrome: 'strong',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'classic-white',
|
||||
useGeotag: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'cinema-spring-default',
|
||||
name: 'CINEMA SPRING',
|
||||
baseFilter: 'none',
|
||||
adjustments: {
|
||||
exposure: 0,
|
||||
contrast: 1,
|
||||
saturation: 1,
|
||||
temperature: 5800,
|
||||
tint: 0,
|
||||
highlight: -1,
|
||||
shadow: 1,
|
||||
denoise: 2,
|
||||
clarity: 1,
|
||||
grain: 3,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
cinema: 'spring',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'cinema-summer-default',
|
||||
name: 'CINEMA SUMMER',
|
||||
baseFilter: 'none',
|
||||
adjustments: {
|
||||
exposure: 0,
|
||||
contrast: 2,
|
||||
saturation: 2,
|
||||
temperature: 6000,
|
||||
tint: -1,
|
||||
highlight: 1,
|
||||
shadow: -1,
|
||||
denoise: 1,
|
||||
clarity: 2,
|
||||
grain: 2,
|
||||
colorChrome: 'weak',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
cinema: 'summer',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'cinema-autumn-default',
|
||||
name: 'CINEMA AUTUMN',
|
||||
baseFilter: 'none',
|
||||
adjustments: {
|
||||
exposure: 0,
|
||||
contrast: 1,
|
||||
saturation: 2,
|
||||
temperature: 7200,
|
||||
tint: -2,
|
||||
highlight: -2,
|
||||
shadow: 1,
|
||||
denoise: 2,
|
||||
clarity: 0,
|
||||
grain: 4,
|
||||
colorChrome: 'weak',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
cinema: 'autumn',
|
||||
useGeotag: true,
|
||||
},
|
||||
{
|
||||
id: 'cinema-winter-default',
|
||||
name: 'CINEMA WINTER',
|
||||
baseFilter: 'none',
|
||||
adjustments: {
|
||||
exposure: 1,
|
||||
contrast: -1,
|
||||
saturation: -2,
|
||||
temperature: 4500,
|
||||
tint: 2,
|
||||
highlight: -2,
|
||||
shadow: 2,
|
||||
denoise: 3,
|
||||
clarity: -2,
|
||||
grain: 3,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'weak',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
cinema: 'winter',
|
||||
useGeotag: true,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,497 @@
|
||||
// Pure JPEG/EXIF writer — no imports, so the codec can be exercised directly.
|
||||
//
|
||||
// Skia re-encodes the photo, which drops the original EXIF block (see
|
||||
// jpegDpi.ts). This rebuilds the APP1/EXIF segment: it reads the SOURCE
|
||||
// photo's TIFF, keeps every tag it does not own, and stamps the fields this app
|
||||
// is responsible for — software + version, capture date + zone, GPS, pixel
|
||||
// dimensions — plus the baseline tags a conforming EXIF block must carry
|
||||
// (ExifVersion, FlashPixVersion, ComponentsConfiguration, ColorSpace, Interop).
|
||||
// Same in-place byte approach as jpegDpi.ts: no dependency, no native module.
|
||||
//
|
||||
// ponytail: IFD1 (the embedded thumbnail) and MakerNote are dropped. A carried
|
||||
// thumbnail would show the PRE-export pixels, and MakerNote holds absolute
|
||||
// offsets that a relocated TIFF invalidates. Read them back when a use case
|
||||
// needs them.
|
||||
|
||||
// Stamped into every file this app writes (kept in step with app.json).
|
||||
export const SOFTWARE = 'RecipesCam 1.2';
|
||||
|
||||
export interface ExifStamp {
|
||||
software?: string;
|
||||
// Shutter instant. null/undefined = keep the loaded photo's own
|
||||
// DateTimeOriginal and only fill it in when the source has none.
|
||||
dateTime?: Date | null;
|
||||
gps?: { latitude: number; longitude: number } | null;
|
||||
// Place name shown on the Gallery frame (EXIF 0x889d). Stock photos carry it
|
||||
// whenever the capture was geotagged; ours have to state it from the same
|
||||
// info the watermark prints.
|
||||
locationName?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
dpi?: number | null;
|
||||
// Device identity for the Gallery watermark blob (ref. watermarkBlob). Only
|
||||
// used when the source photo carries no blob of its own.
|
||||
device?: DeviceIdentity | null;
|
||||
// What the studio would once have burned into the pixels: the frame's tagline
|
||||
// as ImageDescription (0x010e) and the whole look as JSON in UserComment
|
||||
// (0x9286). Kept in metadata only — an exported file must stay clean.
|
||||
imageDescription?: string | null;
|
||||
userComment?: string | null;
|
||||
}
|
||||
|
||||
/** Raw device identity for the HyperOS Gallery watermark tag (0x889e). */
|
||||
export interface DeviceIdentity {
|
||||
device: string; // ro.product.device codename, e.g. "aurora"
|
||||
marketName: string; // ro.product.marketname, e.g. "Xiaomi 14 Ultra"
|
||||
manufacturer: string; // Build.MANUFACTURER, e.g. "Xiaomi"
|
||||
model: string; // Build.MODEL — fallback when marketName is empty
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
tag: number;
|
||||
type: number;
|
||||
count: number;
|
||||
data: Uint8Array;
|
||||
ref?: 'exif' | 'gps' | 'interop'; // IFD pointer entry — value patched during layout
|
||||
}
|
||||
|
||||
const BYTE = 1, ASCII = 2, SHORT = 3, LONG = 4, RATIONAL = 5, UNDEFINED = 7, SLONG = 9, SRATIONAL = 10;
|
||||
|
||||
const TYPE_SIZE: Record<number, number> = {
|
||||
[BYTE]: 1, [ASCII]: 1, [SHORT]: 2, [LONG]: 4, [RATIONAL]: 8,
|
||||
[UNDEFINED]: 1, [SLONG]: 4, [SRATIONAL]: 8,
|
||||
};
|
||||
|
||||
// Tags this module owns, or that would dangle once the TIFF is rebuilt.
|
||||
const DROP = new Set<number>([
|
||||
0x0112, // Orientation — the exported pixels are already upright (stamped back as 1)
|
||||
0x0100, 0x0101, // ImageWidth / ImageLength (stamped from the real export size)
|
||||
0x011a, 0x011b, 0x0128, // XResolution / YResolution / ResolutionUnit (stamped when dpi is set)
|
||||
0x0131, 0x0132, // Software / DateTime
|
||||
0x8769, 0x8825, 0xa005, 0x014a, // Exif / GPS / Interop / SubIFD pointers (rebuilt)
|
||||
0x0200, 0x0201, 0x0202, // thumbnail offset/length
|
||||
0x927c, // MakerNote — internal offsets do not survive relocation
|
||||
]);
|
||||
|
||||
// --- little-endian writers -------------------------------------------------
|
||||
|
||||
function u16w(b: Uint8Array, p: number, v: number): void {
|
||||
b[p] = v & 0xff;
|
||||
b[p + 1] = (v >> 8) & 0xff;
|
||||
}
|
||||
|
||||
function u32w(b: Uint8Array, p: number, v: number): void {
|
||||
b[p] = v & 0xff;
|
||||
b[p + 1] = (v >> 8) & 0xff;
|
||||
b[p + 2] = (v >> 16) & 0xff;
|
||||
b[p + 3] = (v >> 24) & 0xff;
|
||||
}
|
||||
|
||||
// --- entry builders --------------------------------------------------------
|
||||
|
||||
// ASCII-typed byte string, NUL-terminated as EXIF requires. The bytes are UTF-8:
|
||||
// a place name ("TAM KỲ, ĐÀ NẴNG") or a market name can carry accents, and the
|
||||
// charCodeAt & 0xff shortcut would mangle them into latin-1. Pure-ASCII text
|
||||
// encodes byte-for-byte the same as before.
|
||||
function asciiEntry(tag: number, text: string): Entry {
|
||||
const bytes: number[] = [];
|
||||
for (const ch of text) {
|
||||
const cp = ch.codePointAt(0) as number;
|
||||
if (cp < 0x80) bytes.push(cp);
|
||||
else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
|
||||
else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
||||
else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
||||
}
|
||||
bytes.push(0);
|
||||
return { tag, type: ASCII, count: bytes.length, data: new Uint8Array(bytes) };
|
||||
}
|
||||
|
||||
function shortEntry(tag: number, v: number): Entry {
|
||||
const data = new Uint8Array(2);
|
||||
u16w(data, 0, v);
|
||||
return { tag, type: SHORT, count: 1, data };
|
||||
}
|
||||
|
||||
// UNDEFINED value: a raw byte string whose length IS the count (ExifVersion etc).
|
||||
function undefinedEntry(tag: number, text: string): Entry {
|
||||
const data = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff;
|
||||
return { tag, type: UNDEFINED, count: data.length, data };
|
||||
}
|
||||
|
||||
// UserComment (0x9286) must open with an 8-byte character-code header; the rest
|
||||
// is UTF-8, which every reader that accepts "ASCII" here decodes correctly.
|
||||
function userCommentEntry(text: string): Entry {
|
||||
const body = new TextEncoder().encode(text);
|
||||
const data = new Uint8Array(8 + body.length);
|
||||
data.set([0x41, 0x53, 0x43, 0x49, 0x49, 0, 0, 0]); // "ASCII\0\0\0"
|
||||
data.set(body, 8);
|
||||
return { tag: 0x9286, type: UNDEFINED, count: data.length, data };
|
||||
}
|
||||
|
||||
function longEntry(tag: number, v: number): Entry {
|
||||
const data = new Uint8Array(4);
|
||||
u32w(data, 0, v);
|
||||
return { tag, type: LONG, count: 1, data };
|
||||
}
|
||||
|
||||
function rationalEntry(tag: number, num: number, den: number, type = RATIONAL): Entry {
|
||||
const data = new Uint8Array(8);
|
||||
u32w(data, 0, num);
|
||||
u32w(data, 4, den);
|
||||
return { tag, type, count: 1, data };
|
||||
}
|
||||
|
||||
// GPS coordinate: 3 RATIONALs (degrees, minutes, seconds), denominator 10000
|
||||
// for the seconds so ~0.1 m survives.
|
||||
function gpsCoordEntry(tag: number, value: number): Entry {
|
||||
const abs = Math.abs(value);
|
||||
let deg = Math.floor(abs);
|
||||
let min = Math.floor((abs - deg) * 60);
|
||||
let sec = Math.round(((abs - deg) * 60 - min) * 60 * 10000);
|
||||
if (sec >= 600000) { sec = 0; min += 1; }
|
||||
if (min >= 60) { min = 0; deg += 1; }
|
||||
const data = new Uint8Array(24);
|
||||
u32w(data, 0, deg); u32w(data, 4, 1);
|
||||
u32w(data, 8, min); u32w(data, 12, 1);
|
||||
u32w(data, 16, sec); u32w(data, 20, 10000);
|
||||
return { tag, type: RATIONAL, count: 3, data };
|
||||
}
|
||||
|
||||
// A big-endian source TIFF hands us big-endian value bytes; they are re-emitted
|
||||
// into a little-endian TIFF, so every numeric value must be swapped first
|
||||
// (ASCII/BYTE/UNDEFINED are byte strings and stay as they are).
|
||||
function swapToLittleEndian(data: Uint8Array, type: number): Uint8Array {
|
||||
const unit = type === SHORT ? 2 : type === LONG || type === SLONG ? 4 : type === RATIONAL || type === SRATIONAL ? 4 : 0;
|
||||
if (!unit) return data;
|
||||
// `new Uint8Array(data)` (not data.slice()) — the input may be a Node Buffer,
|
||||
// whose slice() aliases the source and would corrupt the caller's bytes.
|
||||
const out = new Uint8Array(data);
|
||||
for (let p = 0; p + unit <= out.length; p += unit) {
|
||||
for (let i = 0; i < unit >> 1; i++) {
|
||||
const a = out[p + i];
|
||||
out[p + i] = out[p + unit - 1 - i];
|
||||
out[p + unit - 1 - i] = a;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asciiValue(e: Entry | undefined): string | null {
|
||||
if (!e || e.type !== ASCII || e.data.length === 0) return null;
|
||||
let s = '';
|
||||
for (let i = 0; i < e.data.length && e.data[i] !== 0; i++) s += String.fromCharCode(e.data[i]);
|
||||
return s || null;
|
||||
}
|
||||
|
||||
// "YYYY:MM:DD HH:MM:SS" — the EXIF date format (colons, not dashes).
|
||||
function exifDate(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}:${p(d.getMonth() + 1)}:${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
// "±HH:MM" — the OffsetTime* tag format. Framing tools read it to place a
|
||||
// capture correctly, and it is what makes the date unambiguous without GPS.
|
||||
function tzOffset(d: Date): string {
|
||||
const mins = -d.getTimezoneOffset();
|
||||
const a = Math.abs(mins);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${mins < 0 ? '-' : '+'}${p(Math.floor(a / 60))}:${p(a % 60)}`;
|
||||
}
|
||||
|
||||
// The watermark blob HyperOS Gallery reads back (EXIF tag 0x889e) before it
|
||||
// will open its watermark editor: without it, a photo edited here fails with
|
||||
// "cannot recognize the parameters". Only the stock camera app writes this tag
|
||||
// — CameraX/HAL never does — so a camera capture has to synthesize it from the
|
||||
// same props the stock app derives it from. Shape matches the stock bytes:
|
||||
// {"buildDevice":"aurora",...,"waterLogo":"XIAOMI","waterName":"14 Ultra"}.
|
||||
function watermarkBlob(d: DeviceIdentity): string {
|
||||
const brand = d.manufacturer.trim();
|
||||
const market = d.marketName.trim();
|
||||
// "Xiaomi 14 Ultra" -> "14 Ultra": Gallery prints waterName, not the brand.
|
||||
const waterName = market.toLowerCase().startsWith(`${brand.toLowerCase()} `)
|
||||
? market.slice(brand.length + 1)
|
||||
: market || d.model;
|
||||
return JSON.stringify({
|
||||
buildDevice: d.device,
|
||||
customize: '',
|
||||
cvLensName: '',
|
||||
filterName: '',
|
||||
livephotoInfo: '',
|
||||
version: 2,
|
||||
waterLogo: brand.toUpperCase(),
|
||||
waterName,
|
||||
});
|
||||
}
|
||||
|
||||
// --- parse -----------------------------------------------------------------
|
||||
|
||||
interface ParsedTiff {
|
||||
ifd0: Entry[];
|
||||
exif: Entry[];
|
||||
gps: Entry[];
|
||||
interop: Entry[];
|
||||
}
|
||||
|
||||
function parseTiff(tiff: Uint8Array): ParsedTiff {
|
||||
const none: ParsedTiff = { ifd0: [], exif: [], gps: [], interop: [] };
|
||||
if (tiff.length < 8) return none;
|
||||
const le = tiff[0] === 0x49 && tiff[1] === 0x49;
|
||||
const be = tiff[0] === 0x4d && tiff[1] === 0x4d;
|
||||
if (!le && !be) return none;
|
||||
|
||||
const u16 = (p: number): number => (le ? tiff[p] | (tiff[p + 1] << 8) : (tiff[p] << 8) | tiff[p + 1]);
|
||||
const u32 = (p: number): number =>
|
||||
le
|
||||
? (tiff[p] | (tiff[p + 1] << 8) | (tiff[p + 2] << 16) | (tiff[p + 3] << 24)) >>> 0
|
||||
: ((tiff[p] << 24) | (tiff[p + 1] << 16) | (tiff[p + 2] << 8) | tiff[p + 3]) >>> 0;
|
||||
|
||||
const read = (off: number): { list: Entry[]; ptr: Record<number, number> } => {
|
||||
const out: { list: Entry[]; ptr: Record<number, number> } = { list: [], ptr: {} };
|
||||
if (off <= 0 || off + 2 > tiff.length) return out;
|
||||
const n = u16(off);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = off + 2 + i * 12;
|
||||
if (p + 12 > tiff.length) break;
|
||||
const tag = u16(p);
|
||||
const type = u16(p + 2);
|
||||
const count = u32(p + 4);
|
||||
if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) {
|
||||
out.ptr[tag] = u32(p + 8); // sub-IFD pointers are rebuilt, never copied
|
||||
continue;
|
||||
}
|
||||
const size = TYPE_SIZE[type];
|
||||
if (!size || DROP.has(tag)) continue;
|
||||
const len = size * count;
|
||||
if (len <= 0 || len > 4 * 1024 * 1024) continue; // ignore absurd/overflowing counts
|
||||
if (len <= 4) {
|
||||
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(tiff.slice(p + 8, p + 8 + len), type) : tiff.slice(p + 8, p + 8 + len) });
|
||||
} else {
|
||||
const o = u32(p + 8);
|
||||
if (o + len > tiff.length) continue;
|
||||
const raw = tiff.slice(o, o + len);
|
||||
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(raw, type) : raw });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const a = read(u32(4));
|
||||
const exifRead = a.ptr[0x8769] ? read(a.ptr[0x8769]) : { list: [], ptr: {} };
|
||||
return {
|
||||
ifd0: a.list,
|
||||
exif: exifRead.list,
|
||||
gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).list : [],
|
||||
interop: exifRead.ptr[0xa005] ? read(exifRead.ptr[0xa005]).list : [],
|
||||
};
|
||||
}
|
||||
|
||||
// Locate the APP1/EXIF segment: start/end bound the whole segment, tiff the
|
||||
// payload after the "Exif\0\0" signature.
|
||||
function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff: Uint8Array } | null {
|
||||
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return null;
|
||||
let i = 2;
|
||||
while (i + 1 < jpeg.length && jpeg[i] === 0xff) {
|
||||
const start = i;
|
||||
while (jpeg[i] === 0xff) i++;
|
||||
const marker = jpeg[i];
|
||||
i++;
|
||||
if (marker === 0xd9 || marker === 0xda) return null; // EOI / SOS — headers only
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
||||
if (i + 2 > jpeg.length) return null;
|
||||
const len = (jpeg[i] << 8) | jpeg[i + 1];
|
||||
if (len < 2 || i + len > jpeg.length) return null;
|
||||
const dataStart = i + 2;
|
||||
if (
|
||||
marker === 0xe1 && len >= 8 &&
|
||||
jpeg[dataStart] === 0x45 && jpeg[dataStart + 1] === 0x78 && jpeg[dataStart + 2] === 0x69 &&
|
||||
jpeg[dataStart + 3] === 0x66 && jpeg[dataStart + 4] === 0x00 && jpeg[dataStart + 5] === 0x00
|
||||
) {
|
||||
return { start, end: i + len, tiff: jpeg.subarray(dataStart + 6, i + len) };
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- serialize -------------------------------------------------------------
|
||||
|
||||
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array {
|
||||
const ifdLen = (n: number) => 2 + n * 12 + 4;
|
||||
const outLen = (es: Entry[]) => {
|
||||
let s = 0;
|
||||
for (const e of es) if (e.data.length > 4) s += (e.data.length + 1) & ~1;
|
||||
return s;
|
||||
};
|
||||
|
||||
const o0 = 8;
|
||||
const d0 = o0 + ifdLen(ifd0.length);
|
||||
const oE = d0 + outLen(ifd0);
|
||||
const dE = oE + ifdLen(exif.length);
|
||||
const oG = dE + outLen(exif);
|
||||
const dG = oG + ifdLen(gps.length);
|
||||
const oI = dG + outLen(gps);
|
||||
const dI = oI + ifdLen(interop.length);
|
||||
const t = new Uint8Array(dI + outLen(interop));
|
||||
|
||||
t[0] = 0x49; t[1] = 0x49; t[2] = 0x2a; t[3] = 0x00; // "II", 42
|
||||
u32w(t, 4, o0);
|
||||
|
||||
const writeIfd = (off: number, es: Entry[], dataOff: number): void => {
|
||||
u16w(t, off, es.length);
|
||||
let c = dataOff;
|
||||
es.forEach((e, i) => {
|
||||
const p = off + 2 + i * 12;
|
||||
u16w(t, p, e.tag);
|
||||
u16w(t, p + 2, e.type);
|
||||
u32w(t, p + 4, e.count);
|
||||
if (e.ref === 'exif') { u32w(t, p + 8, oE); return; }
|
||||
if (e.ref === 'gps') { u32w(t, p + 8, oG); return; }
|
||||
if (e.ref === 'interop') { u32w(t, p + 8, oI); return; }
|
||||
if (e.data.length <= 4) { t.set(e.data, p + 8); return; }
|
||||
u32w(t, p + 8, c);
|
||||
t.set(e.data, c);
|
||||
c += (e.data.length + 1) & ~1; // values start on a word boundary
|
||||
});
|
||||
u32w(t, off + 2 + es.length * 12, 0); // no IFD1
|
||||
};
|
||||
|
||||
writeIfd(o0, ifd0, d0);
|
||||
writeIfd(oE, exif, dE);
|
||||
writeIfd(oG, gps, dG);
|
||||
writeIfd(oI, interop, dI);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the JPEG's APP1/EXIF block: `sourceJpeg` (the loaded photo) donates
|
||||
* every tag this module does not own; `stamp` sets the ones the app owns.
|
||||
* Returns a new array; input is never modified. A JPEG that cannot take the
|
||||
* segment (not a JPEG, EXIF larger than 64 KB) comes back untouched.
|
||||
*/
|
||||
export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: Uint8Array | null): Uint8Array {
|
||||
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return jpeg;
|
||||
|
||||
// An unreadable source (content:// uri, IO error) must not wipe the tags the
|
||||
// jpeg already carries, so fall back to the jpeg's own EXIF block.
|
||||
const src = findExifSegment(sourceJpeg && sourceJpeg.length > 4 ? sourceJpeg : jpeg);
|
||||
const parsed = src ? parseTiff(src.tiff) : { ifd0: [], exif: [], gps: [], interop: [] };
|
||||
const ifd0 = new Map(parsed.ifd0.map((e) => [e.tag, e]));
|
||||
const exif = new Map(parsed.exif.map((e) => [e.tag, e]));
|
||||
const gps = new Map(parsed.gps.map((e) => [e.tag, e]));
|
||||
const interop = new Map(parsed.interop.map((e) => [e.tag, e]));
|
||||
|
||||
const now = exifDate(stamp.dateTime ?? new Date());
|
||||
const keepSourceDate = !stamp.dateTime;
|
||||
const sourceDate = asciiValue(ifd0.get(0x0132)) ?? asciiValue(exif.get(0x9003));
|
||||
const dateStr = keepSourceDate ? sourceDate ?? now : now;
|
||||
|
||||
if (!keepSourceDate || !ifd0.has(0x0132)) ifd0.set(0x0132, asciiEntry(0x0132, dateStr));
|
||||
if (!keepSourceDate || !exif.has(0x9003)) exif.set(0x9003, asciiEntry(0x9003, dateStr));
|
||||
if (!keepSourceDate || !exif.has(0x9004)) exif.set(0x9004, asciiEntry(0x9004, dateStr));
|
||||
if (!keepSourceDate) {
|
||||
// We own the timestamp for camera shots, so we also state its zone.
|
||||
const off = asciiEntry(0x9010, tzOffset(stamp.dateTime as Date));
|
||||
ifd0.set(0x9010, off); // OffsetTime (0th IFD)
|
||||
exif.set(0x9011, { ...off, tag: 0x9011 }); // OffsetTimeOriginal
|
||||
exif.set(0x9012, { ...off, tag: 0x9012 }); // OffsetTimeDigitized
|
||||
}
|
||||
ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE));
|
||||
ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight
|
||||
|
||||
// The frame's own labels, metadata-only: ImageDescription names the look and
|
||||
// UserComment carries the settings so the file reopens as it was saved.
|
||||
const desc = stamp.imageDescription?.trim();
|
||||
if (desc) ifd0.set(0x010e, asciiEntry(0x010e, desc));
|
||||
const comment = stamp.userComment?.trim();
|
||||
if (comment) exif.set(0x9286, userCommentEntry(comment));
|
||||
|
||||
// Baseline tags a conforming EXIF block is expected to carry. Framing apps
|
||||
// reject a file that omits them, and HALs (the emulator's especially) do not
|
||||
// always supply them, so fill the gaps and keep whatever the source had.
|
||||
if (!exif.has(0x9000)) exif.set(0x9000, undefinedEntry(0x9000, '0230')); // ExifVersion
|
||||
if (!exif.has(0xa000)) exif.set(0xa000, undefinedEntry(0xa000, '0100')); // FlashPixVersion
|
||||
if (!exif.has(0x9101)) exif.set(0x9101, { tag: 0x9101, type: UNDEFINED, count: 4, data: new Uint8Array([1, 2, 3, 0]) }); // YCbCr
|
||||
if (!exif.has(0xa001)) exif.set(0xa001, shortEntry(0xa001, 65535)); // ColorSpace = uncalibrated
|
||||
if (!interop.has(0x0001)) interop.set(0x0001, asciiEntry(0x0001, 'R98')); // InteropIndex
|
||||
if (!interop.has(0x0002)) interop.set(0x0002, undefinedEntry(0x0002, '0100')); // InteropVersion
|
||||
|
||||
if (stamp.width && stamp.height) {
|
||||
ifd0.set(0x0100, longEntry(0x0100, stamp.width));
|
||||
ifd0.set(0x0101, longEntry(0x0101, stamp.height));
|
||||
exif.set(0xa002, longEntry(0xa002, stamp.width)); // PixelXDimension
|
||||
exif.set(0xa003, longEntry(0xa003, stamp.height)); // PixelYDimension
|
||||
}
|
||||
|
||||
if (stamp.dpi && stamp.dpi > 0) {
|
||||
ifd0.set(0x011a, rationalEntry(0x011a, stamp.dpi, 1));
|
||||
ifd0.set(0x011b, rationalEntry(0x011b, stamp.dpi, 1));
|
||||
ifd0.set(0x0128, shortEntry(0x0128, 2)); // resolution unit = inch
|
||||
}
|
||||
|
||||
// HyperOS Gallery refuses to open its watermark editor without 0x889e, and a
|
||||
// CameraX capture never carries it. Never overwrite a blob the source has.
|
||||
const xiaomi = !!stamp.device && /xiaomi|redmi|poco/i.test(stamp.device.manufacturer);
|
||||
if (stamp.device && xiaomi && !exif.has(0x889e)) {
|
||||
exif.set(0x889e, asciiEntry(0x889e, watermarkBlob(stamp.device)));
|
||||
}
|
||||
|
||||
// Stock Xiaomi photos name the device in 0x9a00 and in Model (0x0110) as the
|
||||
// MARKET name ("Xiaomi 14 Ultra"); the framing tool matches those against its
|
||||
// known-device list and reports an unknown device otherwise. A CameraX capture
|
||||
// only has the raw codename, so state the market name. A source that already
|
||||
// carries 0x9a00 is a stock photo of some other device — leave it alone.
|
||||
if (stamp.device && xiaomi && !ifd0.has(0x9a00)) {
|
||||
const market = (stamp.device.marketName || stamp.device.model).trim();
|
||||
if (market) {
|
||||
ifd0.set(0x9a00, asciiEntry(0x9a00, market));
|
||||
ifd0.set(0x0110, asciiEntry(0x0110, market));
|
||||
}
|
||||
}
|
||||
|
||||
// Place name the frame prints (0x889d) — stock writes it in both IFDs.
|
||||
const place = stamp.locationName?.trim();
|
||||
if (place) {
|
||||
if (!ifd0.has(0x889d)) ifd0.set(0x889d, asciiEntry(0x889d, place));
|
||||
if (!exif.has(0x889d)) exif.set(0x889d, asciiEntry(0x889d, place));
|
||||
}
|
||||
|
||||
if (stamp.gps) {
|
||||
const { latitude: lat, longitude: lon } = stamp.gps;
|
||||
gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S'));
|
||||
gps.set(0x0002, gpsCoordEntry(0x0002, lat));
|
||||
gps.set(0x0003, asciiEntry(0x0003, lon >= 0 ? 'E' : 'W'));
|
||||
gps.set(0x0004, gpsCoordEntry(0x0004, lon));
|
||||
}
|
||||
|
||||
const list0 = [...ifd0.values()];
|
||||
if (exif.size > 0) list0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4), ref: 'exif' });
|
||||
if (gps.size > 0) list0.push({ tag: 0x8825, type: LONG, count: 1, data: new Uint8Array(4), ref: 'gps' });
|
||||
list0.sort((a, b) => a.tag - b.tag);
|
||||
const listE = [...exif.values()].sort((a, b) => a.tag - b.tag);
|
||||
const listG = [...gps.values()].sort((a, b) => a.tag - b.tag);
|
||||
if (interop.size > 0) listE.push({ tag: 0xa005, type: LONG, count: 1, data: new Uint8Array(4), ref: 'interop' });
|
||||
listE.sort((a, b) => a.tag - b.tag);
|
||||
const listI = [...interop.values()].sort((a, b) => a.tag - b.tag);
|
||||
|
||||
const tiff = serializeTiff(list0, listE, listG, listI);
|
||||
const payload = 2 + 6 + tiff.length;
|
||||
if (payload > 0xffff) return jpeg; // APP1 length is a 16-bit field
|
||||
|
||||
const app1 = new Uint8Array(2 + payload);
|
||||
app1[0] = 0xff; app1[1] = 0xe1;
|
||||
// APP1 length is a big-endian 16-bit field (the TIFF block after it is little-endian)
|
||||
app1[2] = (payload >> 8) & 0xff;
|
||||
app1[3] = payload & 0xff;
|
||||
app1.set([0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 4); // "Exif\0\0"
|
||||
app1.set(tiff, 10);
|
||||
|
||||
const old = findExifSegment(jpeg);
|
||||
const head = old ? jpeg.subarray(0, old.start) : jpeg.subarray(0, 2);
|
||||
const tail = old ? jpeg.subarray(old.end) : jpeg.subarray(2);
|
||||
const out = new Uint8Array(head.length + app1.length + tail.length);
|
||||
out.set(head, 0);
|
||||
out.set(app1, head.length);
|
||||
out.set(tail, head.length + app1.length);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { SkCanvas, SkPaint, Skia, BlendMode } from '@shopify/react-native-skia';
|
||||
import { FrameId } from '../types';
|
||||
|
||||
export interface FrameDefinition {
|
||||
id: FrameId;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const FRAMES: FrameDefinition[] = [
|
||||
{ id: 'none', name: 'NO FRAME' },
|
||||
{ id: 'classic-white', name: 'CLASSIC BORDER' },
|
||||
{ id: 'polaroid', name: 'RETRO INSTANT' },
|
||||
{ id: 'wallframe', name: 'WALL FRAME' },
|
||||
];
|
||||
|
||||
// Real Polaroid print proportions (88 x 107 mm card, 79 x 79 mm image print).
|
||||
// All fractions are relative to the CARD WIDTH so the layout scales to any
|
||||
// pixel size while keeping the physical look.
|
||||
export const POLAROID_CARD = {
|
||||
// Horizontal + top margin: (88 - 79)/2 / 88 mm each side.
|
||||
sideMargin: (88 - 79) / 2 / 88, // ≈ 0.05114
|
||||
// Bottom deck: remaining height of the 107 mm card below the 79 mm print,
|
||||
// minus the top margin: (107 - 79 - 4.5) / 88 mm.
|
||||
bottomDeck: (107 - 79 - (88 - 79) / 2) / 88, // ≈ 0.26705
|
||||
} as const;
|
||||
// Image window width as a fraction of card width (79 / 88 mm).
|
||||
export const POLAROID_WIN_W = 1 - 2 * POLAROID_CARD.sideMargin; // ≈ 0.89773
|
||||
|
||||
export interface PolaroidLayout {
|
||||
card: { x: number; y: number; w: number; h: number };
|
||||
window: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
// Wall frame: the PNG artwork lives at the project root (wallframe.png,
|
||||
// 4000x3117 RGBA, transparent window cols 675..3322 x rows 675..2441). Only the
|
||||
// geometry lives here; the bitmap itself is drawn by the caller. The phone is
|
||||
// portrait, so the app shows the artwork ROTATED 90° CW (original top edge →
|
||||
// right edge): a portrait frame whose transparent window is a fixed 2:3
|
||||
// vertical opening. The photo is cover-cropped (center) into that opening — the
|
||||
// mat of a real frame hides the print edges. WALLFRAME_W/H and WALLFRAME_WIN
|
||||
// are the rotated display orientation; the affine in wallframeLayout maps the
|
||||
// source PNG pixels onto the frame rect so the drawn opening and the photo
|
||||
// window always line up.
|
||||
export const WALLFRAME_PNG_W = 4000; // source PNG width
|
||||
export const WALLFRAME_PNG_H = 3117; // source PNG height
|
||||
export const WALLFRAME_W = 3117; // rotated display width (= PNG height)
|
||||
export const WALLFRAME_H = 4000; // rotated display height (= PNG width)
|
||||
// Transparent window as fractions of the rotated image, derived from the same
|
||||
// affine used to draw the bitmap: X = w - (y/3117)w, Y = (x/4000)h.
|
||||
export const WALLFRAME_WIN = {
|
||||
left: 676 / 3117, // ≈ 0.21688
|
||||
top: 675 / 4000, // 0.16875
|
||||
w: 1766 / 3117, // ≈ 0.56657
|
||||
h: 2647 / 4000, // 0.66175
|
||||
} as const;
|
||||
// Landscape hang: the PNG's OWN orientation, so nothing is rotated — the
|
||||
// canvas is the artwork (4000x3117) and the opening is the source window as
|
||||
// fractions of it. Same 3:2 mat opening, hung the other way round.
|
||||
export const WALLFRAME_LAND_W = WALLFRAME_PNG_W; // 4000
|
||||
export const WALLFRAME_LAND_H = WALLFRAME_PNG_H; // 3117
|
||||
export const WALLFRAME_LAND_WIN = {
|
||||
left: 675 / WALLFRAME_PNG_W, // 0.16875
|
||||
top: 675 / WALLFRAME_PNG_H, // ≈ 0.21688
|
||||
w: 2647 / WALLFRAME_PNG_W, // 0.66175
|
||||
h: 1766 / WALLFRAME_PNG_H, // ≈ 0.56657
|
||||
} as const;
|
||||
|
||||
export interface WallframeLayout {
|
||||
frame: { x: number; y: number; w: number; h: number };
|
||||
window: { x: number; y: number; w: number; h: number };
|
||||
// Row-major Skia matrix that rotates the source PNG (WALLFRAME_PNG_W/H) 90° CW
|
||||
// into the frame rect. Feed it to <Group matrix> (viewfinder) or concat it on
|
||||
// the canvas (export) before drawing the bitmap.
|
||||
drawMatrix: number[];
|
||||
}
|
||||
|
||||
// Geometry of the wall-frame inside an arbitrary container. The frame is
|
||||
// contain-fitted ('contain', centered, letterboxed) for the live viewfinder,
|
||||
// or fills the container exactly ('fill', export canvas already at frame size).
|
||||
// `landscape` hangs the artwork the other way round: same PNG, no rotation.
|
||||
export function wallframeLayout(
|
||||
contW: number,
|
||||
contH: number,
|
||||
mode: 'contain' | 'fill' = 'contain',
|
||||
landscape = false
|
||||
): WallframeLayout {
|
||||
const FW = landscape ? WALLFRAME_LAND_W : WALLFRAME_W;
|
||||
const FH = landscape ? WALLFRAME_LAND_H : WALLFRAME_H;
|
||||
const WIN = landscape ? WALLFRAME_LAND_WIN : WALLFRAME_WIN;
|
||||
let frameW: number;
|
||||
let frameH: number;
|
||||
if (mode === 'fill') {
|
||||
frameW = contW;
|
||||
frameH = contH;
|
||||
} else {
|
||||
const s = Math.min(contW / FW, contH / FH);
|
||||
frameW = FW * s;
|
||||
frameH = FH * s;
|
||||
}
|
||||
const fx = (contW - frameW) / 2;
|
||||
const fy = (contH - frameH) / 2;
|
||||
const scale = frameW / FW; // == frameH / FH
|
||||
return {
|
||||
frame: { x: fx, y: fy, w: frameW, h: frameH },
|
||||
window: {
|
||||
x: fx + WIN.left * frameW,
|
||||
y: fy + WIN.top * frameH,
|
||||
w: WIN.w * frameW,
|
||||
h: WIN.h * frameH,
|
||||
},
|
||||
// Portrait: the source PNG must be turned 90° CW onto the canvas.
|
||||
// Landscape: it already sits the right way up — scale and centre it.
|
||||
drawMatrix: landscape
|
||||
? [scale, 0, fx, 0, scale, fy, 0, 0, 1]
|
||||
: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1],
|
||||
};
|
||||
}
|
||||
|
||||
// Geometry of a retro-polaroid card inside an arbitrary container. The photo is
|
||||
// NEVER cropped: the image window keeps the photo aspect ratio (photoW/photoH)
|
||||
// and the card grows/shrinks around it, so the whole picture always fits the
|
||||
// window. 'contain' fits the full card inside the container (centered, with
|
||||
// letterbox bars); 'fill' sizes the container exactly to the natural card
|
||||
// proportions (used at export where the canvas IS the card).
|
||||
export function polaroidLayout(
|
||||
contW: number,
|
||||
contH: number,
|
||||
photoAspect: number, // photo width / photo height (>1 = landscape)
|
||||
mode: 'contain' | 'fill' = 'contain'
|
||||
): PolaroidLayout {
|
||||
const S = POLAROID_CARD;
|
||||
// Height of the card once the photo window (width WIN_W of the card width,
|
||||
// height = that / photoAspect) is stacked below the top margin and above the
|
||||
// bottom deck: cardH = cardW*(sideMargin + bottomDeck) + cardW*WIN_W/photoAspect
|
||||
const naturalAspect = S.sideMargin + S.bottomDeck + POLAROID_WIN_W / photoAspect; // cardH/cardW
|
||||
|
||||
let cardW: number;
|
||||
let cardH: number;
|
||||
if (mode === 'fill') {
|
||||
cardW = contW;
|
||||
cardH = contH;
|
||||
} else {
|
||||
cardW = Math.min(contW, contH / naturalAspect);
|
||||
cardH = cardW * naturalAspect;
|
||||
}
|
||||
const cx = (contW - cardW) / 2;
|
||||
const cy = (contH - cardH) / 2;
|
||||
|
||||
const top = S.sideMargin * cardW;
|
||||
const side = S.sideMargin * cardW;
|
||||
let winW = POLAROID_WIN_W * cardW;
|
||||
let winH = winW / photoAspect;
|
||||
// Container aspect may leave less vertical room than the natural card (e.g.
|
||||
// phone camera feed) — shrink the window to the available space instead of
|
||||
// overflowing the card.
|
||||
const maxWinH = cardH - top - S.bottomDeck * cardW;
|
||||
if (winH > maxWinH) {
|
||||
winH = maxWinH;
|
||||
winW = winH * photoAspect;
|
||||
}
|
||||
return {
|
||||
card: { x: cx, y: cy, w: cardW, h: cardH },
|
||||
window: { x: cx + (cardW - winW) / 2, y: cy + top, w: winW, h: winH },
|
||||
};
|
||||
}
|
||||
|
||||
export function drawFrameOnCanvas(
|
||||
canvas: SkCanvas,
|
||||
width: number,
|
||||
height: number,
|
||||
frameId: FrameId
|
||||
) {
|
||||
if (frameId === 'none') return;
|
||||
|
||||
const paint = Skia.Paint();
|
||||
|
||||
if (frameId === 'classic-white') {
|
||||
// A clean white border around the picture
|
||||
paint.setColor(Skia.Color('#ffffff'));
|
||||
paint.setStyle(0); // Fill
|
||||
|
||||
const borderWidth = Math.min(width, height) * 0.05; // 5% border
|
||||
|
||||
// Top border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, borderWidth), paint);
|
||||
// Bottom border
|
||||
canvas.drawRect(Skia.XYWHRect(0, height - borderWidth, width, borderWidth), paint);
|
||||
// Left border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint);
|
||||
// Right border
|
||||
canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint);
|
||||
} else if (frameId === 'cinematic') {
|
||||
// Cinematic black bars on top and bottom
|
||||
paint.setColor(Skia.Color('#000000'));
|
||||
paint.setStyle(0); // Fill
|
||||
|
||||
const barHeight = height * 0.12; // 12% cinematic letterbox height
|
||||
|
||||
// Top letterbox
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, barHeight), paint);
|
||||
// Bottom letterbox
|
||||
canvas.drawRect(Skia.XYWHRect(0, height - barHeight, width, barHeight), paint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { GradientMask } from '../types';
|
||||
import {
|
||||
CLARITY_GAIN,
|
||||
DEHAZE_FLOOR_T,
|
||||
DEHAZE_MAX_OMEGA,
|
||||
DEHAZE_PATCH_STEP,
|
||||
DEHAZE_PATCH_TAPS,
|
||||
} from './toneShader';
|
||||
|
||||
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
|
||||
// adjustments that are a SHAPE rather than a whole-frame knob.
|
||||
//
|
||||
// HEAL and MOSAIC repair and hide; these two grade. A shape is dragged on the
|
||||
// photo, and inside it (through the alpha the shape's own maths produces) the
|
||||
// three knobs below move the pixels and nothing outside them does. A linear
|
||||
// gradient is the shape whose alpha is a straight ramp across the drag; a radial
|
||||
// one is an ellipse whose alpha holds full until its feather and dies at its
|
||||
// rim. Two shapes, one shader: the kind is a number in the mask's own uniform
|
||||
// and the block that reads it branches, because that is the whole difference
|
||||
// between them.
|
||||
//
|
||||
// The maths is the GLSL spec these were asked for (its scratchpad md, sections
|
||||
// 1 and 2 for the masks and the blend shader for the colour): the linear one
|
||||
// projects the pixel onto the drag with a dot product and smoothsteps the
|
||||
// result; the radial one moves the pixel into the ellipse's own frame, rotates
|
||||
// it back by -angle, divides by the semi-axes and smoothsteps inward from the
|
||||
// rim. Written in SkSL because that is what this renderer is (Canvaskit, see
|
||||
// skiaShim.ts), stage for stage: the one pass the spec asks for per mask, in
|
||||
// the order the user drew them, each reading what the one before it left.
|
||||
//
|
||||
// The spec's section 4 — "the system needs to restrict all the above effects to
|
||||
// operate only within the mask's area" — is the rest of the block below: the
|
||||
// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and
|
||||
// BLACK move, and the two spatial ones (CLARITY against the frame's own blur,
|
||||
// DEHAZE through the dark channel) that the caller hands in the blurred
|
||||
// reference for and the atmospheric light for. Every one of them rides the same
|
||||
// alpha the shape produces and lands through the same `mix`, so a mask at half
|
||||
// strength is half of the move.
|
||||
export const MASK_KIND = { linear: 0, radial: 1 } as const;
|
||||
// Exposure is stored as the EV itself — the spec's own -5..+5 — because that is
|
||||
// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units
|
||||
// are elsewhere. The other two are -10..+10 like every other knob, and are turned
|
||||
// into the spec's -1..+1 on the way to the shader.
|
||||
export const MASK_EXPOSURE_MAX = 5;
|
||||
// How much of the semi-axis a fresh ellipse fades over. Half: the edge is soft
|
||||
// enough to be a gradient mask rather than a cut-out, and every pixel of the
|
||||
// ellipse is still on the shape's own side of the rim.
|
||||
export const MASK_DEFAULT_FEATHER = 0.5;
|
||||
// The smallest drag that is a shape rather than a press: a hundredth of the
|
||||
// width. Below this the mask is dropped instead of laid down — the same floor
|
||||
// readMasks refuses a stored one at.
|
||||
export const MASK_MIN = 0.01;
|
||||
|
||||
const num = (v: unknown, fallback: number) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const clampA = (v: number) => (v < -10 ? -10 : v > 10 ? 10 : v);
|
||||
const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v > MASK_EXPOSURE_MAX ? MASK_EXPOSURE_MAX : v);
|
||||
|
||||
// The stored masks, made readable: numbers, inside the frame, one kind — the
|
||||
// same guard HEAL's and MOSAIC's lists get, so a hand-written or older file
|
||||
// cannot produce a shape the overlay and the renderer disagree about. A mask
|
||||
// with no shape (a linear drag of no length, an ellipse of no radius) is
|
||||
// dropped: it would paint nothing and could never be taken hold of on the photo.
|
||||
// What readMasks hands back: the stored mask with every knob resolved to a
|
||||
// number. The two spatial ones and the four tonal ones are optional in the
|
||||
// stored type (a mask saved before they existed has none), so the reader's
|
||||
// return type is the one that says they are there.
|
||||
export type ReadMask = GradientMask & {
|
||||
highlights: number;
|
||||
shadows: number;
|
||||
whites: number;
|
||||
blacks: number;
|
||||
clarity: number;
|
||||
dehaze: number;
|
||||
};
|
||||
|
||||
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
||||
if (!Array.isArray(masks)) return [];
|
||||
return masks
|
||||
.filter((m) => m?.kind === 'linear' || m?.kind === 'radial')
|
||||
.map((m) => ({
|
||||
kind: m.kind,
|
||||
x: clamp01(num(m.x, 0.5)),
|
||||
y: clamp01(num(m.y, 0.5)),
|
||||
ex: clamp01(num(m.ex, 0.5)),
|
||||
ey: clamp01(num(m.ey, 0.5)),
|
||||
rx: Math.max(0, num(m.rx, 0)),
|
||||
ry: Math.max(0, num(m.ry, 0)),
|
||||
angle: num(m.angle, 0),
|
||||
feather: clamp01(num(m.feather, MASK_DEFAULT_FEATHER)),
|
||||
exposure: clampEV(num(m.exposure, 0)),
|
||||
contrast: clampA(num(m.contrast, 0)),
|
||||
saturation: clampA(num(m.saturation, 0)),
|
||||
// The spec's section 4 knobs: the two tone soft masks, the two ends, and
|
||||
// the two spatial moves. All -10..+10 like every other knob here, all 0 on
|
||||
// a mask stored before they existed.
|
||||
highlights: clampA(num(m.highlights, 0)),
|
||||
shadows: clampA(num(m.shadows, 0)),
|
||||
whites: clampA(num(m.whites, 0)),
|
||||
blacks: clampA(num(m.blacks, 0)),
|
||||
clarity: clampA(num(m.clarity, 0)),
|
||||
dehaze: clampA(num(m.dehaze, 0)),
|
||||
}))
|
||||
.filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0));
|
||||
}
|
||||
|
||||
// The two spatial knobs read what the pass cannot build on its own: CLARITY the
|
||||
// frame's own blurred reference, handed in as a second child, and both of them
|
||||
// the frame's atmospheric light. One question, asked in one place, so the
|
||||
// renderer builds the blur for exactly the masks with a spatial knob on them and
|
||||
// the effect is cached for the same answer, and so the shader is built with the
|
||||
// spatial block (DEHAZE's own dark channel lives in it).
|
||||
export function masksHaveSpatial(masks: GradientMask[]): boolean {
|
||||
return readMasks(masks).some((m) => m.clarity !== 0 || m.dehaze !== 0);
|
||||
}
|
||||
|
||||
// The uniform block the shader for `n` masks reads: the shapes, the ellipse
|
||||
// parameters, the knobs with the kind, the tone soft masks, the spatial pair,
|
||||
// then the frame the fractions are of — and, when the caller has one, the
|
||||
// atmospheric light the dehaze reads. Declaration order, arrays expanded — one
|
||||
// buffer is one upload per render, the same shape healUniforms and
|
||||
// mosaicUniforms use. Its length is a function of the list, not a fixed
|
||||
// capacity, because the shader carries exactly the masks the recipe holds.
|
||||
export function maskUniforms(
|
||||
masks: GradientMask[],
|
||||
width: number,
|
||||
height: number,
|
||||
air?: [number, number, number] | null
|
||||
): Float32Array {
|
||||
const list = readMasks(masks);
|
||||
const n = list.length;
|
||||
const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = list[i];
|
||||
u.set([m.x, m.y, m.ex, m.ey], i * 4);
|
||||
u.set([m.rx, m.ry, m.angle, m.feather], (n + i) * 4);
|
||||
// The knobs are handed over in the renderer's own units: EV for exposure,
|
||||
// -1..1 for the other two, which is what the spec's blend shader reads.
|
||||
u.set(
|
||||
[m.exposure, m.contrast / 10, m.saturation / 10, MASK_KIND[m.kind]],
|
||||
(2 * n + i) * 4
|
||||
);
|
||||
// The tone soft masks and the two ends, then the spatial pair.
|
||||
u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4);
|
||||
u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4);
|
||||
}
|
||||
u.set([width, height, 0, 0], 5 * n * 4);
|
||||
if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4);
|
||||
return u;
|
||||
}
|
||||
|
||||
// The colour inside a mask, in the spec's own order and, for the knobs the app
|
||||
// also has frame-wide, in the app's own formulas: exposure first (a power of
|
||||
// two, so a stop is a stop), then contrast about the middle, then saturation as
|
||||
// a mix away from the pixel's own REC-709 luma. Then the spec's section 2 and 3
|
||||
// on top — HIGHLIGHT and SHADOW through the two smoothstep soft masks its own
|
||||
// formula names, WHITE and BLACK as the per-channel point moves TONE_SKSL
|
||||
// makes, and the two spatial ones against the blurred reference the caller hands
|
||||
// in. Every one of them means inside the mask what it means on the whole frame
|
||||
// (toneShader.ts is the reference the four tonal ones are written from), because
|
||||
// the same name on the same knob should not be two different moves. The result
|
||||
// is clamped to the range a file can hold — the spec's own guard, and it is
|
||||
// `mix`ed back over the base by the mask's alpha, so a mask at half strength is
|
||||
// half of the move rather than the whole of it.
|
||||
//
|
||||
// `blur` is the frame's bilateral reference (the same one CLARITY uses
|
||||
// frame-wide) and `air` the atmospheric light; both are the constants 0 when the
|
||||
// shader was built without the second child, and then the two spatial knobs are
|
||||
// left out of the pass — the caller only builds that child for masks that ask
|
||||
// for them (masksHaveSpatial). `dark` is the patch's dark channel for DEHAZE,
|
||||
// read in the block below (the frame's own pixels are only in reach there).
|
||||
const adjustFn = (spatial: boolean) => `
|
||||
half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
|
||||
c = c * half(pow(2.0, a.x));
|
||||
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
||||
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||
// The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to
|
||||
// be the same move as the whole-frame HIGHLIGHT on a smaller area, not a
|
||||
// second opinion about what the name means. HIGHLIGHT and SHADOW are additive
|
||||
// shifts of the luma, HIGHLIGHT weighted by the headroom left (1 - l) so it
|
||||
// cannot drag a blown white to grey, and the colour difference rides along at
|
||||
// a damped gain (TONE_SKSL's own cg) so a lift or a pull cannot collapse a
|
||||
// colour. WHITE and BLACK are per-channel point moves, cubic in each channel's
|
||||
// own distance from the end it owns: the toe and the shoulder move, the
|
||||
// midtones do not, and a white that is lowered stays white.
|
||||
// The spec's soft masks, computed in float and narrowed: smoothstep on half is
|
||||
// one more type the shader does not have to guess at.
|
||||
float lf = clamp(float(l), 0.0, 1.0);
|
||||
float mh = smoothstep(0.50, 1.00, lf);
|
||||
float ms = 1.0 - smoothstep(0.00, 0.55, lf);
|
||||
half lifted = half(clamp(lf + tone.x * mh * (1.0 - lf) + tone.y * 0.34 * ms, 0.0, 1.0));
|
||||
half cg = clamp(lifted / max(l, half(0.0004)), half(0.55), half(1.35));
|
||||
c = clamp(half3(lifted) + (c - half3(l)) * cg, half3(0.0), half3(1.0));
|
||||
half3 dk = half3(1.0) - c;
|
||||
c = clamp(c + half(tone.w * 0.18) * dk * dk * dk + half(tone.z * 0.18) * c * c * c, half3(0.0), half3(1.0));
|
||||
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||
c = mix(half3(nl), c, half(1.0 + a.z));
|
||||
${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason:
|
||||
// sharpening haze only makes it read as detail.
|
||||
if (fx.y != 0.0) {
|
||||
// DEHAZE (doc section 3.2), DEHAZE_SKSL's own formula on the mask's pixels:
|
||||
// dark is the patch's dark channel, the MINIMUM of min(r,g,b)/A over a
|
||||
// neighbourhood — read by the block below, which is where the frame's pixels
|
||||
// are — so a patch with anything genuinely dark in it is not haze and is left
|
||||
// alone (the patch average this used to read called every patch hazy). Signed
|
||||
// like the frame-wide knob: below zero t rises above 1 and the same
|
||||
// expression puts the scattered light back.
|
||||
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
|
||||
half t = clamp(half(1.0 - fx.y * ${DEHAZE_MAX_OMEGA} * clamp(dark, half(0.0), half(1.0))), half(${DEHAZE_FLOOR_T}), half(${1 + DEHAZE_MAX_OMEGA}));
|
||||
c = clamp((c - aa) / t + aa, half3(0.0), half3(1.0));
|
||||
}
|
||||
// CLARITY (doc section 3.1): the pixel against its own blurred surroundings.
|
||||
// Positive sharpens. Negative SOFTENS toward that same reference — a masked
|
||||
// CLARITY -10 is a soften of the mask's own detail, which is the property the
|
||||
// knob is named for, and not the inverted unsharp it used to be (that only
|
||||
// sank the mask's whites and left its contrast where it was).
|
||||
// The reference is the bilateral one (CLARITY_BLUR_SPAN, 6% of the frame) and
|
||||
// the frame-wide negative CLARITY mixes toward that same reference with the
|
||||
// same factor (CLARITY_BLEND_SKSL), so the two readings of the knob are one
|
||||
// move on two different areas.
|
||||
if (fx.x > 0.0) {
|
||||
c = clamp(c + half3(half(fx.x * ${CLARITY_GAIN.toFixed(1)})) * (c - blur), half3(0.0), half3(1.0));
|
||||
} else if (fx.x < 0.0) {
|
||||
c = mix(c, blur, half(clamp(-fx.x, 0.0, 1.0)));
|
||||
}
|
||||
` : ''} return clamp(c, half3(0.0), half3(1.0));
|
||||
}
|
||||
`;
|
||||
|
||||
// One unrolled block per mask, for the reason heal.ts unrolls its own: SkSL
|
||||
// indexes a uniform array by constant only, so the shader is built for the count
|
||||
// it is handed rather than for a capacity, and the renderer caches them by count
|
||||
// (exportEngine's maskEffectFor). A linear mask's alpha is the spec's dot
|
||||
// product, and its zero-length guard is the filter in readMasks rather than a
|
||||
// branch here: a mask with no length is not a mask. A radial mask's alpha is
|
||||
// 1 inside the feather and 0 at the rim.
|
||||
const maskBlock = (i: number, spatial: boolean) => `
|
||||
{
|
||||
float a = 0.0;
|
||||
if (adj[${i}].w < 0.5) {
|
||||
float2 d = (rects[${i}].zw - rects[${i}].xy) * size.xy;
|
||||
float len2 = dot(d, d);
|
||||
float t = dot(pos - rects[${i}].xy * size.xy, d) / len2;
|
||||
a = smoothstep(0.0, 1.0, clamp(t, 0.0, 1.0));
|
||||
} else {
|
||||
float2 st = (pos - rects[${i}].xy * size.xy) / size.x;
|
||||
float ca = cos(-rads[${i}].z);
|
||||
float sa = sin(-rads[${i}].z);
|
||||
float2 r = float2(st.x * ca - st.y * sa, st.x * sa + st.y * ca);
|
||||
float d = length(r / max(rads[${i}].xy, float2(1e-5)));
|
||||
a = 1.0 - smoothstep(max(0.0, 1.0 - rads[${i}].w), 1.0, d);
|
||||
}
|
||||
if (a > 0.0) {
|
||||
${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PATCH_STEP of
|
||||
// the frame's width — the same fraction the frame-wide pass reads, so the
|
||||
// mask's DEHAZE and the frame's are the same neighbourhood on any size of
|
||||
// render. Skipped when the mask has no DEHAZE, so a CLARITY-only mask
|
||||
// pays nothing for it.
|
||||
half dark = half(1.0);
|
||||
if (fx[${i}].y != 0.0) {
|
||||
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
|
||||
float stp = max(1.0, size.x * ${DEHAZE_PATCH_STEP});
|
||||
for (int j = -${DEHAZE_PATCH_TAPS}; j <= ${DEHAZE_PATCH_TAPS}; j++) {
|
||||
for (int k = -${DEHAZE_PATCH_TAPS}; k <= ${DEHAZE_PATCH_TAPS}; k++) {
|
||||
half3 p = clamp(img.eval(pos + float2(float(k), float(j)) * stp).rgb, half3(0.0), half3(1.0));
|
||||
dark = min(dark, min(min(p.r / aa.r, p.g / aa.g), p.b / aa.b));
|
||||
}
|
||||
}
|
||||
}` : ' half dark = half(0.0);'}
|
||||
c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// The pass. It reads the pixels the pipeline has already built — the grade, the
|
||||
// grain, the vignette — and writes the local adjustments back over them, one
|
||||
// mask at a time, so a mask over another mask reads the first one's result
|
||||
// exactly as a stack of local adjustments does. It runs BEFORE HEAL on purpose:
|
||||
// a repair laid inside a mask then borrows pixels that already carry the mask's
|
||||
// light, which is what makes it match its surroundings, and a MOSAIC over a mask
|
||||
// hides what the mask left. The frame the masks are of comes in as `size`, the
|
||||
// same uniform HEAL and MOSAIC take. `spatial` adds the two things only CLARITY
|
||||
// and DEHAZE read: the frame's own blurred reference as a second child, whose
|
||||
// image is in the same coordinates the pass runs in, and the atmospheric light.
|
||||
export function gradientMaskSkSL(count: number, spatial = false): string {
|
||||
return `
|
||||
uniform shader img;
|
||||
uniform float4 rects[${count}];
|
||||
uniform float4 rads[${count}];
|
||||
uniform float4 adj[${count}];
|
||||
uniform float4 tone[${count}];
|
||||
uniform float4 fx[${count}];
|
||||
uniform float4 size;
|
||||
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
|
||||
${adjustFn(spatial)}
|
||||
half4 main(float2 pos) {
|
||||
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
|
||||
return c;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// 35mm & 120 FILM GRAIN — the structures the marketing card promises, per
|
||||
// stock: "authentic grain structures plus halation bloom, tuned per stock
|
||||
// rather than one global overlay."
|
||||
//
|
||||
// The phone's grainShader.ts rolls the film and sizes the cell; this is the
|
||||
// same film pulled through the lab's stock table. One grain field is still
|
||||
// printed for the whole picture, but WHICH field — how coarse, how clumpy, and
|
||||
// how much the highlights bleed back — is the emulsion's and its format's, not
|
||||
// a constant.
|
||||
//
|
||||
// FORMAT. A 35mm frame is 24x36mm; a 120 frame is 6x6 or bigger — two to four
|
||||
// times the linear area for the same print. So at one picture width the 35mm
|
||||
// negative's grain prints COARSER and a 120 negative's FINER and smoother: the
|
||||
// same emulsion is two different textures. `cell` is the format's share of the
|
||||
// 1080-wide reference the GRAIN knob was tuned at — 1.0 is the 35mm baseline
|
||||
// that ships today, so an existing 35mm recipe keeps the grain it has.
|
||||
//
|
||||
// STOCK. Within a format the emulsion sets the rest: how hard the grain clumps
|
||||
// (`spread` and the octave `mix`), and how far the highlights bleed back as
|
||||
// halation. Halation is RED because red is the light that passes the emulsion,
|
||||
// bounces off the anti-halation backing and returns: the colour negative, whose
|
||||
// red-sensitive layer sits deepest, halates hardest. Slides halate less. B&W has
|
||||
// no colour layer to bleed, so it gets a weak, barely-warm grey. A sensor has no
|
||||
// backing at all — LEICA's two looks are digital, so they carry none.
|
||||
//
|
||||
// The GRAIN knob stays the only control, exactly as it is today: its amount
|
||||
// scales BOTH the grain's alpha and the halation's, so GRAIN OFF is still a
|
||||
// clean frame and the OFF/WEAK/STRONG chips still mean 0/3/6. Nothing here
|
||||
// moves a stock's look until grain is dialled in.
|
||||
|
||||
import type { BaseFilter } from '../types';
|
||||
|
||||
export type FilmFormat = '35mm' | '120';
|
||||
|
||||
export interface GrainStock {
|
||||
format: FilmFormat;
|
||||
/** The emulsion this entry stands for — the record behind the numbers. */
|
||||
film: string;
|
||||
/** Grain cell, as a share of the 1080-wide reference (see GRAIN_REF). */
|
||||
cell: number;
|
||||
/** Contrast of the noise, i.e. how hard the grain clumps. */
|
||||
spread: number;
|
||||
/** Octave weights of the grain field, fine → coarse. */
|
||||
mix: [number, number, number];
|
||||
/** Halation strength at GRAIN 10, 0..1. */
|
||||
halation: number;
|
||||
/** Halo radius in 1080-reference units (picture-relative, see grainCell). */
|
||||
halo: number;
|
||||
/** What the halo is made of: the highlight, tinted. */
|
||||
haloRGB: [number, number, number];
|
||||
}
|
||||
|
||||
// The baseline field the GRAIN knob was calibrated against, and the one the
|
||||
// phone still prints: a fine octave carrying most of the structure, then its
|
||||
// clumps at 2x and 4x. Coarser and clumpier (35mm), or the base octave opened
|
||||
// up and the clumps pulled back (120's finer, smoother texture).
|
||||
const CLUMPY: [number, number, number] = [0.55, 0.3, 0.15];
|
||||
const SMOOTH: [number, number, number] = [0.62, 0.26, 0.12];
|
||||
|
||||
// Digital: a sensor has no emulsion and no backing — the grain the knob prints
|
||||
// is the user's own choice, so it keeps the neutral baseline field and no
|
||||
// halation. Both LEICA looks land here.
|
||||
const SENSOR: GrainStock = {
|
||||
format: '35mm',
|
||||
film: 'digital sensor',
|
||||
cell: 1,
|
||||
spread: 2.95,
|
||||
mix: CLUMPY,
|
||||
halation: 0,
|
||||
halo: 0,
|
||||
haloRGB: [1, 1, 1],
|
||||
};
|
||||
|
||||
export const GRAIN_STOCKS: Record<BaseFilter, GrainStock> = {
|
||||
// --- 35mm: the consumer, press and cinema stocks ---
|
||||
'classic-neg': {
|
||||
format: '35mm',
|
||||
film: 'colour negative, 35mm',
|
||||
cell: 1.15,
|
||||
spread: 3.1,
|
||||
mix: CLUMPY,
|
||||
halation: 0.45,
|
||||
halo: 7,
|
||||
haloRGB: [1, 0.18, 0.06],
|
||||
},
|
||||
'classic-chrome': {
|
||||
format: '35mm',
|
||||
film: 'colour slide, 35mm',
|
||||
cell: 1,
|
||||
spread: 2.95,
|
||||
mix: CLUMPY,
|
||||
halation: 0.25,
|
||||
halo: 6,
|
||||
haloRGB: [1, 0.22, 0.08],
|
||||
},
|
||||
'classic-vivid': {
|
||||
format: '35mm',
|
||||
film: 'colour slide, vivid, 35mm',
|
||||
cell: 1,
|
||||
spread: 2.95,
|
||||
mix: CLUMPY,
|
||||
halation: 0.3,
|
||||
halo: 6,
|
||||
haloRGB: [1, 0.2, 0.07],
|
||||
},
|
||||
eterna: {
|
||||
format: '35mm',
|
||||
film: 'motion-picture negative, 35mm',
|
||||
cell: 1.1,
|
||||
spread: 3.2,
|
||||
mix: CLUMPY,
|
||||
halation: 0.38,
|
||||
halo: 6.5,
|
||||
haloRGB: [1, 0.2, 0.07],
|
||||
},
|
||||
'mono-high-contrast': {
|
||||
format: '35mm',
|
||||
film: 'B&W, high contrast, 35mm',
|
||||
cell: 1.25,
|
||||
spread: 3.4,
|
||||
mix: CLUMPY,
|
||||
halation: 0.15,
|
||||
halo: 4.5,
|
||||
haloRGB: [1, 0.72, 0.55],
|
||||
},
|
||||
// --- 120: the pro emulsions whose reputation is the big negative ---
|
||||
provia: {
|
||||
format: '120',
|
||||
film: 'Fuji Provia 100F, 120',
|
||||
cell: 0.62,
|
||||
spread: 2.5,
|
||||
mix: SMOOTH,
|
||||
halation: 0.18,
|
||||
halo: 5.5,
|
||||
haloRGB: [1, 0.24, 0.09],
|
||||
},
|
||||
velvia: {
|
||||
format: '120',
|
||||
film: 'Fuji Velvia 50, 120',
|
||||
cell: 0.55,
|
||||
spread: 2.35,
|
||||
mix: SMOOTH,
|
||||
halation: 0.3,
|
||||
halo: 6,
|
||||
haloRGB: [1, 0.2, 0.07],
|
||||
},
|
||||
astia: {
|
||||
format: '120',
|
||||
film: 'Fuji Astia 100F, 120',
|
||||
cell: 0.62,
|
||||
spread: 2.5,
|
||||
mix: SMOOTH,
|
||||
halation: 0.16,
|
||||
halo: 5.5,
|
||||
haloRGB: [1, 0.26, 0.1],
|
||||
},
|
||||
monochrome: {
|
||||
format: '120',
|
||||
film: 'Fuji Acros 100, 120',
|
||||
cell: 0.72,
|
||||
spread: 2.7,
|
||||
mix: SMOOTH,
|
||||
halation: 0.12,
|
||||
halo: 4,
|
||||
haloRGB: [1, 0.78, 0.62],
|
||||
},
|
||||
// --- digital ---
|
||||
leica: SENSOR,
|
||||
'leica-vivid': SENSOR,
|
||||
none: SENSOR,
|
||||
};
|
||||
|
||||
// The stock a base filter prints with. Every id is in the table, so the fallback
|
||||
// is only there for a recipe that names a filter this build does not know.
|
||||
export function grainStockFor(base: BaseFilter | string | null | undefined): GrainStock {
|
||||
return (base && GRAIN_STOCKS[base as BaseFilter]) || SENSOR;
|
||||
}
|
||||
|
||||
// The roll of film: one seed per page load, hashed into the noise's domain, so
|
||||
// two visitors never print the same clumps while every render inside one
|
||||
// session — the preview, the compare copy and the file — prints the same one.
|
||||
export const GRAIN_SEED: readonly [number, number] = [Math.random() * 61.7, Math.random() * 43.9];
|
||||
|
||||
// Grain is sized against the PICTURE, never against the screen: one noise cell
|
||||
// per 1/1080 of the picture's width, the size the GRAIN knob was tuned at. A
|
||||
// stock scales that cell by its format and its emulsion (see GRAIN_STOCKS).
|
||||
export const GRAIN_REF = 1080;
|
||||
|
||||
// The cell GRAIN_SKSL is handed for a picture of `pictureWidth` px: the stock's
|
||||
// share of the reference, scaled by the SIZE knob (a percentage of the stock's
|
||||
// own cell), and floored at one pixel because a cell smaller than the target's
|
||||
// own pixel cannot be resolved — it prints as static instead of grain, which is
|
||||
// aliasing, not a finer emulsion. The floor is handed to the shader too, so the
|
||||
// field's own patch-to-patch mix cannot cross it.
|
||||
export function grainCell(pictureWidth: number, stock: GrainStock, sizePct = 100, minCell = 1): number {
|
||||
return Math.max((pictureWidth / GRAIN_REF) * stock.cell * (sizePct / 100), minCell);
|
||||
}
|
||||
|
||||
// The clump count the panel READS OUT: how many clumps the design puts across an
|
||||
// inch of a 300 dpi print, i.e. 300px of the GRAIN_REF frame. A statement about
|
||||
// the stock and the SIZE knob, never about one patch of the frame — the field
|
||||
// mixes two lattices ±ZONE_SWING/2 either side of it, patch by patch (see
|
||||
// GRAIN_SKSL) — and never about the screen, so the same stock reads the same
|
||||
// number in the preview and in the file.
|
||||
export const GRAIN_DPI = 300;
|
||||
|
||||
export function grainPerInch(stock: GrainStock, sizePct = 100): number {
|
||||
return Math.round(GRAIN_DPI / (stock.cell * (sizePct / 100)));
|
||||
}
|
||||
|
||||
// The halo radius in px for the same picture: measured in reference units too,
|
||||
// so a 120 stock's tighter grain cannot leave it a 35mm-sized halo.
|
||||
export function halationSigma(pictureWidth: number, stock: GrainStock): number {
|
||||
return Math.max((pictureWidth / GRAIN_REF) * stock.halo, 0.6);
|
||||
}
|
||||
|
||||
// `u` is the cell, `seed` this session's roll, `mix`/`spread` the stock's own
|
||||
// structure. The three octaves go COARSER only: a finer one lands under the
|
||||
// pixel, the clumping is lost (rho(1) 0.275 -> 0, measured) and the field is
|
||||
// static again. `spread` holds the AMOUNT knob on the spread it was tuned with.
|
||||
//
|
||||
// The field is JITTERED CLUMPS, not value noise. Value noise prints the density
|
||||
// of the cell's four corners, so every clump sits on a knot of one square grid,
|
||||
// and that grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye
|
||||
// reads as diagonal lines. Here a clump lands at a random spot inside its cell
|
||||
// instead: no two clumps share a grid, and the printed frame's off-origin
|
||||
// autocorrelation falls to 0.03 from 0.44 (measured) — nothing left to tile. The
|
||||
// hash behind the jitter is sin-free for the same reason: a float sinus whose
|
||||
// argument grows with the picture folds back on itself, a lattice of its own.
|
||||
//
|
||||
// Emulsion is not ONE grain size across the frame: the coating settles unevenly,
|
||||
// so the clumps run coarser in patches and tighter in others. The same hash read
|
||||
// as a SLOW value noise is what draws those patches — one every 1/ZONE_FREQ
|
||||
// cells, turned off the axes and smoothed, because a step at a patch border
|
||||
// would print as a seam — and that weight MIXES two lattices, ZONE_SWING/2
|
||||
// either side of the design cell, instead of warping one. A cell that varies
|
||||
// with position is what the eye reads as a smear: the phase of a lattice built
|
||||
// on cell(pos) accumulates as d(phase)/ds = 1/cell - s*cell'/cell^2, and that
|
||||
// second term grows with the distance s from the picture's own origin, so the
|
||||
// clumps are stretched wherever the patch's cell runs — measured on one
|
||||
// classic-neg paint (1024px, cell 1.09, 64 tiles): swing on, the tiles' lag-1
|
||||
// correlation spans -0.065..0.747, clumps drawn out into smooth blotches;
|
||||
// swing forced to 0, the SAME field's tiles span -0.057..0.045. Two FIXED
|
||||
// lattices cannot do that — their phase is linear in the picture, so a patch can
|
||||
// only change how much of each is printed, never how either is shaped, and
|
||||
// their beat falls at 1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm
|
||||
// cell: the pixel scale, not a line. Neither the mean nor the spread moves:
|
||||
// both lattices carry grainClump's own 0.5685 and the mix is renormalised by
|
||||
// sqrt(w^2+(1-w)^2), so a coarser patch prints bigger clumps — the tiles span
|
||||
// 3.2x in lag-1 correlation at the preview's cell 1.70, against 1.14x with the
|
||||
// swing off — not a brighter or a harder one, which is why the panel can read
|
||||
// out one number while the frame carries a range.
|
||||
export const ZONE_FREQ = 1 / 96;
|
||||
export const ZONE_SWING = 0.4;
|
||||
// ponytail: the field is evaluated once per lattice, so the grain pass costs
|
||||
// 1.94x what the single warped lattice did (700px field, software CanvasKit,
|
||||
// measured). One evaluation cannot hold two cell sizes, so this is the price of
|
||||
// the mix; revisit only if a preview budget asks for the pass back.
|
||||
|
||||
export const GRAIN_SKSL = `
|
||||
uniform float u;
|
||||
uniform float mincell;
|
||||
uniform vec2 seed;
|
||||
uniform vec3 mixw;
|
||||
uniform float spread;
|
||||
float grainHash(vec2 q) {
|
||||
vec3 p3 = fract(vec3(q.x, q.y, q.x) * 0.1031);
|
||||
p3 += dot(p3, p3.yzx + 33.33);
|
||||
return fract((p3.x + p3.y) * p3.z);
|
||||
}
|
||||
// One clump per cell, at a random spot inside it; what is printed is the
|
||||
// distance to the nearest, so the clump is a smooth mound, not one pixel of
|
||||
// static.
|
||||
float grainClump(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = p - i;
|
||||
float near = 4.0;
|
||||
for (int y = -1; y <= 1; y++) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
vec2 g = vec2(float(x), float(y));
|
||||
vec2 c = g + vec2(grainHash(i + g), grainHash(i + g + vec2(19.19, 7.77))) - f;
|
||||
near = min(near, dot(c, c));
|
||||
}
|
||||
}
|
||||
return 1.0 - min(sqrt(near), 1.0);
|
||||
}
|
||||
// The patches of grain size: the same hash read slowly, smoothed so a border
|
||||
// between two patches is a slope and not a step.
|
||||
float grainZone(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = p - i;
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(grainHash(i), grainHash(i + vec2(1.0, 0.0)), f.x),
|
||||
mix(grainHash(i + vec2(0.0, 1.0)), grainHash(i + vec2(1.0, 1.0)), f.x), f.y);
|
||||
}
|
||||
// The stock's own field: three octaves, no two of them on the same grid.
|
||||
// Each is turned to its own angle — 20, 47, 73 degrees — and sits on its own
|
||||
// rung of the ladder, 1 / 0.53 / 0.29, off the dyadic 1 / 0.5 / 0.25 where
|
||||
// the coarse octaves' cells land back on the fine one's and stack. q is in
|
||||
// CELLS, so one text serves both lattices below.
|
||||
float grainField(vec2 q) {
|
||||
return grainClump(mat2(0.9397, -0.3420, 0.3420, 0.9397) * q) * mixw.x
|
||||
+ grainClump(mat2(0.6820, -0.7314, 0.7314, 0.6820) * q * 0.53 + vec2(13.7, 7.3)) * mixw.y
|
||||
+ grainClump(mat2(0.2924, -0.9563, 0.9563, 0.2924) * q * 0.29 + vec2(4.1, 27.9)) * mixw.z;
|
||||
}
|
||||
vec4 main(vec2 pos) {
|
||||
vec2 p = pos.xy / max(u, 0.0001) + seed;
|
||||
// This patch's weight, 0..1: the coarse lattice where the coating settled
|
||||
// heavy, the fine one where it settled tight.
|
||||
float w = grainZone(mat2(0.9397, -0.3420, 0.3420, 0.9397) * p * ${ZONE_FREQ.toFixed(6)});
|
||||
// The two lattices the patch mixes, never under the floor — under it the
|
||||
// clumps are sub-pixel and print as static, which is aliasing, not a finer
|
||||
// emulsion.
|
||||
float fine = max(u * ${(1 - ZONE_SWING / 2).toFixed(2)}, mincell);
|
||||
float coarse = max(u * ${(1 + ZONE_SWING / 2).toFixed(2)}, mincell);
|
||||
float a = grainField(pos.xy / fine + seed);
|
||||
float b = grainField(pos.xy / coarse + seed);
|
||||
// Back onto the field the AMOUNT knob was calibrated on: clumps sit higher
|
||||
// and tighter than the value noise they replace (mean 0.569 against 0.500,
|
||||
// sigma 0.123 against 0.081, measured), so the sum is put back on that mean
|
||||
// and that spread before the knob's own gain is applied. The two lattices
|
||||
// are independent, so the blend carries sqrt(w^2+(1-w)^2) of one field's
|
||||
// spread and that is taken back out with it — patch size must not read as
|
||||
// patch contrast.
|
||||
float n = (w * (a - 0.5685) + (1.0 - w) * (b - 0.5685))
|
||||
* inversesqrt(w * w + (1.0 - w) * (1.0 - w)) * 0.52 + 0.5;
|
||||
return vec4(vec3(clamp((n - 0.5) * spread + 0.5, 0.0, 1.0)), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Flat uniform buffer for makeShader — declaration order above: u, mincell,
|
||||
// seed, mix, spread. `minCell` is the floor grainCell() applied, so the field's
|
||||
// own patch mix cannot take a lattice under it.
|
||||
export function grainUniformArray(cell: number, stock: GrainStock, minCell = 1): number[] {
|
||||
return [cell, minCell, GRAIN_SEED[0], GRAIN_SEED[1], stock.mix[0], stock.mix[1], stock.mix[2], stock.spread];
|
||||
}
|
||||
|
||||
// HALATION: the highlight bleed the backing returns. Threshold first — a
|
||||
// mid-tone never reaches the emulsion's scatter ceiling, so only what is going
|
||||
// white bleeds — then the highlight is TINTED (the stock's halo colour, red for
|
||||
// every colour emulsion) before the caller blurs it wide and screens it back.
|
||||
export const HALATION_SKSL = `
|
||||
uniform shader src;
|
||||
uniform vec3 tint;
|
||||
uniform float t0;
|
||||
uniform float t1;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
|
||||
return vec4(clamp(c.rgb * tint, 0.0, 1.0) * smoothstep(t0, t1, luma), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
// The print has to be near white before the backing sees anything: a touch
|
||||
// higher than the HDF bloom's threshold, so a bright sky does not smear the
|
||||
// frame the way a specular highlight does.
|
||||
export const HALATION_T0 = 0.62;
|
||||
export const HALATION_T1 = 0.92;
|
||||
|
||||
// What the halo is worth at GRAIN 10, over the stock's own strength: a screen
|
||||
// blend of a blurred highlight is a strong move, so a halating stock lands
|
||||
// under half alpha at full grain.
|
||||
export const HALATION_MAX = 0.6;
|
||||
|
||||
export function halationUniformArray(stock: GrainStock): number[] {
|
||||
return [...stock.haloRGB, HALATION_T0, HALATION_T1];
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { HealSpot } from '../types';
|
||||
|
||||
// FX tab > HEAL — the dust brush, and the patch search behind it.
|
||||
//
|
||||
// A spot is a circle on the rendered photo plus the patch it borrows: the
|
||||
// renderer copies the pixels at (sx, sy) onto (x, y), feathers the edge, and
|
||||
// shifts what it pasted onto the light of the place it landed in, so a repair
|
||||
// is a draw of the same picture rather than a blur over the dust. All five
|
||||
// numbers are fractions of the render — x/y/sx/sy of its width and height, r of
|
||||
// its width — which is what makes one set of spots survive the preview and the
|
||||
// export rendering the same photo at two sizes, and keeps the circle round
|
||||
// whatever the photo's shape.
|
||||
//
|
||||
// There is no ceiling on the list. The shader is built to carry exactly the
|
||||
// spots the recipe holds (healSkSL), so a new repair can never push an old one
|
||||
// out: the dust you healed first is the dust that stays healed.
|
||||
//
|
||||
// The source is SEARCHED for rather than asked for. Lightroom picks the patch
|
||||
// and lets you drag the second circle afterwards; the search below is the same
|
||||
// idea without the second circle, and it is a pure function of a sampler so a
|
||||
// synthetic picture can hold it to account.
|
||||
//
|
||||
// Feather, as a fraction of the radius: inside it the patch is copied, outward
|
||||
// it fades to nothing, so the circle leaves no rim of its own. It is the outer
|
||||
// 15% and no more, because that band is the only place the dust being repaired
|
||||
// is mixed back into the patch — a wide fade keeps the speck's own edge alive
|
||||
// as a faint ring inside the circle, which is a blur of the dust rather than a
|
||||
// repair. sub-pixel at the default brush, still a soft edge at a big one.
|
||||
export const HEAL_FEATHER = 0.85;
|
||||
// The brush's radius, as a fraction of the photo's width — where this tool
|
||||
// starts, at the sensor-dust end, where a speck is a few thousandths of the
|
||||
// frame. The bounds and the wheel that moves it between them belong to the
|
||||
// brush both FX tools share (brush.ts).
|
||||
export const HEAL_DEFAULT_R = 0.012;
|
||||
// How far the search looks, in radii, and how many directions it looks in.
|
||||
const SEARCH_DISTANCES = [2.6, 4.2, 6.5];
|
||||
const SEARCH_DIRS = 8;
|
||||
// The taps that stand for "the light around a spot": a ring just OUTSIDE the
|
||||
// brush, where the dust being repaired is not — the scale the eye reads a
|
||||
// spot's surroundings at. Reading the inside instead compares a candidate with
|
||||
// the very dark the repair is trying to erase, and the patch that matches a
|
||||
// speck best is then the one carrying a speck of its own — which is how a
|
||||
// repair ends up moving dust a few pixels instead of removing it.
|
||||
//
|
||||
// Twelve taps read as their MEDIAN, not their mean. The ring can only be a
|
||||
// little way out — far enough out and it would be reading a light the spot does
|
||||
// not sit in — so a few of its taps land on the speck's own softened edge, or
|
||||
// on a neighbour's: readings in the minority, which the median drops and the
|
||||
// mean would drag the whole light down by.
|
||||
const RING_R = 1.15;
|
||||
const RING_TAPS: [number, number][] = Array.from({ length: 12 }, (_, i) => {
|
||||
const a = (i / 12) * Math.PI * 2;
|
||||
return [Math.cos(a), Math.sin(a)] as [number, number];
|
||||
});
|
||||
// The taps that decide whether a candidate patch is clean: the inside of the
|
||||
// patch, held against the patch's own mean. Dust is an outlier in its own
|
||||
// neighbourhood; skin, however grainy, is not. The last ring is the patch's own
|
||||
// edge — the circle is copied at full strength out to HEAL_FEATHER of its
|
||||
// radius, so that is where a neighbour's dust leaking into the patch shows up,
|
||||
// and the middle of the patch would never see it.
|
||||
const INSIDE_TAPS: [number, number][] = [
|
||||
[0, 0],
|
||||
[-0.5, 0],
|
||||
[0.5, 0],
|
||||
[0, -0.5],
|
||||
[0, 0.5],
|
||||
[-0.35, -0.35],
|
||||
[0.35, -0.35],
|
||||
[-0.35, 0.35],
|
||||
[0.35, 0.35],
|
||||
...Array.from({ length: 8 }, (_, i) => {
|
||||
const a = (i / 8) * Math.PI * 2;
|
||||
return [Math.cos(a) * HEAL_FEATHER, Math.sin(a) * HEAL_FEATHER] as [number, number];
|
||||
}),
|
||||
];
|
||||
// How far a candidate's light may sit from the spot's own before it is refused
|
||||
// outright, in the 0-255 levels the sampler answers in. Sending a patch from a
|
||||
// different light is exactly what makes a repair show up as a mark of its own —
|
||||
// a light square where dark ground was, a dark one where light was — so past
|
||||
// this the search gives up and the caller leaves the speck alone. The user sees
|
||||
// an untouched speck, which is honest, and tries another size or another spot.
|
||||
const LIGHT_GATE = 20;
|
||||
// Light outweighs cleanliness in the score, so the winner is the same light
|
||||
// first and the cleaner patch second; within the gate the cleanliness term only
|
||||
// breaks ties between patches the eye would call the same.
|
||||
const LIGHT_WEIGHT = 3;
|
||||
|
||||
const num = (v: unknown, fallback: number) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
// The stored spots, made readable: numbers, inside the frame. Everything below
|
||||
// reads a recipe through this, so a hand-written or older file cannot produce a
|
||||
// spot the brush and the renderer disagree about.
|
||||
export function readHeal(heal: HealSpot[] | undefined): HealSpot[] {
|
||||
if (!Array.isArray(heal)) return [];
|
||||
return heal
|
||||
.map((s) => ({
|
||||
x: clamp01(num(s?.x, 0)),
|
||||
y: clamp01(num(s?.y, 0)),
|
||||
r: Math.max(0, num(s?.r, 0)),
|
||||
sx: clamp01(num(s?.sx, s?.x ?? 0)),
|
||||
sy: clamp01(num(s?.sy, s?.y ?? 0)),
|
||||
}))
|
||||
.filter((s) => s.r > 0);
|
||||
}
|
||||
|
||||
// The uniform block the shader for `n` spots reads: the circles, the patches,
|
||||
// then the frame the fractions are of. Declaration order, arrays expanded —
|
||||
// that is how the runtime effect wants its uniforms, and one buffer is one
|
||||
// upload per render. Its length is a function of the list, not a fixed
|
||||
// capacity, because the shader carries exactly the spots the recipe holds.
|
||||
export function healUniforms(spots: HealSpot[], width: number, height: number): Float32Array {
|
||||
const list = readHeal(spots);
|
||||
const n = list.length;
|
||||
const u = new Float32Array((n * 2 + 1) * 4);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = list[i];
|
||||
u.set([s.x, s.y, s.r, 0], i * 4);
|
||||
u.set([s.sx, s.sy, 0, 0], (n + i) * 4);
|
||||
}
|
||||
u.set([width, height, 0, 0], n * 2 * 4);
|
||||
return u;
|
||||
}
|
||||
|
||||
// The ring the pasted circle is matched at, and how its taps are laid out.
|
||||
// Measured just OUTSIDE the brush — RING_R of its radius, where the search
|
||||
// reads a spot's light too — because that is the only ring with nothing of the
|
||||
// repair in it: inside the circle is the borrowed patch at one radius and the
|
||||
// speck being covered at another, and a light read off either of those is the
|
||||
// dust talking rather than the photo. Outside, both sides are photographs — the
|
||||
// ground the repair has to sit in, and the ground the patch was borrowed from.
|
||||
// Sixteen taps are spread around it, at the SAME sixteen places on both sides,
|
||||
// so a tap's pair of readings differ by the light between the two places and
|
||||
// not by where in the photo they were taken. Sixteen rather than eight because
|
||||
// a tap that lands on an edge bends its own direction only as far as the next
|
||||
// ring tap: the finer the ring, the less of the rim one hard pixel of the photo
|
||||
// can reach around.
|
||||
const BLEND_TAPS = 16;
|
||||
// ...and one pixel further out again than RING_R. The ring is read by a pixel
|
||||
// that lies under the brush, so the reading has to clear the dust's own edge,
|
||||
// and that edge is soft: a speck r pixels across spreads about a pixel past
|
||||
// where it is drawn, in the pixels the shader samples. RING_R alone leaves the
|
||||
// nearest tap or two inside that spread, and a tap that lands on the dust is
|
||||
// the dust's light — which is exactly the blotch this reading exists to avoid.
|
||||
// One pixel is the width of the spread, in the same pixels the sampler works
|
||||
// in, so it is a pixel here rather than a fraction of the radius: a fraction
|
||||
// would be nothing at all at the sensor-dust end of the brush.
|
||||
const RING_PAD = 1;
|
||||
|
||||
// ...but one light for the whole circle is a light no ring has when the ring is
|
||||
// not all one thing. A twig against the sky, a hairline, the edge of a table
|
||||
// under the brush: a few taps of the ring land on the thing and the rest on the
|
||||
// ground, and the MEAN of them all is a colour that is neither — a level the
|
||||
// place does not have, pasted over the whole circle. The donor can be of exactly
|
||||
// the right light and still come out as a blotch, which is the mark the repair
|
||||
// was supposed to stop leaving. So the light is read once PER DIRECTION, and a
|
||||
// pixel takes the correction of the two readings it lies between, interpolated
|
||||
// by its own angle around the spot: the rim then meets the place all the way
|
||||
// round instead of on average, and the taps that landed on the twig correct the
|
||||
// part of the rim that is near the twig rather than the whole of it. It is the
|
||||
// same taps and the same one draw — each pixel reads them at its own angle
|
||||
// instead of sharing everyone's single number.
|
||||
|
||||
// One unrolled block per spot. SkSL indexes a uniform array by constant only
|
||||
// (see TONE_SKSL's mixer), so the spots are written out rather than looped, and
|
||||
// the shader is built for the count it is handed rather than for a capacity —
|
||||
// that is what lets the list be uncapped. A count costs one RuntimeEffect to
|
||||
// compile, so the renderer caches them by count (exportEngine's healEffectFor).
|
||||
//
|
||||
// What is pasted is the patch's own pixels SHIFTED onto the light of the place
|
||||
// it lands in — the gradient of the borrowed texture, carried at the level of
|
||||
// the destination: the border is made to match, and everything inside keeps the
|
||||
// texture it was borrowed for. That is the whole of "seamless" the brush needs:
|
||||
// at the rim the patch sits within a level or two of the photo around it,
|
||||
// instead of up to LIGHT_GATE levels off it, so the repair stops reading as a
|
||||
// soft blotch of its own and the feather has almost nothing left to hide. The
|
||||
// correction is read per direction around the ring, so the border is matched
|
||||
// where it is rather than on average, and the pixels of a twig that run through
|
||||
// the ring bend the part of the rim they are near instead of the whole circle.
|
||||
//
|
||||
// ponytail: the correction lives on the rim — every pixel takes the two ring
|
||||
// readings it lies between and interpolates — so it is the boundary of a Poisson
|
||||
// edit and not its interior: a repair laid over something the patch cannot
|
||||
// reproduce still carries the patch's own texture inside, bent to fit the rim.
|
||||
// The other half of the solve (a correction that relaxes inside the circle, a
|
||||
// Jacobi ping-pong per spot) lands when a real photo shows an interior the eye
|
||||
// can find from the rim alone. A direction's level is also one tap pair on each
|
||||
// side, so the grain the mean used to average away now rides the rim as a wedge
|
||||
// of a level or two across the circle — a tangential average of three taps per
|
||||
// direction is the rung for that, when a photo shows the wedge.
|
||||
const spotBlock = (i: number) => `
|
||||
{
|
||||
float4 s = spots[${i}];
|
||||
if (s.z > 0.0) {
|
||||
float rad = s.z * size.x;
|
||||
float d = distance(pos, s.xy * size.xy);
|
||||
if (d < rad) {
|
||||
float4 t = srcs[${i}];
|
||||
float2 pd = s.xy * size.xy;
|
||||
float2 ps = t.xy * size.xy;
|
||||
float r = rad * ${HEAL_FEATHER};
|
||||
float rr = rad * ${RING_R} + ${RING_PAD.toFixed(1)};
|
||||
// Which way round the spot this pixel sits, one millionth of a pixel off
|
||||
// the exact centre so the angle is defined there too.
|
||||
float ang = atan(pos.y - pd.y + 1e-6, pos.x - pd.x + 1e-6);
|
||||
half3 corr = half3(0.0);
|
||||
float wsum = 0.0;
|
||||
for (int k = 0; k < ${BLEND_TAPS}; k++) {
|
||||
float a = float(k) * 6.283185307 / float(${BLEND_TAPS});
|
||||
float2 o = float2(cos(a), sin(a)) * rr;
|
||||
// How far this pixel's angle is from this tap's, the short way round.
|
||||
float da = ang - a;
|
||||
da = abs(da - 6.283185307 * floor(da * 0.1591549431 + 0.5));
|
||||
float w = max(0.0, 1.0 - da * float(${BLEND_TAPS}) * 0.1591549431);
|
||||
corr += half(w) * (img.eval(pd + o).rgb - img.eval(ps + o).rgb);
|
||||
wsum += w;
|
||||
}
|
||||
corr *= half(1.0 / wsum);
|
||||
half m = half(1.0 - smoothstep(r, rad, d));
|
||||
c = mix(c, img.eval(pos + ps - pd) + half4(corr, 0.0), m);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// The pass. It reads the pixels the pipeline has already built (the child is a
|
||||
// snapshot of the surface) and writes the borrowed patches back over them, so a
|
||||
// repair is one draw: no blur, no smoothing, and the grain and the frame land
|
||||
// on top of it afterwards exactly as they land on the rest of the photo.
|
||||
export function healSkSL(count: number): string {
|
||||
return `
|
||||
uniform shader img;
|
||||
uniform float4 spots[${count}];
|
||||
uniform float4 srcs[${count}];
|
||||
uniform float4 size;
|
||||
|
||||
half4 main(float2 pos) {
|
||||
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => spotBlock(i)).join('')}
|
||||
return c;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// The patch to borrow for a spot at (x, y) of radius r, from a sampler that
|
||||
// answers fractions of the same photo. The candidates are a ring of offsets in
|
||||
// eight directions at three distances — the patch has to be far enough that the
|
||||
// dust is not in it, near enough that the light is the same — plus each one
|
||||
// mirrored through the spot, which is the pair Lightroom's own auto-source
|
||||
// leans on.
|
||||
//
|
||||
// Each candidate is asked two questions. "Is this the same light?" holds the
|
||||
// mean of what would be pasted against the mean of the spot's own ring, the
|
||||
// pixels just outside the dust — the light the repair has to sit in, read where
|
||||
// the dust is not, because comparing against the spot's own inside compares a
|
||||
// candidate with the very dark the repair is trying to erase, and the patch
|
||||
// that matches a speck best is then the one carrying a speck of its own. "Is
|
||||
// there dust on it?" holds the candidate's inside against its own inside mean:
|
||||
// dust is an outlier in its own neighbourhood, grain is not. A candidate whose
|
||||
// light is off by more than LIGHT_GATE is not scored at all — a patch from the
|
||||
// wrong light is a mark of its own, and moving it a few pixels for a wrong
|
||||
// clone is not a repair.
|
||||
//
|
||||
// Returns null when the frame is too small to hold any candidate, or when every
|
||||
// candidate is refused: the caller then leaves the speck where it is rather
|
||||
// than pasting a patch it cannot stand behind.
|
||||
export function findHealSource(
|
||||
sample: (fx: number, fy: number) => { r: number; g: number; b: number },
|
||||
x: number,
|
||||
y: number,
|
||||
r: number
|
||||
): { sx: number; sy: number } | null {
|
||||
if (!(r > 0)) return null;
|
||||
const inside = (cx: number, cy: number) => cx - r >= 0 && cx + r <= 1 && cy - r >= 0 && cy + r <= 1;
|
||||
const rgb = (v: { r: number; g: number; b: number }) => (v.r + v.g + v.b) / 3;
|
||||
// The light the repair has to sit in: the median of the spot's ring, where
|
||||
// the dust is not. Measured once, because it is the same number for every
|
||||
// candidate.
|
||||
const ring = RING_TAPS.map(([dx, dy]) => rgb(sample(clamp01(x + dx * RING_R * r), clamp01(y + dy * RING_R * r)))).sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const target = (ring[RING_TAPS.length / 2 - 1] + ring[RING_TAPS.length / 2]) / 2;
|
||||
// The score of one candidate: null when it is refused for its light, else how
|
||||
// far off that light is times its weight, plus how much the patch varies
|
||||
// inside itself.
|
||||
const score = (cx: number, cy: number): number | null => {
|
||||
let content = 0;
|
||||
const inside: number[] = [];
|
||||
for (const [dx, dy] of INSIDE_TAPS) {
|
||||
const v = rgb(sample(clamp01(cx + dx * r), clamp01(cy + dy * r)));
|
||||
inside.push(v);
|
||||
content += v;
|
||||
}
|
||||
content /= INSIDE_TAPS.length;
|
||||
const light = Math.abs(target - content);
|
||||
if (light > LIGHT_GATE) return null;
|
||||
let dirty = 0;
|
||||
for (const v of inside) dirty += Math.abs(v - content);
|
||||
return light * LIGHT_WEIGHT + dirty / INSIDE_TAPS.length;
|
||||
};
|
||||
let best: { sx: number; sy: number; score: number } | null = null;
|
||||
for (let d = 0; d < SEARCH_DIRS; d++) {
|
||||
const a = (d / SEARCH_DIRS) * Math.PI * 2;
|
||||
for (const dist of SEARCH_DISTANCES) {
|
||||
const cx = x + Math.cos(a) * dist * r;
|
||||
const cy = y + Math.sin(a) * dist * r;
|
||||
for (const [px, py] of [
|
||||
[cx, cy],
|
||||
[2 * x - cx, 2 * y - cy],
|
||||
]) {
|
||||
if (!inside(px, py)) continue;
|
||||
const s = score(px, py);
|
||||
if (s === null) continue;
|
||||
// A tie keeps the earlier candidate: the ring is walked from the right,
|
||||
// so the patch nearest the spot wins — the one most likely to share its
|
||||
// light.
|
||||
if (!best || s < best.score) best = { sx: px, sy: py, score: s };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best ? { sx: best.sx, sy: best.sy } : null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user