Compare commits
275 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f4b62a39a | |||
| 213ef0ca95 | |||
| 452b40665a | |||
| 6039f2aa4e | |||
| 87c68ea7bb | |||
| de5d42e272 | |||
| 791e7d093c | |||
| fbc13d437d | |||
| 17624945f9 | |||
| 0ac9f2ddfa | |||
| 8b27f9542d | |||
| 61085a1fa2 | |||
| 5af5430f21 | |||
| d5f35502e5 | |||
| 22753ef50b | |||
| 3e7a826f59 | |||
| e8156927df | |||
| 90ce9fdd8b | |||
| 74f438caa8 | |||
| 5ffa713f35 | |||
| 2803f76aac | |||
| 8fccfdd001 | |||
| d36fa78250 | |||
| 99e54835ba | |||
| 3befdf50eb | |||
| ed179fc200 | |||
| e32ab2f3a3 | |||
| af5f94e94e | |||
| cb9e96dd8d | |||
| bcc24a62c3 | |||
| c557108eee | |||
| 8901e0c378 | |||
| e27998d820 | |||
| d2e2ebe108 | |||
| 7b52e3f3e3 | |||
| 1429802b57 | |||
| bb09a9f555 | |||
| e21e7bae55 | |||
| 1e26887a17 | |||
| af1d047227 | |||
| 549bfab310 | |||
| 32d1541af1 | |||
| 6fdd0f3240 | |||
| c314d4608f | |||
| e03fed60f9 | |||
| 102dc13351 | |||
| fc3f46c302 | |||
| 90e504e2a2 | |||
| 91d1bd7d9d | |||
| 53e7606b37 | |||
| 6a052cb9d9 | |||
| 08b7b91ef9 | |||
| 41f941dc59 | |||
| db983e54d4 | |||
| 24121e259d | |||
| e1707bd9b9 | |||
| e520068c68 | |||
| d231d298a5 | |||
| 1ea60a2ad9 | |||
| 05c2380a8b | |||
| 2d2e662e1a | |||
| 6c468cd114 | |||
| fa2e9e133e | |||
| 3433c9da8a | |||
| 1647362c68 | |||
| 259e5f3378 | |||
| c3d46b0d15 | |||
| 29759721cb | |||
| 9fc7b89e74 | |||
| 45fc852d04 | |||
| f834d10762 | |||
| c26a4c21a7 | |||
| b163676e67 | |||
| 779ae14a21 | |||
| bfda4b4b9e | |||
| 66de0c35b1 | |||
| 582599049c | |||
| 0533ee7080 | |||
| 036b99f67a | |||
| 3d12635a62 | |||
| e39a53663e | |||
| 2a3dd6fdbe | |||
| 7f611b2171 | |||
| 848800a77d | |||
| 2b3146884c | |||
| c8fe5c919d | |||
| 8f286506b2 | |||
| f1d7eeff99 | |||
| 5f78875549 | |||
| af23f3a7c9 | |||
| eee9328bbf | |||
| efe07e20ba | |||
| d5e9144d89 | |||
| 8d3b7bf134 | |||
| bb2d548d6a | |||
| 04ed177e5c | |||
| ce8258b0f0 | |||
| a87ee4d528 | |||
| a77c9452b9 | |||
| cda08c3908 | |||
| a3bb412070 | |||
| 3242b3aa48 | |||
| ac0337147f | |||
| c80c8729a8 | |||
| 8bc5e08926 | |||
| f8a8844f6d | |||
| c42e336a12 | |||
| 8f22f4a5bd | |||
| a372cc94f3 | |||
| 03bc5eb2be | |||
| aa981e4e65 | |||
| 8dd8ba9bc7 | |||
| 801877d763 | |||
| 740e8ed36e | |||
| 9bfb39be91 | |||
| af76b46c3e | |||
| b835415c20 | |||
| 57005ae1e2 | |||
| 5dbcdd36e7 | |||
| 347b66099a | |||
| da7a23bb4b | |||
| 4704e936ba | |||
| eb47afc63e | |||
| 1c7be6dfd4 | |||
| 3b4c49c41d | |||
| 7b836984fc | |||
| 44601897b0 | |||
| 5338f66134 | |||
| 33c56bef93 | |||
| 70d77f97d8 | |||
| 06ec0b3b12 | |||
| 93f56186b9 | |||
| 3e30d5e315 | |||
| 3edafcfe1e | |||
| 9fc03e28f7 | |||
| 96b4dcce5b | |||
| 66b159b3bd | |||
| 0df62b6248 | |||
| 2e06e5ab5d | |||
| 651e0ad09a | |||
| 8ed094cd9e | |||
| 16c098cd6a | |||
| c4d95e5722 | |||
| 5db943ddd5 | |||
| 2e1feb195a | |||
| 23f534ffb8 | |||
| ce691aa8cb | |||
| 9aa9bc0db0 | |||
| 7363bd9099 | |||
| 3d759cc71b | |||
| 177f582aab | |||
| c346b657eb | |||
| 2e36acd18b | |||
| cef7868db8 | |||
| d7ec00c08e | |||
| 1be75efd5a | |||
| 8021529657 | |||
| c486397388 | |||
| fbed70b9da | |||
| f98ce2c042 | |||
| 480fd148a3 | |||
| 140b4f88b2 | |||
| 1e93c8484b | |||
| e63183b298 | |||
| c4d904a207 | |||
| b30c7403e3 | |||
| 77cf872b16 | |||
| 44468ac178 | |||
| 79f0d77a8e | |||
| 0616b786ea | |||
| e43c02c36f | |||
| 895a055a03 | |||
| 07a38be5a9 | |||
| 7a333c1be5 | |||
| c802c43e04 | |||
| baf37f13d7 | |||
| ef48a59d44 | |||
| 45b505864e | |||
| 8d0a46e7f5 | |||
| 177e3c3a9c | |||
| 84b8e64652 | |||
| 256fe6c2e2 | |||
| 22fa4f289b | |||
| fe116f0e83 | |||
| 80416a51b8 | |||
| c1397c58cb | |||
| e98b4d9d5c | |||
| acbb2bba4b | |||
| 76d84503c9 | |||
| 90a7ec9e46 | |||
| e4f5407c19 | |||
| bc550569ad | |||
| 3f5ef88de5 | |||
| 0b827ecfa3 | |||
| d5da6ebcba | |||
| 4f87f022da | |||
| c93f9fdd19 | |||
| 39f80088c7 | |||
| e2d468b77d | |||
| 137d54d37d | |||
| 9b24ce7bbc | |||
| e53d6264ca | |||
| fb9c9f98db | |||
| 2c3565cf85 | |||
| fd330c05e0 | |||
| 31435aac77 | |||
| 7b6a1a805f | |||
| 9aa3424e92 | |||
| c47ce212eb | |||
| 6bbf18dc44 | |||
| 91aeacbe46 | |||
| 166a677590 | |||
| 097e383b86 | |||
| e61dccc784 | |||
| 25b1312e0a | |||
| a56581c757 | |||
| 36fb93658b | |||
| 3e3045d912 | |||
| 325d5c0470 | |||
| 851563ed73 | |||
| 75ff444621 | |||
| c9e72595f7 | |||
| e0b8f14dfc | |||
| 61b1210a47 | |||
| a3175210f9 | |||
| 74c0b2747f | |||
| 35d8d57984 | |||
| f7031c1809 | |||
| 4476372b9e | |||
| 92776d2cfc | |||
| 6996d1bdb0 | |||
| 2f34c36131 | |||
| 642fccd2b0 | |||
| 8e330ee28b | |||
| cc186c4d09 | |||
| 899921ef8a | |||
| 2ced93a425 | |||
| 6d60d452e0 | |||
| 03ec925aec | |||
| 3b92e4e2d2 | |||
| bd57dd72b3 | |||
| c0aaa67361 | |||
| 34f8601c91 | |||
| 64d41f67e9 | |||
| 426488a788 | |||
| 53cb1c97e9 | |||
| 477c71d1f4 | |||
| 753eea0d7b | |||
| d7241531f9 | |||
| fbe9a1bb5b | |||
| 36cd711302 | |||
| 77729ad99a | |||
| 134489a5ec | |||
| 067b32131e | |||
| 5e28b42067 | |||
| 4a740b1e28 | |||
| c1ee2cf30a | |||
| 291968a342 | |||
| 1cb2618fd7 | |||
| 79b0d0db86 | |||
| 3aedd67ca0 | |||
| 52732a17ee | |||
| d0fddba1e8 | |||
| bd206a8be9 | |||
| 2c78ff80e8 | |||
| 2b45419ec4 | |||
| 4c65923eff | |||
| 19fd5b644f | |||
| 12936731b3 | |||
| d1abda141a | |||
| 7ba1e64e7c | |||
| 81637b8502 | |||
| 0f2e109aa2 | |||
| 3312facd82 | |||
| 8dc409777e |
@@ -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
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR — Local catalogue, không cloud sync
|
||||
|
||||
Trạng thái: chấp nhận · 2026-09-28 · nhánh `recipes-web`
|
||||
|
||||
## Bối cảnh
|
||||
|
||||
Studio PWA (`docker/frontend`) đọc thư mục trên máy người dùng qua File System Access API.
|
||||
Bản nháp thiết kế `lightroom_web_app.md` giả định endpoint `/v1/sync/edits` và auth
|
||||
`Authorization: Bearer localStorage['pro_user_token']`. Backend thật (Fastify + SQLite) chỉ biết
|
||||
ảnh upload qua `/api/photos` (cap 12/tài khoản) và recipe qua `/api/recipes`; auth bằng cookie
|
||||
`SESSION_COOKIE` HttpOnly. Không có `photoId` phía server để gắn edit vào — endpoint đó không
|
||||
thể tồn tại. Tính năng được yêu cầu là *quản lý local*, nên phần sync là YAGNI.
|
||||
|
||||
## Quyết định
|
||||
|
||||
1. **Freeze scope:** catalog và edit chỉ nằm trên máy người dùng. Không thêm bảng, không endpoint sync.
|
||||
2. **IndexedDB `recipescam-library`** (version 1), ba store — theo đúng `src/engine/library.ts`:
|
||||
- `folders` — keyPath `name`
|
||||
- `photos` — keyPath `id` = `${folder}/${name}`, index `folder`, `taken`
|
||||
- `edits` — keyPath `photoId`, `{ recipe, updatedAt }`
|
||||
3. **API cùng origin** qua nginx proxy `/api/` → `fetch(..., { credentials: 'same-origin' })`
|
||||
(`src/api.ts`). Không `'include'`, không Bearer.
|
||||
4. **Nhận diện ảnh:** trước mắt chỉ `folder/name`. Khi cần sync thật: partial hash
|
||||
(first + last 64 KiB + size), và chỉ hash những frame *thực sự có edit*. Lưu ý `scanFolder`
|
||||
vốn đã đọc trọn bytes mỗi RAW cho `rawThumbnail`, nên "hash đắt vì phải đọc 30–50 MB" không
|
||||
phải lập luận đúng; cái làm `filename+size+mtime` mất map recipe là rename và copy từ card
|
||||
(mtime đổi, bytes y hệt).
|
||||
|
||||
## Hệ quả
|
||||
|
||||
- Không có gì để "cắt Cloud Sync": trong repo chưa từng có code sync.
|
||||
- Nếu sau này cần: thêm `library_edits (user_id, key, recipe, updated_at)` + `GET/POST
|
||||
/api/library/edits` (upsert theo batch, LWW theo `updated_at`), client flush khi `online`,
|
||||
xác thực bằng cookie session, key = partial hash. Ước ~60–80 dòng.
|
||||
@@ -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` | — |
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recipescam-recipe version="1" algorithm="xor16-v1" salt="46ec2fd2">
|
||||
<payload>a0354a7ac8e3f5679b2472f23e9df10fbcf6a5e9efd98af22091a80e06f7abd9b8c36462f5fa2e1c04846f1e8d7eaf1a554c588917391b9ae4a47cd3425c44d6426b7acb45e1fd0a4785ecc1700ae666307ad72ff1d07925f45f8fb9f18c68ca02a7b90843b9a0084a010ca10f8b96fbdfc68d00d4c9e059681e1d9da8e6f465600aada6c8ad24a11d28913b5aa266742dfe5ce1c8da9ace034af1858d503223117b558025c7bd59b3c6f8e05ae800921a1ad7a73625985069b9246acf165bb25f2aa934975c0030d3ed29b343cc2ecf9b767d45e8141b6f36f812524966890dccfd4972c6cd312a94541f38a9133262bbda307b70dc5f8c73bac91ced9954429459ffbfb3c66f95d199bebde1bd912ca5f9c360c2ef11fcdb4bc7d1b6786f6da0de996a396884ff5915c7bbc5425e82d5b39ae939e815678aae54005af3e3ced5aa85a1bfe8074f7a6e29aaf6391d9b79e8b694db24c0bbb4344f52641de13d28cad53f16ec6f4f81750a8d36e26053b71226349ad9d1b330d7bff3a3709dd937364e78497fac415ac73e4192e9ac887c39f1176d80aedd1b1df9270bc186aec942966dff90580acc01507c1dad1168b42f08a74d6efff2aff31964c93f19927455e6be0c680b5789bdf2778c3270f5e1c2e07ed9bba3255d71a2c4ec04aef696b0573daaf8734e9037c0b4dbc724510259e5252db982f70ad349af7937ccf384f125745369e6e8f34e6b03435b1d04ac215761af5bded5c5a8c54bc3f2bbc715507868ab7e80f18fb624cd12498e6c262b792e1295c11b9319492a6ac6c403104aab07fb46b6125571a8e794fbe452fb268cdcd064262fc57feb0a2a3e39f7cb5b148fcc5b4cae36dabb0cdd22afbec38cab2ed89c954311ca88f4933f3f72026ebde64a7d1bb74fdc1c626f4496d6c88352f59f2440ee64a6a7dcfb4afb71aead33f09493632e488915e47348775f5afd678ed89cc737a2bed1fa18ca950ea6f93849656f895cac266de7f82b793a0f436486566adceb38aea46e5011a3c922367a285e4cb4d52a9153b31d184c4289cea7b62f682cf12f13b12340519b8d204f18537204508c98342f4f7bf3b3414e46810b87bd610264539ff029f9</payload>
|
||||
</recipescam-recipe>
|
||||
@@ -1,2 +1,33 @@
|
||||
.env
|
||||
data/
|
||||
|
||||
# Local-only: test fixtures, plus copies of files already tracked at this
|
||||
# level (md/ mirrors the seven *.md beside it, old_film.png mirrors
|
||||
# frontend/public/assets/old_film.png byte for byte). They are needed on the
|
||||
# box that runs the checks, not in the history.
|
||||
/sample/
|
||||
/md/
|
||||
/old_film.png
|
||||
|
||||
# The NAS image bundle: 120 MB a build, and a binary every commit before this
|
||||
# line carried its own copy — which is what made the repository thirteen
|
||||
# gigabytes. It is built where it is loaded, and the checksum beside it is the
|
||||
# only part worth keeping in the history.
|
||||
/recipescam-images.tar.gz
|
||||
|
||||
# The measurement frames at this level are the same bytes as the copies in
|
||||
# /sample/ — which is the folder the checks read (`SAMPLE_DIR`, defaulting to
|
||||
# it). Tracked here for years they were 227 MB of a file the history already
|
||||
# carried twice; what runs a check finds them in /sample/.
|
||||
/*.RAF
|
||||
/*.DNG
|
||||
/*.NEF
|
||||
/*.RW2
|
||||
/Main*.jpg
|
||||
/L1040680.jpg
|
||||
/APACHAI-*.jpg
|
||||
/D0004128.JPG
|
||||
/DSCF1701.JPG
|
||||
/P1010256.JPG
|
||||
/lightroom_shadow.jpg
|
||||
/recipescam_shadow.jpg
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Giải Mã Kỹ Thuật: Tại Sao Thuật Toán Đưa Lên Web App Vẫn Không Thể Như Lightroom?
|
||||
|
||||
Rất nhiều kỹ sư đồ họa khi chuyển đổi thuật toán tone mapping sang WebGL đều gặp phải một thực tế phũ phàng: **công thức toán học trông rất hoàn hảo trên lý thuyết, nhưng khi chạy trên Web App thì Highlights kéo xuống bị xám xịt như bùn tro, Shadows kéo lên thì vỡ nát hạt sạn hoặc bết màu, không thể nào trong trẻo như Lightroom.**
|
||||
|
||||
Nguyên nhân không nằm ở việc bạn viết sai code GLSL, mà bắt nguồn từ **6 rào cản kiến trúc cốt tử** giữa môi trường xử lý ảnh chuyên nghiệp (Adobe Camera Raw) và môi trường đồ họa trình duyệt (WebGL / WebGPU).
|
||||
|
||||
---
|
||||
|
||||
## 1. Rào Cản Cốt Tử #1: Dữ Liệu Đầu Vào (Scene-Referred RAW vs Display-Referred 8-bit)
|
||||
|
||||
Đây là lý do lớn nhất chiếm tới $60\%$ sự khác biệt:
|
||||
|
||||
```
|
||||
[QUY TRÌNH LIGHTROOM]
|
||||
File RAW (14-bit Bayer) ──► Linear Sensor Space ──► Headroom rộng [0.0 đến 4.0+]
|
||||
(Chi tiết Highlights vượt ngưỡng 1.0 VẪN CÒN NGUYÊN)
|
||||
|
||||
[QUY TRÌNH WEB APP THÔNG THƯỜNG]
|
||||
File JPEG/PNG (8-bit) ──► Bị máy ảnh bake sẵn sRGB ──► Bị cắt cụt cứng (Clamped) [0.0, 1.0]
|
||||
(Mọi chi tiết > 1.0 đều đã bị vứt bỏ thành (255, 255, 255))
|
||||
```
|
||||
|
||||
### Tại sao Highlights của Lightroom cứu được mây trời, còn Web App biến thành màu "Xám Bùn" (Dirty Gray)?
|
||||
* **Trong Lightroom (RAW):** Khi chụp một đám mây bị chói, cảm biến máy ảnh thu nhận năng lượng ánh sáng tuyến tính thực tế có thể lên tới $2.5$ hoặc $3.0$ (vượt xa điểm trắng hiển thị $1.0$). Khi bạn kéo `Highlights -100`, Lightroom kéo các giá trị $2.5$ đó nén về khoảng $[0.7 - 0.9]$. Các vân mây, khối hình vẫn giữ nguyên gradient chuyển tiếp.
|
||||
* **Trên Web App (JPEG):** Ảnh đưa vào WebGL là ảnh 8-bit đã bị thiết bị chụp "nướng chín" (baked). Mọi pixel của đám mây trắng đã bị ép cứng về giá trị tối đa: $R=255, G=255, B=255$ ($1.0$). Gradient nội tại đã **bằng 0**. Khi bạn áp dụng công thức giảm Highlights:
|
||||
|
||||
$$1.0 - \text{HighlightAmount} \times \text{Factor} = 0.75$$
|
||||
|
||||
Một mảng pixel phẳng lì toàn số $1.0$ biến thành một mảng pixel phẳng lì toàn số $0.75$. Đó chính là lý do đám mây **không hề hiện lại vân mây** mà biến thành một mảng màu xám xịt nhân tạo.
|
||||
|
||||
### Tại sao Shadows của Lightroom mịn màng, còn Web App bị vỡ khối (Banding)?
|
||||
* Vùng tối sâu của file RAW 14-bit chứa hàng ngàn bước thang độ xám (Gray levels).
|
||||
* Vùng tối của file 8-bit JPEG chỉ có khoảng $5 - 10$ mức số nguyên (từ $0$ đến $10$). Khi kéo `Shadows +100`, Web App phóng đại $10$ mức này lên thành $50 - 60$, tạo ra hiện tượng bậc thang đứt gãy (Color Banding) và nhiễu nén JPEG rác (Macroblocking artifacts).
|
||||
|
||||
---
|
||||
|
||||
## 2. Rào Cản Kỹ Thuật #2: Độ Chính Xác FBO WebGL (8-bit Unsigned Byte vs 16/32-bit Float)
|
||||
|
||||
Trong pipeline nhiều lớp (Multi-pass Pipeline) của WebGL:
|
||||
|
||||
```
|
||||
Pass 1: Blur/Guided Filter ──► Framebuffer (FBO) ──► Pass 2: Tone Composite
|
||||
```
|
||||
|
||||
* **Sai lầm phổ biến trên Web:** Khởi tạo texture FBO bằng kiểu mặc định:
|
||||
```javascript
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
```
|
||||
Kiểu `gl.UNSIGNED_BYTE` giới hạn bộ đệm FBO chỉ có **256 mức giá trị** cho mỗi kênh màu.
|
||||
* **Hậu quả:**
|
||||
1. Khi Pass 1 tính toán lớp nền (Base Layer) qua bộ lọc Guided Filter, các phép tính trung bình dấu phẩy động bị làm tròn (rounding error) liên tục về $1/255$.
|
||||
2. Sang Pass 2, phép trừ $\text{Detail} = \text{Orig} - \text{Base}$ thực hiện trên các giá trị đã bị làm tròn thô kệch, tạo ra các viền bậc thang nhân tạo li ti khắp bề mặt ảnh.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rào Cản Thuật Toán #3: Local Laplacian Pyramid vs Single-Pass Guided Filter
|
||||
|
||||
Trong các tài liệu thông thường, người ta hay đơn giản hóa rằng Lightroom dùng "Bilateral" hay "Guided Filter". Nhưng trên thực tế, kiến trúc xử lý Tone của Adobe Camera Raw (từ Process Version 2012 trở đi) sử dụng thuật toán đỉnh cao: **Local Laplacian Filters (Aubry, Paris, Hasinoff, Kautz - MIT/Adobe Research)**.
|
||||
|
||||
```
|
||||
[CÁCH WEB APP THƯỜNG LÀM]
|
||||
Ảnh Đầu Vào
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
Base Layer Detail Layer
|
||||
(1 bán kính) (1 bán kính)
|
||||
│ │
|
||||
└─────────────┬─────────────┘
|
||||
▼
|
||||
Ảnh kết quả (Dễ bẹt khối)
|
||||
|
||||
[CÁCH LIGHTROOM THỰC SỰ LÀM]
|
||||
Ảnh Đầu Vào
|
||||
│
|
||||
┌───────────┼───────────┐
|
||||
▼ ▼ ▼
|
||||
Cấp 0 Cấp 1 Cấp 2 ... (Pyramid N Cấp)
|
||||
(Micro) (Meso) (Macro)
|
||||
│ │ │
|
||||
[Nắn chỉnh từng nhánh bằng đường cong Tone Curve cục bộ phi tuyến]
|
||||
│ │ │
|
||||
└───────────┼───────────┘
|
||||
▼
|
||||
Tái cấu trúc: Khối sâu 3D, chuyển tiếp dải sáng hoàn hảo
|
||||
```
|
||||
|
||||
* **Điểm yếu của Web App:** Khi chỉ dùng 1 bán kính mờ duy nhất cho Base Layer, bạn chỉ nắn sáng được ở một "tần số" cố định. Nếu bán kính lọc quá nhỏ $\implies$ sinh ra viền xung quanh vật thể; nếu bán kính lọc quá lớn $\implies$ các khối chi tiết trung bình bị phẳng lì (mất tương phản vi mô).
|
||||
* **Lightroom:** Nén sáng trên một kim tự tháp đa tầng (Laplacian Pyramid). Nó nén vùng sáng tổng thể nhưng đồng thời **khuếch đại tương phản cục bộ tại mọi cấp độ phân giải**, giúp ngọn núi vừa sáng rõ vừa gồ ghề sắc cạnh.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rào Cản Màu Sắc #4: Lỗi Chia Tỷ Lệ Màu Vùng Tối & Lệch Sắc Thái (Chroma Shift)
|
||||
|
||||
Trong đoạn shader WebGL của các ứng dụng tự phát triển, thường có dòng code:
|
||||
|
||||
```glsl
|
||||
float scale = (newLum + 0.0001) / (lumOrig + 0.0001);
|
||||
vec3 result = orig * scale;
|
||||
```
|
||||
|
||||
Đây là nguyên nhân gây ra hiện tượng **"nát màu"** ở vùng tối:
|
||||
1. **Hiện tượng phát nổ nhiễu (Chroma Explosion):** Tại các pixel cực tối, $\text{lumOrig} \approx 0.001$. Khi kéo Shadows, $\text{newLum}$ được nâng lên $0.05$. Tỷ lệ `scale` lúc này bằng:
|
||||
|
||||
$$\frac{0.05}{0.001} = 50 \text{ lần!}$$
|
||||
|
||||
Một hạt nhiễu đỏ nhỏ li ti mắt thường không thấy ở vùng tối bị nhân lên gấp $50$ lần, biến thành một đốm màu đỏ rực khổng lồ loang lổ.
|
||||
2. **Lệch sắc thái (Hue Distortion):** Ba kênh $R, G, B$ khi bị nhân tuyến tính với hệ số lớn sẽ chạm trần clipping ($1.0$) không đồng đều. Kênh nào chạm trần trước sẽ bị gãy tỷ lệ, khiến màu xanh lá của rừng cây chuyển sang màu vàng chuối hoặc xanh neon bẩn.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rào Cản Không Gian Màu #5: Linear sRGB vs ACEScg / ProPhoto RGB
|
||||
|
||||
* WebGL thông thường chỉ chuyển: $\text{sRGB} \xrightarrow{pow(2.2)} \text{Linear sRGB}$.
|
||||
* Không gian **sRGB có dải màu (Color Gamut) rất hẹp**. Khi bạn tăng sáng một vùng tối có màu rêu đậm, màu sắc sau khi tăng sáng sẽ nhanh chóng văng ra ngoài biên giới tam giác sRGB (Out of Gamut), dẫn đến việc màu bị bẹt và xỉn.
|
||||
* **Lightroom:** Xử lý toàn bộ trong không gian **ProPhoto RGB** (hoặc ACEScg). Đây là không gian màu siêu rộng (Wide Color Gamut), bao phủ hầu hết các màu mắt người nhìn thấy được. Dù có đẩy Shadows hay nén Highlights kịch kim, màu sắc vẫn nằm gọn trong không gian tính toán an toàn trước khi được Tone Map mượt mà về màn hình hiển thị.
|
||||
|
||||
---
|
||||
|
||||
## 6. Giải Pháp Đột Phá: Làm Thế Nào Để Web App Tiệm Cận 90% - 95% Lightroom?
|
||||
|
||||
Để đưa Web App của bạn lên chất lượng tương đương Lightroom ngay trên tệp ảnh 8-bit thông thường, hãy áp dụng ngay **4 giải pháp kỹ thuật sống còn** dưới đây:
|
||||
|
||||
### Giải Pháp 1: Bắt buộc dùng WebGL2 Float Texture cho Framebuffer (FBO)
|
||||
Khử hoàn toàn lỗi vỡ hạt và đường viền răng cưa bằng cách kích hoạt kết cấu dấu phẩy động 16-bit:
|
||||
|
||||
```javascript
|
||||
// Sử dụng WebGL2 context
|
||||
const gl = canvas.getContext('webgl2');
|
||||
gl.getExtension('EXT_color_buffer_float');
|
||||
|
||||
// Tạo FBO Texture lưu trữ số thực nửa độ chính xác (Half Float - 16 bit)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null);
|
||||
```
|
||||
|
||||
### Giải Pháp 2: Khôi Phục Highlights Ảo (Pseudo-Highlight Inpainting & Desaturation)
|
||||
Vì ảnh 8-bit không có headroom, khi người dùng kéo `Highlights < 0`, thay vì giảm độ sáng đơn thuần làm xám đám mây, hãy áp dụng kỹ thuật **Highlight Soft-Roll Rolloff + Khử bão hòa biên độ cao**:
|
||||
|
||||
```glsl
|
||||
// Kỹ thuật cứu cháy sáng cho ảnh 8-bit trên WebGL:
|
||||
if (u_highlights < 0.0) {
|
||||
// 1. Chỉ nén những vùng có gradient (không nén vùng cháy tuyệt đối 1.0)
|
||||
float highlightMask = smoothstep(0.6, 0.95, baseLum);
|
||||
|
||||
// 2. Nén dải sáng theo đường cong lũy thừa mềm (Soft Knee)
|
||||
float compress = pow(baseLum, 1.0 - u_highlights * 0.5);
|
||||
newLum = mix(baseLum, compress, highlightMask);
|
||||
|
||||
// 3. Với các pixel chạm ngưỡng 1.0, hòa trộn nhẹ với màu sắc môi trường xung quanh
|
||||
// để tái tạo gradient nhân tạo, tránh để mảng trắng chuyển sang mảng xám xịt.
|
||||
}
|
||||
```
|
||||
|
||||
### Giải Pháp 3: Ổn Định Tỷ Lệ Màu Vùng Tối Bằng Soft-Division (Khử Nát Màu)
|
||||
Thay thế công thức chia tỷ lệ thô bằng hàm **Soft Saturation Clamp**:
|
||||
|
||||
```glsl
|
||||
// Thay vì: scale = newLum / lumOrig;
|
||||
// HÃY DÙNG CÔNG THỨC ỔN ĐỊNH CỦA ACR:
|
||||
float safeLumOrig = max(lumOrig, 0.005);
|
||||
float scale = newLum / safeLumOrig;
|
||||
|
||||
// Nén hệ số khuếch đại tối đa để không bao giờ khuếch đại nhiễu quá 3.0 lần
|
||||
float clampedScale = min(scale, 1.0 + max(u_shadows, 0.0) * 2.5);
|
||||
|
||||
// Tái cấu trúc RGB an toàn
|
||||
vec3 result = orig * clampedScale;
|
||||
|
||||
// Dập tắt nhiễu màu sàn đen: Vùng cực tối (< 2%) ép dần về đơn sắc (Desaturate to Neutral)
|
||||
float noiseFloor = smoothstep(0.0, 0.03, newLum);
|
||||
result = mix(vec3(newLum), result, noiseFloor);
|
||||
```
|
||||
|
||||
### Giải Pháp 4: Chuyển Sang Không Gian Màu Tri Giác OKLab Thay Vì sRGB
|
||||
Thay vì nắn độ sáng trên RGB rồi chia tỷ lệ, hãy chuyển đổi pixel sang không gian màu **OKLab**:
|
||||
* Kênh $L$: Độ sáng tri giác người (Perceptual Lightness).
|
||||
* Kênh $a, b$: Sắc độ màu (Chrominance).
|
||||
|
||||
Khi bạn can thiệp vào Shadows, Highlights, Blacks, Whites: **chỉ tác động duy nhất vào kênh $L$**. Kênh $a, b$ được giữ nguyên hoặc nhân nhẹ theo định luật Hunt Effect. Điều này triệt tiêu $100\%$ hiện tượng cây cối bị biến thành màu bùn hay da mặt bị ố vàng.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bảng So Sánh Trước & Sau Khi Cải Cách Pipeline
|
||||
|
||||
| Tiêu chí | Web App Hiện Tại Của Bạn | Web App Sau Khi Tối Ưu Nâng Cao |
|
||||
| :--- | :--- | :--- |
|
||||
| **Độ sâu FBO Texture** | 8-bit `UNSIGNED_BYTE` (256 mức) | 16-bit `RGBA16F` (Float - Vô hạn mức mượt) |
|
||||
| **Kéo Shadows $+100$** | Hạt sạn loang lổ, bợt màu, xám đục | Trong trẻo, giữ nguyên sắc xanh của lá và độ sâu đen |
|
||||
| **Kéo Highlights $-100$** | Mây biến thành mảng xám bùn nhân tạo | Chuyển tiếp êm dịu, không sinh viền giả |
|
||||
| **Không gian tính toán** | Linear sRGB thô sơ | OKLab / Perceptual Space với Soft-Division |
|
||||
| **Hiện tượng mép cạnh** | Xuất hiện viền Halo hoặc Stroke | Triệt tiêu mép cạnh hoàn toàn ($100\%$ tự nhiên) |
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"test": "node test/security.mjs"
|
||||
"test": "node test/security.mjs",
|
||||
"test:immich": "node test/immich.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
@@ -172,6 +172,29 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
|
||||
}
|
||||
}
|
||||
|
||||
// PRO, the operator's own switch: the admin ticks it in the users table and the
|
||||
// account reads the studio's PRO features from then on, proven address or not.
|
||||
// It only ever adds to what a verified account already has — unticking it
|
||||
// cannot take the tier away from an address that has been proven.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'pro')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN pro INTEGER NOT NULL DEFAULT 0`);
|
||||
}
|
||||
}
|
||||
|
||||
// The Immich servers an account added to its LIBRARY — one JSON array per user,
|
||||
// each entry holding the address, the key its owner pasted and the albums they
|
||||
// ticked (see src/immich.ts). A column rather than a table because the list is
|
||||
// only ever read whole and written whole: there is nothing to query across
|
||||
// accounts, and a user's row is already the unit that is deleted together.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'immich')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN immich TEXT`);
|
||||
}
|
||||
}
|
||||
|
||||
// The mailed code, added once visitors were expected to prove an address
|
||||
// without leaving the page. Rows minted before it keep working as links: their
|
||||
// `code` is NULL, which no typed guess can match (see verifyEmailCode).
|
||||
@@ -228,6 +251,9 @@ export const PHOTO_HISTORY_MAX = 3;
|
||||
// `avatar` is the stored file name, or null for "no picture".
|
||||
// `emailVerified` is 0/1 from SQLite; the route layer turns it into the
|
||||
// `verified` the client reads.
|
||||
// `pro` is the admin's own 0/1 grant — the "Activated Pro" box in the users
|
||||
// table, and the only way an unproven address reaches the PRO tier.
|
||||
// `createdAt` is the signup instant, which the PRO cutoff reads (see server.ts).
|
||||
export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
@@ -235,6 +261,8 @@ export type User = {
|
||||
blocked: number;
|
||||
deletedAt: string | null;
|
||||
emailVerified: number;
|
||||
pro: number;
|
||||
createdAt: string;
|
||||
};
|
||||
export type Recipe = {
|
||||
id: number;
|
||||
@@ -268,10 +296,11 @@ const now = () => new Date().toISOString();
|
||||
|
||||
export function createUser(email: string, password: string): User | null {
|
||||
try {
|
||||
const createdAt = now();
|
||||
const info = db
|
||||
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
|
||||
.run(email, hashPassword(password), now());
|
||||
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0 };
|
||||
.run(email, hashPassword(password), createdAt);
|
||||
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0, createdAt };
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
|
||||
throw err;
|
||||
@@ -281,14 +310,14 @@ export function createUser(email: string, password: string): User | null {
|
||||
export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined {
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, password_hash FROM users WHERE email = ?',
|
||||
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, created_at AS createdAt, password_hash FROM users WHERE email = ?',
|
||||
)
|
||||
.get(email) as (User & { password_hash: string }) | undefined;
|
||||
}
|
||||
|
||||
export function findUserById(id: number): User | undefined {
|
||||
return db
|
||||
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified FROM users WHERE id = ?')
|
||||
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, created_at AS createdAt FROM users WHERE id = ?')
|
||||
.get(id) as User | undefined;
|
||||
}
|
||||
|
||||
@@ -589,6 +618,8 @@ export type AdminUser = {
|
||||
avatar: string | null;
|
||||
blocked: number;
|
||||
deletedAt: string | null;
|
||||
pro: number;
|
||||
emailVerified: number;
|
||||
};
|
||||
|
||||
export function listUsersWithCounts(): AdminUser[] {
|
||||
@@ -596,7 +627,8 @@ export function listUsersWithCounts(): AdminUser[] {
|
||||
.prepare(
|
||||
`SELECT users.id AS id, users.email AS email, users.created_at AS createdAt,
|
||||
users.avatar AS avatar, users.blocked AS blocked,
|
||||
users.deleted_at AS deletedAt, COUNT(photos.id) AS photos
|
||||
users.deleted_at AS deletedAt, users.pro AS pro,
|
||||
users.email_verified AS emailVerified, COUNT(photos.id) AS photos
|
||||
FROM users LEFT JOIN photos ON photos.user_id = users.id
|
||||
GROUP BY users.id
|
||||
ORDER BY users.id`,
|
||||
@@ -604,6 +636,44 @@ export function listUsersWithCounts(): AdminUser[] {
|
||||
.all() as AdminUser[];
|
||||
}
|
||||
|
||||
// ---- Immich servers, per account ---------------------------------------------
|
||||
// What src/immich.ts stores for one server: the address, the key its owner
|
||||
// pasted, and the crossing choices the dialog made. `version` and `canDownload`
|
||||
// are the answers of the last probe, kept so the LIBRARY tree can draw without
|
||||
// asking Immich anything.
|
||||
export type ImmichServer = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
key: string;
|
||||
type: 'api' | 'share';
|
||||
version: string | null;
|
||||
canDownload: boolean | null;
|
||||
albums: string[];
|
||||
};
|
||||
|
||||
// The key is in clear in this column, like the password hash beside it: the file
|
||||
// is the deployment's own disk. What matters is that it never travels back out —
|
||||
// the routes send a masked copy and nothing else (see src/immich.ts).
|
||||
export function immichServers(userId: number): ImmichServer[] {
|
||||
const row = db.prepare('SELECT immich FROM users WHERE id = ?').get(userId) as
|
||||
| { immich: string | null }
|
||||
| undefined;
|
||||
if (!row?.immich) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(row.immich);
|
||||
return Array.isArray(parsed) ? (parsed as ImmichServer[]) : [];
|
||||
} catch {
|
||||
// A column hand-edited into nonsense reads as "no server added", which is
|
||||
// the same thing the user sees and can fix from the dialog.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function setImmichServers(userId: number, servers: ImmichServer[]): void {
|
||||
db.prepare('UPDATE users SET immich = ? WHERE id = ?').run(JSON.stringify(servers), userId);
|
||||
}
|
||||
|
||||
// ---- moderation -------------------------------------------------------------
|
||||
// Blocking and removing both drop the account's live sessions: the state has to
|
||||
// take effect on the next request, not whenever the cookie happens to expire.
|
||||
@@ -613,6 +683,13 @@ export function setUserBlocked(id: number, blocked: boolean): boolean {
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
// The PRO grant, ticked or unticked from the users table. No session sweep: the
|
||||
// tier is read off the row on every request, so the next one already sees it.
|
||||
export function setUserPro(id: number, pro: boolean): boolean {
|
||||
const info = db.prepare('UPDATE users SET pro = ? WHERE id = ?').run(pro ? 1 : 0, id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
export function setUserRemoved(id: number, removed: boolean): boolean {
|
||||
const info = db
|
||||
.prepare('UPDATE users SET deleted_at = ? WHERE id = ?')
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
// Immich, as a second photo source for the LIBRARY module — server side only.
|
||||
//
|
||||
// Two constraints decide this file's whole shape (see md/immich-library-plan.md):
|
||||
// the SPA is served with COEP `require-corp`, so nothing cross-origin can be
|
||||
// fetched from the page; and an Immich key reads a whole family library, so it
|
||||
// must never reach the browser. One move answers both — the page talks to these
|
||||
// routes, these routes talk to Immich, and the key stays in the users table.
|
||||
//
|
||||
// Every route here is read-only towards Immich: no upload, no edit, no delete.
|
||||
// That is what the dialog asks for, and it is why the worst a stolen session can
|
||||
// do is read what its own key could already read.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { immichServers, setImmichServers, type ImmichServer, type User } from './db';
|
||||
|
||||
const MAX_URL = 300;
|
||||
const MAX_KEY = 400;
|
||||
const MAX_NAME = 60;
|
||||
const MAX_ALBUMS = 200;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
// What the shelf's search box may carry. Bounded because it goes on to another
|
||||
// box as a query, and a search box is a thing people paste into.
|
||||
const MAX_QUERY = 100;
|
||||
|
||||
// A server is a box the operator may not control, and a hang there must not
|
||||
// become a hung LIBRARY. Streaming an original is the one call that legitimately
|
||||
// takes minutes, so it gets a clock of its own.
|
||||
const PROBE_MS = 8_000;
|
||||
const FETCH_MS = 5 * 60_000;
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const THUMB_SIZES = new Set(['thumbnail', 'preview', 'fullsize']);
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
// The three refusals the UI can say something about: the key is wrong (or too
|
||||
// narrow), the box cannot be reached, or it answered with something unusable.
|
||||
type Failure = { ok: false; error: 'auth' | 'unreachable' | 'server' | 'invalid' };
|
||||
|
||||
type RawAsset = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
originalFileName?: string;
|
||||
fileCreatedAt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
exifInfo?: { fileSizeInByte?: number };
|
||||
};
|
||||
type RawAlbum = {
|
||||
id?: string;
|
||||
albumName?: string;
|
||||
assetCount?: number;
|
||||
albumThumbnailAssetId?: string | null;
|
||||
shared?: boolean;
|
||||
};
|
||||
|
||||
type AlbumRow = {
|
||||
id: string;
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
coverId: string | null;
|
||||
shared: boolean;
|
||||
};
|
||||
|
||||
const albumRow = (album: RawAlbum, count?: number): AlbumRow => ({
|
||||
id: album.id ?? '',
|
||||
albumName: album.albumName ?? '',
|
||||
assetCount: count ?? album.assetCount ?? 0,
|
||||
coverId: album.albumThumbnailAssetId ?? null,
|
||||
shared: album.shared === true,
|
||||
});
|
||||
|
||||
// What the client needs to build a row: identity, a name, a date, a size. The
|
||||
// pixels are never here — the tile route fetches those.
|
||||
const assetRow = (asset: RawAsset) => ({
|
||||
id: asset.id ?? '',
|
||||
name: asset.originalFileName ?? '',
|
||||
takenAt: asset.fileCreatedAt ?? null,
|
||||
width: asset.width ?? null,
|
||||
height: asset.height ?? null,
|
||||
size: asset.exifInfo?.fileSizeInByte ?? null,
|
||||
});
|
||||
|
||||
// ---- validation at the trust boundary --------------------------------------
|
||||
function body(req: FastifyRequest): Json {
|
||||
const parsed = req.body as unknown;
|
||||
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Json) : {};
|
||||
}
|
||||
|
||||
// The address is the one string that could aim this proxy anywhere, so it is
|
||||
// checked here: http(s) only, no embedded credentials, no path — Immich is
|
||||
// reached at the root of its own origin.
|
||||
function cleanUrl(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim().replace(/\/+$/, '');
|
||||
if (!trimmed || trimmed.length > MAX_URL) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
if (url.username || url.password) return null;
|
||||
if (url.search || url.hash) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function cleanKey(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const key = raw.trim();
|
||||
if (!key || key.length > MAX_KEY || /\s/.test(key)) return null;
|
||||
return key;
|
||||
}
|
||||
|
||||
// A name is what the tree shows beside the node; empty means the host, which is
|
||||
// what the user would have called it anyway.
|
||||
function cleanName(raw: unknown, url: string): string {
|
||||
if (typeof raw === 'string') {
|
||||
const name = raw.trim().slice(0, MAX_NAME);
|
||||
if (name) return name;
|
||||
}
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Album IDs are pasted by hand (a `…/albums/<uuid>` URL is the normal way to get
|
||||
// one), so the shape is checked and the list capped: these values end up in a
|
||||
// query to Immich, which is exactly where an unchecked string would hurt.
|
||||
function cleanAlbums(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: string[] = [];
|
||||
for (const item of raw) {
|
||||
const found = typeof item === 'string' ? item.match(UUID_RE)?.[0]?.toLowerCase() : undefined;
|
||||
if (!found || out.includes(found)) continue;
|
||||
out.push(found);
|
||||
if (out.length >= MAX_ALBUMS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// What the browser may know about a key: which one it is, never what it is. Four
|
||||
// characters are enough for its owner to recognise it.
|
||||
const mask = (key: string) => `••••${key.slice(-4)}`;
|
||||
|
||||
// ---- talking to Immich -------------------------------------------------------
|
||||
type Call = {
|
||||
method?: 'GET' | 'POST';
|
||||
path: string;
|
||||
query?: Record<string, string | number | undefined>;
|
||||
json?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
// One place builds the upstream request, so the key is attached the same way
|
||||
// everywhere: an API key rides in `x-api-key`, a share link's key in `?key=`,
|
||||
// which is where Immich's own shared routes read it from. Neither ever reaches a
|
||||
// log line we write.
|
||||
async function call(server: ImmichServer, req: Call): Promise<Response> {
|
||||
const url = new URL(`${server.url}/api${req.path}`);
|
||||
for (const [name, value] of Object.entries(req.query ?? {})) {
|
||||
if (value !== undefined) url.searchParams.set(name, String(value));
|
||||
}
|
||||
const headers: Record<string, string> = { ...req.headers };
|
||||
if (server.type === 'api') headers['x-api-key'] = server.key;
|
||||
else url.searchParams.set('key', server.key);
|
||||
const init: RequestInit = { method: req.method ?? 'GET', headers, signal: AbortSignal.timeout(req.timeout ?? PROBE_MS) };
|
||||
if (req.json !== undefined) {
|
||||
headers['content-type'] = 'application/json';
|
||||
init.body = JSON.stringify(req.json);
|
||||
}
|
||||
return fetch(url, init);
|
||||
}
|
||||
|
||||
async function jsonOf<T>(res: Response): Promise<T | null> {
|
||||
try {
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Immich's own answer, narrowed to the three the UI can act on.
|
||||
const refusal = (res: Response): Failure =>
|
||||
res.status === 401 || res.status === 403 ? { ok: false, error: 'auth' } : { ok: false, error: 'server' };
|
||||
|
||||
const unreachable = (): Failure => ({ ok: false, error: 'unreachable' });
|
||||
|
||||
// The album list, from whichever kind of key this is: a share link is one album
|
||||
// it has to ask `shared-links/me` for, an API key gets the whole visible list.
|
||||
async function listAlbums(server: ImmichServer): Promise<AlbumRow[] | Failure> {
|
||||
if (server.type === 'share') {
|
||||
const res = await call(server, { path: '/shared-links/me' });
|
||||
if (!res.ok) return refusal(res);
|
||||
const me = await jsonOf<{ album?: RawAlbum; assets?: RawAsset[] }>(res);
|
||||
return me?.album ? [albumRow(me.album, me.assets?.length)] : [];
|
||||
}
|
||||
const res = await call(server, { path: '/albums' });
|
||||
if (!res.ok) return refusal(res);
|
||||
return ((await jsonOf<RawAlbum[]>(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album));
|
||||
}
|
||||
|
||||
// The tags this key can see whose name holds what was typed. Asked for on every
|
||||
// search rather than cached: a tag made on Immich has to be found by the next
|
||||
// one, and the list is small. A key without `tag.read` is answered 403, which is
|
||||
// a key that cannot search by tag — not a search that failed.
|
||||
async function tagIdsMatching(server: ImmichServer, q: string): Promise<string[]> {
|
||||
const res = await call(server, { path: '/tags' });
|
||||
if (!res.ok) return [];
|
||||
const tags = (await jsonOf<{ id?: string; name?: string }[]>(res)) ?? [];
|
||||
const needle = q.toLowerCase();
|
||||
return tags.filter((tag) => tag.id && (tag.name ?? '').toLowerCase().includes(needle)).map((tag) => tag.id as string);
|
||||
}
|
||||
|
||||
async function firstAssetId(server: ImmichServer): Promise<string | null> {
|
||||
if (server.type === 'share') {
|
||||
const res = await call(server, { path: '/shared-links/me' });
|
||||
if (!res.ok) return null;
|
||||
return (await jsonOf<{ assets?: RawAsset[] }>(res))?.assets?.[0]?.id ?? null;
|
||||
}
|
||||
const res = await call(server, { method: 'POST', path: '/search/metadata', json: { size: 1, page: 1 } });
|
||||
if (!res.ok) return null;
|
||||
const found = await jsonOf<{ assets?: { items?: RawAsset[] } }>(res);
|
||||
return found?.assets?.items?.[0]?.id ?? null;
|
||||
}
|
||||
|
||||
// Whether this key may fetch an original — the one permission the studio
|
||||
// depends on (see the plan's permission table). A one-byte range keeps the check
|
||||
// from downloading a RAW file, and the stream is dropped rather than read.
|
||||
async function canDownload(server: ImmichServer, assetId: string): Promise<boolean | null> {
|
||||
const res = await call(server, { path: `/assets/${assetId}/original`, headers: { Range: 'bytes=0-0' } });
|
||||
await res.body?.cancel();
|
||||
if (res.status === 200 || res.status === 206) return true;
|
||||
if (res.status === 401 || res.status === 403) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
type ProbeResult = { ok: true; version: string | null; albums: AlbumRow[]; canDownload: boolean | null } | Failure;
|
||||
|
||||
// "Check this key" — the only place that reaches a server the user has not saved
|
||||
// yet. It asks the fixed Immich paths above and nothing else, so no part of the
|
||||
// request can steer it past that list.
|
||||
async function probe(url: string, key: string, type: 'api' | 'share'): Promise<ProbeResult> {
|
||||
const server: ImmichServer = { id: 'probe', name: '', url, key, type, version: null, canDownload: null, albums: [] };
|
||||
try {
|
||||
// The version is public, so it answers even for a key that turns out to be
|
||||
// wrong — and the dialog gets to show what it is talking to.
|
||||
const versionRes = await call(server, { path: '/server/version' });
|
||||
if (!versionRes.ok) return refusal(versionRes);
|
||||
const v = await jsonOf<{ major?: number; minor?: number; patch?: number }>(versionRes);
|
||||
const version = v?.major === undefined ? null : `${v.major}.${v.minor ?? 0}.${v.patch ?? 0}`;
|
||||
|
||||
// Reading albums is also the proof the key works: an unusable key is refused
|
||||
// here, before anything is stored.
|
||||
const albums = await listAlbums(server);
|
||||
if (!Array.isArray(albums)) return albums;
|
||||
|
||||
const assetId = await firstAssetId(server);
|
||||
return { ok: true, version, albums, canDownload: assetId ? await canDownload(server, assetId) : null };
|
||||
} catch {
|
||||
// A wrong host, a closed port, a TLS failure, a timeout — one answer.
|
||||
return unreachable();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- routes ------------------------------------------------------------------
|
||||
export function immichRoutes(
|
||||
app: FastifyInstance,
|
||||
member: (req: FastifyRequest, reply: FastifyReply) => User | undefined,
|
||||
): void {
|
||||
const view = (server: ImmichServer) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
url: server.url,
|
||||
type: server.type,
|
||||
version: server.version,
|
||||
canDownload: server.canDownload,
|
||||
albums: server.albums,
|
||||
keyMasked: mask(server.key),
|
||||
});
|
||||
|
||||
// A route names its server by the id we minted, never by an address: nothing a
|
||||
// client sends can aim this proxy at a host that is not in its own list.
|
||||
const pick = (user: User, reply: FastifyReply, id: unknown): ImmichServer | undefined => {
|
||||
const servers = immichServers(user.id);
|
||||
const server = typeof id === 'string' && id ? servers.find((s) => s.id === id) : servers[0];
|
||||
if (!server) {
|
||||
void reply.status(404).send({ error: 'unknown server' });
|
||||
return undefined;
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
// The saved list. No upstream call at all: the tree has to draw on a phone with
|
||||
// no signal. Nothing here suggests an address — the address and the key are each
|
||||
// account's own, typed in the app, so a fresh server is a blank field.
|
||||
app.get('/api/immich/config', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
return reply.send({ ok: true, servers: immichServers(user.id).map(view) });
|
||||
});
|
||||
|
||||
// The dialog's "check this key": nothing is stored, so a typo never becomes a
|
||||
// row, and the answer carries the albums that key can actually see.
|
||||
app.post('/api/immich/probe', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const b = body(req);
|
||||
const url = cleanUrl(b.url);
|
||||
const key = cleanKey(b.key);
|
||||
if (!url || !key) return reply.status(400).send({ ok: false, error: 'invalid' });
|
||||
return reply.send(await probe(url, key, b.type === 'share' ? 'share' : 'api'));
|
||||
});
|
||||
|
||||
// Adding or editing one server. The key is checked against Immich before it is
|
||||
// stored, so every saved server is one that answered — and the probe's version
|
||||
// and download answer are kept with it, which is what lets the tree draw
|
||||
// without asking Immich anything.
|
||||
app.put('/api/immich/config', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const b = body(req);
|
||||
const servers = immichServers(user.id);
|
||||
const existing = typeof b.id === 'string' ? servers.find((s) => s.id === b.id) : undefined;
|
||||
if (b.id !== undefined && !existing) return reply.status(404).send({ error: 'unknown server' });
|
||||
|
||||
// A partial edit (rename, retick the albums) keeps what it does not carry.
|
||||
const url = cleanUrl(b.url) ?? existing?.url ?? null;
|
||||
const key = cleanKey(b.key) ?? existing?.key ?? null;
|
||||
const type: 'api' | 'share' = b.type === 'share' || (existing?.type === 'share' && b.type === undefined) ? 'share' : 'api';
|
||||
if (!url || !key) return reply.status(400).send({ error: 'url and key are required' });
|
||||
|
||||
const probed = await probe(url, key, type);
|
||||
if (!probed.ok) return reply.send(probed);
|
||||
|
||||
const entry: ImmichServer = {
|
||||
id: existing?.id ?? randomBytes(8).toString('hex'),
|
||||
name: cleanName(b.name ?? existing?.name, url),
|
||||
url,
|
||||
key,
|
||||
type,
|
||||
version: probed.version,
|
||||
canDownload: probed.canDownload,
|
||||
albums: 'albums' in b ? cleanAlbums(b.albums) : existing?.albums ?? [],
|
||||
};
|
||||
setImmichServers(user.id, existing ? servers.map((s) => (s.id === entry.id ? entry : s)) : [...servers, entry]);
|
||||
return reply.send({ ok: true, server: view(entry) });
|
||||
});
|
||||
|
||||
// Forgetting one server: the key goes with it, and the synced rows in the
|
||||
// browser's own catalogue become unreachable, which is what the client shows.
|
||||
app.delete('/api/immich/config', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const id = (req.query as { id?: string } | undefined)?.id;
|
||||
const servers = immichServers(user.id);
|
||||
const kept = servers.filter((s) => s.id !== id);
|
||||
if (!id || kept.length === servers.length) return reply.status(404).send({ error: 'unknown server' });
|
||||
setImmichServers(user.id, kept);
|
||||
return reply.send({ ok: true, servers: kept.map(view) });
|
||||
});
|
||||
|
||||
// The ticked albums. An empty list is a decision, not a missing value: it
|
||||
// means "every album this key can see" (see the plan, the album-ID field).
|
||||
app.put('/api/immich/albums', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const b = body(req);
|
||||
const servers = immichServers(user.id);
|
||||
const server = servers.find((s) => s.id === b.id);
|
||||
if (!server) return reply.status(404).send({ error: 'unknown server' });
|
||||
server.albums = cleanAlbums(b.selected);
|
||||
setImmichServers(user.id, servers);
|
||||
return reply.send({ ok: true, albums: server.albums });
|
||||
});
|
||||
|
||||
// The live album list, for the dialog that ticks them. Deliberately not cached
|
||||
// anywhere: a new album on Immich has to appear the next time it is opened.
|
||||
app.get('/api/immich/albums', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const server = pick(user, reply, (req.query as { id?: string }).id);
|
||||
if (!server) return;
|
||||
const albums = await listAlbums(server);
|
||||
if (!Array.isArray(albums)) return reply.status(502).send(albums);
|
||||
return reply.send({ ok: true, albums });
|
||||
});
|
||||
|
||||
// One page of photos: an album's, or — with no album at all — everything the
|
||||
// key can see, which is the "read all of it" case of the album-ID field. A
|
||||
// share link is one album read whole; an API key searches. Both answer in the
|
||||
// same page shape, so the client's sync loop has one case.
|
||||
app.get('/api/immich/assets', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const q = req.query as Record<string, string | undefined>;
|
||||
const server = pick(user, reply, q.server);
|
||||
if (!server) return;
|
||||
const page = Math.max(1, Math.trunc(Number(q.page ?? 1)) || 1);
|
||||
const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(Number(q.size ?? 100)) || 100));
|
||||
const album = q.album && q.album !== 'all' ? q.album : null;
|
||||
if (album && !UUID_RE.test(album)) return reply.status(400).send({ error: 'invalid album id' });
|
||||
// What the shelf's search box carries: words to look for in a file's own name,
|
||||
// or the name of a tag. A share link has no search of its own, so its one
|
||||
// album is sifted here instead.
|
||||
const search = (q.q ?? '').trim().slice(0, MAX_QUERY);
|
||||
|
||||
if (server.type === 'share') {
|
||||
const res = await call(server, { path: '/shared-links/me' });
|
||||
if (!res.ok) return reply.status(502).send(refusal(res));
|
||||
const me = await jsonOf<{ assets?: RawAsset[] }>(res);
|
||||
const needle = search.toLowerCase();
|
||||
const all = (me?.assets ?? [])
|
||||
.filter((a) => a.type === 'IMAGE' || a.type === undefined)
|
||||
.filter((a) => !needle || (a.originalFileName ?? '').toLowerCase().includes(needle));
|
||||
const start = (page - 1) * size;
|
||||
return reply.send({
|
||||
ok: true,
|
||||
page,
|
||||
size,
|
||||
total: all.length,
|
||||
hasMore: start + size < all.length,
|
||||
items: all.slice(start, start + size).map(assetRow),
|
||||
});
|
||||
}
|
||||
|
||||
if (album) {
|
||||
// First try GET /albums/:id (supported across many Immich versions)
|
||||
const albRes = await call(server, { path: `/albums/${album}` });
|
||||
if (albRes.ok) {
|
||||
const albData = await jsonOf<{ assets?: RawAsset[] }>(albRes);
|
||||
if (Array.isArray(albData?.assets) && albData.assets.length > 0) {
|
||||
const allAssets = albData.assets.filter((a) => a.type === 'IMAGE' || a.type === undefined);
|
||||
const start = (page - 1) * size;
|
||||
return reply.send({
|
||||
ok: true,
|
||||
page,
|
||||
size,
|
||||
total: allAssets.length,
|
||||
hasMore: start + size < allAssets.length,
|
||||
items: allAssets.slice(start, start + size).map(assetRow),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One query, one answer. Immich reads every field it is handed as one more
|
||||
// thing an asset must be — `originalFileName` with `tagIds` is a file called
|
||||
// this *and* tagged that — so the box is read as a tag when it names one, and
|
||||
// as a file name otherwise, which keeps the page a page of one list.
|
||||
// ponytail: a query that names a tag finds only that tag's frames; fold the
|
||||
// two answers into one list if a reader ever needs both at once.
|
||||
const tagIds = search ? await tagIdsMatching(server, search) : [];
|
||||
const res = await call(server, {
|
||||
method: 'POST',
|
||||
path: '/search/metadata',
|
||||
timeout: 60_000,
|
||||
json: {
|
||||
size,
|
||||
page,
|
||||
withExif: true,
|
||||
type: 'IMAGE',
|
||||
...(album ? { albumIds: [album] } : {}),
|
||||
...(q.takenBefore ? { takenBefore: q.takenBefore } : {}),
|
||||
...(q.takenAfter ? { takenAfter: q.takenAfter } : {}),
|
||||
...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) return reply.status(502).send(refusal(res));
|
||||
const found = await jsonOf<any>(res);
|
||||
// Robust parsing across all Immich versions:
|
||||
// Immich v1: found.assets.items
|
||||
// Immich v2+: found.items
|
||||
// Raw array: Array.isArray(found)
|
||||
const rawItems: RawAsset[] = found?.assets?.items ?? found?.items ?? (Array.isArray(found) ? found : []);
|
||||
const total: number | null = found?.assets?.total ?? found?.total ?? found?.count ?? null;
|
||||
// What says another page is there: a page that came back full, or the
|
||||
// server's own word for it. `assets.total` is not the size of the library —
|
||||
// in Immich v2 it is the size of the page it came with — and read as the
|
||||
// library it made every page but one look like there was more behind it.
|
||||
const hasMore: boolean =
|
||||
rawItems.length === size ||
|
||||
(typeof found?.nextCursor === 'string' && !!found.nextCursor) ||
|
||||
(typeof found?.assets?.nextPage === 'string' && !!found.assets.nextPage) ||
|
||||
(typeof found?.nextPage === 'string' && !!found.nextPage);
|
||||
return reply.send({
|
||||
ok: true,
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
hasMore,
|
||||
items: rawItems.map(assetRow),
|
||||
});
|
||||
});
|
||||
|
||||
// The timeline buckets (years/months), so the client knows all available years and photo counts in 1 call.
|
||||
app.get('/api/immich/buckets', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const q = req.query as Record<string, string | undefined>;
|
||||
const server = pick(user, reply, q.server);
|
||||
if (!server) return;
|
||||
const size = q.size === 'YEAR' ? 'YEAR' : 'MONTH';
|
||||
const res = await call(server, { path: '/timeline/buckets', query: { size }, timeout: 15_000 });
|
||||
if (!res.ok) return reply.status(502).send(refusal(res));
|
||||
const buckets = await jsonOf<any>(res);
|
||||
return reply.send({ ok: true, buckets: Array.isArray(buckets) ? buckets : [] });
|
||||
});
|
||||
|
||||
// An Immich-generated image, streamed straight through. Always the size the
|
||||
// caller asked for: the grid asks for `thumbnail`, the stage for `preview`,
|
||||
// and only the studio ever asks for the original.
|
||||
app.get('/api/immich/thumb', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const q = req.query as Record<string, string | undefined>;
|
||||
const server = pick(user, reply, q.server);
|
||||
if (!server) return;
|
||||
if (!UUID_RE.test(q.id ?? '')) return reply.status(400).send({ error: 'invalid asset id' });
|
||||
const size = THUMB_SIZES.has(q.size ?? '') ? (q.size as string) : 'thumbnail';
|
||||
|
||||
const res = await call(server, { path: `/assets/${q.id}/thumbnail`, query: { size }, timeout: FETCH_MS });
|
||||
if (!res.ok || !res.body) return reply.status(res.status === 404 ? 404 : 502).send(refusal(res));
|
||||
reply.header('content-type', res.headers.get('content-type') ?? 'image/webp');
|
||||
// The same bytes for every visitor of this account and a day of browser
|
||||
// cache: the grid scrolls on the second look without touching Immich.
|
||||
reply.header('cache-control', 'private, max-age=86400');
|
||||
return reply.send(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
});
|
||||
|
||||
// The file the studio is about to edit, fetched once and never cached: it is
|
||||
// by far the largest thing this API ever sends.
|
||||
app.get('/api/immich/original', async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const q = req.query as Record<string, string | undefined>;
|
||||
const server = pick(user, reply, q.server);
|
||||
if (!server) return;
|
||||
if (!UUID_RE.test(q.id ?? '')) return reply.status(400).send({ error: 'invalid asset id' });
|
||||
|
||||
const res = await call(server, { path: `/assets/${q.id}/original`, timeout: FETCH_MS });
|
||||
if (!res.ok || !res.body) return reply.status(res.status === 404 ? 404 : 502).send(refusal(res));
|
||||
reply.header('content-type', res.headers.get('content-type') ?? 'application/octet-stream');
|
||||
const length = res.headers.get('content-length');
|
||||
if (length) reply.header('content-length', length);
|
||||
reply.header('cache-control', 'no-store');
|
||||
return reply.send(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
});
|
||||
|
||||
// Upload an edited photo from Studio to Immich Cloud
|
||||
app.post(
|
||||
'/api/immich/upload',
|
||||
{ bodyLimit: 50 * 1024 * 1024 },
|
||||
async (req, reply) => {
|
||||
const user = member(req, reply);
|
||||
if (!user) return;
|
||||
const q = req.query as Record<string, string | undefined>;
|
||||
const server = pick(user, reply, q.server);
|
||||
if (!server) return;
|
||||
if (server.type !== 'api') {
|
||||
return reply.status(400).send({ error: 'Upload is only supported for API-key Immich connections' });
|
||||
}
|
||||
|
||||
const body = req.body;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) {
|
||||
return reply.status(400).send({ error: 'invalid body or empty photo' });
|
||||
}
|
||||
|
||||
const rawName = (q.name ?? '').trim() || `recipescam_${Date.now()}.jpg`;
|
||||
const fileName = rawName.endsWith('.jpg') || rawName.endsWith('.jpeg') || rawName.endsWith('.png') ? rawName : `${rawName}.jpg`;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
|
||||
form.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
|
||||
form.append('deviceId', 'RecipesCam');
|
||||
form.append('fileCreatedAt', new Date().toISOString());
|
||||
form.append('fileModifiedAt', new Date().toISOString());
|
||||
form.append('isFavorite', 'false');
|
||||
|
||||
try {
|
||||
let immichRes = await fetch(new URL(`${server.url}/api/assets`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': server.key,
|
||||
},
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
if (immichRes.status === 404) {
|
||||
// Fallback for older Immich versions (< 1.106)
|
||||
const fallbackForm = new FormData();
|
||||
fallbackForm.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
|
||||
fallbackForm.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
|
||||
fallbackForm.append('deviceId', 'RecipesCam');
|
||||
fallbackForm.append('fileCreatedAt', new Date().toISOString());
|
||||
fallbackForm.append('fileModifiedAt', new Date().toISOString());
|
||||
fallbackForm.append('isFavorite', 'false');
|
||||
|
||||
immichRes = await fetch(new URL(`${server.url}/api/asset/upload`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': server.key,
|
||||
},
|
||||
body: fallbackForm,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
}
|
||||
|
||||
if (!immichRes.ok) {
|
||||
const errText = await immichRes.text().catch(() => '');
|
||||
return reply.status(immichRes.status).send({ error: 'Immich upload failed', details: errText });
|
||||
}
|
||||
|
||||
const data = await immichRes.json().catch(() => ({}));
|
||||
const assetId = (data as any)?.id;
|
||||
|
||||
// If target album is given, add to album
|
||||
const targetAlbum = q.album || (server.albums?.length === 1 ? server.albums[0] : undefined);
|
||||
if (targetAlbum && assetId) {
|
||||
try {
|
||||
await fetch(new URL(`${server.url}/api/albums/${targetAlbum}/assets`), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-api-key': server.key,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ids: [assetId] }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return reply.send({ ok: true, id: assetId, result: data });
|
||||
} catch (err: any) {
|
||||
return reply.status(502).send({ error: 'Failed to connect to Immich server', details: String(err) });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
+121
-40
@@ -1,12 +1,13 @@
|
||||
import { recipeFile } from './recipeFile';
|
||||
import { sendVerificationMail } from './mailer';
|
||||
import { placeName } from './place';
|
||||
import { immichRoutes } from './immich';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { exec, spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import {
|
||||
DATA_DIR,
|
||||
MAX_PHOTO_BYTES,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
setUserAvatar,
|
||||
setUserBlocked,
|
||||
setUserPassword,
|
||||
setUserPro,
|
||||
setUserRemoved,
|
||||
topRatedPhotos,
|
||||
updateRecipe,
|
||||
@@ -85,19 +87,36 @@ const ADMIN_EMAILS = new Set(
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
|
||||
const isAdmin = (user: Pick<User, 'email'>) => ADMIN_EMAILS.has(user.email.toLowerCase());
|
||||
|
||||
// What an account is worth. A signup proves nothing until the address it gave
|
||||
// is confirmed, so an unverified account is a guest with a name: the PRO tier,
|
||||
// its own listings and every write stay shut. Admins come from the
|
||||
// deployment's own allowlist — trusted by construction, so no letter is needed
|
||||
// and a broken relay cannot lock the operator out of their own site.
|
||||
const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user);
|
||||
// What an account is worth. Being signed in is the basic tier and nothing more
|
||||
// is required of it (see requireMember): the account has its own recipes and
|
||||
// its own photo folder. This is the proof of address, which the PRO tier still
|
||||
// carries on top. Admins come from the deployment's own allowlist — trusted by
|
||||
// construction, so no letter is needed and a broken relay cannot lock the
|
||||
// operator out of their own site.
|
||||
const isVerified = (user: Pick<User, 'email' | 'emailVerified'>) => user.emailVerified === 1 || isAdmin(user);
|
||||
|
||||
// The one date in the tier model: an account that signed up before it keeps the
|
||||
// studio it was promised, so its "Activated Pro" box is ticked by the calendar
|
||||
// rather than by the operator. Signups from the cutoff on start basic and the
|
||||
// box is theirs to tick (or the address to prove).
|
||||
const PRO_CUTOFF_MS = Date.parse('2026-12-01T00:00:00Z');
|
||||
const grandfathered = (createdAt: string) => Date.parse(createdAt) < PRO_CUTOFF_MS;
|
||||
|
||||
// The PRO tier itself: a proven address, an admin, a grandfathered signup, or a
|
||||
// grant the operator ticked in the users table. The grant only ever adds —
|
||||
// unticking an account that has already proven its address leaves it PRO,
|
||||
// because the address is still the stronger proof of the two.
|
||||
const isPro = (user: Pick<User, 'email' | 'emailVerified' | 'pro' | 'createdAt'>) =>
|
||||
isVerified(user) || user.pro === 1 || grandfathered(user.createdAt);
|
||||
|
||||
// The public shape of an account. `admin` is the allowlist's answer, so the
|
||||
// client can decide whether to offer /admin without a second round trip — and
|
||||
// the server still enforces it on every admin route below.
|
||||
// `verified` is the studio's PRO gate: true only for a proven address.
|
||||
// `verified` is the proof of address (the client offers the resend/verify
|
||||
// routes off it); `pro` is the studio's own gate, which is that proof or the
|
||||
// operator's grant.
|
||||
// `avatar` is a URL the client can drop straight into an <img>, or null when
|
||||
// the account never picked a picture. The `v` is the stored file's own name, so
|
||||
// the URL changes with the picture and can be cached hard.
|
||||
@@ -106,6 +125,7 @@ const publicUser = (user: User) => ({
|
||||
email: user.email,
|
||||
admin: isAdmin(user),
|
||||
verified: isVerified(user),
|
||||
pro: isPro(user),
|
||||
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
|
||||
});
|
||||
|
||||
@@ -130,9 +150,13 @@ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body,
|
||||
|
||||
// Photo uploads are the raw image bytes, not multipart: one file per request
|
||||
// needs no boundary parsing, so no dependency and no parser attack surface.
|
||||
app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: 'buffer' }, (_req, body, done) => {
|
||||
done(null, body);
|
||||
});
|
||||
app.addContentTypeParser(
|
||||
['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream'],
|
||||
{ parseAs: 'buffer', bodyLimit: 50 * 1024 * 1024 },
|
||||
(_req, body, done) => {
|
||||
done(null, body);
|
||||
}
|
||||
);
|
||||
|
||||
// A restore body is a whole data dir, so it is the one body far past the
|
||||
// instance limit. Buffered, not streamed: `tar` needs the archive to be
|
||||
@@ -253,17 +277,29 @@ function auth(req: FastifyRequest): User | undefined {
|
||||
return token ? sessionUser(token) : undefined;
|
||||
}
|
||||
|
||||
// The gate every personal route takes instead of `auth`. Two different
|
||||
// refusals, because the studio acts on them differently: 401 sends a guest to
|
||||
// the sign-in dialog, 403 asks a signed-in account to open its mail.
|
||||
// The gate every personal route takes instead of `auth`: the basic tier is
|
||||
// simply being signed in — recipes, the photo folder, an export of the photo
|
||||
// being edited. One refusal, because the studio acts on it the one way: 401
|
||||
// sends a guest to the sign-in dialog.
|
||||
function requireMember(req: FastifyRequest, reply: FastifyReply): User | undefined {
|
||||
const user = auth(req);
|
||||
if (!user) {
|
||||
void reply.status(401).send({ error: 'unauthorized' });
|
||||
return undefined;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// The PRO tier's own gate, on the routes that are the tier: the geocoder is the
|
||||
// one left (every other route that used to take it is the basic tier's now).
|
||||
function requirePro(req: FastifyRequest, reply: FastifyReply): User | undefined {
|
||||
const user = auth(req);
|
||||
if (!user) {
|
||||
void reply.status(401).send({ error: 'unauthorized' });
|
||||
return undefined;
|
||||
}
|
||||
if (!isVerified(user)) {
|
||||
void reply.status(403).send({ error: 'email not verified' });
|
||||
if (!isPro(user)) {
|
||||
void reply.status(403).send({ error: 'pro required' });
|
||||
return undefined;
|
||||
}
|
||||
return user;
|
||||
@@ -389,6 +425,30 @@ async function lookupGeo(ip: string): Promise<GeoPlace | null> {
|
||||
// ---- routes ----
|
||||
app.get('/api/health', async () => ({ ok: true }));
|
||||
|
||||
// Native file manager launcher (Open in window explorer)
|
||||
app.post('/api/open-explorer', async (req, reply) => {
|
||||
const b = bodyOf(req);
|
||||
if (!b || typeof b.path !== 'string') return reply.status(400).send({ ok: false, error: 'Invalid path' });
|
||||
const rawPath = b.path.trim();
|
||||
if (!rawPath) return reply.status(400).send({ ok: false, error: 'Path empty' });
|
||||
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
const winPath = rawPath.replace(/\//g, '\\');
|
||||
exec(`explorer.exe /select,"${winPath}"`);
|
||||
} else if (platform === 'darwin') {
|
||||
exec(`open -R "${rawPath}"`);
|
||||
} else {
|
||||
const dir = dirname(rawPath);
|
||||
exec(`xdg-open "${dir}"`);
|
||||
}
|
||||
return reply.send({ ok: true });
|
||||
} catch (err: any) {
|
||||
return reply.status(500).send({ ok: false, error: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
// The counter's one write. Public and unauthenticated by design: it is fired by
|
||||
// a beacon from every page, so it must never be a way to probe who is signed in
|
||||
// — it answers 204 whatever happens and echoes nothing back.
|
||||
@@ -447,17 +507,19 @@ app.post('/api/ratings', async (req, reply) => {
|
||||
// lifetime. The windows are the server's clock in UTC, so every visitor and
|
||||
// every cache agrees on which frame is today's; the week starts on Monday, the
|
||||
// ISO week the page's own copy implies. Public like the tally it is drawn from.
|
||||
// A quiet day — and a quiet Monday, when the week's window is minutes old — is
|
||||
// the normal state of an install, and an empty window must not empty the hero,
|
||||
// so the all-time bests stand in under a label of their own. Asked for only
|
||||
// when both windows are empty: a day with one vote pays nothing for it.
|
||||
const HIGHLIGHT_LIMIT = 5;
|
||||
app.get('/api/highlights', async () => {
|
||||
const now = new Date();
|
||||
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const monday = midnight - ((new Date(midnight).getUTCDay() + 6) % 7) * 86_400_000;
|
||||
return {
|
||||
highlights: {
|
||||
day: topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT),
|
||||
week: topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT),
|
||||
},
|
||||
};
|
||||
const day = topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT);
|
||||
const week = topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT);
|
||||
const ever = day.length || week.length ? [] : topRatedPhotos('', HIGHLIGHT_LIMIT);
|
||||
return { highlights: { day, week, ever } };
|
||||
});
|
||||
|
||||
app.post('/api/auth/signup', async (req, reply) => {
|
||||
@@ -496,7 +558,7 @@ app.get('/api/auth/verify', async (req, reply) => {
|
||||
// fresh code costs one of the three resends an hour, so five guesses per code
|
||||
// is the budget an attacker has either way — and the cap lives with the secret.
|
||||
app.post('/api/auth/verify-code', async (req, reply) => {
|
||||
// `auth`, not `requirePro`: the whole point of the route is the account that
|
||||
// `auth`, not `requireMember`: the whole point of the route is the account that
|
||||
// has not passed the gate yet.
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
@@ -516,7 +578,7 @@ app.post('/api/auth/verify-code', async (req, reply) => {
|
||||
const allowResend = limiter(3, 60 * 60_000);
|
||||
|
||||
app.post('/api/auth/resend-verification', async (req, reply) => {
|
||||
// `auth`, not `requirePro`: the whole point of the route is the account that
|
||||
// `auth`, not `requireMember`: the whole point of the route is the account that
|
||||
// has not passed the gate yet.
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
@@ -562,7 +624,7 @@ app.get('/api/auth/me', async (req, reply) => {
|
||||
// password is required either way, so a stolen cookie alone cannot lock the
|
||||
// owner out — and the login limiter caps guesses at it.
|
||||
app.patch('/api/auth/me', async (req, reply) => {
|
||||
// `auth`, not `requirePro`: editing your own profile is how an unverified
|
||||
// `auth`, not `requireMember`: editing your own profile is how an unverified
|
||||
// account fixes a mistyped address, so this route stays open to it.
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
@@ -599,13 +661,13 @@ app.patch('/api/auth/me', async (req, reply) => {
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
return reply.status(200).send({ recipes: listRecipes(user.id) });
|
||||
});
|
||||
|
||||
app.post('/api/recipes', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
const b = bodyOf(req);
|
||||
const payload = b && recipePayload(b);
|
||||
@@ -616,7 +678,7 @@ app.post('/api/recipes', async (req, reply) => {
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
|
||||
@@ -630,7 +692,7 @@ app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
|
||||
@@ -690,7 +752,7 @@ app.get('/api/photos', async () => ({ photos: listPhotos() }));
|
||||
// The caller's own folder — the count the studio's SAVE PHOTO shows comes from
|
||||
// here, and the admin drill-down reads the same rows through /admin/photos.
|
||||
app.get('/api/photos/mine', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
|
||||
});
|
||||
@@ -710,7 +772,7 @@ app.get('/api/place', async (req, reply) => {
|
||||
});
|
||||
|
||||
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
if (!allowUpload(String(user.id))) return tooMany(reply);
|
||||
|
||||
@@ -741,7 +803,7 @@ app.put<{ Params: { id: string } }>(
|
||||
'/api/photos/:id',
|
||||
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
|
||||
async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
|
||||
@@ -771,7 +833,7 @@ app.put<{ Params: { id: string } }>(
|
||||
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
|
||||
// under a server-generated name. The picture it replaces goes with it.
|
||||
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
if (!allowUpload(String(user.id))) return tooMany(reply);
|
||||
|
||||
@@ -847,7 +909,7 @@ app.put<{ Params: { id: string } }>(
|
||||
'/api/photos/:id/base',
|
||||
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
|
||||
async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
if (!allowUpload(String(user.id))) return tooMany(reply);
|
||||
const id = Number(req.params.id);
|
||||
@@ -872,7 +934,7 @@ app.put<{ Params: { id: string } }>(
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/photos/:id/base', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
|
||||
@@ -919,7 +981,7 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', async (req,
|
||||
// consent), and only their own row is reachable — the user_id in the WHERE is
|
||||
// the authorisation.
|
||||
app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
|
||||
@@ -934,7 +996,7 @@ app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
|
||||
// dropped when the caller owns it (or curates the whole strip), and the file
|
||||
// goes with it — `deletePhotoOf` / `deletePhoto` return the name to unlink.
|
||||
app.delete<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
const user = requireMember(req, reply);
|
||||
if (!user) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
|
||||
@@ -986,6 +1048,10 @@ app.get('/api/admin/users', async (req, reply) => {
|
||||
admin: ADMIN_EMAILS.has(u.email),
|
||||
blocked: !!u.blocked,
|
||||
removed: !!u.deletedAt,
|
||||
// The box shows the truth of `isPro`, not the stored column: an
|
||||
// allowlisted operator and a signup from before the cutoff are PRO
|
||||
// whatever the column says, and unticking them cannot take that away.
|
||||
pro: isPro(u),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -1027,8 +1093,14 @@ app.patch<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply)
|
||||
if (typeof b.removed !== 'boolean') return reply.status(400).send({ error: 'invalid removed' });
|
||||
setUserRemoved(id, b.removed);
|
||||
}
|
||||
if (b.pro !== undefined) {
|
||||
if (typeof b.pro !== 'boolean') return reply.status(400).send({ error: 'invalid pro' });
|
||||
setUserPro(id, b.pro);
|
||||
}
|
||||
const row = listUsersWithCounts().find((u) => u.id === id);
|
||||
return reply.status(200).send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt } });
|
||||
return reply
|
||||
.status(200)
|
||||
.send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt, pro: !!row && isPro(row) } });
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => {
|
||||
@@ -1097,6 +1169,15 @@ app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply
|
||||
return reply.status(200).send({ id, slots: set });
|
||||
});
|
||||
|
||||
// ---- Immich, as a second photo source ---------------------------------------
|
||||
// The LIBRARY module may carry Immich albums beside the folders on disk. The
|
||||
// browser cannot talk to Immich itself (the SPA is served with COEP require-corp
|
||||
// and an Immich key must not reach it), so the routes behind /api/immich/*
|
||||
// proxy it, read-only, with each account's own key kept in the users table.
|
||||
// Nothing here is required for the rest of the API: with no server added, the
|
||||
// dialog is the only thing that ever calls these.
|
||||
immichRoutes(app, requireMember);
|
||||
|
||||
// ---- backup / restore -----------------------------------------------------
|
||||
// The deployment's whole state is DATA_DIR: one SQLite file plus the two media
|
||||
// folders. The archive is a plain tar.gz of exactly those three, which makes
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
// Immich proxy self-check for the API. Boots the real server against a
|
||||
// throwaway DATA_DIR and a fake Immich on loopback, then walks the boundaries
|
||||
// that matter: who may reach the proxy, what a key is allowed to become, and
|
||||
// what the browser is allowed to learn about it.
|
||||
//
|
||||
// npm run test:immich (from docker/backend/)
|
||||
//
|
||||
// Node only — no test framework, no network beyond loopback. The fake Immich is
|
||||
// deliberately strict: it refuses any request that does not carry the key it
|
||||
// expects, so a route that forgets to attach one fails here rather than in the
|
||||
// LIBRARY.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
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 || 3412);
|
||||
const BASE = `http://127.0.0.1:${PORT}/api`;
|
||||
const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-immich-'));
|
||||
|
||||
const KEY = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaabcd';
|
||||
const KEY_NODL = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb1234'; // the same key without asset.download
|
||||
const SHARE_KEY = 'share-key-1234567890';
|
||||
const ALBUM_A = '11111111-1111-4111-8111-111111111111';
|
||||
const ALBUM_B = '22222222-2222-4222-8222-222222222222';
|
||||
const TAG_HOME = '33333333-3333-4333-8333-333333333333';
|
||||
const TAG_TRIP = '44444444-4444-4444-8444-444444444444';
|
||||
const assetId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes');
|
||||
const ORIGINAL = Buffer.from('II*\0fake-original-bytes');
|
||||
|
||||
// The first frame is the one tagged `Nhà`, so a search for that word has exactly
|
||||
// one answer to find.
|
||||
const asset = (n, album) => ({
|
||||
id: assetId(n),
|
||||
type: 'IMAGE',
|
||||
originalFileName: `photo-${n}.jpg`,
|
||||
fileCreatedAt: `2026-01-0${n}T10:00:00.000Z`,
|
||||
width: 4000,
|
||||
height: 3000,
|
||||
exifInfo: { fileSizeInByte: 1000 * n },
|
||||
album,
|
||||
tags: n === 1 ? TAG_HOME : n === 2 ? TAG_TRIP : null,
|
||||
});
|
||||
|
||||
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}` : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- the fake Immich --------------------------------------------------------
|
||||
// Every request is recorded, so "did the proxy attach the key, and did it ask
|
||||
// for the size it promised?" is an assertion rather than a hope.
|
||||
const seen = [];
|
||||
function startFake() {
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
const shareKey = url.searchParams.get('key');
|
||||
seen.push({
|
||||
path: url.pathname,
|
||||
size: url.searchParams.get('size'),
|
||||
apiKey: apiKey ?? null,
|
||||
shareKey: shareKey ?? null,
|
||||
key: apiKey ?? shareKey ?? null,
|
||||
range: req.headers.range ?? null,
|
||||
});
|
||||
const send = (status, payload, type = 'application/json') => {
|
||||
res.writeHead(status, { 'content-type': type });
|
||||
res.end(Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
|
||||
};
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
|
||||
const authed = apiKey === KEY || apiKey === KEY_NODL;
|
||||
// A share link is a key too, but only Immich's asset routes read it from
|
||||
// `?key=` — the album and search routes are API-key territory.
|
||||
const shareAuthed = shareKey === SHARE_KEY;
|
||||
|
||||
if (url.pathname === '/api/server/version') return send(200, { major: 3, minor: 1, patch: 0, prerelease: null });
|
||||
|
||||
if (url.pathname === '/api/shared-links/me') {
|
||||
if (shareKey !== SHARE_KEY) return send(401, { message: 'Authentication required' });
|
||||
return send(200, {
|
||||
id: 'link-1',
|
||||
album: { id: ALBUM_B, albumName: 'Album chia sẻ', assetCount: 2 },
|
||||
allowDownload: true,
|
||||
assets: [asset(5), asset(6)],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/albums') {
|
||||
if (!authed) return send(401, { message: 'Authentication required' });
|
||||
return send(200, [
|
||||
{ id: ALBUM_A, albumName: 'Nhà', assetCount: 2, albumThumbnailAssetId: assetId(1), shared: false },
|
||||
{ id: ALBUM_B, albumName: 'Du lịch', assetCount: 1, albumThumbnailAssetId: assetId(3), shared: true },
|
||||
]);
|
||||
}
|
||||
|
||||
// A tag list, and the one key that may not read it: searching by tag is the
|
||||
// one search a narrow key loses, and it has to lose it as an empty list
|
||||
// rather than as a failed search.
|
||||
if (url.pathname === '/api/tags') {
|
||||
if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' });
|
||||
if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: tag.read' });
|
||||
return send(200, [
|
||||
{ id: TAG_HOME, name: 'Nhà', value: 'nhà' },
|
||||
{ id: TAG_TRIP, name: 'du lịch', value: 'du lịch' },
|
||||
]);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/search/metadata') {
|
||||
const all = [asset(1, ALBUM_A), asset(2, ALBUM_A), asset(3, ALBUM_B)];
|
||||
let filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all;
|
||||
// The same reading the proxy makes of a search box: every field given is
|
||||
// one more thing an asset must be.
|
||||
if (Array.isArray(body.tagIds)) filtered = filtered.filter((a) => body.tagIds.includes(a.tags));
|
||||
if (typeof body.originalFileName === 'string') {
|
||||
filtered = filtered.filter((a) => a.originalFileName.includes(body.originalFileName));
|
||||
}
|
||||
const size = Number(body.size ?? 100);
|
||||
const page = Number(body.page ?? 1);
|
||||
const items = filtered.slice((page - 1) * size, page * size);
|
||||
return send(200, {
|
||||
albums: { items: [], total: 0 },
|
||||
assets: { items, total: filtered.length, count: items.length, nextPage: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' });
|
||||
|
||||
const thumb = url.pathname.match(/^\/api\/assets\/([^/]+)\/thumbnail$/);
|
||||
if (thumb) {
|
||||
if (![1, 2, 3, 5, 6].some((n) => assetId(n) === thumb[1])) return send(404, { message: 'Not found' });
|
||||
return send(200, THUMB, 'image/webp');
|
||||
}
|
||||
|
||||
const original = url.pathname.match(/^\/api\/assets\/([^/]+)\/original$/);
|
||||
if (original) {
|
||||
if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: asset.download' });
|
||||
if (req.headers.range) {
|
||||
res.writeHead(206, { 'content-type': 'image/jpeg', 'content-range': 'bytes 0-0/24' });
|
||||
return res.end(ORIGINAL.subarray(0, 1));
|
||||
}
|
||||
return send(200, ORIGINAL, 'image/jpeg');
|
||||
}
|
||||
|
||||
return send(404, { message: 'Not found' });
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })));
|
||||
}
|
||||
|
||||
// ---- one cookie jar per actor, as in test/security.mjs ----------------------
|
||||
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?.() ?? [];
|
||||
for (const line of set) {
|
||||
const value = line.split(';')[0];
|
||||
if (value.startsWith('rc_session=')) cookie = value;
|
||||
}
|
||||
const type = res.headers.get('content-type') ?? '';
|
||||
const body = type.includes('json') ? await res.json() : Buffer.from(await res.arrayBuffer());
|
||||
return { status: res.status, headers: res.headers, body };
|
||||
},
|
||||
post(path, payload) {
|
||||
return this.req(path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
put(path, payload) {
|
||||
return this.req(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
async signup(email) {
|
||||
const res = await this.post('/auth/signup', { email, password: 'supersecret1' });
|
||||
if (res.status !== 201) throw new Error(`signup for ${email} answered ${res.status}`);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fake = await startFake();
|
||||
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,
|
||||
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 {
|
||||
if ((await fetch(`${BASE}/health`)).ok) return true;
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = `http://127.0.0.1:${fake.port}`;
|
||||
|
||||
try {
|
||||
if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`);
|
||||
const stamp = Date.now();
|
||||
const user = actor();
|
||||
const other = actor();
|
||||
await user.signup(`immich${stamp}@test.local`);
|
||||
await other.signup(`immich-other${stamp}@test.local`);
|
||||
|
||||
// ---- who may reach the proxy at all --------------------------------------
|
||||
const anonymous = await fetch(`${BASE}/immich/config`);
|
||||
check('the proxy refuses a signed-out caller', anonymous.status === 401, `got ${anonymous.status}`);
|
||||
check('the proxy refuses a signed-out thumb', (await fetch(`${BASE}/immich/thumb?server=x&id=${assetId(1)}`)).status === 401);
|
||||
|
||||
// ---- the "check this key" probe -----------------------------------------
|
||||
const noKey = await user.post('/immich/probe', { url });
|
||||
check('a probe without a key is refused', noKey.status === 400 && noKey.body?.error === 'invalid', JSON.stringify(noKey.body));
|
||||
const badScheme = await user.post('/immich/probe', { url: 'file:///etc', key: KEY });
|
||||
check('a probe refuses a non-http address', badScheme.status === 400, JSON.stringify(badScheme.body));
|
||||
const dead = await user.post('/immich/probe', { url: 'http://127.0.0.1:1', key: KEY });
|
||||
check('a probe of a dead host answers unreachable', dead.body?.error === 'unreachable', JSON.stringify(dead.body));
|
||||
const wrongKey = await user.post('/immich/probe', { url, key: 'nope' });
|
||||
check('a probe with a wrong key answers auth', wrongKey.body?.error === 'auth', JSON.stringify(wrongKey.body));
|
||||
|
||||
const good = await user.post('/immich/probe', { url, key: KEY });
|
||||
check('a probe reports the server version', good.body?.version === '3.1.0', JSON.stringify(good.body?.version));
|
||||
check('a probe lists the albums the key sees', good.body?.albums?.length === 2, JSON.stringify(good.body?.albums));
|
||||
check('a probe reports the album count', good.body?.albums?.[0]?.assetCount === 2, JSON.stringify(good.body?.albums?.[0]));
|
||||
check('a probe reports download permission', good.body?.canDownload === true, JSON.stringify(good.body?.canDownload));
|
||||
check('a probe of a key without asset.download says no', (await user.post('/immich/probe', { url, key: KEY_NODL })).body?.canDownload === false);
|
||||
check('a probe stores nothing', (await user.req('/immich/config')).body?.servers?.length === 0);
|
||||
|
||||
// ---- storing a server ----------------------------------------------------
|
||||
const saved = await user.put('/immich/config', { url, key: KEY, name: 'nas' });
|
||||
const serverId = saved.body?.server?.id;
|
||||
check('a server is saved', saved.status === 200 && typeof serverId === 'string', JSON.stringify(saved.body));
|
||||
check('the saved name is kept', saved.body?.server?.name === 'nas', JSON.stringify(saved.body?.server?.name));
|
||||
check('the key comes back masked', saved.body?.server?.keyMasked === '••••abcd', JSON.stringify(saved.body?.server?.keyMasked));
|
||||
check('the key never comes back in clear', !JSON.stringify(saved.body).includes(KEY), JSON.stringify(saved.body));
|
||||
|
||||
const config = await user.req('/immich/config');
|
||||
check('the saved list suggests no address of its own', config.body?.defaultUrl === undefined, JSON.stringify(config.body?.defaultUrl));
|
||||
check('the saved server is listed with its version', config.body?.servers?.[0]?.version === '3.1.0', JSON.stringify(config.body?.servers?.[0]));
|
||||
|
||||
// A server saved without a name falls back to its host, which is what the tree
|
||||
// shows beside the node.
|
||||
const unnamed = await user.put('/immich/config', { url, key: KEY });
|
||||
check('an unnamed server is named after its host', unnamed.body?.server?.name === '127.0.0.1', JSON.stringify(unnamed.body?.server?.name));
|
||||
check('the same address twice is two servers', (await user.req('/immich/config')).body?.servers?.length === 2);
|
||||
|
||||
// ---- one account cannot touch another's servers --------------------------
|
||||
check("another account sees none of it", (await other.req('/immich/config')).body?.servers?.length === 0);
|
||||
const stolen = await other.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
|
||||
check("another account's server id is unknown", stolen.status === 404, `got ${stolen.status}`);
|
||||
const stolenDelete = await other.req(`/immich/config?id=${serverId}`, { method: 'DELETE' });
|
||||
check("another account cannot delete it", stolenDelete.status === 404, `got ${stolenDelete.status}`);
|
||||
const stolenEdit = await other.put('/immich/albums', { id: serverId, selected: [ALBUM_A] });
|
||||
check("another account cannot retick its albums", stolenEdit.status === 404, `got ${stolenEdit.status}`);
|
||||
|
||||
// ---- ticking albums, and the pasted IDs ---------------------------------
|
||||
const ticked = await user.put('/immich/albums', {
|
||||
id: serverId,
|
||||
selected: [ALBUM_A, `https://immich.example/albums/${ALBUM_B}`, ALBUM_A, 'not-a-uuid', ALBUM_B.toUpperCase()],
|
||||
});
|
||||
check(
|
||||
'pasted ids are cleaned, deduped and capped',
|
||||
JSON.stringify(ticked.body?.albums) === JSON.stringify([ALBUM_A, ALBUM_B]),
|
||||
JSON.stringify(ticked.body?.albums),
|
||||
);
|
||||
const empty = await user.put('/immich/albums', { id: serverId, selected: [] });
|
||||
check('an empty tick list is kept as "every album"', Array.isArray(empty.body?.albums) && empty.body.albums.length === 0);
|
||||
|
||||
// ---- the page of photos -------------------------------------------------
|
||||
const live = await user.req(`/immich/albums?id=${serverId}`);
|
||||
check('the live album list is filtered to the fields the tree needs', live.body?.albums?.[0]?.albumName === 'Nhà', JSON.stringify(live.body?.albums?.[0]));
|
||||
check('the live album list keeps the count', live.body?.albums?.[1]?.assetCount === 1, JSON.stringify(live.body?.albums?.[1]));
|
||||
|
||||
const albumPage = await user.req(`/immich/assets?server=${serverId}&album=${ALBUM_A}&page=1&size=10`);
|
||||
check('an album page returns that album only', albumPage.body?.items?.length === 2 && albumPage.body.total === 2, JSON.stringify(albumPage.body));
|
||||
check('a row carries what the catalogue needs', albumPage.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(albumPage.body?.items?.[0]));
|
||||
check('a short page ends the walk', albumPage.body?.hasMore === false, JSON.stringify(albumPage.body?.hasMore));
|
||||
|
||||
const allPhotos = await user.req(`/immich/assets?server=${serverId}&album=all&page=1&size=10`);
|
||||
check('no album means every photo the key sees', allPhotos.body?.items?.length === 3, JSON.stringify(allPhotos.body?.items?.length));
|
||||
const paged = await user.req(`/immich/assets?server=${serverId}&page=2&size=2`);
|
||||
check('pages divide the same list', paged.body?.items?.length === 1 && paged.body?.hasMore === false, JSON.stringify(paged.body));
|
||||
|
||||
const badAlbum = await user.req(`/immich/assets?server=${serverId}&album=nope`);
|
||||
check('a malformed album id is refused', badAlbum.status === 400, `got ${badAlbum.status}`);
|
||||
|
||||
// ---- the search box ------------------------------------------------------
|
||||
const byName = await user.req(`/immich/assets?server=${serverId}&q=photo-2`);
|
||||
check('a search reads the words as a file name', byName.body?.items?.length === 1 && byName.body?.items?.[0]?.name === 'photo-2.jpg', JSON.stringify(byName.body?.items));
|
||||
const byTag = await user.req(`/immich/assets?server=${serverId}&q=${encodeURIComponent('Nhà')}`);
|
||||
check('a search that names a tag finds that tag', byTag.body?.items?.length === 1 && byTag.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(byTag.body?.items));
|
||||
check('the tag ids it found travel as tag ids', seen.at(-1)?.path === '/api/search/metadata', JSON.stringify(seen.at(-1)));
|
||||
const noAnswer = await user.req(`/immich/assets?server=${serverId}&q=nothing-at-all`);
|
||||
check('a search that answers nothing is an empty page, not a failure', noAnswer.status === 200 && noAnswer.body?.items?.length === 0 && noAnswer.body?.hasMore === false, JSON.stringify(noAnswer.body));
|
||||
|
||||
|
||||
// ---- the pixels ---------------------------------------------------------
|
||||
const tile = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
|
||||
check('a tile streams through the proxy', tile.status === 200 && tile.body.equals(THUMB), `got ${tile.status}`);
|
||||
check('a tile keeps Immich own content type', tile.headers.get('content-type') === 'image/webp', tile.headers.get('content-type') ?? '');
|
||||
check('a tile is cacheable for a day', /max-age=86400/.test(tile.headers.get('cache-control') ?? ''), tile.headers.get('cache-control') ?? '');
|
||||
check('a tile defaults to the small size', seen.at(-1)?.size === 'thumbnail', JSON.stringify(seen.at(-1)));
|
||||
check('a tile travels with the key', seen.at(-1)?.key === KEY, JSON.stringify(seen.at(-1)));
|
||||
await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}&size=preview`);
|
||||
check('the stage size is passed through', seen.at(-1)?.size === 'preview', JSON.stringify(seen.at(-1)));
|
||||
await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}&size=../../etc`);
|
||||
check('an unknown size falls back to the small one', seen.at(-1)?.size === 'thumbnail', JSON.stringify(seen.at(-1)));
|
||||
|
||||
const badAsset = await user.req(`/immich/thumb?server=${serverId}&id=../secret`);
|
||||
check('a malformed asset id never reaches Immich', badAsset.status === 400, `got ${badAsset.status}`);
|
||||
const missingAsset = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(9)}`);
|
||||
check('a missing asset stays a 404', missingAsset.status === 404, `got ${missingAsset.status}`);
|
||||
|
||||
const original = await user.req(`/immich/original?server=${serverId}&id=${assetId(1)}`);
|
||||
check('an original streams through the proxy', original.status === 200 && original.body.equals(ORIGINAL), `got ${original.status}`);
|
||||
check('an original is never cached', original.headers.get('cache-control') === 'no-store', original.headers.get('cache-control') ?? '');
|
||||
|
||||
// The studio is the only caller of an original, so a key without
|
||||
// asset.download has to be a clear refusal rather than a broken download.
|
||||
const noDownload = await user.put('/immich/config', { url, key: KEY_NODL, name: 'reader' });
|
||||
const readerId = noDownload.body?.server?.id;
|
||||
check('a key without download is still accepted', noDownload.status === 200 && noDownload.body?.server?.canDownload === false, JSON.stringify(noDownload.body));
|
||||
const refused = await user.req(`/immich/original?server=${readerId}&id=${assetId(1)}`);
|
||||
check('its original is refused with the reason', refused.status === 502 && refused.body?.error === 'auth', JSON.stringify(refused.body));
|
||||
// The same narrow key searching by a tag name it cannot read: the 403 on the
|
||||
// tag list is an empty list of tags, not a search that came back broken.
|
||||
const narrowSearch = await user.req(`/immich/assets?server=${readerId}&q=${encodeURIComponent('Nhà')}`);
|
||||
check('a key without tag.read still searches by file name', narrowSearch.status === 200 && narrowSearch.body?.items?.length === 0, JSON.stringify(narrowSearch.body));
|
||||
|
||||
// ---- a share link is one album ------------------------------------------
|
||||
const shareProbe = await user.post('/immich/probe', { url, key: SHARE_KEY, type: 'share' });
|
||||
check('a share key probes into its own single album', shareProbe.body?.albums?.length === 1, JSON.stringify(shareProbe.body?.albums));
|
||||
check('the shared album is named from Immich', shareProbe.body?.albums?.[0]?.albumName === 'Album chia sẻ', JSON.stringify(shareProbe.body?.albums?.[0]));
|
||||
const shareSaved = await user.put('/immich/config', { url, key: SHARE_KEY, type: 'share', name: 'Chia sẻ' });
|
||||
const shareId = shareSaved.body?.server?.id;
|
||||
check('a share server is stored', shareSaved.status === 200 && typeof shareId === 'string', JSON.stringify(shareSaved.body));
|
||||
const shareAssets = await user.req(`/immich/assets?server=${shareId}&page=1&size=10`);
|
||||
check('a share key lists its album whole', shareAssets.body?.items?.length === 2, JSON.stringify(shareAssets.body));
|
||||
const shareSearch = await user.req(`/immich/assets?server=${shareId}&q=photo-5`);
|
||||
check('a share link searches its one album', shareSearch.body?.items?.length === 1 && shareSearch.body?.items?.[0]?.name === 'photo-5.jpg', JSON.stringify(shareSearch.body?.items));
|
||||
const shareTile = await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`);
|
||||
check('a share tile streams too', shareTile.status === 200 && shareTile.body.equals(THUMB), `got ${shareTile.status}`);
|
||||
check('a share request travels with ?key=', seen.at(-1)?.shareKey === SHARE_KEY, JSON.stringify(seen.at(-1)));
|
||||
check('a share key is never sent as an x-api-key header', seen.at(-1)?.apiKey === null, JSON.stringify(seen.at(-1)));
|
||||
|
||||
// ---- forgetting a server -------------------------------------------------
|
||||
const forgotten = await user.req(`/immich/config?id=${shareId}`, { method: 'DELETE' });
|
||||
check('a server can be forgotten', forgotten.status === 200 && forgotten.body?.servers?.length === 3, JSON.stringify(forgotten.body?.servers?.length));
|
||||
check('its id stops working', (await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`)).status === 404);
|
||||
const unknownDelete = await user.req('/immich/config?id=deadbeef', { method: 'DELETE' });
|
||||
check('forgetting an unknown server is a 404', unknownDelete.status === 404, `got ${unknownDelete.status}`);
|
||||
|
||||
// ---- no key ever reaches the browser ------------------------------------
|
||||
const everything = JSON.stringify([
|
||||
config.body,
|
||||
saved.body,
|
||||
(await user.req('/immich/config')).body,
|
||||
live.body,
|
||||
albumPage.body,
|
||||
]);
|
||||
check('no route echoes a key', !everything.includes(KEY) && !everything.includes(SHARE_KEY));
|
||||
check('the server log holds no key either', !serverLog.includes(KEY) && !serverLog.includes(SHARE_KEY));
|
||||
} catch (err) {
|
||||
fail++;
|
||||
console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`);
|
||||
console.log(serverLog.slice(-2000));
|
||||
} finally {
|
||||
server.kill('SIGTERM');
|
||||
fake.server.close();
|
||||
rmSync(DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -722,6 +722,37 @@ try {
|
||||
JSON.stringify(afterDelete),
|
||||
);
|
||||
|
||||
// ---- the award column on a quiet week ------------------------------------
|
||||
// Both windows are the clock's, so a vote the API cannot backdate is aged here
|
||||
// instead. Nothing has been voted on today or this week — the state the column
|
||||
// used to answer with nothing at all — and the all-time bests have to stand in.
|
||||
const aged = await user.upload(PNG, 'image/png');
|
||||
const agedId = aged.body?.photo?.id;
|
||||
await vote('10.9.9.5', `photo:${agedId}`, 4);
|
||||
{
|
||||
const db = new Database(join(DATA_DIR, 'recipescam.db'));
|
||||
db.prepare('UPDATE ratings SET at = ? WHERE key = ?').run(
|
||||
new Date(Date.now() - 30 * 86_400_000).toISOString(),
|
||||
`photo:${agedId}`,
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
const quiet = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
|
||||
check(
|
||||
'a vote older than both windows leaves them empty',
|
||||
(quiet.day ?? []).length === 0 && (quiet.week ?? []).length === 0,
|
||||
JSON.stringify(quiet),
|
||||
);
|
||||
const ever = (quiet.ever ?? []).find((r) => r.id === agedId);
|
||||
check('and the all-time bests stand in for them', ever?.avg === 4 && ever?.n === 1, JSON.stringify(ever));
|
||||
await user.req(`/photos/${agedId}`, { method: 'DELETE' });
|
||||
const nothing = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
|
||||
check(
|
||||
'with nothing ever rated the column has no frame to show',
|
||||
(nothing.ever ?? []).length === 0,
|
||||
JSON.stringify(nothing),
|
||||
);
|
||||
|
||||
// ---- 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}`);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Phân Tích Kỹ Thuật & Thuật Toán Clarity Chống Lỗi Viền "Stroke" (Halo-Free Local Contrast)
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiện Tượng Viền "Stroke" (Halo Artifact) Là Gì?
|
||||
|
||||
Khi bạn kéo thanh trượt **Clarity** lên cao ($+50$ đến $+100$) trên một thuật toán chưa hoàn thiện:
|
||||
* Quanh tán cây, mép ngọn núi, đường chân trời hoặc viền người nổi lên một **đường viền trắng sáng rực** hoặc **đường viền đen dày** như một nét vẽ viền (Stroke/Outline).
|
||||
* Ảnh trông bị gắt, nhân tạo và có cảm giác "bẩn" ở các vùng biên tương phản cao.
|
||||
|
||||
```
|
||||
MẶT CẮT BIÊN ĐỘ ÁNH SÁNG QUA ĐƯỜNG BIÊN ĐỘ TƯƠNG PHẢN (Vách đá vs Bầu trời):
|
||||
|
||||
Độ Sáng (Luminance)
|
||||
▲
|
||||
│ [Overshoot: Viền trắng sáng rực]
|
||||
│ ┌───┐
|
||||
1.0 ├───────────────────────────┘ │ (Bầu trời sáng)
|
||||
│ │
|
||||
│ ┌───────────┘
|
||||
0.2 ├──────┐ │ (Vách đá tối)
|
||||
│ └───┐ │
|
||||
│ └────────┘
|
||||
0.0 ┴─────────────────────────────────────────► Không gian (X)
|
||||
[Undershoot: Viền đen sẫm]
|
||||
```
|
||||
|
||||
### Nguyên nhân toán học cốt lõi: Dùng sai bộ lọc làm mờ (Linear Gaussian / Box Filter)
|
||||
Thuật toán tăng nét truyền thống (Unsharp Masking - USM) phân rã ảnh theo công thức:
|
||||
|
||||
$$\text{Detail} = I - \text{GaussianBlur}(I, \sigma)$$
|
||||
|
||||
$$I_{\text{out}} = I + k \times \text{Detail}$$
|
||||
|
||||
1. **Bộ lọc Gaussian làm mờ "mù quáng":** Nó tính trung bình trọng số của tất cả các pixel lân cận mà **không quan tâm** điểm ảnh đó thuộc về vách đá đen hay bầu trời sáng.
|
||||
2. **Hệ quả tại đường biên (Step Edge):**
|
||||
* Về phía bầu trời: Gaussian kéo cả giá trị tối của vách đá vào $\implies$ Giá trị mờ thấp hơn thực tế $\implies$ Khi lấy $I - \text{Blur}$ sẽ sinh ra một giá trị dương rất lớn $\implies$ **Tạo viền trắng rực (Overshoot)**.
|
||||
* Về phía vách đá: Gaussian kéo giá trị sáng của bầu trời vào $\implies$ Giá trị mờ cao hơn thực tế $\implies$ Phép trừ tạo ra giá trị âm sâu $\implies$ **Tạo viền đen đậm (Undershoot)**.
|
||||
3. Vì Clarity tác động lên dải bán kính trung bình (Meso-radius: $15\text{px} - 40\text{px}$), vùng giao thoa này rộng ra cả chục pixel, tạo thành một "vệt nét viền" dày cộp mắt thường nhìn thấy rõ rệt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phân Định Rõ: Sharpening vs Clarity vs Contrast
|
||||
|
||||
Nhiều nhà phát triển thường nhầm lẫn giữa ba khái niệm xử lý tương phản:
|
||||
|
||||
```
|
||||
[Micro-Frequency] ───────────── [Meso-Frequency] ───────────── [Macro-Frequency]
|
||||
Bán kính: 1px - 2px Bán kính: 15px - 40px Bán kính: Toàn bức ảnh
|
||||
◄── SHARPENING ──► ◄──── CLARITY ────► ◄── CONTRAST (S-Curve) ──►
|
||||
Tăng chi tiết sợi tóc, Tăng khối 3D, độ nổi rãnh Đẩy toàn bộ ảnh: Trắng sáng
|
||||
vân vải, gai lá. đá, mây trời dải trung tính. hơn, Đen tối hơn (Dễ cháy/bết).
|
||||
```
|
||||
|
||||
| Tiêu chí so sánh | Sharpening (Độ sắc nét) | Contrast (Độ tương phản) | Clarity (Độ trong khối) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Vùng tác động** | Chi tiết tần số rất cao (Cạnh $1\text{px} - 2\text{px}$). | Toàn cục (Global Tone Curve). | Chi tiết tần số trung bình (Meso-Contrast). |
|
||||
| **Mặt nạ kiểm soát** | Không giới hạn dải sáng. | Đẩy cong cả dải Highlight & Shadow. | **Chỉ tác động vào dải Midtones ($20\% - 80\%$).** |
|
||||
| **Nguy cơ lỗi** | Nhiễu hạt (Noise/Grain). | Cháy sáng (Clipping) & bết đen. | Viền sáng/tối nhân tạo (Haloing/Stroke). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Bí Quyết Của Lightroom: Triệt Tiêu Viền Stroke Như Thế Nào?
|
||||
|
||||
Từ phiên bản Process Version 2012 (PV2012) trở đi, Adobe đã thay thế hoàn toàn thuật toán Unsharp Masking trong thanh trượt Clarity bằng **Bộ lọc bảo toàn góc cạnh (Edge-Preserving Filter)** kết hợp mô hình **Laplacian Pyramid đa tỉ lệ**:
|
||||
|
||||
```
|
||||
[Ảnh Gốc Input I]
|
||||
│
|
||||
▼
|
||||
[Bộ Lọc Bảo Toàn Cạnh: Guided Filter / Bilateral Filter]
|
||||
│
|
||||
┌──────────────────┴──────────────────┐
|
||||
▼ ▼
|
||||
[Base Layer: Khối nền] [Detail Layer: Vân chi tiết]
|
||||
(Mịn bề mặt, nhưng GIỮ NGUYÊN Detail = I - Base
|
||||
độ sắc của các góc cạnh cứng) (Chỉ chứa vân nổi lăn tăn,
|
||||
│ HOÀN TOÀN KHÔNG CHỨA CẠNH CỨNG)
|
||||
│ │
|
||||
│ ▼
|
||||
│ [Nhân Hệ Số Clarity + Mask]
|
||||
│ Detail' = Detail * (1 + k * MidMask)
|
||||
│ │
|
||||
└──────────────────┬──────────────────┘
|
||||
▼
|
||||
[Reconstructed: Base + Detail']
|
||||
│
|
||||
▼
|
||||
[Kết Quả: Khối Nổi 3D Sâu,
|
||||
100% KHÔNG CÓ VIỀN HALO/STROKE]
|
||||
```
|
||||
|
||||
### 3 Trụ Cột Kỹ Thuật Của Lightroom
|
||||
|
||||
#### A. Khái niệm Edge-Preserving Smoothing (Làm mờ không băng qua cạnh)
|
||||
* Bộ lọc (như *Guided Filter* hoặc *Domain Transform*) khi quét qua tán cây trên nền trời sẽ nhận diện: chênh lệch sáng giữa cây và trời là một "bức tường" (Hard Edge).
|
||||
* Nó làm mờ tán cây nội bộ, làm mờ bầu trời nội bộ, nhưng **dừng lại ở chân tường**, không trộn lẫn điểm ảnh giữa hai bên.
|
||||
* Vì `Base Layer` giữ nguyên vẹn cạnh cứng, nên `Detail Layer` ($I - \text{Base}$) tại đường biên vách núi/bầu trời sẽ có giá trị bằng $0$.
|
||||
* Khi bạn tăng Clarity lên $+100$, hệ số khuếch đại nhân với $0$ vẫn là $0 \implies$ **Hoàn toàn triệt tiêu viền Halo**.
|
||||
|
||||
#### B. Khóa hai đầu (Midtone Luminance Weighting)
|
||||
Clarity không can thiệp vào điểm đen tuyệt đối hay vùng mây trắng tinh:
|
||||
|
||||
$$M(L) = \sin(\pi \times L) \quad \text{hoặc} \quad M(L) = 4.0 \times L \times (1.0 - L)$$
|
||||
|
||||
* Khi $L = 0.0$ (Black point) $\implies M = 0$.
|
||||
* Khi $L = 1.0$ (White point) $\implies M = 0$.
|
||||
* Khi $L = 0.5$ (Midtone) $\implies M = 1.0$ (Tác động cực đại).
|
||||
|
||||
#### C. Xử lý trên không gian tri giác (Perceptual Luminance)
|
||||
Clarity chỉ thao tác trên thành phần năng lượng ánh sáng (Luminance), sau đó co giãn đều cả 3 kênh RGB theo tỉ lệ. Nếu cộng thẳng vào từng kênh RGB độc lập, màu sắc sẽ bị biến dạng (chuyển sang màu vàng neon hoặc xanh bùn).
|
||||
|
||||
---
|
||||
|
||||
## 4. Mô Hình Toán Học Triển Khai Cho Web UI
|
||||
|
||||
Để đạt tốc độ $60\text{ fps}$ trên trình duyệt, ta sử dụng **Fast Guided Filter** (hoặc xấp xỉ Bilateral 2-pass):
|
||||
|
||||
### Bước 1: Trích xuất Perceptual Luminance trong không gian Linear
|
||||
$$L = 0.2126 \times R_{\text{linear}} + 0.7152 \times G_{\text{linear}} + 0.0722 \times B_{\text{linear}}$$
|
||||
|
||||
### Bước 2: Tách lớp Base và Detail
|
||||
Cho một texture $I_{\text{base}}$ được làm mờ bằng bộ lọc bảo toàn cạnh:
|
||||
|
||||
$$\text{Detail} = L - L_{\text{base}}$$
|
||||
|
||||
### Bước 3: Áp dụng hàm nén phi tuyến (Tone Compression Curve)
|
||||
Để vùng tối sâu và vùng sáng gắt không bị bão hòa, lượng chi tiết bù thêm được điều chế:
|
||||
|
||||
$$L_{\text{new}} = L + \text{Clarity} \times \text{Detail} \times \left(4.0 \times L_{\text{base}} \times (1.0 - L_{\text{base}})\right)$$
|
||||
|
||||
### Bước 4: Tái cấu trúc RGB
|
||||
$$\text{Scale} = \frac{L_{\text{new}} + \epsilon}{L + \epsilon} \implies \text{RGB}_{\text{out}} = \text{RGB}_{\text{in}} \times \text{Scale}$$
|
||||
|
||||
---
|
||||
|
||||
## 5. Mã Nguồn WebGL Fragment Shader (Halo-Free Clarity)
|
||||
|
||||
Shader này nhận vào 2 texture:
|
||||
1. `u_image`: Ảnh gốc.
|
||||
2. `u_base_texture`: Ảnh đã qua bộ lọc cạnh (chạy qua Box Filter kích thước lớn sau khi downsample $4\times$, hoặc Guided Filter Pass).
|
||||
|
||||
```glsl
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D u_image; // Ảnh gốc
|
||||
uniform sampler2D u_base_texture; // Base layer từ Edge-Preserving Filter
|
||||
uniform float u_clarity_amount; // Slider từ -1.0 đến +1.0 (-100 đến +100 trên UI)
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
// 1. Chuyển đổi sRGB sang Linear Light
|
||||
vec3 sRGBToLinear(vec3 c) {
|
||||
return pow(c, vec3(2.2));
|
||||
}
|
||||
|
||||
// 2. Chuyển đổi Linear sang sRGB
|
||||
vec3 linearTosRGB(vec3 c) {
|
||||
return pow(c, vec3(1.0 / 2.2));
|
||||
}
|
||||
|
||||
// 3. Tính Perceptual Luminance Rec.709
|
||||
float getLuminance(vec3 c) {
|
||||
return dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 srcColor = texture2D(u_image, v_texCoord);
|
||||
vec4 baseColor = texture2D(u_base_texture, v_texCoord);
|
||||
|
||||
vec3 linearOrig = sRGBToLinear(srcColor.rgb);
|
||||
vec3 linearBase = sRGBToLinear(baseColor.rgb);
|
||||
|
||||
float lumOrig = getLuminance(linearOrig);
|
||||
float lumBase = getLuminance(linearBase);
|
||||
|
||||
// BƯỚC 1: Tách lớp High/Medium Frequency Detail
|
||||
float detail = lumOrig - lumBase;
|
||||
|
||||
// BƯỚC 2: Mặt nạ hình chuông (Bell Curve) khóa chặn 2 đầu
|
||||
// Đỉnh cực đại tại Luminance = 0.5 (Midtones), triệt tiêu về 0 tại Blacks & Whites
|
||||
float midtoneWeight = 4.0 * lumBase * (1.0 - lumBase);
|
||||
midtoneWeight = clamp(midtoneWeight, 0.0, 1.0);
|
||||
|
||||
// BƯỚC 3: Khuếch đại hoặc làm dịu Detail (Clarity)
|
||||
// Dùng hàm nén mềm (soft-knee) để tránh clipping khi đẩy Clarity quá cao
|
||||
float enhancedDetail = detail * (1.0 + u_clarity_amount * 1.8 * midtoneWeight);
|
||||
|
||||
// Tính toán Luminance mới
|
||||
float newLum = lumBase + enhancedDetail;
|
||||
newLum = clamp(newLum, 0.0, 1.0);
|
||||
|
||||
// BƯỚC 4: Tái cấu trúc RGB theo tỷ lệ Luminance mới để giữ nguyên màu sắc
|
||||
float scale = (newLum + 0.0001) / (lumOrig + 0.0001);
|
||||
vec3 result = linearOrig * scale;
|
||||
|
||||
// Giữ an toàn dải giá trị [0.0, 1.0]
|
||||
result = clamp(result, 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(linearTosRGB(result), srcColor.a);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Cách Tạo Texture `u_base_texture` Siêu Tốc Trên Web ($60\text{ fps}$)
|
||||
|
||||
Nhiều người e ngại Edge-Preserving Filter quá nặng cho trình duyệt. Dưới đây là kiến trúc pipeline xử lý nhẹ nhàng trong 1–2 mili-giây:
|
||||
|
||||
```
|
||||
[Ảnh Gốc (Full Size: 4K)]
|
||||
│
|
||||
▼ (Blit Downsample 1/4)
|
||||
[FrameBuffer Nhỏ (1080p hoặc 720p)]
|
||||
│
|
||||
▼ (Chạy 2-Pass Separable Guided/Bilateral Filter trên phân giải nhỏ)
|
||||
[Base Texture Thu Nhỏ]
|
||||
│
|
||||
▼ (Bilinear Hardware Upsample tự động của GPU khi texture2D)
|
||||
[Đưa vào Shader Clarity ở Mục 5]
|
||||
```
|
||||
|
||||
1. **Downsample trước khi lọc:** Làm mờ bán kính $30\text{px}$ trên ảnh $4\text{K}$ tương đương bán kính $7\text{px}$ trên ảnh $1/4$ độ phân giải. Giảm được **$94\%$ khối lượng tính toán**.
|
||||
2. **Upsample tự động qua Texture Sampler:** Thuộc tính `gl.LINEAR` của WebGL sẽ tự động nội suy mịn màng khi upscale texture `u_base_texture` trở lại kích thước đầy đủ.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bảng Tự Kiểm Soát (Self-Check Checklist)
|
||||
|
||||
1. **Không còn đường viền Stroke:** Phóng to $200\%$ ở mép tán cây/đỉnh núi tiếp giáp với mây trời. Nếu không xuất hiện vệt viền trắng phát sáng hoặc viền đen dày, bạn đã thành công.
|
||||
2. **Bầu trời không bị tăng nhiễu:** Khi kéo Clarity $+100$, bầu trời phẳng không được phép nổi hạt cát li ti (nhờ mặt nạ Midtone Weight đã dập tắt tác động ở dải sáng $> 0.85$).
|
||||
3. **Màu da không bị ố vàng/cam:** Việc chuẩn hóa kênh màu thông qua Perceptual Luminance Scale giúp khuôn mặt người giữ nguyên sắc độ tự nhiên, không bị biến dạng sắc thái màu.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Phân Tích Kỹ Thuật & Thuật Toán Tối Ưu Thanh Trượt "Blacks" (Lightroom vs Web UI)
|
||||
|
||||
---
|
||||
|
||||
## 1. So Sánh Bản Chất: "Shadows" Khác Gì "Blacks"?
|
||||
|
||||
Rất nhiều nhà phát triển nhầm lẫn giữa hai thanh trượt **Shadows** và **Blacks**:
|
||||
|
||||
```
|
||||
[0% Luminance] ─────────────────────────────────────────── [100% Luminance]
|
||||
|◄───────── BLACKS (0% - 15%) ─────────►|
|
||||
|◄──────────── SHADOWS (10% - 45%) ────────────►|
|
||||
|◄── MIDTONES / HIGHLIGHTS ──►|
|
||||
```
|
||||
|
||||
| Tiêu chí | Thanh trượt Shadows | Thanh trượt Blacks (Chủ đề bài viết) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Vùng tác động** | Vùng bóng đổ rộng ($10\% - 45\%$ Luminance). | Chóp chân đồ thị histogram ($0\% - 15\%$ Luminance). |
|
||||
| **Bản chất xử lý** | **Local Tone Mapping** (Cần lọc cạnh Guided/Bilateral filter để giữ chi tiết vi mô). | **Global Tone Curve / Endpoint Remapping** (Tái cấu trúc điểm chân histogram & độ dốc Toe). |
|
||||
| **Mục đích** | Cứu chi tiết mảng tối của chủ thể (tán cây, vách đá). | Thiết lập chiều sâu tuyệt đối (Depth), độ trong trẻo (Clarity) và điểm neo đen của ảnh. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Chẩn Đoán Lỗi Kỹ Thuật Ở Ảnh 2 (Ứng Dụng Tự Code)
|
||||
|
||||
Quan sát Ảnh 2, có thể nhận diện ngay 3 sai sót cốt lõi trong công thức toán học:
|
||||
|
||||
### A. Lỗi nâng sàn tuyệt đối (Pedestal / Offset Lift)
|
||||
* **Hiện tượng:** Toàn bộ bức ảnh như bị phủ một lớp sương mù màu xám đục (Milky / Faded look).
|
||||
* **Nguyên nhân:** Thuật toán hiện tại đang cộng trực tiếp một hằng số vào kênh màu:
|
||||
$$\text{Color}_{out} = \text{Color}_{in} + \text{BlackOffset}$$
|
||||
Phép tính này biến điểm đen tuyệt đối ($0.0$) thành màu xám ($0.15 - 0.25$), làm mất hoàn toàn độ sâu trường ảnh.
|
||||
|
||||
### B. Lan sang dải Midtones quá mức (Overshoot Falloff)
|
||||
* **Hiện tượng:** Mặt nước, thân thác nước và các vùng trung tính (Midtones) bị bạc màu và mất tương phản rõ rệt.
|
||||
* **Nguyên nhân:** Mặt nạ trọng số không dốc đủ mạnh hoặc dùng hàm suy giảm tuyến tính, khiến các giá trị độ sáng tới $40\% - 50\%$ vẫn bị đội lên.
|
||||
|
||||
### C. Triệt tiêu độ bão hòa do thao tác sai không gian màu (Saturation Collapse)
|
||||
* Khi tăng Luminance của vùng cực tối mà không bù trừ tỷ lệ Chroma trong không gian màu tri giác, màu xanh của rêu đá và cây cối bị biến thành màu xám bùn.
|
||||
|
||||
---
|
||||
|
||||
## 3. Bí Quyết Của Lightroom Khi Tăng "Blacks +100"
|
||||
|
||||
Ở Ảnh 3 (Lightroom), dù Blacks đã kéo kịch trần $+100$:
|
||||
1. **Không hề có màng sương xám:** Lightroom giữ nguyên điểm $0.0$ tuyệt đối hoặc mở rộng dải chân (Toe Expansion) theo hàm mũ mượt mà, điểm đen được kéo giãn có kiểm soát chứ không bị nhấc bổng lên như một tấm màng phẳng.
|
||||
2. **Bảo tồn tương phản vi mô (Micro-Contrast):** Dải chuyển giữa vùng tối sâu ($0\% - 5\%$) lên vùng tối vừa ($10\% - 20\%$) được làm dốc mềm (Soft Knee Curve), giữ được độ nổi khối của vách núi và mặt nước.
|
||||
3. **Giới hạn trần nghiêm ngặt:** Tại mốc $\text{Luminance} \approx 0.2$, tác động của thanh trượt Blacks giảm hoàn toàn về $0.0$. Vùng thác nước trắng và bầu trời không đổi 1 giá trị số nào.
|
||||
|
||||
---
|
||||
|
||||
## 4. Mô Hình Toán Học Đề Xuất Cho Web UI
|
||||
|
||||
Thuật toán chuẩn gồm 3 bước xử lý trên không gian ánh sáng tuyến tính (Linear Space):
|
||||
|
||||
### Bước 1: Hàm trọng số Toe (Toe Weighting Function)
|
||||
Sử dụng đường cong Hermite suy giảm bậc cao để khóa chặt vùng tác động dưới $20\%$:
|
||||
|
||||
$$W(L) = \text{clamp}\left(1.0 - \frac{L}{L_{threshold}},\, 0.0,\, 1.0\right)^3 \quad (\text{với } L_{threshold} \approx 0.18)$$
|
||||
|
||||
### Bước 2: Kéo giãn dải tối không phá hủy điểm 0 (Non-linear Toe Lift)
|
||||
Thay vì phép cộng $\text{Color} + \Delta$, ta sử dụng phép biến đổi lũy thừa đối xứng (Power Curve Remapping):
|
||||
|
||||
$$L_{lifted} = L + \text{Amount} \times W(L) \times \left(\sqrt{L} - L\right)$$
|
||||
|
||||
* Khi $L = 0.0 \implies \sqrt{0} - 0 = 0$ (Điểm đen tuyệt đối không bị biến thành màu xám đục).
|
||||
* Với $0 < L < 0.15 \implies \sqrt{L} > L$ (Vùng bóng tối sâu được giải phóng chi tiết cực kỳ tự nhiên).
|
||||
|
||||
### Bước 3: Giữ gìn tỷ lệ màu sắc (Chroma Preservation)
|
||||
Áp dụng tỷ lệ tăng sáng của độ chói lên trực tiếp các kênh RGB:
|
||||
|
||||
$$\text{Scale} = \frac{L_{lifted} + \epsilon}{L + \epsilon} \implies \text{RGB}_{out} = \text{RGB}_{in} \times \text{Scale}$$
|
||||
|
||||
---
|
||||
|
||||
## 5. Mã Nguồn WebGL Shader Mẫu (Fragment Shader GLSL)
|
||||
|
||||
Bạn có thể thay thế Shader hiện tại bằng đoạn mã sau. Shader này chạy trực tiếp trong 1 Single Pass, tối ưu hóa $60\text{ fps}$ cho WebGL:
|
||||
|
||||
```glsl
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D u_image;
|
||||
uniform float u_black_amount; // Giá trị từ -1.0 đến +1.0 (-100 đến +100 trên UI)
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
// 1. Chuyển đổi sRGB sang Linear
|
||||
vec3 sRGBToLinear(vec3 c) {
|
||||
return pow(c, vec3(2.2));
|
||||
}
|
||||
|
||||
// 2. Chuyển đổi Linear sang sRGB
|
||||
vec3 linearTosRGB(vec3 c) {
|
||||
return pow(c, vec3(1.0 / 2.2));
|
||||
}
|
||||
|
||||
// 3. Tính Perceptual Luminance chuẩn Rec.709
|
||||
float getLuminance(vec3 c) {
|
||||
return dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 texColor = texture2D(u_image, v_texCoord);
|
||||
vec3 linearColor = sRGBToLinear(texColor.rgb);
|
||||
|
||||
float lum = getLuminance(linearColor);
|
||||
|
||||
// Ngưỡng tối đa vùng Blacks tác động (khoảng 18% ánh sáng tuyến tính)
|
||||
const float BLACKS_THRESHOLD = 0.18;
|
||||
|
||||
// Trọng số suy giảm mượt (Bậc 3 Hermite Falloff)
|
||||
float t = clamp(lum / BLACKS_THRESHOLD, 0.0, 1.0);
|
||||
float weight = (1.0 - t) * (1.0 - t) * (1.0 - t);
|
||||
|
||||
vec3 result = linearColor;
|
||||
|
||||
if (u_black_amount > 0.0) {
|
||||
// --- TRƯỜNG HỢP NÂNG BLACKS (+0 ĐẾN +100) ---
|
||||
// Sử dụng hàm căn bậc hai có trọng số để nâng dải tối mà KHÔNG làm xám điểm đen
|
||||
float expansion = sqrt(lum) - lum;
|
||||
|
||||
// Điều chỉnh hệ số nâng dựa trên slider
|
||||
float liftedLum = lum + (u_black_amount * 0.7) * weight * expansion;
|
||||
|
||||
// Khử nhiễu sàn: Giữ tỷ lệ RGB theo độ chói mới để tránh lệch sắc thái
|
||||
float scale = (liftedLum + 0.0001) / (lum + 0.0001);
|
||||
result = linearColor * scale;
|
||||
|
||||
// Bù nhẹ độ bão hòa (Saturation Recovery) theo lượng Black được mở
|
||||
float satBoost = 1.0 + (weight * u_black_amount * 0.25);
|
||||
result = mix(vec3(liftedLum), result, satBoost);
|
||||
|
||||
} else if (u_black_amount < 0.0) {
|
||||
// --- TRƯỜNG HỢP ÉP BLACKS (-0 ĐẾN -100: Làm sâu thêm điểm tối) ---
|
||||
float crushFactor = 1.0 + (u_black_amount * weight * 0.85);
|
||||
result = linearColor * max(crushFactor, 0.0);
|
||||
}
|
||||
|
||||
// Bảo vệ dải giá trị an toàn [0.0, 1.0]
|
||||
result = clamp(result, 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(linearTosRGB(result), texColor.a);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Bảng Tự Kiểm Soát (Self-Check Checklist)
|
||||
|
||||
1. **Kiểm tra độ chói điểm $0.0$:** Nhập một pixel đen tuyền $(0, 0, 0)$ qua hàm, giá trị đầu ra **bắt buộc** phải là $(0, 0, 0)$. Nếu ra $(0.1, 0.1, 0.1)$ tức là thuật toán vẫn đang bị lỗi Offset.
|
||||
2. **Không áp dụng phép cộng trực tiếp:** Mọi thao tác nâng vùng tối đều phải là hàm tỷ lệ nhân (`linearColor * scale`) thay vì cộng `linearColor + delta`.
|
||||
3. **Thao tác trên dải Linear Light:** Đảm bảo toàn bộ phép tính lũy thừa và căn bậc hai diễn ra sau khi đã giải mã Gamma $2.2$.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Phân Tích Kỹ Thuật & Giải Pháp Tối Ưu Tính Năng "Shadow Recovery" Cho Ứng Dụng Web UI
|
||||
|
||||
---
|
||||
|
||||
## 1. Chẩn Đoán Lỗi Kỹ Thuật Ở Ảnh 2 (Ứng Dụng Hiện Tại)
|
||||
|
||||
So sánh giữa **Ảnh 2** (app tự code) và **Ảnh 3** (Lightroom):
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------------------+
|
||||
| Hiện tượng ở Ảnh 2 | Nguyên nhân kỹ thuật cốt lõi |
|
||||
+--------------------------------+--------------------------------------------------+
|
||||
| Vùng tối biến thành mảng xám đục| Cộng giá trị tuyến tính vào sRGB (Không gian màu |
|
||||
| (Milky / Foggy haze) | phi tuyến, nâng cả Black point tuyệt đối lên). |
|
||||
+--------------------------------+--------------------------------------------------+
|
||||
| Đường phân tách loang lổ, gãy nét| Dùng ngưỡng cứng (Hard Threshold) hoặc hàm bước |
|
||||
| (Posterization / Color Banding)| (Step function) thay vì mặt nạ suy giảm mượt mà. |
|
||||
+--------------------------------+--------------------------------------------------+
|
||||
| Mất hoàn toàn khối & vân chi | Áp dụng phép biến đổi toàn cục (Global Operator) |
|
||||
| tiết (Mặt núi, vách đá, tán cây)| thay vì tách lớp tương phản cục bộ (Local Contrast)|
|
||||
+--------------------------------+--------------------------------------------------+
|
||||
| Cây cối đổi màu bùn, mất sắc độ| Thay đổi Luminance nhưng không bù Saturation |
|
||||
| (Chroma wash-out) | hoặc thao tác sai trên từng kênh R, G, B độc lập.|
|
||||
+--------------------------------+--------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Bí Quyết Của Lightroom: Cơ Chế "Shadow Recovery" Chuyên Nghiệp
|
||||
|
||||
Lightroom **không** dùng công thức đơn giản như `RGB = RGB + shadow * mask`. Thuật toán của Adobe dựa trên 3 trụ cột:
|
||||
|
||||
### A. Tách lớp đa tỉ lệ (Multi-Scale Tone Decomposition)
|
||||
Thay vì nâng sáng trực tiếp từng điểm ảnh, ảnh được phân rã thành:
|
||||
$$\text{Image} = \text{Base Layer (Ánh sáng tổng thể)} \times \text{Detail Layer (Vân bề mặt & Góc cạnh)}$$
|
||||
|
||||
* **Base Layer:** Được trích xuất bằng bộ lọc bảo toàn góc cạnh (**Edge-Preserving Filter** như *Guided Filter* hoặc *Bilateral Filter*).
|
||||
* Khi kéo **Shadow +100**: Thuật toán chỉ nâng độ sáng trên **Base Layer**, trong khi giữ nguyên hoặc khuếch đại nhẹ **Detail Layer**. Nhờ đó, các rãnh đá, tán lá và gợn sóng giữ nguyên độ sắc nét và chiều sâu.
|
||||
|
||||
### B. Không gian màu tính toán (Scene-Referred Linear Space)
|
||||
* Ảnh sRGB trên web có sẵn Gamma nén ($EOM \approx 2.2$). Mọi phép tính phải đưa về **Linear Light** trước khi thao tác, sau đó mới ánh xạ ngược lại sRGB:
|
||||
$$I_{linear} = (I_{sRGB})^{2.2} \quad \xrightarrow{\text{Xử lý}} \quad I_{out} = (I_{processed})^{1/2.2}$$
|
||||
|
||||
### C. Khóa điểm đen (Anchor Black Point) & Bù trừ bão hòa (Chroma Boost)
|
||||
* Giữ điểm đen ($0.0$) cố định để ảnh không bị bạc nhược (milky).
|
||||
* Hiện tượng sinh lý thị giác (*Hunt Effect*): Khi vùng tối sáng lên, mắt người sẽ thấy nó nhạt hơn thực tế. Lightroom tự động tăng nhẹ độ bão hòa màu sắc (Saturation) tỉ lệ thuận với lượng Shadow được kéo lên.
|
||||
|
||||
---
|
||||
|
||||
## 3. Kiến Trúc Thuật Toán Đề Xuất Cho Web UI (WebGL/GLSL)
|
||||
|
||||
Để chạy mượt mà $60\text{ fps}$ trên trình duyệt, quy trình xử lý tối ưu gồm 2 Pass Shader:
|
||||
|
||||
```
|
||||
[Ảnh Đầu Vào sRGB]
|
||||
│
|
||||
▼ (Pass 1)
|
||||
[Chuyển sang Linear Light] ───> [Tạo Base Layer bằng Fast Guided Filter / Box Blur]
|
||||
│
|
||||
┌────────────────────────────────────┘
|
||||
▼ (Pass 2: Composite & Tone Mapping Shader)
|
||||
[1. Tính Detail Layer: Detail = Input / (Base + ε)]
|
||||
[2. Tính Hệ số Shadow Mask: w = SmoothFalloff(Base)]
|
||||
[3. Nâng Base Layer: Base' = Base * (1.0 + ShadowAmount * Curve(Base))]
|
||||
[4. Khôi phục chi tiết: Reconstructed = Base' * Detail]
|
||||
[5. Bù trừ Saturation trên không gian OKLab / HSV]
|
||||
[6. Chuyển ngược về sRGB Output]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mã Nguồn Shader Mẫu (Fragment Shader GLSL)
|
||||
|
||||
Đoạn Shader WebGL dưới đây có thể tích hợp trực tiếp vào Canvas WebGL của bạn để khắc phục hoàn toàn lỗi bết màu:
|
||||
|
||||
```glsl
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D u_texture; // Ảnh gốc
|
||||
uniform sampler2D u_blur_texture; // Ảnh đã qua lọc mờ (Base layer từ Guided Filter / Gaussian)
|
||||
uniform float u_shadow_amount; // Giá trị từ 0.0 đến 1.0 (tương ứng +0 đến +100)
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
// 1. Chuyển sRGB sang Tuyến tính (Linear)
|
||||
vec3 sRGBToLinear(vec3 c) {
|
||||
return pow(c, vec3(2.2));
|
||||
}
|
||||
|
||||
// 2. Chuyển Tuyến tính sang sRGB
|
||||
vec3 linearTosRGB(vec3 c) {
|
||||
return pow(c, vec3(1.0 / 2.2));
|
||||
}
|
||||
|
||||
// 3. Tính độ sáng tri giác (Perceptual Luminance)
|
||||
float getLuminance(vec3 c) {
|
||||
return dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
// 4. Đường cong suy giảm mượt (Smooth Shadow Mask)
|
||||
// Chỉ tác động mạnh vào vùng tối (0.05 - 0.4), không đụng vào Highlight (>0.6) và khóa điểm đen
|
||||
float getShadowWeight(float lum) {
|
||||
// Khóa điểm đen tuyệt đối để tránh màng sương đục (milky haze)
|
||||
float blackAnchor = smoothstep(0.0, 0.08, lum);
|
||||
|
||||
// Suy giảm mượt về 0 khi tiến tới vùng sáng
|
||||
float highlightFalloff = 1.0 - smoothstep(0.2, 0.65, lum);
|
||||
|
||||
return blackAnchor * highlightFalloff;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 srcColor = texture2D(u_texture, v_texCoord);
|
||||
vec4 baseColor = texture2D(u_blur_texture, v_texCoord);
|
||||
|
||||
// Chuyển sang không gian ánh sáng tuyến tính
|
||||
vec3 I_orig = sRGBToLinear(srcColor.rgb);
|
||||
vec3 I_base = sRGBToLinear(baseColor.rgb);
|
||||
|
||||
float baseLum = getLuminance(I_base);
|
||||
float origLum = getLuminance(I_orig);
|
||||
|
||||
// Bước A: Tách lớp vân bề mặt (Detail Layer)
|
||||
// Detail = I_orig / (I_base + eps)
|
||||
vec3 detail = I_orig / (I_base + vec3(0.001));
|
||||
|
||||
// Bước B: Tính toán lượng sáng cần bù vào Base Layer
|
||||
float mask = getShadowWeight(baseLum);
|
||||
|
||||
// Sử dụng công thức mũ tự nhiên thay vì cộng dồn tuyến tính
|
||||
// Gain = (1.0 - baseLum)^2 giúp kéo vùng tối sâu mà không làm cháy vùng trung gian
|
||||
float liftFactor = pow(1.0 - baseLum, 1.8) * u_shadow_amount * 2.2;
|
||||
vec3 liftedBase = I_base * (1.0 + mask * liftFactor);
|
||||
|
||||
// Bước C: Tái kết hợp Detail với Base đã được nâng sáng
|
||||
vec3 result = liftedBase * detail;
|
||||
|
||||
// Bước D: Bù trừ màu sắc (Chroma Compensation) để tránh bợt màu
|
||||
float newLum = getLuminance(result);
|
||||
if (origLum > 0.001) {
|
||||
float boost = 1.0 + (mask * u_shadow_amount * 0.35); // Bù màu nhẹ cho vùng tối
|
||||
result = mix(vec3(newLum), result, boost);
|
||||
}
|
||||
|
||||
// Bước E: Giữ điểm đen và chuyển ngược lại sRGB
|
||||
result = clamp(result, 0.0, 1.0);
|
||||
gl_FragColor = vec4(linearTosRGB(result), srcColor.a);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Danh Sách Kiểm Tra Khi Triển Khai (Action Plan)
|
||||
|
||||
1. **Khử ngay phép cộng tuyến tính trên 8-bit RGB:** Không bao giờ viết dạng `r = r + shadowValue`.
|
||||
2. **Triển khai Box Filter / Guided Filter trước:** Dùng 1 texture phụ chứa bản blur bán kính lớn ($\approx 2\% - 5\%$ chiều rộng ảnh) để làm `u_blur_texture`.
|
||||
3. **Độ chính xác dấu phẩy động:** Thiết lập WebGL Context với cấu hình Float Texture (`OES_texture_float` hoặc `WebGL2`) để khử triệt để hiện tượng vỡ khối viền (banding).
|
||||
@@ -1,3 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
public/wasm/
|
||||
|
||||
# Stale one-off harnesses: each imports rawDevelop.ts from an older day, so a
|
||||
# run today dies on its missing ./skiaShim and ./tiffDecode imports. The
|
||||
# checks the repo carries are the *-check.mjs beside them.
|
||||
/scripts/test-all-raws.mjs
|
||||
/scripts/test-color-accuracy.mjs
|
||||
/scripts/test-extract-speed.mjs
|
||||
|
||||
@@ -4,6 +4,12 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# The build's own name, shown at the foot of the studio's tab rail (vite.config
|
||||
# reads it): `docker compose build frontend --build-arg
|
||||
# VERSION=$(git rev-parse --short HEAD)` puts the commit in the page, which is
|
||||
# how the image on the NAS says which revision it is. Left unset, the build
|
||||
# stamps its own time.
|
||||
ARG VERSION=
|
||||
# tsc --noEmit && vite build; the prebuild hook copies canvaskit.wasm into public/
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -6,6 +6,20 @@
|
||||
<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" />
|
||||
<!-- The same thing under the name it now has: Chrome warns that the `apple-`
|
||||
spelling is deprecated, and iOS Safari still reads only that one, so the
|
||||
installed app carries both. -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<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="/" />
|
||||
|
||||
@@ -51,6 +51,44 @@ server {
|
||||
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 shell is the third name that never changes and whose contents do: with no
|
||||
# header of its own a browser is free to guess a freshness window out of
|
||||
# Last-Modified — a tenth of the file's age — and hand a visitor the PREVIOUS
|
||||
# build's index.html for hours after a deploy. That page names that build's
|
||||
# hashed bundle, so a fixed shader keeps running as the broken one and a hard
|
||||
# reload is the only way out. The SPA fallback below lands here too (the
|
||||
# internal redirect re-matches locations), so /app and /library are covered by
|
||||
# the same line.
|
||||
# (Restated: an add_header in a location drops every add_header inherited, and
|
||||
# this document needs the isolation pair for the wasm renderer.)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
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;
|
||||
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
|
||||
|
||||
Generated
+10
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"exifr": "^7.1.3",
|
||||
"libheif-js": "^1.23.5",
|
||||
"libraw-wasm": "^1.6.0",
|
||||
"onnxruntime-web": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -1606,6 +1607,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/libheif-js": {
|
||||
"version": "1.23.5",
|
||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz",
|
||||
"integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==",
|
||||
"license": "LGPL-3.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libraw-wasm": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/libraw-wasm/-/libraw-wasm-1.6.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"exifr": "^7.1.3",
|
||||
"libheif-js": "^1.23.5",
|
||||
"libraw-wasm": "^1.6.0",
|
||||
"onnxruntime-web": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 80 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,38 @@
|
||||
{
|
||||
"name": "RecipesCam — Color Recipes & Film Camera Studio",
|
||||
"short_name": "RecipesCam",
|
||||
"id": "/",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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.
|
||||
//
|
||||
// v2 is the bump that flushed a shell this worker had already pinned: before the
|
||||
// header went on in nginx, a browser was free to guess a freshness window for
|
||||
// index.html and answer a navigation with the previous build's shell — and its
|
||||
// hashed bundle with it, which the STATIC rule below then served cache-first
|
||||
// forever. Every navigation here now goes past the browser's own cache, so the
|
||||
// build a visitor gets is the one the server has.
|
||||
const VERSION = 'recipescam-v29';
|
||||
|
||||
// nginx answers all three routes with the same index.html (SPA fallback), so they
|
||||
// are one document under three keys: an offline navigation finds it whichever key
|
||||
// it asks. The manifest is not that document and is precached with them for the
|
||||
// same reason — it is what makes the installed app an app, and offline it is
|
||||
// asked for on every launch.
|
||||
const SHELL = ['/', '/app', '/library', '/manifest.json'];
|
||||
|
||||
// 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)\//;
|
||||
|
||||
// The shell, read past the browser's cache on purpose: a `cache.add` of '/'
|
||||
// consults that cache like any other fetch, so a shell stored during a stale
|
||||
// window would be precached as the offline shell of the build that replaced it.
|
||||
const shellRequest = (url) => new Request(url, { cache: 'reload' });
|
||||
|
||||
// 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 route that was asked for if this browser has ever been on it, then
|
||||
// the studio the app is for, then the landing page for a build without /app. The
|
||||
// route first because that is the copy a visit keeps up to date: the other two may
|
||||
// be shells of a build the deploy has replaced.
|
||||
const offlineShell = async (request) =>
|
||||
(await caches.match(new Request(new URL(request.url).pathname))) ??
|
||||
(await caches.match('/app')) ??
|
||||
(await caches.match('/')) ??
|
||||
Response.error();
|
||||
|
||||
// The files the shell is built out of, read out of the shell itself: the hashed
|
||||
// bundle, the stylesheet, the icon. A worker that precached only the document
|
||||
// would answer an offline launch with a page whose JavaScript this machine does
|
||||
// not have — and it never would, because the visit that first loads the app is
|
||||
// the visit this worker is not yet controlling, so its own STATIC rule below
|
||||
// never sees those requests. The shell is the one place their names are written.
|
||||
const shellFiles = (html) => [...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)].map((m) => m[1]);
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// Best effort, one file at a time: a deploy caught mid-flight must not leave the
|
||||
// worker uninstalled, so each one fails on its own.
|
||||
event.waitUntil(
|
||||
caches.open(VERSION)
|
||||
.then(async (cache) => {
|
||||
await Promise.all(SHELL.map((url) => cache.add(shellRequest(url)).catch(() => {})));
|
||||
const shell = await cache.match('/app') ?? await cache.match('/');
|
||||
if (!shell) return;
|
||||
for (const url of shellFiles(await shell.text())) {
|
||||
await cache.add(shellRequest(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.
|
||||
// `no-store` because a plain fetch is not the network: it may be answered by the
|
||||
// browser's own cache, which is the other place a stale shell hides.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request, { cache: 'no-store' })
|
||||
.then((response) => {
|
||||
// The shell kept for offline is the one the server handed over last, not
|
||||
// the one this worker installed with: a navigation that fails — a deploy
|
||||
// recreating the container is the ordinary way one does — otherwise
|
||||
// answers with the build of the first visit, `activate` never runs to
|
||||
// replace it while this worker's bytes stay the same, and the browser
|
||||
// runs an old build until a reload happens to land after the server is
|
||||
// back. Stored under the route the navigation asked for, which is one of
|
||||
// the three SHELL keys — a navigation request is not a key a cache takes.
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
const key = new Request(new URL(request.url).pathname);
|
||||
caches.open(VERSION).then((cache) => cache.put(key, copy)).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => offlineShell(request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
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()));
|
||||
});
|
||||
@@ -62,28 +62,28 @@ assert.equal(Math.round(stops(spike(87)) * 10) / 10, 0.5, 'a dark frame is asked
|
||||
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');
|
||||
// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±10.
|
||||
assert.deepEqual(autoTone(spike(255)), { highlight: -10, shadow: 0 }, 'blown frame only pulls highlights');
|
||||
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 10 }, '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');
|
||||
// (9 units), p01 = 2/255 = 0.008 (6 units).
|
||||
assert.deepEqual(autoTone(flat()), { highlight: -9, shadow: 6 }, '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');
|
||||
assert.deepEqual(autoTone(blown), { highlight: -10, shadow: 6 }, '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.
|
||||
// p99 at 0.953, 0.053 over 0.9 -> 5.3 units -> 5. 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');
|
||||
assert.deepEqual(autoTone(shoulder), { highlight: -5, shadow: 0 }, 'p99 just over 0.9 pulls half 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.
|
||||
// but only 6 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');
|
||||
assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 6 }, '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);
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// The catalogue's own copy, end to end: a roll of two frames is read, the
|
||||
// catalogue is written into a folder the visitor picked, the browser's storage is
|
||||
// taken away from under it, and the catalogue is read back out — with the frames
|
||||
// never read a second time.
|
||||
//
|
||||
// The folder of frames is a stand-in served off a local server, because the check
|
||||
// cannot hand a real directory to a page. The backup folder is not: the picker
|
||||
// answers with a directory out of the origin's own file system (OPFS), which is a
|
||||
// real `FileSystemDirectoryHandle` with a real `createWritable`, so what is
|
||||
// written is written by the code under test through the API it will use on a
|
||||
// visitor's disk — and what the check then reads back is the bytes on it.
|
||||
//
|
||||
// What the run claims, in order:
|
||||
// * the catalogue file holds the rows and neither of the two things a file
|
||||
// cannot hold — a handle and a tile;
|
||||
// * the tiles are written beside it, one per frame, under `thumbs/<frame id>`;
|
||||
// * a second run writes no tile at all;
|
||||
// * a catalogue with nothing in it gets every row back, tiles included, and the
|
||||
// frames themselves are never read from the disk again — not by the restore,
|
||||
// which takes the tiles off the backup folder, and not by the reading that
|
||||
// follows it, which matches each frame on its size and its write time.
|
||||
//
|
||||
// npm run build && node scripts/backup-check.mjs
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/backup-check.mjs
|
||||
//
|
||||
// Playwright is not a dependency of this package, so a run without it says SKIP
|
||||
// and exits 0. APP_PORT says where the preview goes, CHROME says which browser.
|
||||
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 ?? 4198);
|
||||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4321);
|
||||
// Two frames of one folder, both read off the sample roll.
|
||||
const FILES = ['P1010256.JPG', 'DSCF1701.JPG'];
|
||||
const FOLDER = 'TestRoll';
|
||||
const BACKUP = 'BackupRoll';
|
||||
|
||||
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 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 (!FILES.includes(name)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const bytes = await readFile(path.join(SAMPLES, name));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'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);
|
||||
}
|
||||
|
||||
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 words this screen says are read below, so the run is pinned to one of the
|
||||
// two dictionaries rather than to whatever the browser's own language is.
|
||||
await context.addInitScript(() => localStorage.setItem('rc.lang', 'en'));
|
||||
// The picker, twice over: the roll of frames is a stand-in — a page cannot be
|
||||
// handed a real directory by a test — and the backup folder is the origin's own
|
||||
// file system, which is a real one. Which of the two a call is for is the `id`
|
||||
// the code passes, the same one it passes to a visitor's browser.
|
||||
await context.addInitScript(
|
||||
({ origin, files, folder, backup }) => {
|
||||
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);
|
||||
};
|
||||
// What a frame costs is its bytes, and the bytes of a frame are read through
|
||||
// `arrayBuffer` — on the file, or on a slice of it, which is how a scan reads
|
||||
// the head of a JPEG. Only the stand-in's own files are counted: the tiles
|
||||
// that come back out of the backup folder are read the same way, and they are
|
||||
// named after the frames.
|
||||
window.__frameReads = 0;
|
||||
const off = new WeakMap();
|
||||
const part = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) {
|
||||
const cut = part.apply(this, args);
|
||||
off.set(cut, off.get(this) ?? this);
|
||||
return cut;
|
||||
};
|
||||
const pull = Blob.prototype.arrayBuffer;
|
||||
Blob.prototype.arrayBuffer = function () {
|
||||
if ((off.get(this) ?? this).__fake) window.__frameReads++;
|
||||
return pull.apply(this);
|
||||
};
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => {
|
||||
const file = new File([await load(name)], name, {
|
||||
type: 'image/jpeg',
|
||||
// One write time per frame, and the same one on every read: a fresh
|
||||
// `Date.now()` would make every frame look touched and hide what is
|
||||
// being checked — the reading after the restore has to match.
|
||||
lastModified: Date.UTC(2016, 1, 15, 9, 30),
|
||||
});
|
||||
Object.defineProperty(file, '__fake', { value: true });
|
||||
return file;
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
const handle = { kind: 'directory', name: label };
|
||||
Object.defineProperties(handle, {
|
||||
values: { value: () => [...(node.files ?? []).map(fileHandle)][Symbol.iterator]() },
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
getDirectoryHandle: { value: async (name) => {
|
||||
throw new Error(`no folder named ${name}`);
|
||||
} },
|
||||
getFileHandle: {
|
||||
value: async (name) => {
|
||||
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
|
||||
return fileHandle(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const root = (label) => dirHandle(label, { files });
|
||||
window.showDirectoryPicker = async (opts) => {
|
||||
if (opts?.id !== 'recipescam-backup') return root(folder);
|
||||
const opfs = await navigator.storage.getDirectory();
|
||||
// A folder of the origin's own, emptied at the start of the run and named
|
||||
// as the picker would name it.
|
||||
await opfs.removeEntry(backup, { recursive: true }).catch(() => {});
|
||||
const dir = await opfs.getDirectoryHandle(backup, { create: true });
|
||||
// OPFS handles are always writable and say so only through the API the
|
||||
// picker's handles carry: the two methods are stood in where the real
|
||||
// handle leaves them out, so the code under test takes the same road it
|
||||
// would with a folder off a disk.
|
||||
for (const mode of ['queryPermission', 'requestPermission']) {
|
||||
if (typeof dir[mode] !== 'function') Object.defineProperty(dir, mode, { value: async () => 'granted' });
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
},
|
||||
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, files: FILES, folder: FOLDER, backup: BACKUP }
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
// The restore asks before it replaces anything, and a dismissed dialog is a
|
||||
// cancel — the run says yes the way a reader would.
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
const note = () => page.$eval('.adm-note', (el) => el.textContent ?? '').catch(() => '');
|
||||
const waitNote = (re) =>
|
||||
page.waitForFunction(
|
||||
(src) => new RegExp(src).test(document.querySelector('.adm-note')?.textContent ?? ''),
|
||||
re.source,
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
const count = () => page.$eval('[data-key="lib-count"]', (el) => el.textContent ?? '');
|
||||
// The bytes of a frame actually read off the disk, counted inside the page: the
|
||||
// whole claim of the catalogued size and write time is this number staying put.
|
||||
const framesRead = () => page.evaluate(() => window.__frameReads ?? 0);
|
||||
// The catalogue the page itself keeps, read straight out of the browser: what the
|
||||
// restore puts back is a row in there, not a picture on the screen.
|
||||
const rows = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const open = indexedDB.open('recipescam-library');
|
||||
open.onsuccess = () => {
|
||||
const req = open.result.transaction('photos', 'readonly').objectStore('photos').getAll();
|
||||
req.onsuccess = () =>
|
||||
resolve(
|
||||
req.result.map((r) => ({
|
||||
id: r.id,
|
||||
tile: r.thumb ? r.thumb.size : 0,
|
||||
handle: !!r.handle,
|
||||
star: r.star ?? null,
|
||||
}))
|
||||
);
|
||||
req.onerror = () => resolve([]);
|
||||
};
|
||||
open.onerror = () => resolve([]);
|
||||
})
|
||||
);
|
||||
// Everything the backup folder holds, walked the way a person looking at it in a
|
||||
// file manager would.
|
||||
const backupTree = () =>
|
||||
page.evaluate(async (name) => {
|
||||
const opfs = await navigator.storage.getDirectory();
|
||||
const root = await opfs.getDirectoryHandle(name);
|
||||
const out = [];
|
||||
const walk = async (dir, at) => {
|
||||
for await (const [child, handle] of dir.entries()) {
|
||||
const rel = at ? `${at}/${child}` : child;
|
||||
if (handle.kind === 'directory') await walk(handle, rel);
|
||||
else out.push({ rel, size: (await handle.getFile()).size });
|
||||
}
|
||||
};
|
||||
await walk(root, '');
|
||||
return out;
|
||||
}, BACKUP);
|
||||
|
||||
let code = 0;
|
||||
try {
|
||||
// --- the roll is read ------------------------------------------------------
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await waitNote(/Scanned TestRoll/);
|
||||
const read = await rows();
|
||||
step('the roll was read and both frames are in the catalogue', read.length === 2, `${read.length} rows`);
|
||||
step('every frame has its tile', read.every((r) => r.tile > 0));
|
||||
step('the count on the bar says two', /2/.test(await count()), await count());
|
||||
const firstRead = await framesRead();
|
||||
step('reading a roll of two frames costs two frames', firstRead === 2, `${firstRead} reads`);
|
||||
|
||||
// --- the catalogue is written ---------------------------------------------
|
||||
await page.click('[data-key="lib-backup"]');
|
||||
await waitNote(/Backed up 2 frames, 2 tiles written/);
|
||||
const tree = await backupTree();
|
||||
const cat = await page.evaluate(async (name) => {
|
||||
const opfs = await navigator.storage.getDirectory();
|
||||
const dir = await opfs.getDirectoryHandle(name);
|
||||
return JSON.parse(await (await (await dir.getFileHandle('recipescam-catalogue.json')).getFile()).text());
|
||||
}, BACKUP);
|
||||
step('the catalogue file holds both rows', cat.photos.length === 2, `${cat.photos.length}`);
|
||||
step(
|
||||
'a row carries no handle and no tile',
|
||||
cat.photos.every((r) => !('handle' in r) && !('thumb' in r)),
|
||||
Object.keys(cat.photos[0]).join(',')
|
||||
);
|
||||
step(
|
||||
'a row carries the size and the write time of its frame',
|
||||
cat.photos.every((r) => r.size > 0 && r.mtime === Date.UTC(2016, 1, 15, 9, 30))
|
||||
);
|
||||
const tiles = tree.filter((f) => f.rel.startsWith('thumbs/'));
|
||||
step(
|
||||
'the tiles are beside it, one file per frame',
|
||||
tiles.length === 2 && tiles.every((f) => f.size > 0) && tiles.every((f) => /^thumbs\/TestRoll\/[^/]+$/.test(f.rel)),
|
||||
tiles.map((f) => f.rel).join(' ')
|
||||
);
|
||||
|
||||
// --- a second run writes no tile ------------------------------------------
|
||||
await page.click('[data-key="lib-backup"]');
|
||||
await waitNote(/Backed up 2 frames, 0 tiles written/);
|
||||
|
||||
// --- the browser's storage is taken away ----------------------------------
|
||||
await page.goto('about:blank');
|
||||
await page.goto(`${base}/`);
|
||||
const gone = await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const req = indexedDB.deleteDatabase('recipescam-library');
|
||||
req.onsuccess = () => resolve(true);
|
||||
req.onerror = req.onblocked = () => resolve(false);
|
||||
})
|
||||
);
|
||||
step('the catalogue is gone from the browser', gone === true);
|
||||
await page.goto(`${base}/library`);
|
||||
step('the library comes up empty', /^0\b/.test((await count()).trim()), await count());
|
||||
step('and has read nothing', (await framesRead()) === 0);
|
||||
|
||||
// --- and it is put back ----------------------------------------------------
|
||||
await page.click('[data-key="lib-restore"]');
|
||||
await waitNote(/Restored 2 frames/);
|
||||
const back = await rows();
|
||||
step('both rows are back', back.length === 2, `${back.length} rows`);
|
||||
step('each row came back with its tile', back.every((r) => r.tile > 0));
|
||||
step('no row came back with a handle — a handle is not a file', back.every((r) => !r.handle));
|
||||
step('the restore read no frame from the disk', (await framesRead()) === 0, `${await framesRead()} reads`);
|
||||
step('the count on the bar says two again', /2/.test(await count()), await count());
|
||||
|
||||
// --- the folder is picked again, and no frame is read for it ---------------
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await waitNote(/Scanned TestRoll/);
|
||||
const joined = await rows();
|
||||
step('the reading gave every row its handle back', joined.length === 2 && joined.every((r) => r.handle));
|
||||
step('and read no frame to do it', (await framesRead()) === 0, `${await framesRead()} reads`);
|
||||
} catch (err) {
|
||||
step(`the run finished`, false, String(err).split('\n')[0]);
|
||||
code = 1;
|
||||
}
|
||||
|
||||
console.log(failures ? `FAIL ${failures} step(s)` : 'ok the catalogue survives its browser');
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
sampleServer.close();
|
||||
process.exit(failures || code ? 1 : 0);
|
||||
@@ -0,0 +1,122 @@
|
||||
// NOISE REDUCTION blurs the CHROMA of the frame and leaves its LUMA alone —
|
||||
// thay_doi_thong_so_giong_lightroom.md §4.1A, "chi tiết ảnh không bị mất đi 1 sợi
|
||||
// tóc nào".
|
||||
//
|
||||
// The knob used to be a `MakeBlur` image filter on the draw, i.e. one sigma over
|
||||
// all three channels, which is the doc's own §4 warning about losing hair and
|
||||
// skin written into the engine — and a filter on the draw can only be handed the
|
||||
// shader's INPUT, so the pass could never read the graded pixels it was meant to
|
||||
// correct either. It is a two-child pass now (NR_SKSL: the frame, and a blurred
|
||||
// copy of it), and this check pins both halves of that: the shape of the wiring
|
||||
// in exportEngine, and the one property that makes the knob safe — the luma of
|
||||
// the output is the luma of the input, at every amount.
|
||||
//
|
||||
// node scripts/denoise-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 { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
||||
|
||||
const transpile = (path) =>
|
||||
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'denoise-check-'));
|
||||
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
writeFileSync(
|
||||
join(dir, 'toneShader.mjs'),
|
||||
transpile('../shared/utils/toneShader.ts').replace(
|
||||
/^import .*from ['"]\.\/colorUtils['"];$/m,
|
||||
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
|
||||
),
|
||||
);
|
||||
const { NR_SKSL, NR_CHROMA_SPAN } = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
|
||||
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
|
||||
|
||||
// The engine still builds this shader, still hands it the frame and its blur,
|
||||
// and no longer blurs the whole draw.
|
||||
assert.match(engine, /Skia\.RuntimeEffect\.Make\(NR_SKSL\)/, 'NR_SKSL is not compiled any more');
|
||||
assert.match(
|
||||
engine,
|
||||
/denoiseEffect\.makeShaderWithChildren\(\s*\[Math\.min\(1, denoiseKnob \/ 10\)\],\s*\[\s*own\(imageShaderChild\(snap\)\), own\(imageShaderChild\(blurred\)\)\s*\]\s*\)/,
|
||||
'the denoise pass no longer takes the frame and its blurred copy as two children'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
engine,
|
||||
/adjustments\.denoise > 0[\s\S]{0,200}?ImageFilter\.MakeBlur/,
|
||||
'the whole frame is blurred on the draw again — that is the detail the knob used to take'
|
||||
);
|
||||
assert.ok(
|
||||
NR_CHROMA_SPAN >= 0.003 && NR_CHROMA_SPAN <= 0.005,
|
||||
`NR_CHROMA_SPAN ${NR_CHROMA_SPAN} is not the doc's 3..5 pixels of a frame`
|
||||
);
|
||||
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
const SIZE = 4;
|
||||
const NR_LUM = [0.2126, 0.7152, 0.0722];
|
||||
const luma = (c) => c[0] * NR_LUM[0] + c[1] * NR_LUM[1] + c[2] * NR_LUM[2];
|
||||
const near = (a, b, why) => assert.ok(Math.abs(a - b) <= 2 / 255, `${why} (${a} vs ${b})`);
|
||||
|
||||
function flat(rgb) {
|
||||
const surf = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
const paint = new CanvasKit.Paint();
|
||||
// Color4f, not Color: CanvasKit's Color takes 0..255 and would truncate a
|
||||
// channel of 0.8 to zero (the rest of the checks in here pass ints for that
|
||||
// reason, these colours are fractions).
|
||||
paint.setColor(CanvasKit.Color4f(rgb[0], rgb[1], rgb[2], 1));
|
||||
surf.getCanvas().drawPaint(paint);
|
||||
return surf.makeImageSnapshot();
|
||||
}
|
||||
const asChild = (image) =>
|
||||
image.makeShaderOptions(
|
||||
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
|
||||
);
|
||||
|
||||
// A warm pixel over a neutral neighbourhood: the two ends of the knob are the
|
||||
// pixel itself and the neighbourhood's hue on the pixel's own brightness.
|
||||
const sharp = [0.8, 0.2, 0.35];
|
||||
const blurred = [0.4, 0.45, 0.5];
|
||||
|
||||
function render(amount) {
|
||||
const effect = CanvasKit.RuntimeEffect.Make(NR_SKSL);
|
||||
assert.ok(effect, 'NR_SKSL does not compile');
|
||||
const src = flat(sharp);
|
||||
const ref = flat(blurred);
|
||||
const shader = effect.makeShaderWithChildren([amount], [asChild(src), asChild(ref)]);
|
||||
assert.ok(shader, 'the pass did not take two children — the blur is not wired in');
|
||||
const out = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setShader(shader);
|
||||
out.getCanvas().drawPaint(paint);
|
||||
const px = out.makeImageSnapshot().readPixels(0, 0, {
|
||||
width: SIZE, height: SIZE, colorType: CanvasKit.ColorType.RGBA_8888,
|
||||
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
|
||||
});
|
||||
return [px[0] / 255, px[1] / 255, px[2] / 255];
|
||||
}
|
||||
|
||||
const at0 = render(0);
|
||||
near(at0[0], sharp[0], 'amount 0 is not the pixel itself (red)');
|
||||
near(at0[1], sharp[1], 'amount 0 is not the pixel itself (green)');
|
||||
near(at0[2], sharp[2], 'amount 0 is not the pixel itself (blue)');
|
||||
|
||||
// The colour difference is the neighbourhood's at amount 1 — and that is a real
|
||||
// move: a pixel that came back unchanged would mean the pass did nothing.
|
||||
const at1 = render(1);
|
||||
const chroma = (c) => c.map((v) => v - luma(c));
|
||||
near(chroma(at1)[0], chroma(blurred)[0], 'amount 1 does not carry the blurred red difference');
|
||||
near(chroma(at1)[2], chroma(blurred)[2], 'amount 1 does not carry the blurred blue difference');
|
||||
|
||||
// The property the doc's §4.1A is about, and the reason the knob costs no
|
||||
// detail: whatever the amount, the output's brightness is the pixel's own.
|
||||
for (const amount of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
near(luma(render(amount)), luma(sharp), `amount ${amount} moved the pixel's luma`);
|
||||
}
|
||||
|
||||
console.log('denoise ok: the chroma of the frame is blurred, its luma never is');
|
||||
@@ -1,27 +1,156 @@
|
||||
// Highlight roll-off, both ends of the pipeline, as one soft knee:
|
||||
// Highlight roll-off in the develop, and the four tonal knobs in the tone pass.
|
||||
//
|
||||
// THE DEVELOP holds the 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.
|
||||
// drawn 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 — which is also the only reason HIGHLIGHT has detail left at the
|
||||
// top to move.
|
||||
//
|
||||
// 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.
|
||||
// THE TONE PASS moves the luma by the four knobs (HIGHLIGHT, SHADOW, WHITE,
|
||||
// BLACK), and each one is the MOVE ITS OWN LIGHTROOM EXPORT MADE, measured:
|
||||
// TONE_KNOBS in toneShader.ts holds, per knob and per side of zero, the knot
|
||||
// table of that move on the encoded luma. They are NOT the doc's four compact
|
||||
// windows any more — the windows were confined to a quarter of the ramp each and
|
||||
// were worth a few hundredths of luma inside it and the exact identity outside,
|
||||
// which is the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi"
|
||||
// report: WHITE ±100 was a literal no-op. The reference exports say something
|
||||
// else: WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43 over the
|
||||
// frame), BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), HIGHLIGHT
|
||||
// +100 is a +0.24 lift peaking on luma 0.18 and dead by 0.72.
|
||||
//
|
||||
// The four are applied ONE AFTER THE OTHER (BLACK -> SHADOW -> HIGHLIGHT ->
|
||||
// WHITE), each clamped on the way to the next, so a composition of monotone maps
|
||||
// is monotone by construction and the ramp needs no guard whatever the four
|
||||
// sliders say — and the knot tables are cut so that L + move is non-decreasing on
|
||||
// every segment, which the sweep below measures rather than asserts.
|
||||
//
|
||||
// Both are SkSL, so the SHAPE is pinned on the source; the tables are then
|
||||
// re-run here as a twin and checked against the measurement they were FITTED to
|
||||
// (the nine exports in the repo root), so a table that drifts off the export is a
|
||||
// red check. The frame-through-the-real-shader diff lives in the scratchpad
|
||||
// harness render.mjs, which needs the nine JPEGs.
|
||||
//
|
||||
// node scripts/highlight-knee-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
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');
|
||||
const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8');
|
||||
const develop = read('../src/engine/rawDevelop.ts');
|
||||
const tone = read('../shared/utils/toneShader.ts');
|
||||
// The tone shader is IMPORTED, transpiled, so the numbers below are the ones the
|
||||
// shader is built with and not a regex's reading of them.
|
||||
const transpile = (path) =>
|
||||
ts.transpileModule(read(path), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const dir = mkdtempSync(join(tmpdir(), 'highlight-knee-check-'));
|
||||
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
writeFileSync(
|
||||
join(dir, 'toneShader.mjs'),
|
||||
transpile('../shared/utils/toneShader.ts').replace(
|
||||
/^import .*from ['"]\.\/colorUtils['"];$/m,
|
||||
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
|
||||
),
|
||||
);
|
||||
const {
|
||||
TONE_SKSL: sksl,
|
||||
TONE_MATH_SKSL: maths,
|
||||
EXPOSURE_SKSL,
|
||||
TONE_KNOBS,
|
||||
TONE_BLACK_EDGE,
|
||||
TONE_BASE_RADIUS,
|
||||
TONE_BASE_SIGMA,
|
||||
toneUniformArray,
|
||||
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
|
||||
|
||||
const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const codeOf = (s) => s.replace(/\/\/[^\n]*/g, '');
|
||||
|
||||
// THE FOUR KNOBS, as the source declares them. Each curve is a knot table on the
|
||||
// encoded luma: positions ascending from the floor to the head, values the move
|
||||
// at +100 (the minus side is read with an amount below zero).
|
||||
const KNOBS = ['bl', 'sh', 'hl', 'wh'];
|
||||
for (const k of KNOBS) {
|
||||
for (const side of ['plus', 'minus']) {
|
||||
const c = TONE_KNOBS[k][side];
|
||||
assert.equal(c.p.length, c.v.length, `${k}.${side}: ${c.p.length} knots against ${c.v.length} values`);
|
||||
assert.equal(c.p[0], 0, `${k}.${side} does not start on the floor`);
|
||||
assert.equal(c.p[c.p.length - 1], 1, `${k}.${side} does not end on the head`);
|
||||
for (let i = 1; i < c.p.length; i++)
|
||||
assert.ok(c.p[i] > c.p[i - 1], `${k}.${side} knot ${i} is not past the one before it`);
|
||||
}
|
||||
}
|
||||
// One knob as the shader gets it: the table drawn STRAIGHT between the knots — a
|
||||
// hinge per segment, written as a sum of clamps because SkSL indexes arrays by
|
||||
// constant only — scaled by the amount, added to the luma it reads and clamped.
|
||||
// The side of zero picks the table, and the clamp is what makes a -- knob's own
|
||||
// clip a plateau instead of a fold.
|
||||
for (const k of KNOBS)
|
||||
assert.match(
|
||||
maths,
|
||||
text(`float tone${k}(float L, float amt) {\n return clamp(L + (amt >= 0.0 ? amt * (`),
|
||||
`${k} is not the measured table read as a polyline`
|
||||
);
|
||||
assert.match(maths, /: -amt \* \(/);
|
||||
// ...pinned at the SOURCE's own numbers: the hinge from knot 0.045 to knot 0.185
|
||||
// on BLACK's plus side is worth 0.1155 - 0.0876, over a segment 0.140 wide.
|
||||
assert.match(maths, text(' + (0.0279) * clamp((L - 0.045) / 0.140, 0.0, 1.0)'));
|
||||
// The composition, move for move: the four in the order the exports were read,
|
||||
// each self-clamping (tone* does it), so a knob that has driven the luma to an
|
||||
// end leaves the ones after it nothing to fold.
|
||||
assert.match(
|
||||
maths,
|
||||
text('float toneCurve(float L, float bl, float sh, float hl, float wh) {\n L = tonebl(L, bl);\n L = tonesh(L, sh);\n L = tonehl(L, hl);\n L = tonewh(L, wh);\n return clamp(L, 0.0, 1.0);\n}')
|
||||
);
|
||||
// ...and the four COMPACT WINDOWS the knobs used to be are gone from the CODE —
|
||||
// they were the "không thay đổi" bug. The prose is not read for these:
|
||||
// TONE_MATH_SKSL still tells the story of what replaced them.
|
||||
assert.doesNotMatch(codeOf(maths), /toneBlackW|toneBlack\(|toneShadowW|toneHighW|toneWhiteW|toneBump|holdLo|holdHi/);
|
||||
assert.doesNotMatch(
|
||||
tone,
|
||||
/export const TONE_WHITE_EDGE|export const TONE_BLACK_LIFT|export const TONE_BLACK_CRUSH|export const TONE_HIGH_GAIN|export const TONE_WHITE_GAIN|export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/,
|
||||
'a window edge or rate is still declared'
|
||||
);
|
||||
// The BLACK knob's Hunt chroma mask is the one thing that still reads the doc's
|
||||
// 0.18 — it is a chroma floor, not a knob window.
|
||||
assert.match(maths, new RegExp(text(`float uBl = clamp(1.0 - t / ${TONE_BLACK_EDGE}, 0.0, 1.0);`)));
|
||||
const knobBlock = maths.slice(maths.indexOf('float tonebl'), maths.indexOf('float toneCurve'));
|
||||
assert.ok(knobBlock.includes('float tonewh'), 'the four knob functions are gone');
|
||||
assert.doesNotMatch(
|
||||
knobBlock,
|
||||
/smoothstep|pow\(|exp\(/,
|
||||
'a knob is not the measured table any more — it bends again'
|
||||
);
|
||||
// DR rides the same four moves with a share of each instead of masked terms of
|
||||
// its own, so it cannot fight a knob over a band or invert the ramp.
|
||||
assert.match(maths, text('float blVal = clamp(bl + dr * 0.12, -1.0, 1.0);'));
|
||||
assert.match(maths, text('float shVal = clamp(sh + dr * 0.06, -1.0, 1.0);'));
|
||||
assert.doesNotMatch(maths, /o = clamp\(base/, 'the ramp clamps the base before the curve again — the ends of the ramp are the anchors');
|
||||
assert.doesNotMatch(codeOf(sksl), /float a4 = /, 'the ramp is back inside the pass — one copy, not two');
|
||||
// The uniform block: the shader's declarations, arrays expanded and in
|
||||
// declaration order, have to be the numbers `toneUniformArray` writes — a
|
||||
// mismatch is a silent off-by-one down the whole block.
|
||||
const declared = [...sksl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce(
|
||||
(n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1),
|
||||
0
|
||||
);
|
||||
const arrayFn = tone.match(/export function toneUniformArray\(u: ToneUniforms\): number\[\] \{\n return \[([\s\S]*?)\n \];/)?.[1];
|
||||
assert.ok(arrayFn, 'toneUniformArray is gone');
|
||||
const written = arrayFn
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((n, s) => n + (s.startsWith('...u.hsl') ? 8 : 1), 0);
|
||||
assert.equal(written, declared, `toneUniformArray writes ${written} floats, the pass declares ${declared}`);
|
||||
|
||||
// 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.
|
||||
@@ -30,57 +159,503 @@ 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');
|
||||
const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8');
|
||||
assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths');
|
||||
assert.match(mask, /c = half3\(exposureMove\(vec3\(c\), a\.x\)\);/);
|
||||
// The mask hands the ramp its OWN pixel as the base, twice over: a shape has no
|
||||
// neighbourhood of its own, and base == t is a ratio of exactly 1, so what a mask
|
||||
// does with SHADOW is what it always did. The knob means the same thing on both
|
||||
// sides of the call; what differs is the neighbourhood, and a mask has none.
|
||||
assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/);
|
||||
assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary saturation clamp');
|
||||
assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula');
|
||||
|
||||
// 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.
|
||||
// The four tents, one per quarter of the ramp, each clipped by its neighbour so
|
||||
// no luma is counted by two of them. These weight the stock SPLIT TONES now — the
|
||||
// four knobs no longer ride them, they ride their own measured curves (TONE_KNOBS).
|
||||
assert.match(sksl, /float blMask = 1\.0 - smoothstep\(0\.00, 0\.25, t\);/);
|
||||
assert.match(sksl, /float shMask = clamp\(1\.0 - smoothstep\(0\.25, 0\.50, t\) - blMask, 0\.0, 1\.0\);/);
|
||||
assert.match(sksl, /float whMask = smoothstep\(0\.75, 1\.00, t\);/);
|
||||
assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask, 0\.0, 1\.0\);/);
|
||||
// The composition, and NOT a sum of bumps on the identity: the four MEASURED
|
||||
// curves are applied one after the other, each self-clamping via its own tone*()
|
||||
// helper, which is what makes one knob's travel independent of another's (the
|
||||
// shared ceiling used to take 0.663 of BLACK's travel off on the monochrome
|
||||
// stock) and the ramp monotone by construction rather than by a guard. No
|
||||
// linear-light knee runs ahead of them either — CURVE_SKSL is a different pass.
|
||||
assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are summed again — the moves must be sequential');
|
||||
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
|
||||
assert.doesNotMatch(sksl, /float lin\(/, 'the linear-light knee ahead of the knobs came back');
|
||||
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
|
||||
assert.match(sksl, /float target = clamp\(o, 0\.0, 1\.0\);/);
|
||||
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
|
||||
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
|
||||
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
|
||||
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
|
||||
assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve');
|
||||
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
|
||||
// The linear-light knee that used to run ahead of all this is GONE from the tone
|
||||
// pass: HIGHLIGHT is one zone move in both directions now, and a second pass over
|
||||
// the same knob would double-count it.
|
||||
assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back');
|
||||
assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back');
|
||||
assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again');
|
||||
// The rebuild after the ramp: the doc's ratio (R_new = R_old * Luma_new /
|
||||
// Luma_old), as ONE shared scale o / t, so the differences move with the light
|
||||
// and neither the hue nor the saturation goes with them. The caps are what make
|
||||
// it fit — applying the ratio past the ceiling clips a channel outright and the
|
||||
// hue goes with it (a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100,
|
||||
// scratchpad hl-variants.mjs) — and the scale is held at 1.0 only below
|
||||
// t = 0.0004, where the ratio would multiply a near-black pixel's cast by
|
||||
// whatever pedestal BLACK has just lifted.
|
||||
assert.match(maths, /float safeT = max\(t, 0\.005\);/);
|
||||
assert.match(maths, /float k = t > 0\.0004 \? min\(o \/ safeT, 3\.5\) : 1\.0;/);
|
||||
assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ max\(hiC - t, 0\.0001\)\);/);
|
||||
assert.match(maths, /if \(loC < t\) k = min\(k, o \/ max\(t - loC, 0\.0001\)\);/);
|
||||
assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
|
||||
assert.match(maths, /vec3 res = lightMove\(c, t, target\);/);
|
||||
assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back');
|
||||
assert.doesNotMatch(maths, /float k = 1\.0;/, 'the chroma-constant scale came back — a shadow lift drains the colour');
|
||||
// The transfer pair has to be the accurate one where it is still used (the
|
||||
// exposure pass), or that pass 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.
|
||||
// The develop's arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is its pair (S is what
|
||||
// puts the asymptote on 1.0: T + 1/(2S) = 1).
|
||||
const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T)));
|
||||
const T = 0.7;
|
||||
const S = 1 / (2 * (1 - 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);
|
||||
// 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: 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}`);
|
||||
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);
|
||||
}
|
||||
assert.ok(Math.abs(0.7 + 1 / (2 * (1 / (2 * (1 - 0.7)))) - 1) < 1e-9, 'the develop plateau left 1.0');
|
||||
// The asymptote: everything the sensor held above the knee lands under it, on
|
||||
// exactly 1.0.
|
||||
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
|
||||
assert.ok(Math.abs(T + 1 / (2 * S) - 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.
|
||||
// (1.93) on 248 — a ramp of a dozen code values where the old fade-to-white left
|
||||
// nothing above 250 at all. This is the headroom the four tone knobs move.
|
||||
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);
|
||||
assert.equal(Math.round(enc(knee(1.0, T, S)) * 255), 237);
|
||||
assert.equal(Math.round(enc(knee(1.93, T, S)) * 255), 248);
|
||||
|
||||
// The ramp as arithmetic — the same four windows, the same rates and the same
|
||||
// sequential composition the SkSL above carries, so the shape is measured and
|
||||
// not described. Each move is monotone for any amount in [-1, 1] and lands on 0
|
||||
// and 1 without moving either, so the composition is monotone by construction
|
||||
// and the two ends of the ramp are FIXED POINTS whatever the sliders say.
|
||||
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
// Float-exact comparisons are a trap once a value has been through a division
|
||||
// and a multiply (x / 0.25 * 0.25 is not x) — assert to within a code value.
|
||||
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-12, `${msg ?? ''} ${a} != ${b}`);
|
||||
// The readings below come out of this same arithmetic to five decimals, so the
|
||||
// tolerance is the rounding of the number written down, well inside the code
|
||||
// value the cube can see.
|
||||
const near4 = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-4, `${msg ?? ''} ${a} != ${b}`);
|
||||
// Exact to the last digit where it matters: a window of zero is not "nearly"
|
||||
// zero, and that is the claim the independence checks rest on.
|
||||
const exact = (a, b, msg) => assert.equal(a, b, `${msg ?? ''} ${a} != ${b}`);
|
||||
const smoothstep = (e0, e1, x) => {
|
||||
const u = clamp01((x - e0) / (e1 - e0));
|
||||
return u * u * (3 - 2 * u);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The ramp as arithmetic — the same tables, the same order and the same clamps
|
||||
// the SkSL above carries, so the shape is measured and not described.
|
||||
//
|
||||
// ONE knob's curve, as KNOB_SKSL emits it: the table drawn straight between the
|
||||
// knots, scaled by the amount, added to the luma it reads and clamped.
|
||||
const poly = (c, L) => {
|
||||
let v = c.v[0];
|
||||
for (let i = 1; i < c.p.length; i++)
|
||||
v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1);
|
||||
return v;
|
||||
};
|
||||
const knob = (L, k, amt) =>
|
||||
clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L)));
|
||||
// toneCurve, move for move, with the clamp the shader puts after each one:
|
||||
// BLACK, then SHADOW, then HIGHLIGHT, then WHITE. `afterBlack` is the luma the
|
||||
// SHADOW move reads — the value a stock's own SHADOW cannot drag, because BLACK
|
||||
// runs before it.
|
||||
function ramp(t, k = {}) {
|
||||
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
|
||||
const afterBlack = knob(t, 'bl', clamp(bl + dr * 0.12, -1, 1));
|
||||
const o = knob(
|
||||
knob(knob(afterBlack, 'sh', clamp(sh + dr * 0.06, -1, 1)), 'hl', clamp(hl - dr * 0.09, -1, 1)),
|
||||
'wh',
|
||||
clamp(wh - dr * 0.18, -1, 1)
|
||||
);
|
||||
return { o, afterBlack };
|
||||
}
|
||||
|
||||
// THE MEASUREMENT THE TABLES WERE FITTED TO. Nine genuine Lightroom Classic
|
||||
// 14.2 exports of Main.jpg — the frame at the repo root — each knob at +100 and
|
||||
// at -100, the per-pixel luma difference against Main.jpg binned by base luma and
|
||||
// averaged. The row is that move at nine luma positions, off the dense grid the
|
||||
// tables were cut from (0.008 of luma is the worst the fit is allowed anywhere);
|
||||
// the tolerance below is 0.01, a little over a code value at ±100.
|
||||
const MEASURED_L = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
|
||||
const MEASURED = {
|
||||
'hl+': [0.0419, 0.1116, 0.1814, 0.2323, 0.1607, 0.1011, 0.078, 0.0452, 0.0108],
|
||||
'hl-': [-0.0201, -0.0363, -0.0475, -0.0589, -0.0701, -0.0808, -0.1298, -0.1627, -0.1461],
|
||||
'sh+': [0.1971, 0.24, 0.2467, 0.2533, 0.25, 0.23, 0.205, 0.175, 0.1333],
|
||||
'sh-': [-0.0434, -0.0874, -0.1285, -0.1583, -0.1359, -0.0912, -0.0734, -0.0473, -0.012],
|
||||
'wh+': [0.2036, 0.2901, 0.3714, 0.4422, 0.51, 0.5623, 0.4998, 0.3999, 0.3022],
|
||||
'wh-': [-0.0019, -0.0038, -0.0058, -0.0085, -0.0123, -0.0203, -0.03, -0.0461, -0.0609],
|
||||
'bl+': [0.0905, 0.1049, 0.1132, 0.1151, 0.099, 0.0843, 0.0702, 0.052, 0.0328],
|
||||
'bl-': [-0.0501, -0.0997, -0.1498, -0.1983, -0.2429, -0.2247, -0.1574, -0.1141, -0.0773],
|
||||
};
|
||||
for (const [name, want] of Object.entries(MEASURED)) {
|
||||
const k = name.slice(0, 2);
|
||||
const amt = name.endsWith('+') ? 1 : -1;
|
||||
want.forEach((move, i) => {
|
||||
const got = knob(MEASURED_L[i], k, amt);
|
||||
assert.ok(
|
||||
Math.abs(got - clamp01(MEASURED_L[i] + move)) <= 0.01,
|
||||
`${name} at luma ${MEASURED_L[i]}: the table moves to ${got.toFixed(4)}, the export to ${(clamp01(MEASURED_L[i] + move)).toFixed(4)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
// The film stocks ride these same curves, so their numbers are their own.
|
||||
const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0];
|
||||
assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all');
|
||||
// The keys are quoted or not depending on whether they are identifiers, so the
|
||||
// quotes come off before the lookup.
|
||||
const filmFlat = filmTone.replace(/['"]/g, '');
|
||||
for (const [name, src, kn, toe, head] of [
|
||||
['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
|
||||
['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
|
||||
['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.2126235, 0.7396688],
|
||||
['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.1734727, 0.8150685],
|
||||
]) {
|
||||
assert.ok(
|
||||
filmFlat.includes(`${name}: { ${src} }`),
|
||||
`${name} is not on the ${src} its ends were solved at`
|
||||
);
|
||||
near4(ramp(0.25, kn).o, toe, `${name}'s shadow knot moved`);
|
||||
if (head) near4(ramp(0.75, kn).o, head, `${name}'s highlight knot moved`);
|
||||
}
|
||||
|
||||
// Non-decreasing on EVERY segment, for every amount in [-1, 1] — not just at the
|
||||
// ±100 the exports were sampled at. A fold in a tone curve is a band the eye
|
||||
// reads, and a knob's own shape is what has to rule it out: the tables are cut so
|
||||
// L + move never turns back.
|
||||
for (const k of KNOBS)
|
||||
for (let amt = -1; amt <= 1.0001; amt += 1 / 64) {
|
||||
let prev = -Infinity;
|
||||
for (let i = 0; i <= 4000; i++) {
|
||||
const o = knob(i / 4000, k, amt);
|
||||
assert.ok(o >= prev - 1e-12, `${k} at ${amt.toFixed(4)} folds the ramp at luma ${i / 4000}`);
|
||||
prev = o;
|
||||
}
|
||||
}
|
||||
// Every knob on zero is EXACTLY the identity — the pass also runs for the stock
|
||||
// split tones and for DR alone, so a neutral setting must not curve the frame.
|
||||
for (let i = 0; i <= 2000; i++) exact(ramp(i / 2000, {}).o, i / 2000, `identity broke at ${i / 2000}`);
|
||||
// The ends of the ramp are where the MEASUREMENT put them, and nowhere else. The
|
||||
// floor is the one that surprises: HIGHLIGHT +100 lifts a true black by 0.084 —
|
||||
// the biggest move any knob makes on luma 0 — while BLACK +100, the knob named
|
||||
// for it, is worth 0.0042 there; and the head moves under WHITES -100 alone
|
||||
// (-0.10 on a true white, which is why its table does not tail off to zero).
|
||||
// Nothing may drag an end further than the export did.
|
||||
for (const k of KNOBS)
|
||||
for (const side of ['plus', 'minus']) {
|
||||
const c = TONE_KNOBS[k][side];
|
||||
const amt = side === 'plus' ? 1 : -1;
|
||||
near4(knob(0, k, amt), clamp01(c.v[0]), `${k}.${side} floor travel`);
|
||||
near4(knob(1, k, amt), clamp01(1 + c.v[c.v.length - 1]), `${k}.${side} head travel`);
|
||||
}
|
||||
for (const k of KNOBS)
|
||||
for (const side of ['plus', 'minus']) {
|
||||
const c = TONE_KNOBS[k][side];
|
||||
assert.ok(Math.abs(c.v[0]) <= 0.15, `${k}.${side} drags the floor by ${c.v[0]} — past what any export did`);
|
||||
assert.ok(Math.abs(c.v[c.v.length - 1]) <= 0.1, `${k}.${side} drags the head by ${c.v[c.v.length - 1]} — past what any export did`);
|
||||
}
|
||||
// The two landmarks the whole change is measured by, read at the export's own
|
||||
// numbers: WHITES +100 puts everything above luma 0.50 on 1.0 — it rounds to the
|
||||
// ceiling from there up, which is the "+0.43 mean" a user sees as the frame
|
||||
// opening — and BLACKS -100 puts everything below 0.21 on 0.0 (under a code
|
||||
// value), the crushed toe.
|
||||
for (let L = 0.5; L <= 1.0001; L += 1 / 512)
|
||||
assert.equal(Math.round(knob(L, 'wh', 1) * 255), 255, `WHITES +100 leaves luma ${L} short of the ceiling`);
|
||||
for (let L = 0; L <= 0.2; L += 1 / 512)
|
||||
assert.ok(knob(L, 'bl', -1) < 0.006, `BLACKS -100 leaves luma ${L} above the floor`);
|
||||
// On a mid-grey the four are all live — the old windows left the 0.50 midpoint to
|
||||
// nothing but the two ends, which is the "không thay đổi" the report is about.
|
||||
for (const k of KNOBS) {
|
||||
assert.ok(Math.abs(knob(0.5, k, 1) - 0.5) > 0.01, `${k} +100 does nothing on a mid-grey`);
|
||||
assert.ok(Math.abs(knob(0.5, k, -1) - 0.5) > 0.001, `${k} -100 does nothing on a mid-grey`);
|
||||
}
|
||||
// ...and no combination of the four at full deflection can drive an end of the
|
||||
// ramp off the cube, fold the ramp back on itself, or cross the toe over the
|
||||
// head — the composition is monotone by construction, and this is that
|
||||
// construction run over all 162 combinations.
|
||||
const combos = [];
|
||||
for (const bl of [-1, 0, 1])
|
||||
for (const sh of [-1, 0, 1])
|
||||
for (const hl of [-1, 0, 1])
|
||||
for (const wh of [-1, 0, 1]) for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr });
|
||||
for (const k of combos) {
|
||||
const lo = ramp(0, k).o;
|
||||
const hi = ramp(1, k).o;
|
||||
assert.ok(lo >= 0 && hi <= 1, `the ramp left the cube at ${JSON.stringify(k)}`);
|
||||
assert.ok(lo <= hi, `the toe ${lo} climbed over the head ${hi} at ${JSON.stringify(k)}`);
|
||||
let prev = -Infinity;
|
||||
for (let i = 0; i <= 2000; i++) {
|
||||
const o = ramp(i / 2000, k).o;
|
||||
assert.ok(o >= prev - 1e-12, `the ramp folds at ${i / 2000} with ${JSON.stringify(k)}`);
|
||||
prev = o;
|
||||
}
|
||||
}
|
||||
|
||||
// The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own
|
||||
// chroma difference, and the one scale o / t the cube then gets to pull back.
|
||||
const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]);
|
||||
// lightMove, as TONE_MATH_SKSL emits it — the one move every brightness change in
|
||||
// the pass goes through (a tone knob, a mask's tone knob, the exposure knob).
|
||||
// NOT clamped on the way out here: the check below wants to see that the scale
|
||||
// alone already landed the pixel inside the cube, and a silent clamp would hide
|
||||
// the case where it did not.
|
||||
function lightMove(rgb, t, o) {
|
||||
let k = t > 0.0004 ? Math.min(o / Math.max(t, 0.005), 3.5) : 1;
|
||||
const hiC = Math.max(...rgb);
|
||||
const loC = Math.min(...rgb);
|
||||
if (hiC > t) k = Math.min(k, (1 - o) / (hiC - t));
|
||||
if (loC < t) k = Math.min(k, o / Math.max(t - loC, 0.0001));
|
||||
return rgb.map((c) => o + (c - t) * k);
|
||||
}
|
||||
// The rebuild, with the base layer the shader now draws the ramp through. `base`
|
||||
// defaults to the pixel itself — the degenerate call, and the one a mask makes —
|
||||
// which lands `target` back on `o` and is the move this function had before there
|
||||
// was a base at all. Away from that the pixel's own DIFFERENCE from the base is
|
||||
// ADDED to the neighbourhood's new luma (Base' + Detail), never scaled by it.
|
||||
function rebuild(rgb, k, base) {
|
||||
const t = lumaOf(rgb);
|
||||
const b = base ?? t;
|
||||
const o = ramp(t, k).o;
|
||||
const target = clamp01(o);
|
||||
const out = lightMove(rgb, t, target);
|
||||
return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target };
|
||||
}
|
||||
// The transfer pair the exposure pass crosses into linear light with, and back.
|
||||
const srgbToLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
const linToSrgb = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
|
||||
// exposureMove, as TONE_MATH_SKSL emits it: the linear sensor moves by the stops,
|
||||
// and the encoded value that lands there is the luma the pixel is rebuilt onto.
|
||||
// The light moves by exp2(ev) in LINEAR light; the colour moves by the one shared
|
||||
// scale of lightMove. A per-channel multiply does neither — it clips the three
|
||||
// channels by three different amounts and takes the hue with it (29.2° at +1 EV
|
||||
// on the scratchpad probe, exp-variant.mjs; this variant measures 0.00°).
|
||||
function exposureMove(rgb, ev) {
|
||||
const c = rgb.map(clamp01);
|
||||
const t = lumaOf(c);
|
||||
// The stop as a RATIO on the pixel's own encoded luma, which is what makes the
|
||||
// knob the identity at 0 EV: pointing the luma straight at the encoded linear
|
||||
// target brightens a colour by a couple of code values even on zero.
|
||||
const lin = Math.max(lumaOf(c.map(srgbToLin)), 1e-6);
|
||||
const stop = linToSrgb(Math.min(1, lin * 2 ** ev)) / linToSrgb(lin);
|
||||
return lightMove(c, t, clamp01(t * stop)).map(clamp01);
|
||||
}
|
||||
function hueOf(c) {
|
||||
const mx = Math.max(...c), mn = Math.min(...c), d = mx - mn;
|
||||
if (d < 1e-9) return NaN;
|
||||
let h;
|
||||
if (mx === c[0]) h = (c[1] - c[2]) / d + (c[1] < c[2] ? 6 : 0);
|
||||
else if (mx === c[1]) h = (c[2] - c[0]) / d + 2;
|
||||
else h = (c[0] - c[1]) / d + 4;
|
||||
return ((h * 60) % 360 + 360) % 360;
|
||||
}
|
||||
const colourCases = [
|
||||
[0.9, 0.72, 0.6], // skin — the case that moved 24° under the ratio
|
||||
[1, 0.97, 0.92], // a warm white at the very top of the ramp
|
||||
[0.45, 0.65, 0.9], // sky
|
||||
[1, 0.6, 0.2], // orange, one channel already on the ceiling
|
||||
[0.45, 0.85, 0.4], // green
|
||||
[0.05, 0.03, 0.02], // a shadow with a cast
|
||||
[0.01, 0.008, 0.006],// and the same cast with almost no light on it at all
|
||||
];
|
||||
const greyCases = [[0.1, 0.1, 0.1], [0.5, 0.5, 0.5], [0.7, 0.7, 0.7], [0.9, 0.9, 0.9], [0.97, 0.97, 0.97]];
|
||||
const knobSets = [];
|
||||
for (const hl of [-1, -0.5, 0, 0.5, 1])
|
||||
for (const wh of [-1, 0, 1])
|
||||
for (const sh of [-1, 0, 1])
|
||||
for (const bl of [-1, 0, 1]) knobSets.push({ hl, wh, sh, bl });
|
||||
for (const k of knobSets) {
|
||||
for (const rgb of colourCases) {
|
||||
const { out, clamped, o } = rebuild(rgb, k);
|
||||
// The clamp is never what saves the pixel: the scale already landed the
|
||||
// result inside the cube, which is the whole point of it.
|
||||
for (let i = 0; i < 3; i++)
|
||||
assert.ok(Math.abs(out[i] - clamped[i]) < 1e-12, `the cube clipped ${i} of ${rgb} at ${JSON.stringify(k)}`);
|
||||
// Hue cannot move: every channel difference is scaled by the same number.
|
||||
const dh = hueOf(clamped) - hueOf(rgb);
|
||||
assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `hue moved ${dh} for ${rgb} at ${JSON.stringify(k)}`);
|
||||
// ...and the new luma is the ramp's, exactly (the differences sum to zero
|
||||
// in this weighting, so the scale drops out of the luma).
|
||||
close(lumaOf(clamped), o, `luma ${rgb} at ${JSON.stringify(k)}`);
|
||||
}
|
||||
// A grey is a grey: no difference to carry, so it lands on the ramp value and
|
||||
// picks up no cast on the way.
|
||||
for (const rgb of greyCases) {
|
||||
const { clamped, o } = rebuild(rgb, k);
|
||||
for (const c of clamped) close(c, o, `grey drifted at ${JSON.stringify(k)}`);
|
||||
}
|
||||
}
|
||||
// Every knob on zero is the identity for the colour too, not just the luma.
|
||||
for (const rgb of [...colourCases, ...greyCases]) {
|
||||
const { clamped } = rebuild(rgb, {});
|
||||
for (let i = 0; i < 3; i++) close(clamped[i], rgb[i], 'the colour rebuild is not the identity at zero');
|
||||
}
|
||||
// The chroma RIDES THE RATIO: where the cube has room the channel differences
|
||||
// come out multiplied by the one scale o / t. That is what keeps the saturation —
|
||||
// an HSL saturation is a ratio of differences and a common scale never touches it
|
||||
// — and the hue along with it, which is the report behind this move: held at
|
||||
// k = 1.0 (the chroma carried unchanged) a dark red came back at 0.505 of
|
||||
// saturation from 0.746 with SHADOW at +100, and at 0.370 with SHADOW and BLACK
|
||||
// both, which is a colour going grey under a lift.
|
||||
for (const [rgb, knobs] of [
|
||||
[[0.7, 0.55, 0.45], { hl: 0.5 }],
|
||||
[[0.25, 0.12, 0.08], { sh: 0.5, bl: 0.5 }],
|
||||
]) {
|
||||
const lifted = rebuild(rgb, knobs);
|
||||
const grew = (lifted.clamped[0] - lifted.clamped[1]) / (rgb[0] - rgb[1]);
|
||||
assert.ok(Math.abs(lifted.target / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`);
|
||||
close(grew, lifted.target / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`);
|
||||
}
|
||||
|
||||
// THE BASE LAYER. A band with texture in it — SHADOW's own quarter, 0.26 to
|
||||
// 0.50, at the deflection this was reported at (the full +100, which is +1 here):
|
||||
//
|
||||
// read at the pixel every pixel lands on its own o, so the band's spread
|
||||
// comes out multiplied by whatever slope the curve has
|
||||
// over it — 1.2347 of its own spread at this deflection,
|
||||
// the SHADOW +100 table steepening through 0.26..0.50.
|
||||
// read at the base every pixel of ONE neighbourhood takes the same move,
|
||||
// o(base) - base, and its own difference from the base is
|
||||
// added to it, so the texture inside the region comes out
|
||||
// at ITS OWN size and the same lift lands on the pixels
|
||||
// either way.
|
||||
//
|
||||
// The base is the whole point of the second reading: what SHADOW does to a
|
||||
// frame's texture is a property of the neighbourhood, not of the pixel. (The
|
||||
// spread here is kept WHOLE because this twin's band sits on ONE base, where the
|
||||
// reconstruction is exact by construction; on a real frame the band spans many
|
||||
// neighbourhoods and the live number is the one to read.)
|
||||
const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31));
|
||||
const spread = (xs) => Math.max(...xs) - Math.min(...xs);
|
||||
const bandBase = band.reduce((a, b) => a + b, 0) / band.length;
|
||||
const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]);
|
||||
|
||||
// Every knob on zero is the identity, whatever base is handed in
|
||||
for (const b of [0.01, 0.1, 0.35, 0.7, 0.99])
|
||||
for (const rgb of [...colourCases, ...greyCases]) {
|
||||
const g = rebuild(rgb, {}, b);
|
||||
for (let i = 0; i < 3; i++) close(g.clamped[i], rgb[i], `the base path is not the identity at zero, base ${b}`);
|
||||
}
|
||||
|
||||
// THE EXPOSURE KNOB, the same move on a different input. Behind it: -2..+2 EV in
|
||||
// half stops, on the frame and inside a gradient mask.
|
||||
// A grey is the knob it always was — a stop on a neutral is a stop on its light,
|
||||
// and nothing else: this is the number the old linear per-channel multiply put
|
||||
// there, so no exposure a user has dialled in moves.
|
||||
for (const g of [0.05, 0.1, 0.5, 0.7, 0.9, 0.97])
|
||||
for (const ev of [-2, -1, -0.5, 0.5, 1, 2])
|
||||
close(
|
||||
exposureMove([g, g, g], ev)[0],
|
||||
linToSrgb(Math.min(1, srgbToLin(g) * 2 ** ev)),
|
||||
`the exposure is no longer a stop on a grey: ${g} at ${ev} EV`,
|
||||
);
|
||||
// ...and zero stops is the identity on a COLOUR too, exactly — the knob has to be
|
||||
// able to leave the frame alone.
|
||||
for (const rgb of [...colourCases, ...greyCases])
|
||||
for (let i = 0; i < 3; i++)
|
||||
close(exposureMove(rgb, 0)[i], rgb[i], 'the exposure move is not the identity at 0 EV');
|
||||
// Hue cannot move, at any stop, on any colour: this is the whole fix. The old
|
||||
// pass multiplied the three channels by the same number in LINEAR light and then
|
||||
// clipped them by three different amounts, and the hue went with them — 29.2° on
|
||||
// the skin tone at +1 EV, 33.3° at +2 EV (scratchpad exp-variant.mjs), against
|
||||
// 0.00° here.
|
||||
for (const rgb of colourCases)
|
||||
for (const ev of [-2, -1, -0.5, 0, 0.5, 1, 2]) {
|
||||
const out = exposureMove(rgb, ev);
|
||||
const dh = hueOf(out) - hueOf(rgb);
|
||||
assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `the exposure moved the hue ${dh}° on ${rgb} at ${ev} EV`);
|
||||
assert.ok(out.every((c) => c >= -1e-12 && c <= 1 + 1e-12), `the exposure left the cube on ${rgb} at ${ev} EV`);
|
||||
}
|
||||
// A pixel already on the ceiling: the channel that used to clip lands exactly ON
|
||||
// the ceiling and the other two follow it down at the one shared scale, so the
|
||||
// pixel gives up saturation rather than having the three clip by three different
|
||||
// amounts — which is where the old pass lost the hue.
|
||||
const blown = exposureMove([1, 0.6, 0.2], 5);
|
||||
assert.ok(blown.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure overshot the ceiling');
|
||||
close(blown[0], 1, 'the channel that hit the ceiling stopped short of it');
|
||||
assert.ok(blown[1] > 0.9 && blown[2] > 0.85, 'the pixel collapsed to white instead of keeping its colour');
|
||||
assert.ok(Math.abs(hueOf(blown) - hueOf([1, 0.6, 0.2])) < 1e-9, 'the pixel lost its hue at the ceiling');
|
||||
// ...and a pixel the move really does drive to 1.0 (an exposure past the head of
|
||||
// the ramp) is white, in all three channels at once.
|
||||
const white = exposureMove([0.98, 0.98, 0.98], 5);
|
||||
for (let i = 0; i < 3; i++) close(white[i], 1, 'a blown pixel stopped short of white');
|
||||
// Darkening is the mirror: the light comes down, and a colour with no room below
|
||||
// gives up saturation and arrives neutral, not negative.
|
||||
const crushed = exposureMove([0.02, 0.01, 0.005], -5);
|
||||
assert.ok(crushed.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure went outside the cube on the way down');
|
||||
assert.ok(crushed[0] >= crushed[1] && crushed[1] >= crushed[2], 'the exposure inverted the channel order on the way down');
|
||||
// Black has no light to move: every stop leaves it where it is, and none of them
|
||||
// divides by zero on the way.
|
||||
for (const ev of [-5, -1, 0, 1, 5]) assert.equal(exposureMove([0, 0, 0], ev)[0], 0, `black moved at ${ev} EV`);
|
||||
|
||||
// THE PASS ITSELF, compiled and run. Everything above is a twin, and a twin is
|
||||
// only as good as its reading of the source; nothing else compiles EXPOSURE_SKSL,
|
||||
// so a wrapper whose uniform stopped matching its own main would only show up in
|
||||
// the app. Four pixels through the real shader, against the twin.
|
||||
const exposureSrc = EXPOSURE_SKSL;
|
||||
assert.match(exposureSrc, /uniform float ev;/, 'the exposure pass no longer takes its stops');
|
||||
assert.match(exposureSrc, /return vec4\(exposureMove\(clamp\(c\.rgb, 0\.0, 1\.0\), ev\), c\.a\);/, 'the pass stopped calling exposureMove');
|
||||
const { default: CanvasKitInit } = await import('canvaskit-wasm/bin/full/canvaskit.js');
|
||||
const ck = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
const effect = ck.RuntimeEffect.Make(exposureSrc);
|
||||
assert.ok(effect, 'EXPOSURE_SKSL does not compile — the whole frame loses its exposure');
|
||||
const throughPass = (rgb, ev) => {
|
||||
const surface = ck.MakeSurface(4, 4);
|
||||
const paint = new ck.Paint();
|
||||
paint.setColor(ck.Color(...rgb));
|
||||
surface.getCanvas().drawPaint(paint);
|
||||
const child = surface.makeImageSnapshot().makeShaderOptions(
|
||||
ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Linear, ck.MipmapMode.None,
|
||||
);
|
||||
const shaderPaint = new ck.Paint();
|
||||
shaderPaint.setShader(effect.makeShaderWithChildren([ev], [child]));
|
||||
const out = ck.MakeSurface(4, 4);
|
||||
out.getCanvas().drawRect(ck.XYWHRect(0, 0, 4, 4), shaderPaint);
|
||||
const px = out.getCanvas().readPixels(0, 1, {
|
||||
width: 4, height: 1, colorType: ck.ColorType.RGBA_8888, alphaType: ck.AlphaType.Unpremul, colorSpace: ck.ColorSpace.SRGB,
|
||||
});
|
||||
return [px[0], px[1], px[2]];
|
||||
};
|
||||
for (const [rgb, ev] of [[[128, 128, 128], 1], [[230, 150, 50], 1], [[230, 150, 50], 2], [[20, 10, 5], -2]]) {
|
||||
const want = exposureMove(rgb.map((v) => v / 255), ev).map((v) => Math.round(v * 255));
|
||||
const got = throughPass(rgb, ev);
|
||||
assert.ok(
|
||||
got.every((v, i) => Math.abs(v - want[i]) <= 1),
|
||||
`the pass and its twin disagree on ${rgb} at ${ev} EV: ${got} against ${want}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('highlight-knee-check ok');
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
// Immich as a folder beside the folders on the disk: the dialog that adds a
|
||||
// server, what the column draws for it, what an album tick means, what the
|
||||
// album-ID field makes of a whole address and of rubbish, and the one thing that
|
||||
// must never happen — the page talking to Immich itself.
|
||||
//
|
||||
// The app answers `/api` on its own origin, and the Immich side of it is stood in
|
||||
// for here the way `backend/src/immich.ts` answers: two keys that see two
|
||||
// different sets of albums, one of them without the right to download originals,
|
||||
// and a share link, which is one album and no picker.
|
||||
//
|
||||
// npm run build && node scripts/immich-check.mjs
|
||||
// APP_PORT=4410 PLAYWRIGHT_CORE=<playwright-core/index.mjs> CHROME=<chrome> \
|
||||
// node scripts/immich-check.mjs
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 4410);
|
||||
|
||||
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');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- the server side of the check -------------------------------------------
|
||||
|
||||
// A uuid per thing, spelled the way the backend spells one: eight, four, four,
|
||||
// four, twelve, all hex. The routes that take an id refuse anything else.
|
||||
const uid = (n) => `${String(n).padStart(8, '0')}-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
const A1 = uid(101);
|
||||
const A2 = uid(102);
|
||||
const B1 = uid(201);
|
||||
const ALBUM_NAME = { [A1]: 'A1', [A2]: 'A2', [B1]: 'B1' };
|
||||
// What each album holds: five frames over the first two, one over the third.
|
||||
const ASSETS = [];
|
||||
let nth = 0;
|
||||
for (const [album, names] of [
|
||||
[A1, ['A1_1.JPG', 'A1_2.JPG']],
|
||||
[A2, ['A2_1.JPG', 'A2_2.JPG', 'A2_3.JPG']],
|
||||
[B1, ['B1_1.JPG']],
|
||||
]) {
|
||||
for (const name of names) ASSETS.push({ id: uid(1000 + ++nth), album, name });
|
||||
}
|
||||
|
||||
// The three boxes an address can name, and the key each of them answers to.
|
||||
const HOSTS = {
|
||||
'https://photos.example.com': { key: 'good-key', version: '1.120.0', canDownload: true, albums: [A1, A2] },
|
||||
'https://view.example.com': { key: 'view-only', version: '1.119.0', canDownload: false, albums: [B1] },
|
||||
'https://share.example.com': { key: 'share-xyz', version: null, canDownload: false, albums: [B1] },
|
||||
};
|
||||
|
||||
// What the backend holds: a saved server, by the id it minted.
|
||||
const saved = [];
|
||||
let minted = 0;
|
||||
|
||||
// Album ids as the backend cleans them: a uuid out of whatever was typed, once
|
||||
// each, at most two hundred of them.
|
||||
function cleanAlbums(raw) {
|
||||
const out = [];
|
||||
for (const one of Array.isArray(raw) ? raw : []) {
|
||||
const hit = String(one).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||
const id = hit ? hit[0].toLowerCase() : '';
|
||||
if (id && !out.includes(id)) out.push(id);
|
||||
}
|
||||
return out.slice(0, 200);
|
||||
}
|
||||
|
||||
// One pixel of PNG, so a tile that lands is a picture the browser decodes.
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
// Anything the page asks a photos host for directly. Nothing may: the address and
|
||||
// the key live on the backend, and the page only ever calls its own origin.
|
||||
let direct = 0;
|
||||
let thumbs = 0;
|
||||
|
||||
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');
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
||||
await context.route(/^https:\/\/[a-z]+\.example\.com\//, (route) => {
|
||||
direct++;
|
||||
return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
|
||||
});
|
||||
|
||||
await context.route('**/api/**', (route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
const path = url.pathname;
|
||||
const send = (status, body) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
const body = () => {
|
||||
try {
|
||||
return JSON.parse(req.postData() ?? '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const host = (serverId) => HOSTS[saved.find((s) => s.id === serverId)?.url ?? ''];
|
||||
const view = (s) => ({ ...s });
|
||||
|
||||
if (path.includes('/auth/me')) return send(200, { user: { id: 1, email: 'check@example.com', name: 'check' } });
|
||||
if (path.includes('/photos')) return send(200, { photos: [] });
|
||||
if (path.includes('/recipes')) return send(200, { recipes: [] });
|
||||
|
||||
if (path === '/api/immich/config') {
|
||||
if (req.method() === 'GET') {
|
||||
return send(200, { ok: true, servers: saved.map(view) });
|
||||
}
|
||||
if (req.method() === 'DELETE') {
|
||||
const at = saved.findIndex((s) => s.id === url.searchParams.get('id'));
|
||||
if (at < 0) return send(404, { ok: false, error: 'invalid' });
|
||||
saved.splice(at, 1);
|
||||
return send(200, { ok: true, servers: saved.map(view) });
|
||||
}
|
||||
const b = body();
|
||||
// An edit names the node and says only what changes — a new key, or nothing
|
||||
// at all — so what it does not repeat is what the node already has.
|
||||
const at = b.id ? saved.findIndex((s) => s.id === b.id) : -1;
|
||||
if (b.id && at < 0) return send(404, { ok: false, error: 'invalid' });
|
||||
const had = at < 0 ? null : saved[at];
|
||||
const url0 = b.url ?? had?.url;
|
||||
const key0 = b.key ?? (had ? HOSTS[had.url]?.key : undefined);
|
||||
if (!url0 || !key0) return send(400, { ok: false, error: 'invalid' });
|
||||
const box = HOSTS[url0];
|
||||
if (!box) return send(200, { ok: false, error: 'unreachable' });
|
||||
if (box.key !== key0) return send(200, { ok: false, error: 'auth' });
|
||||
const server = {
|
||||
id: had?.id ?? `s${++minted}`,
|
||||
name: String(b.name ?? had?.name ?? '').trim() || new URL(url0).hostname,
|
||||
url: url0,
|
||||
type: (b.type ?? had?.type) === 'share' ? 'share' : 'api',
|
||||
version: box.version,
|
||||
canDownload: box.canDownload,
|
||||
albums: had && b.albums === undefined ? had.albums : cleanAlbums(b.albums),
|
||||
keyMasked: `••••${String(key0).slice(-4)}`,
|
||||
};
|
||||
if (had) saved[at] = server;
|
||||
else saved.push(server);
|
||||
return send(200, { ok: true, server: view(server) });
|
||||
}
|
||||
|
||||
if (path === '/api/immich/probe') {
|
||||
const b = body();
|
||||
if (!b.url || !b.key) return send(400, { ok: false, error: 'invalid' });
|
||||
const box = HOSTS[b.url];
|
||||
if (!box) return send(200, { ok: false, error: 'unreachable' });
|
||||
if (box.key !== b.key) return send(200, { ok: false, error: 'auth' });
|
||||
return send(200, {
|
||||
ok: true,
|
||||
version: box.version,
|
||||
albums: box.albums.map((id) => ({
|
||||
id,
|
||||
albumName: ALBUM_NAME[id],
|
||||
assetCount: ASSETS.filter((a) => a.album === id).length,
|
||||
coverId: null,
|
||||
shared: false,
|
||||
})),
|
||||
canDownload: box.canDownload,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/immich/albums') {
|
||||
if (req.method() === 'PUT') {
|
||||
const b = body();
|
||||
const server = saved.find((s) => s.id === b.id);
|
||||
if (!server) return send(404, { ok: false, error: 'invalid' });
|
||||
server.albums = cleanAlbums(b.selected);
|
||||
return send(200, { ok: true, albums: server.albums });
|
||||
}
|
||||
const box = host(url.searchParams.get('id') ?? '');
|
||||
if (!box) return send(502, { ok: false, error: 'unreachable' });
|
||||
return send(200, {
|
||||
ok: true,
|
||||
albums: box.albums.map((id) => ({
|
||||
id,
|
||||
albumName: ALBUM_NAME[id],
|
||||
assetCount: ASSETS.filter((a) => a.album === id).length,
|
||||
coverId: null,
|
||||
shared: false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/immich/assets') {
|
||||
const box = host(url.searchParams.get('server') ?? '');
|
||||
if (!box) return send(502, { ok: false, error: 'unreachable' });
|
||||
const album = url.searchParams.get('album');
|
||||
const page = Number(url.searchParams.get('page') ?? 1);
|
||||
const size = Number(url.searchParams.get('size') ?? 200);
|
||||
const rows = ASSETS.filter((a) => (album && album !== 'all' ? a.album === album : box.albums.includes(a.album)));
|
||||
const items = rows.slice((page - 1) * size, page * size).map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
takenAt: '2026-04-01T10:00:00.000Z',
|
||||
width: 6000,
|
||||
height: 4000,
|
||||
size: 12345678,
|
||||
}));
|
||||
return send(200, { ok: true, page, size, total: rows.length, hasMore: items.length === size, items });
|
||||
}
|
||||
|
||||
if (path === '/api/immich/thumb' || path === '/api/immich/original') {
|
||||
const id = url.searchParams.get('id') ?? '';
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||
return send(400, { ok: false, error: 'invalid' });
|
||||
}
|
||||
const box = host(url.searchParams.get('server') ?? '');
|
||||
if (!box) return send(502, { ok: false, error: 'unreachable' });
|
||||
if (path.endsWith('original') && !box.canDownload) return send(403, { ok: false, error: 'auth' });
|
||||
if (path.endsWith('thumb')) thumbs++;
|
||||
return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
|
||||
}
|
||||
|
||||
return send(404, { ok: false, error: 'no' });
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${base}/library`);
|
||||
|
||||
// The rows the column draws, and the number beside each name.
|
||||
const counts = () =>
|
||||
page.$$eval('[data-key^="lib-node-"]', (els) => {
|
||||
const out = {};
|
||||
for (const e of els) {
|
||||
out[e.dataset.key.replace('lib-node-', '')] = (e.querySelector('.adm-album-count')?.textContent ?? '').trim();
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
|
||||
const settle = (key, n) =>
|
||||
page
|
||||
.waitForFunction(
|
||||
({ key, n }) => {
|
||||
const row = document.querySelector(`[data-key="lib-node-${key}"]`);
|
||||
return (row?.querySelector('.adm-album-count')?.textContent ?? '').trim() === String(n);
|
||||
},
|
||||
{ key, n },
|
||||
{ timeout: 30_000, polling: 50 }
|
||||
)
|
||||
.catch(() => {});
|
||||
const open = async (name) => {
|
||||
// A node's albums are rows under it, and the column folds a folder away until
|
||||
// it is opened — the same click a reader makes.
|
||||
const row = `[data-key="lib-node-${name}"]`;
|
||||
if ((await page.getAttribute(row, 'aria-expanded')) === 'false') await page.click(row);
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
const addDialog = async (url, key, share = false) => {
|
||||
await page.click('[data-key="lib-add-immich"]');
|
||||
await page.waitForSelector('[data-key="immich-url"]');
|
||||
await page.fill('[data-key="immich-url"]', url);
|
||||
await page.fill('[data-key="immich-key"]', key);
|
||||
if (share) await page.check('[data-key="immich-share"]');
|
||||
await page.click('[data-key="immich-check"]');
|
||||
await page.waitForSelector('[data-key="immich-status"]');
|
||||
};
|
||||
const editDialog = async (node) => {
|
||||
await page.click(`[data-key="lib-node-${node}"]`, { button: 'right' });
|
||||
await page.waitForSelector(`[data-key="lib-immich-albums-${node}"]`);
|
||||
await page.click(`[data-key="lib-immich-albums-${node}"]`);
|
||||
await page.waitForSelector('[data-key="immich-ids"]');
|
||||
// The albums are asked for live, so the list may land a moment after the form.
|
||||
await page.waitForSelector('[data-key="immich-albums"]').catch(() => {});
|
||||
};
|
||||
|
||||
// --- adding a server ---------------------------------------------------------
|
||||
await page.waitForSelector('[data-key="lib-add-immich"]', { timeout: 60_000 });
|
||||
await page.click('[data-key="lib-add-immich"]');
|
||||
await page.waitForSelector('[data-key="immich-url"]');
|
||||
step(
|
||||
'the dialog opens blank: the address is each reader’s to type, with a nudge in the field',
|
||||
(await page.inputValue('[data-key="immich-url"]')) === '' &&
|
||||
(await page.getAttribute('[data-key="immich-url"]', 'placeholder')) === 'https://photos.example.com',
|
||||
await page.inputValue('[data-key="immich-url"]')
|
||||
);
|
||||
await page.fill('[data-key="immich-url"]', 'https://photos.example.com');
|
||||
await page.fill('[data-key="immich-key"]', 'good-key');
|
||||
await page.click('[data-key="immich-check"]');
|
||||
await page.waitForSelector('[data-key="immich-status"]');
|
||||
const saidOk = await page.$eval('[data-key="immich-status"]', (e) => !e.className.includes('err'));
|
||||
step('a key that answers is said to be good', saidOk);
|
||||
step(
|
||||
'and the door asks for nothing else: a fresh server is every album its key can see',
|
||||
(await page.$('[data-key="immich-albums"]')) === null &&
|
||||
(await page.$('[data-key="immich-album-' + A1 + '"]')) === null
|
||||
);
|
||||
// The ID field is the only place an album can be named at the door, and it says
|
||||
// what it made of the lines: one with no id in it, and one this key never named.
|
||||
await page.fill('[data-key="immich-ids"]', `khong-phai-uuid\n${B1}`);
|
||||
await page.waitForSelector('[data-key="immich-bad-ids"]', { timeout: 5_000 }).catch(() => {});
|
||||
step(
|
||||
'a line with no album id in it is a line the field says it dropped',
|
||||
(await page.$('[data-key="immich-bad-ids"]')) !== null &&
|
||||
(await page.$eval('[data-key="immich-bad-ids"]', (e) => e.textContent)).includes('1'),
|
||||
await page.$eval('[data-key="immich-bad-ids"]', (e) => e.textContent).catch(() => 'no warning')
|
||||
);
|
||||
step(
|
||||
'and an id this key cannot see is an id the field says it does not know',
|
||||
(await page.$('[data-key="immich-unknown-ids"]')) !== null,
|
||||
await page.$eval('[data-key="immich-unknown-ids"]', (e) => e.textContent).catch(() => 'no warning')
|
||||
);
|
||||
await page.fill('[data-key="immich-ids"]', '');
|
||||
await page.waitForTimeout(200);
|
||||
step(
|
||||
'and clearing the field takes both warnings with it',
|
||||
(await page.$('[data-key="immich-bad-ids"]')) === null &&
|
||||
(await page.$('[data-key="immich-unknown-ids"]')) === null
|
||||
);
|
||||
await page.click('[data-key="immich-save"]');
|
||||
await settle('Immich', 5);
|
||||
await open('Immich');
|
||||
step(
|
||||
'the server becomes a folder on the column, counted album by album',
|
||||
same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }),
|
||||
JSON.stringify(await counts())
|
||||
);
|
||||
step(
|
||||
'and its node wears the source and the name it was given',
|
||||
(await page.$eval('[data-key="lib-node-Immich"] .adm-album-name', (e) => e.textContent)) ===
|
||||
'Immich · photos.example.com'
|
||||
);
|
||||
step('and its frames were tiled off the backend, not off Immich', thumbs > 0, `${thumbs} tiles`);
|
||||
|
||||
// --- an album unticked -------------------------------------------------------
|
||||
await editDialog('Immich');
|
||||
await page.uncheck('[data-key="immich-album-' + A2 + '"]');
|
||||
await page.click('[data-key="immich-save"]');
|
||||
await settle('Immich', 2);
|
||||
await open('Immich');
|
||||
step(
|
||||
'an album unticked is an album the column stops drawing',
|
||||
same(await counts(), { Immich: '2', 'Immich/A1': '2' }),
|
||||
JSON.stringify(await counts())
|
||||
);
|
||||
|
||||
// --- the album-ID field ------------------------------------------------------
|
||||
// A whole address is an id, and a line that is not a uuid is nothing at all: the
|
||||
// reader copies an album out of their browser and pastes what they got.
|
||||
await editDialog('Immich');
|
||||
await page.uncheck('[data-key="immich-album-' + A1 + '"]');
|
||||
await page.fill('[data-key="immich-ids"]', `https://photos.example.com/albums/${A2}?x=1\nkhong-phai-uuid`);
|
||||
await page.click('[data-key="immich-save"]');
|
||||
await settle('Immich', 3);
|
||||
await open('Immich');
|
||||
step(
|
||||
'an album pasted as a whole address is read as its id, and a line that is not one is dropped',
|
||||
same(await counts(), { Immich: '3', 'Immich/A2': '3' }),
|
||||
JSON.stringify(await counts())
|
||||
);
|
||||
|
||||
// Nothing ticked and nothing typed is the empty state, which means every album
|
||||
// the key can see — a decision, not a missing value.
|
||||
await editDialog('Immich');
|
||||
await page.uncheck('[data-key="immich-album-' + A2 + '"]');
|
||||
await page.fill('[data-key="immich-ids"]', '');
|
||||
await page.click('[data-key="immich-save"]');
|
||||
await settle('Immich', 5);
|
||||
await open('Immich');
|
||||
step(
|
||||
'nothing ticked means every album the key can see, not none of them',
|
||||
same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }),
|
||||
JSON.stringify(await counts())
|
||||
);
|
||||
|
||||
// --- a key given to a node that is already there -----------------------------
|
||||
// The one place a node's key is replaced: the same dialog that narrows its
|
||||
// albums. It is probed before it is stored, so a key that does not answer is a
|
||||
// dialog still open on a node unchanged.
|
||||
await editDialog('Immich');
|
||||
await page.fill('[data-key="immich-newkey"]', 'not-the-key');
|
||||
await page.click('[data-key="immich-save"]');
|
||||
await page.waitForSelector('[data-key="immich-error"]', { timeout: 5_000 }).catch(() => {});
|
||||
step(
|
||||
'a key given in place of the one it has is refused out loud, dialog still open',
|
||||
(await page.$('[data-key="immich-error"]')) !== null &&
|
||||
(await page.$('[data-key="immich-modal"]')) !== null,
|
||||
await page.$eval('[data-key="immich-error"]', (e) => e.textContent).catch(() => 'no word said')
|
||||
);
|
||||
await page.fill('[data-key="immich-newkey"]', 'good-key');
|
||||
await page.click('[data-key="immich-save"]');
|
||||
await page.waitForSelector('[data-key="immich-modal"]', { state: 'detached', timeout: 10_000 }).catch(() => {});
|
||||
await settle('Immich', 5);
|
||||
await open('Immich');
|
||||
step(
|
||||
'and one that answers is taken, the node and its albums exactly as they were',
|
||||
(await page.$('[data-key="immich-modal"]')) === null &&
|
||||
same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }),
|
||||
JSON.stringify(await counts())
|
||||
);
|
||||
|
||||
// --- a share link ------------------------------------------------------------
|
||||
await addDialog('https://share.example.com', 'share-xyz', true);
|
||||
step('a share link is not offered an album picker: it is one album', (await page.$('[data-key="immich-albums"]')) === null);
|
||||
await page.click('[data-key="immich-save"]');
|
||||
await page.waitForSelector('[data-key="lib-node-Immich 2"]', { timeout: 30_000 });
|
||||
await settle('Immich 2', 1);
|
||||
step(
|
||||
'and it becomes a second node beside the first, named by its own server',
|
||||
(await page.$eval('[data-key="lib-node-Immich 2"] .adm-album-name', (e) => e.textContent)) ===
|
||||
'Immich · share.example.com',
|
||||
JSON.stringify(await counts())
|
||||
);
|
||||
await page.click('[data-key="lib-node-Immich 2"]', { button: 'right' });
|
||||
await page.waitForSelector('[data-key="lib-immich-sync-Immich 2"]');
|
||||
step(
|
||||
'and its menu carries no album picker',
|
||||
(await page.$('[data-key="lib-immich-albums-Immich 2"]')) === null
|
||||
);
|
||||
step(
|
||||
'nor a rescan, a rename or a tile job — those are the disk’s',
|
||||
(await page.$('[data-key="lib-rescan-Immich 2"]')) === null &&
|
||||
(await page.$('[data-key="lib-rename-Immich 2"]')) === null &&
|
||||
(await page.$('[data-key="lib-thumbs-Immich 2"]')) === null
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// --- a key that does not work ------------------------------------------------
|
||||
await addDialog('https://photos.example.com', 'not-the-key');
|
||||
const saidBad = await page.$eval('[data-key="immich-status"]', (e) => e.className.includes('err'));
|
||||
step('a key that is refused is said to be refused', saidBad);
|
||||
await page.click('[data-key="immich-save"]');
|
||||
await page.waitForTimeout(500);
|
||||
const stillSaving = (await page.$('[data-key="immich-save"]')) !== null;
|
||||
step(
|
||||
'and a refused key is never a node: nothing is stored and the dialog stays open',
|
||||
stillSaving && !('Immich 3' in (await counts())),
|
||||
JSON.stringify(await counts())
|
||||
);
|
||||
await page.click('[data-key="immich-cancel"]');
|
||||
|
||||
// --- what must never happen --------------------------------------------------
|
||||
step('the page never talks to Immich itself', direct === 0, `${direct} calls off-origin`);
|
||||
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(failures ? `\n${failures} failed` : '\nall ok');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,388 @@
|
||||
// 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.
|
||||
//
|
||||
// What it holds down is the two faults that read the same on the screen and have
|
||||
// nothing to do with each other:
|
||||
//
|
||||
// 1. The catalogue is a folder the visitor picked, and the browser keeps the
|
||||
// handle across a reload while dropping the permission on it. Only a gesture
|
||||
// can hand it back, so the click on the folder is where it is asked for —
|
||||
// both for the folder's own handle and for the handle a row of an older build
|
||||
// carries. Without the ask every frame of a folder read yesterday answers
|
||||
// "moved or deleted" while the file sits untouched on the disk.
|
||||
// 2. Two builds behind one domain — a test server and a release server — open
|
||||
// the one database the origin owns. A build that names a version older than
|
||||
// the one on the disk is answered `VersionError` and every read falls back on
|
||||
// an empty column, with no note and nothing to read off the screen.
|
||||
//
|
||||
// npm run build && node scripts/library-check.mjs
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/library-check.mjs
|
||||
// CHROME=<path to a chromium> PLAYWRIGHT_CORE=... node scripts/library-check.mjs
|
||||
// BASE=http://127.0.0.1:8090 node scripts/library-check.mjs # a server already up
|
||||
//
|
||||
// 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. Only Chromium can hand a
|
||||
// page a folder, and only a person can answer the picker: the check stands in for
|
||||
// both, serving two real frames off a local server and answering
|
||||
// `showDirectoryPicker` with a folder made of them.
|
||||
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';
|
||||
const DB = 'recipescam-library';
|
||||
const STORES = ['folders', 'photos', 'thumbs', 'edits', 'dirs'];
|
||||
|
||||
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': '*',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
});
|
||||
res.end(bytes);
|
||||
});
|
||||
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
|
||||
|
||||
// A server already up, when one is named; otherwise the build in `dist/`.
|
||||
let app = null;
|
||||
const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`;
|
||||
if (!process.env.BASE) {
|
||||
app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||||
cwd: FRONTEND,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
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'],
|
||||
});
|
||||
|
||||
// A folder of one frame, handed over the way the browser hands one over: the
|
||||
// handle is kept across a reload, its permission is not, and only a gesture can
|
||||
// hand the permission back. `check.allow` stands in for the gesture, since a run
|
||||
// has no hand; `check.prompt` is the reload that dropped the permission.
|
||||
const INIT = ({ origin, names, folder }) => {
|
||||
const asked = () => localStorage.getItem('check.prompt') === '1';
|
||||
const granted = () => localStorage.getItem('check.granted') === '1';
|
||||
const bump = (key) => localStorage.setItem(key, String(Number(localStorage.getItem(key) ?? 0) + 1));
|
||||
const perm = () => (!asked() || granted() ? 'granted' : 'prompt');
|
||||
const refuse = (what) => {
|
||||
bump('check.refused');
|
||||
localStorage.setItem(`check.refused.${what}`, '1');
|
||||
throw new DOMException('no permission', 'NotAllowedError');
|
||||
};
|
||||
const bytes = new Map();
|
||||
const load = async (n) => {
|
||||
if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer()));
|
||||
return bytes.get(n);
|
||||
};
|
||||
// The browser's answer to an ask for permission: granted with nothing to ask,
|
||||
// refused while the screen is drawing itself, and granted off the reader's own
|
||||
// gesture.
|
||||
const askPerm = async () => {
|
||||
bump('check.requested');
|
||||
if (!asked()) return 'granted';
|
||||
if (localStorage.getItem('check.allow') !== '1') return 'denied';
|
||||
bump('check.answered');
|
||||
localStorage.setItem('check.granted', '1');
|
||||
return 'granted';
|
||||
};
|
||||
const fileHandle = (name) => {
|
||||
const h = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperties(h, {
|
||||
getFile: {
|
||||
value: async () => {
|
||||
if (perm() !== 'granted') refuse('getFile');
|
||||
return new File([await load(name)], name, {
|
||||
type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream',
|
||||
lastModified: Date.UTC(2026, 0, 1),
|
||||
});
|
||||
},
|
||||
},
|
||||
// A real file handle answers these as well, and the row a catalogue of an
|
||||
// older build wrote is opened off its own handle.
|
||||
queryPermission: { value: async () => perm() },
|
||||
requestPermission: { value: askPerm },
|
||||
});
|
||||
return h;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
const h = { kind: 'directory', name: label, __fake: true };
|
||||
Object.defineProperties(h, {
|
||||
values: {
|
||||
value: () =>
|
||||
[
|
||||
...(node.files ?? []).map(fileHandle),
|
||||
...Object.entries(node.dirs ?? {}).map(([n, k]) => dirHandle(n, k)),
|
||||
][Symbol.iterator](),
|
||||
},
|
||||
queryPermission: { value: async () => perm() },
|
||||
requestPermission: { value: askPerm },
|
||||
getDirectoryHandle: {
|
||||
value: async (name) => {
|
||||
const k = node.dirs?.[name];
|
||||
if (!k) throw new DOMException('no folder', 'NotFoundError');
|
||||
return dirHandle(name, k);
|
||||
},
|
||||
},
|
||||
getFileHandle: {
|
||||
value: async (name) => {
|
||||
if (!(node.files ?? []).includes(name)) throw new DOMException('no frame', 'NotFoundError');
|
||||
if (perm() !== 'granted') refuse('getFileHandle');
|
||||
return fileHandle(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
return h;
|
||||
};
|
||||
const root = () => dirHandle(folder, { files: names, dirs: {} });
|
||||
window.showDirectoryPicker = async () => root();
|
||||
// A handle out of IndexedDB is a plain object with no methods; put them back.
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile'))
|
||||
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
|
||||
return row;
|
||||
};
|
||||
for (const m of ['get', 'getAll']) {
|
||||
const original = IDBObjectStore.prototype[m];
|
||||
IDBObjectStore.prototype[m] = function (...a) {
|
||||
const req = original.apply(this, a);
|
||||
const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
|
||||
Object.defineProperty(req, 'result', {
|
||||
configurable: true,
|
||||
get() {
|
||||
const v = d.get.call(this);
|
||||
return Array.isArray(v) ? v.map(revive) : revive(v);
|
||||
},
|
||||
});
|
||||
return req;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// The library is signed-in ground and neither `vite preview` nor a bare container
|
||||
// has an API behind it, so a run is a guest the app bounces back to the landing
|
||||
// page before the column is ever drawn. The one call that decides which it is is
|
||||
// answered here; the rest of `/api` fails the way a serverless build's does.
|
||||
const open = async () => {
|
||||
const context = await browser.newContext();
|
||||
await context.route('**/api/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } }),
|
||||
})
|
||||
);
|
||||
await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], folder: 'CheckRoll' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
const logs = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.on('console', (m) => m.type() === 'error' && logs.push(m.text()));
|
||||
return { context, page, errors, logs };
|
||||
};
|
||||
|
||||
const tiles = '[data-key^="lib-thumb-"]';
|
||||
const status = (page) =>
|
||||
page
|
||||
.$eval('[data-key="lib-status"]', (e) => e.textContent.trim())
|
||||
.catch(() => '');
|
||||
// The handover into the studio is an address; the studio reads it and clears it,
|
||||
// so a run that opened a frame lands on `/app`.
|
||||
const inStudio = (page) => /^\/app(\?|$)/.test(new URL(page.url()).pathname + new URL(page.url()).search);
|
||||
const openFrame = async (page) => {
|
||||
await page.evaluate(() => localStorage.setItem('check.allow', '1'));
|
||||
await page.click('[data-key="lib-open"]');
|
||||
await page.waitForTimeout(3000);
|
||||
};
|
||||
|
||||
// --- 1. a folder whose permission the reload dropped -------------------------
|
||||
{
|
||||
const { context, page, errors } = await open();
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector(tiles, { timeout: 120_000 });
|
||||
step('the roll is indexed', true);
|
||||
|
||||
// The tab closed: the browser kept the handle, not the permission.
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('check.prompt', '1');
|
||||
for (const k of ['check.granted', 'check.requested', 'check.refused', 'check.allow', 'check.answered'])
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForSelector(tiles, { timeout: 60_000 });
|
||||
const noOneToAnswer = await page.evaluate(() => localStorage.getItem('check.granted'));
|
||||
const startupAsks = await page.evaluate(() => Number(localStorage.getItem('check.requested') ?? 0));
|
||||
|
||||
const before = errors.length;
|
||||
await openFrame(page);
|
||||
const answered = Number(await page.evaluate(() => localStorage.getItem('check.answered') ?? 0));
|
||||
const asked = Number(await page.evaluate(() => localStorage.getItem('check.requested') ?? 0));
|
||||
const granted = await page.evaluate(() => localStorage.getItem('check.granted'));
|
||||
const note = await status(page);
|
||||
|
||||
step('the folder really has no permission while the screen draws itself', noOneToAnswer === null, `start-up asked x${startupAsks}, granted=${noOneToAnswer}`);
|
||||
step('the click asks the browser for the folder back', answered > 0 && granted === '1', `answered x${answered} of x${asked}`);
|
||||
step('the frame opens once the click has handed the permission back', inStudio(page), page.url());
|
||||
step(
|
||||
'no "moved or deleted" note for a frame that is still on the disk',
|
||||
inStudio(page) && !/moved or deleted|di chuyển|lib\.missing/.test(note),
|
||||
note || '(no note)'
|
||||
);
|
||||
step('no uncaught error while opening', errors.length === before, errors.slice(before).join(' | '));
|
||||
await context.close();
|
||||
}
|
||||
|
||||
// --- 2. a catalogue a newer build wrote -------------------------------------
|
||||
{
|
||||
const { context, page, errors, logs } = await open();
|
||||
await page.goto(`${base}/`);
|
||||
await page.evaluate(
|
||||
({ DB, STORES }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB, 99);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORES[0])) db.createObjectStore(STORES[0], { keyPath: 'name' });
|
||||
if (!db.objectStoreNames.contains(STORES[1])) {
|
||||
const s = db.createObjectStore(STORES[1], { keyPath: 'id' });
|
||||
s.createIndex('folder', 'folder');
|
||||
s.createIndex('taken', 'taken');
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORES[2])) db.createObjectStore(STORES[2], { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(STORES[3])) db.createObjectStore(STORES[3], { keyPath: 'photoId' });
|
||||
if (!db.objectStoreNames.contains(STORES[4])) db.createObjectStore(STORES[4], { keyPath: 'folder' });
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
req.result.close();
|
||||
resolve();
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
}),
|
||||
{ DB, STORES }
|
||||
);
|
||||
step('a catalogue stands at version 99', true);
|
||||
|
||||
const before = errors.length;
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector(tiles, { timeout: 120_000 });
|
||||
step('the older build reads and writes the newer catalogue', true);
|
||||
|
||||
const version = await page.evaluate(
|
||||
async (DB) => (await indexedDB.databases()).find((d) => d.name === DB)?.version,
|
||||
DB
|
||||
);
|
||||
step('it was taken as it stood, not upgraded', version === 99, `version ${version}`);
|
||||
step('no uncaught error', errors.length === before, errors.slice(before).join(' | '));
|
||||
step(
|
||||
'no "the database is missing"',
|
||||
!logs.some((l) => l.includes('the database is missing')),
|
||||
logs.join(' | ')
|
||||
);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
// --- 3. a frame out of a catalogue an older build wrote ---------------------
|
||||
// That build kept a handle on every row, and a handle out of IndexedDB comes back
|
||||
// without its permission. The shortcut that opens a frame off its own row has to
|
||||
// ask for it back the same way the folder's does, or the frame answers "nothing
|
||||
// at" while it sits untouched on the disk.
|
||||
{
|
||||
const { context, page, errors } = await open();
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector(tiles, { timeout: 120_000 });
|
||||
|
||||
const legacy = await page.evaluate(
|
||||
(name) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(name);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction('photos', 'readwrite');
|
||||
const store = tx.objectStore('photos');
|
||||
const all = store.getAll();
|
||||
all.onsuccess = () => {
|
||||
const rows = all.result;
|
||||
// A record, not a live handle: the browser hands it back with its
|
||||
// methods and with no permission.
|
||||
for (const row of rows) store.put({ ...row, handle: { kind: 'file', name: row.name, __fake: true } });
|
||||
resolve(rows.map((r) => r.id));
|
||||
};
|
||||
all.onerror = () => reject(all.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
}),
|
||||
DB
|
||||
);
|
||||
step('the catalogue keeps a handle on every row, as the older build wrote it', legacy.length === 1, legacy.join());
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('check.prompt', '1');
|
||||
for (const k of ['check.granted', 'check.requested', 'check.allow', 'check.answered']) localStorage.removeItem(k);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForSelector(tiles, { timeout: 60_000 });
|
||||
|
||||
const before = errors.length;
|
||||
await openFrame(page);
|
||||
const note = await status(page);
|
||||
step(
|
||||
'a frame off a legacy row opens instead of reading "nothing at"',
|
||||
inStudio(page) && !/nothing at|lib\.missing/.test(note),
|
||||
note || page.url()
|
||||
);
|
||||
step('no uncaught error', errors.length === before, errors.slice(before).join(' | '));
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
sampleServer.close();
|
||||
app?.kill('SIGTERM');
|
||||
console.log(failures ? `\n${failures} FAILED` : '\nall ok');
|
||||
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,209 @@
|
||||
// A gradient mask carries the LIGHT column's WB pair now — COLOR TEMP and TINT,
|
||||
// on the mask's own pixels. Two things can go wrong and neither shows up as a
|
||||
// crash: the mask's gain drifts from the frame-wide one (two opinions about what
|
||||
// 3000K means), or the uniform block and the shader's declarations fall out of
|
||||
// step (one buffer, expanded arrays — an off-by-one there reads the frame size as
|
||||
// a gain). So this checks the arithmetic and then renders the pass for real:
|
||||
// CanvasKit is already a dependency, so the shader is compiled and a mid-grey is
|
||||
// pushed through it rather than the source being eyeballed.
|
||||
//
|
||||
// node scripts/mask-wb-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 { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
||||
|
||||
const transpile = (path) =>
|
||||
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
// gradientMask.ts imports its two helpers, so the graph is transpiled into one
|
||||
// temp directory and the specifiers are pointed at the .mjs copies (the
|
||||
// convention auto-tone-check.mjs uses). colorUtils and toneShader import types
|
||||
// only, so the compiler drops those lines itself.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mask-wb-check-'));
|
||||
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
writeFileSync(
|
||||
join(dir, 'toneShader.mjs'),
|
||||
transpile('../shared/utils/toneShader.ts').replace(
|
||||
/^import .*from ['"]\.\/colorUtils['"];$/m,
|
||||
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
|
||||
),
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'gradientMask.mjs'),
|
||||
transpile('../shared/utils/gradientMask.ts')
|
||||
.replace(/^import .*from ['"]\.\/colorUtils['"];$/m, 'import { whiteBalanceGain } from "./colorUtils.mjs";')
|
||||
.replace(
|
||||
/^import \{[^\n}]*\} from ['"]\.\/toneShader['"];$/m,
|
||||
'import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS, TONE_MATH_SKSL } from "./toneShader.mjs";',
|
||||
),
|
||||
);
|
||||
const { readMasks, maskUniforms, gradientMaskSkSL } = await import(
|
||||
pathToFileURL(join(dir, 'gradientMask.mjs')).href
|
||||
);
|
||||
const { whiteBalanceGain } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href);
|
||||
|
||||
// --- The gain itself: the one function the frame-wide matrix folds in too.
|
||||
const close = (a, b, eps = 1e-12) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`);
|
||||
const luma = (g) => 0.2126 * g.r + 0.7152 * g.g + 0.0722 * g.b;
|
||||
const neutral = whiteBalanceGain(5500, 0);
|
||||
close(neutral.r, 1);
|
||||
close(neutral.g, 1);
|
||||
close(neutral.b, 1);
|
||||
// The ruler runs the way the engine does: K names the light the frame is said to
|
||||
// have been shot under, and the gain compensates for it, so the picture WARMS as
|
||||
// K rises (kelvinToRGB's own doc, and the swatch under the ruler). 10000K up and
|
||||
// 2500K down, on the same two ends the frame-wide row has.
|
||||
const warm = whiteBalanceGain(10000, 0);
|
||||
assert.ok(warm.r > 1 && warm.b < 1, '10000K is not warm');
|
||||
const cool = whiteBalanceGain(2500, 0);
|
||||
assert.ok(cool.b > 1 && cool.r < 1, '2500K is not cool');
|
||||
// The cast moves, the exposure does not: every gain is divided by its own luma.
|
||||
for (const g of [warm, cool, whiteBalanceGain(4000, 7), whiteBalanceGain(9000, -7)]) close(luma(g), 1);
|
||||
// TINT is the green<->magenta axis, and symmetric about 0.
|
||||
const magenta = whiteBalanceGain(5500, 10);
|
||||
const green = whiteBalanceGain(5500, -10);
|
||||
assert.ok(magenta.r > 1 && magenta.b > 1 && magenta.g < 1, '+TINT is not magenta');
|
||||
assert.ok(green.g > 1 && green.r < 1 && green.b < 1, '-TINT is not green');
|
||||
// Symmetric in the two channels it moves, and only in those: R and B take the
|
||||
// same factor, G its own, and the luma normalisation is what keeps the pair from
|
||||
// being a brightness knob (the weights are unequal, so the two ends are not exact
|
||||
// mirrors of each other in the third decimal).
|
||||
close(magenta.r, magenta.b);
|
||||
close(green.r, green.b);
|
||||
assert.ok(Math.abs(Math.log(magenta.r) + Math.log(green.r)) < 0.01, 'TINT is not symmetric about 0');
|
||||
|
||||
// --- readMasks: a mask that never touched the pair reads the neutral, and a
|
||||
// hand-written file cannot push the gain outside the ruler.
|
||||
const shape = { kind: 'linear', x: 0, y: 0, ex: 1, ey: 1, rx: 0, ry: 0, angle: 0, feather: 0.5 };
|
||||
const mask = (extra) => ({ ...shape, exposure: 0, contrast: 0, saturation: 0, ...extra });
|
||||
const bare = readMasks([mask({})])[0];
|
||||
assert.equal(bare.temperature, 5500, 'an absent WB is not neutral');
|
||||
assert.equal(bare.tint, 0, 'an absent TINT is not neutral');
|
||||
const high = readMasks([mask({ temperature: 2000, tint: 99 })])[0];
|
||||
assert.equal(high.temperature, 2500);
|
||||
assert.equal(high.tint, 10);
|
||||
const low = readMasks([mask({ temperature: 20000, tint: -99 })])[0];
|
||||
assert.equal(low.temperature, 10000);
|
||||
assert.equal(low.tint, -10);
|
||||
|
||||
// --- The uniform block: arrays expanded, declaration order, so the gain has to
|
||||
// land on the slot the shader reads as `wb[i]` and the frame size has to stay one
|
||||
// array further on.
|
||||
const n = 1;
|
||||
const one = maskUniforms([mask({ temperature: 10000 })], 64, 64, [0.5, 0.5, 0.5]);
|
||||
assert.equal(one.length, (6 * n + 1 + 1) * 4, 'the buffer is not six arrays, the frame and the air');
|
||||
const g = whiteBalanceGain(10000, 0);
|
||||
close(one[5 * n * 4 + 0], g.r, 1e-6);
|
||||
// The gain is the SAME function the frame-wide matrix folds in, so 10000K cannot
|
||||
// come to mean one thing on the whole frame and another inside a mask.
|
||||
assert.match(
|
||||
readFileSync(new URL('../shared/utils/colorUtils.ts', import.meta.url), 'utf8'),
|
||||
/const wbGain = whiteBalanceGain\(temperature, tint\);/,
|
||||
'the matrix grew a white balance of its own again',
|
||||
);
|
||||
close(one[5 * n * 4 + 1], g.g, 1e-6);
|
||||
close(one[5 * n * 4 + 2], g.b, 1e-6);
|
||||
assert.equal(one[6 * n * 4 + 0], 64, 'the width no longer sits where the shader reads `size`');
|
||||
assert.equal(one[6 * n * 4 + 1], 64);
|
||||
assert.equal(one[(6 * n + 1) * 4 + 0], 0.5, 'the atmospheric light moved');
|
||||
const plain = maskUniforms([mask({})], 64, 64);
|
||||
for (let c = 0; c < 3; c++) close(plain[5 * n * 4 + c], 1, 1e-6);
|
||||
|
||||
// --- The pass itself. A radial mask whose semi-axis is four frames wide sits far
|
||||
// enough inside its own feather that every pixel has alpha 1, so what comes out
|
||||
// is the gain the mask was handed and nothing else: a wrong slot shows up as a
|
||||
// wrong pixel rather than as a wrong number in a buffer.
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
const SIZE = 64;
|
||||
const wide = { kind: 'radial', x: 0.5, y: 0.5, ex: 0.5, ey: 0.5, rx: 4, ry: 4, angle: 0, feather: 0.5 };
|
||||
|
||||
function render(masks, spatial, fillRGB = [128, 128, 128]) {
|
||||
const effect = CanvasKit.RuntimeEffect.Make(gradientMaskSkSL(masks.length, spatial));
|
||||
assert.ok(effect, `the mask shader for ${masks.length} masks does not compile (spatial ${spatial})`);
|
||||
// The frame the pass reads: a flat mid-grey, drawn once and handed in as the
|
||||
// first child exactly as exportEngine's replaceThrough hands its snapshot in.
|
||||
const src = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
const fill = new CanvasKit.Paint();
|
||||
fill.setColor(CanvasKit.Color(...fillRGB));
|
||||
src.getCanvas().drawPaint(fill);
|
||||
const grey = src.makeImageSnapshot();
|
||||
const asChild = () =>
|
||||
grey.makeShaderOptions(
|
||||
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
|
||||
);
|
||||
const air = spatial ? [0.5, 0.5, 0.5] : null;
|
||||
const children = [asChild()];
|
||||
if (spatial) children.push(asChild());
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setShader(effect.makeShaderWithChildren(maskUniforms(masks, SIZE, SIZE, air), children));
|
||||
const out = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, SIZE, SIZE), paint);
|
||||
const px = out.getCanvas().readPixels(0, 0, {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
colorType: CanvasKit.ColorType.RGBA_8888,
|
||||
alphaType: CanvasKit.AlphaType.Unpremul,
|
||||
colorSpace: CanvasKit.ColorSpace.SRGB,
|
||||
});
|
||||
const at = (x, y) => {
|
||||
const i = (y * SIZE + x) * 4;
|
||||
return [px[i], px[i + 1], px[i + 2]];
|
||||
};
|
||||
return at(32, 32);
|
||||
}
|
||||
const knob = (extra) => ({ ...wide, exposure: 0, contrast: 0, saturation: 0, ...extra });
|
||||
const same = (p, want = 128) => p.every((v) => Math.abs(v - want) <= 1);
|
||||
assert.ok(same(render([knob({})], false)), `a neutral mask moved the grey: ${render([knob({})], false)}`);
|
||||
const after = render([knob({ temperature: 10000 })], false);
|
||||
assert.ok(after[0] > 130 && after[2] < 126, `10000K did not warm the mask's pixels: ${after}`);
|
||||
const tinted = render([knob({ tint: 10 })], false);
|
||||
assert.ok(tinted[0] > 130 && tinted[2] > 130 && tinted[1] < 126, `+TINT is not magenta: ${tinted}`);
|
||||
// The spatial build is a second shader text with a second signature: it has to
|
||||
// compile with the same WB and read `wb` all the same.
|
||||
const spatialPx = render([knob({ temperature: 10000 })], true);
|
||||
assert.ok(spatialPx[0] > 130 && spatialPx[2] < 126, `the spatial pass dropped the WB: ${spatialPx}`);
|
||||
// Two masks: the second array's slot, not the first one's, and both applied.
|
||||
const two = render([knob({}), knob({ temperature: 10000 })], false);
|
||||
assert.ok(two[0] > 130 && two[2] < 126, `the second mask's WB did not land: ${two}`);
|
||||
|
||||
// --- EXPOSURE inside a mask, which is now the frame's own move (exposureMove, the
|
||||
// shared TONE_MATH_SKSL): a stop on LIGHT, decided in the linear domain. The pass
|
||||
// this replaced multiplied the three channels in linear light, so a channel that
|
||||
// reached the ceiling clipped by a different amount than its neighbours and the
|
||||
// hue went with it — 29.2° on a warm skin tone at +1 EV, 33.3° at +2 (scratchpad
|
||||
// exp-variant.mjs), while a mid-grey is the same number both ways (0.6858) and
|
||||
// cannot tell the two apart.
|
||||
const hue = (p) => {
|
||||
const [r, g, b] = p.map((v) => v / 255);
|
||||
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
|
||||
if (d < 1e-9) return NaN;
|
||||
const h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||
return ((h * 60) % 360 + 360) % 360;
|
||||
};
|
||||
const up = render([knob({ exposure: 1 })], false);
|
||||
assert.ok(Math.abs(up[0] - 175) <= 2, `+1 EV is not a stop on light: 128 came out ${up[0]}, not 175`);
|
||||
const down = render([knob({ exposure: -1 })], false);
|
||||
assert.ok(Math.abs(down[0] - 93) <= 2, `-1 EV is not a stop on light: 128 came out ${down[0]}, not 93`);
|
||||
const warm8 = [230, 150, 50];
|
||||
const lit = render([knob({ exposure: 1 })], false, warm8);
|
||||
assert.ok(lit.every((v, i) => v >= warm8[i]), `+1 EV darkened a channel: ${lit}`);
|
||||
assert.ok(Math.abs(hue(lit) - hue(warm8)) < 3, `the exposure moved the hue to ${hue(lit)}° from ${hue(warm8)}°`);
|
||||
// A pixel already against the ceiling gives up saturation rather than hue, and
|
||||
// the channel on the ceiling lands ON it: 255 out, not three clipped at three
|
||||
// different points.
|
||||
const hot = render([knob({ exposure: 2 })], false, warm8);
|
||||
assert.ok(Math.abs(hue(hot) - hue(warm8)) < 3, `the exposure moved the hue at the ceiling: ${hue(hot)}°`);
|
||||
assert.equal(hot[0], 255, 'the channel against the ceiling did not land on it');
|
||||
assert.ok(hot[1] > lit[1] && hot[2] > lit[2], `+2 EV did not brighten past +1 EV: ${lit} then ${hot}`);
|
||||
|
||||
console.log(
|
||||
'mask WB: neutral 5500K/0 leaves the pixel, 10000K warms it, +TINT magenta-ises it; the block, the plain and the spatial shaders all read it',
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
// The offline queue, checked in a browser: a write made with no server is held
|
||||
// rather than lost, a second write to the same route replaces the first, the
|
||||
// reload keeps it, and a server answering sends it on its own.
|
||||
//
|
||||
// The API is stubbed on the page's own origin the way the container's nginx
|
||||
// serves it, and "no server" is a route that fails the request — which is
|
||||
// exactly what a dead server looks like from `fetch`.
|
||||
//
|
||||
// npm run build && node scripts/offline-queue-check.mjs
|
||||
// APP_PORT=4885 PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> \
|
||||
// node scripts/offline-queue-check.mjs
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 4186);
|
||||
// The key pwa/offline.ts holds the queue under.
|
||||
const KEY = 'rc.pending';
|
||||
|
||||
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');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
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}/`).then((r) => r.ok).catch(() => false);
|
||||
if (!up) await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!up) {
|
||||
app.kill('SIGTERM');
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
||||
let gone = false; // whether the page's server is there
|
||||
const sent = []; // every write the stub let through
|
||||
const PHOTO = {
|
||||
id: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
slots: ['strip'],
|
||||
tag: '#CHECK',
|
||||
title: 'Check frame',
|
||||
meta: '#1',
|
||||
consent: true,
|
||||
};
|
||||
|
||||
await context.route('**/api/**', (route) => {
|
||||
if (gone) return route.abort('failed');
|
||||
const req = route.request();
|
||||
const url = req.url();
|
||||
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
if (req.method() !== 'GET') {
|
||||
sent.push({ method: req.method(), path: new URL(url).pathname, body: req.postData() });
|
||||
return json({ key: 'photo:1', rating: { avg: 5, n: 1, mine: 5 } });
|
||||
}
|
||||
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com' } });
|
||||
if (url.includes('/photos')) return json({ photos: [PHOTO] });
|
||||
if (url.includes('/ratings')) return json({ ratings: {} });
|
||||
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
|
||||
if (url.includes('/recipes')) return json({ recipes: [] });
|
||||
return json({});
|
||||
});
|
||||
|
||||
// The bar's own words are asserted, so the page starts in Vietnamese.
|
||||
await context.addInitScript(() => localStorage.setItem('rc.lang', 'vi'));
|
||||
|
||||
const page = await context.newPage();
|
||||
const star = (n) => `[data-key="lp-star-photo:1-${n}"]`;
|
||||
const bar = '[data-key="offline-bar"]';
|
||||
const noise = [];
|
||||
page.on('console', (m) => {
|
||||
// The outage is the check's own doing, and a request that fails is a request
|
||||
// the browser logs. Everything else is a real complaint.
|
||||
if (m.type() === 'error' && !m.text().includes('net::ERR_FAILED')) noise.push(m.text());
|
||||
});
|
||||
const queued = () =>
|
||||
page.evaluate((k) => JSON.parse(localStorage.getItem(k) ?? '[]'), KEY);
|
||||
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector(star(5), { timeout: 60_000 });
|
||||
|
||||
// 1 — a write with no server is held, not lost.
|
||||
gone = true;
|
||||
await page.$eval(star(5), (el) => el.click());
|
||||
await page
|
||||
.waitForFunction((k) => JSON.parse(localStorage.getItem(k) ?? '[]').length === 1, KEY, { timeout: 15_000 })
|
||||
.catch(() => {});
|
||||
let held = await queued();
|
||||
step(
|
||||
'a vote cast with no server is held',
|
||||
held.length === 1 && held[0].method === 'POST' && held[0].path === '/ratings',
|
||||
JSON.stringify(held)
|
||||
);
|
||||
const notice = await page.textContent(bar).catch(() => null);
|
||||
step('and the bar says how many are waiting', notice !== null && notice.includes('Chưa gửi được: 1'), notice ?? 'no bar');
|
||||
|
||||
// 2 — the same vote again replaces the held one instead of stacking.
|
||||
await page.$eval(star(3), (el) => el.click());
|
||||
await page.waitForTimeout(500);
|
||||
held = await queued();
|
||||
step(
|
||||
'a second vote on the same frame is one write, the later one',
|
||||
held.length === 1 && String(held[0].body).includes('"stars":3'),
|
||||
JSON.stringify(held)
|
||||
);
|
||||
|
||||
// 3 — the reload keeps it.
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector(star(5), { timeout: 60_000 });
|
||||
held = await queued();
|
||||
step('and it is still held across a reload with no server', held.length === 1, JSON.stringify(held));
|
||||
|
||||
// 4 — a server answering sends it, with no one asking.
|
||||
gone = false;
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page
|
||||
.waitForFunction((k) => JSON.parse(localStorage.getItem(k) ?? '[]').length === 0, KEY, { timeout: 30_000 })
|
||||
.catch(() => {});
|
||||
held = await queued();
|
||||
step(
|
||||
'the held write goes out when a server answers',
|
||||
held.length === 0 &&
|
||||
sent.some((s) => s.method === 'POST' && s.path === '/api/ratings' && s.body === '{"key":"photo:1","stars":3}'),
|
||||
JSON.stringify(sent)
|
||||
);
|
||||
step('and the bar goes with it', (await page.$(bar)) === null);
|
||||
step('nothing thrown along the way', noise.length === 0, noise[0] ?? '');
|
||||
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(failures === 0 ? 'all pass' : `${failures} failed`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,155 @@
|
||||
// The offline answer to a big read, checked in a browser: the strip's listing is
|
||||
// a few hundred kilobytes and used to have no offline answer at all, because the
|
||||
// last one was kept in localStorage under a cap of two hundred thousand
|
||||
// characters. It is kept whole in IndexedDB now, and an offline visit opens on it.
|
||||
//
|
||||
// The API is stubbed on the page's own origin the way the container's nginx
|
||||
// serves it, and "no server" is a route that fails the request — which is exactly
|
||||
// what a dead server looks like from `fetch`.
|
||||
//
|
||||
// npm run build && node scripts/offline-read-check.mjs
|
||||
// APP_PORT=4886 PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> \
|
||||
// node scripts/offline-read-check.mjs
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 4187);
|
||||
// The cap the read used to be kept under, and the store it is kept in now.
|
||||
const OLD_CAP = 200_000;
|
||||
const DB = 'recipescam-offline';
|
||||
const READS = 'reads';
|
||||
|
||||
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');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// The listing as a studio with a real catalogue has it: enough frames that the
|
||||
// JSON is past the old cap on its own, each carrying the look it was saved with —
|
||||
// a curve is the bulk of one, and a hundred points of it is a small one.
|
||||
const PHOTOS = Array.from({ length: 60 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
slots: ['strip'],
|
||||
tag: `#P${i + 1}`,
|
||||
title: `Frame ${i + 1}`,
|
||||
meta: `ISO 400 · 35mm`,
|
||||
consent: true,
|
||||
hasPreset: false,
|
||||
recipe: { name: 'PROVIPES', curve: Array.from({ length: 512 }, (_, n) => n / 512) },
|
||||
}));
|
||||
const LISTING = JSON.stringify({ photos: PHOTOS });
|
||||
|
||||
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}/`).then((r) => r.ok).catch(() => false);
|
||||
if (!up) await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!up) {
|
||||
app.kill('SIGTERM');
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
||||
let gone = false; // whether the page's server is there
|
||||
|
||||
await context.route('**/api/**', (route) => {
|
||||
if (gone) return route.abort('failed');
|
||||
const req = route.request();
|
||||
const url = req.url();
|
||||
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
if (url.includes('/auth/me')) return json({ user: null });
|
||||
if (url.includes('/photos')) return route.fulfill({ status: 200, contentType: 'application/json', body: LISTING });
|
||||
if (url.includes('/ratings')) return json({ ratings: {} });
|
||||
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
|
||||
if (url.includes('/recipes')) return json({ recipes: [] });
|
||||
return json({});
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const noise = [];
|
||||
page.on('console', (m) => {
|
||||
// The outage is the check's own doing, and a request that fails is a request
|
||||
// the browser logs. Everything else is a real complaint.
|
||||
if (m.type() === 'error' && !m.text().includes('net::ERR_FAILED')) noise.push(m.text());
|
||||
});
|
||||
const frames = () => page.$$eval('.lp-frame', (els) => els.length);
|
||||
const readStale = (key) =>
|
||||
page
|
||||
.evaluate(
|
||||
([db, store, key]) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(db, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store);
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const d = req.result;
|
||||
if (!d.objectStoreNames.contains(store)) return resolve(null);
|
||||
const get = d.transaction(store, 'readonly').objectStore(store).get(key);
|
||||
get.onsuccess = () => resolve(get.result ?? null);
|
||||
get.onerror = () => reject(get.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
}),
|
||||
[DB, READS, key]
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
step('the listing this check serves is past the cap the old code kept', LISTING.length > OLD_CAP, `${LISTING.length} chars`);
|
||||
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.lp-frame', { timeout: 60_000 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lp-frame').length >= 60, null, { timeout: 30_000 }).catch(() => {});
|
||||
const online = await frames();
|
||||
|
||||
const stored = await readStale('/photos');
|
||||
step(
|
||||
'a listing past the old cap is kept for offline',
|
||||
typeof stored === 'string' && stored.length > OLD_CAP,
|
||||
typeof stored === 'string' ? `${stored.length} chars in the store` : 'no record'
|
||||
);
|
||||
|
||||
const leftBehind = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('rc.stale.')).length);
|
||||
step('and nothing is left in localStorage under the old key', leftBehind === 0, `${leftBehind} keys`);
|
||||
|
||||
const ratings = await readStale('/ratings');
|
||||
step('a small read is kept the same way', typeof ratings === 'string', ratings === null ? 'no record' : 'kept');
|
||||
|
||||
// The reload with no server: the listing is the last one it was shown, so the
|
||||
// reel is the same reel rather than an empty band.
|
||||
gone = true;
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.lp-frame', { timeout: 30_000 }).catch(() => {});
|
||||
const offline = await frames();
|
||||
step(
|
||||
'and the offline visit opens on the listing it kept',
|
||||
online >= 60 && offline === online,
|
||||
`${online} frames online, ${offline} offline`
|
||||
);
|
||||
step('nothing thrown along the way', noise.length === 0, noise[0] ?? '');
|
||||
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(failures === 0 ? 'all pass' : `${failures} failed`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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,116 @@
|
||||
// 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. Five 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 five
|
||||
// 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}`);
|
||||
|
||||
// (e) The offer is asked once. The browser's sheet is stubbed, because a check cannot
|
||||
// click the real one: waved away it is a refusal, and a refusal is stored, so the next
|
||||
// visit renders no dialog at all. The stub goes in an init script so it is there on
|
||||
// both loads.
|
||||
await context.setOffline(false);
|
||||
await page.addInitScript(() => {
|
||||
window.__offerInstall = () => {
|
||||
const event = new Event('beforeinstallprompt');
|
||||
event.prompt = () => Promise.resolve();
|
||||
event.userChoice = Promise.resolve({ outcome: 'dismissed' });
|
||||
window.dispatchEvent(event);
|
||||
window.dispatchEvent(new Event('rc:install-offer'));
|
||||
};
|
||||
});
|
||||
const offer = async () => {
|
||||
await page.evaluate(() => window.__offerInstall());
|
||||
await page.waitForTimeout(150);
|
||||
return page.locator('[data-key="install-yes"]').count();
|
||||
};
|
||||
await page.reload();
|
||||
const first = await offer();
|
||||
await page.locator('[data-key="install-yes"]').click();
|
||||
const stored = await page.evaluate(() => localStorage.getItem('rc.install.dismissed.v1'));
|
||||
await page.reload();
|
||||
const again = await offer();
|
||||
check(
|
||||
first === 1 && stored === '1' && again === 0,
|
||||
`install dialog shown ${first} time(s), refused (localStorage '${stored}'), shown again after reload: ${again}`,
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
if (failed) {
|
||||
console.error(`pwa-check: ${failed} of 5 failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('pwa-check ok');
|
||||
@@ -0,0 +1,338 @@
|
||||
// What a reading costs the page, measured rather than argued: a roll of many
|
||||
// folders, a strip drawing it as it lands, and the heap sampled with a forced
|
||||
// collection between samples, so what is left is what somebody still holds.
|
||||
//
|
||||
// npm run build && node scripts/ram-check.mjs
|
||||
// APP_PORT=4950 SAMPLE_PORT=4951 SAMPLE_DIR=<sample folder> \
|
||||
// PLAYWRIGHT_CORE=<playwright-core/index.mjs> CHROME=<chrome> \
|
||||
// node scripts/ram-check.mjs
|
||||
import { spawn } from 'node:child_process';
|
||||
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, '..', 'sample');
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 4950);
|
||||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4951);
|
||||
const JPG = 'P1010256.JPG';
|
||||
const RAW = 'P1010256.RW2';
|
||||
// One small JPEG served under every name: a hundred copies of an eight-megabyte
|
||||
// frame is eight hundred megabytes to prove a point about rows.
|
||||
const SMALL = 'Main.jpg';
|
||||
const SUBDIRS = Number(process.env.SUBDIRS ?? 12);
|
||||
const PER_DIR = Number(process.env.PER_DIR ?? 300);
|
||||
const QUEUE_AT = Number(process.env.QUEUE_AT ?? 4);
|
||||
const QUEUE_N = Number(process.env.QUEUE_N ?? 4);
|
||||
const BUDGET_MS = Number(process.env.BUDGET_MS ?? 90_000);
|
||||
const SLOW = Number(process.env.SLOW ?? 12);
|
||||
|
||||
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
|
||||
if (!playwright) {
|
||||
console.log('SKIP playwright-core is not installed');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// The roll: a picked folder of `SUBDIRS` folders, each of `PER_DIR` frames.
|
||||
let n = 0;
|
||||
const rolls = {};
|
||||
const dirs = {};
|
||||
for (let d = 0; d < SUBDIRS; d++) {
|
||||
const names = [];
|
||||
for (let f = 0; f < PER_DIR; f++) names.push(`B${String(n++).padStart(6, '0')}.JPG`);
|
||||
dirs[`D${String(d).padStart(2, '0')}`] = { files: names, dirs: {} };
|
||||
}
|
||||
rolls.RamRoll = { files: [`B${String(n++).padStart(6, '0')}.JPG`], dirs };
|
||||
|
||||
const mobile = Number(process.env.MOBILE ?? 0);
|
||||
// BASE_URL runs the same reading against a stack already up (the deployed one),
|
||||
// so what is measured is the page the reader actually opens.
|
||||
const app = process.env.BASE_URL
|
||||
? null
|
||||
: spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||||
cwd: FRONTEND,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const base = process.env.BASE_URL ?? `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');
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = ['--no-sandbox', '--enable-unsafe-swiftshader'];
|
||||
if (mobile) args.push(`--js-flags=--max-old-space-size=${mobile}`);
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args,
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
await context.route(`http://127.0.0.1:${SAMPLE_PORT}/**`, async (route) => {
|
||||
const name = path.basename(decodeURIComponent(new URL(route.request().url()).pathname));
|
||||
const file = /^B\d+\.JPG$/.test(name) ? SMALL : /\.jpe?g$/i.test(name) ? JPG : /\.rw2$/i.test(name) ? RAW : null;
|
||||
if (!file) return route.fulfill({ status: 404, body: '' });
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/octet-stream',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: await readFile(path.join(SAMPLES, file)),
|
||||
});
|
||||
});
|
||||
await context.route('**/api/**', (route) => {
|
||||
const url = route.request().url();
|
||||
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check' } });
|
||||
if (url.includes('/photos')) return json({ photos: [] });
|
||||
if (url.includes('/recipes')) return json({ recipes: [] });
|
||||
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'no' }) });
|
||||
});
|
||||
|
||||
await context.addInitScript(
|
||||
({ origin, folder, rolls, small }) => {
|
||||
const bytes = new Map();
|
||||
const load = async (name) => {
|
||||
const file = /^B\d+\.JPG$/.test(name) ? small : name;
|
||||
if (!bytes.has(file)) bytes.set(file, fetch(`${origin}/${file}`).then((r) => r.arrayBuffer()));
|
||||
return bytes.get(file);
|
||||
};
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => {
|
||||
// A read of a frame is nearly all waiting on a real disk, and a
|
||||
// reading that is over in a blink is one the heap cannot be watched
|
||||
// against. `check.slow` is that wait, in milliseconds.
|
||||
const slow = Number(localStorage.getItem('check.slow') ?? 0);
|
||||
if (slow) await new Promise((r) => setTimeout(r, slow));
|
||||
return new File([await load(name)], name, { type: 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1) });
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
const handle = { kind: 'directory', name: label, __fake: true };
|
||||
Object.defineProperties(handle, {
|
||||
values: {
|
||||
value: () => {
|
||||
if (node.fail) throw new Error(`${label} will not open`);
|
||||
return [
|
||||
...(node.files ?? []).map(fileHandle),
|
||||
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
||||
][Symbol.iterator]();
|
||||
},
|
||||
},
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
getDirectoryHandle: {
|
||||
value: async (name) => {
|
||||
const kid = node.dirs?.[name];
|
||||
if (!kid) throw new Error(`no folder named ${name}`);
|
||||
return dirHandle(name, kid);
|
||||
},
|
||||
},
|
||||
getFileHandle: {
|
||||
value: async (name) => {
|
||||
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
|
||||
return fileHandle(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const root = () => dirHandle(folder, { files: rolls[folder].files, dirs: rolls[folder].dirs });
|
||||
window.showDirectoryPicker = async () => root();
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
row.handle = row.handle.kind === 'directory' ? root() : 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}`, folder: 'RamRoll', small: SMALL, rolls }
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Performance.enable');
|
||||
const sample = async () => {
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
const { metrics } = await cdp.send('Performance.getMetrics');
|
||||
const m = Object.fromEntries(metrics.map((x) => [x.name, x.value]));
|
||||
const dom = await page.evaluate(() => document.querySelectorAll('*').length);
|
||||
return { heap: m.JSHeapUsedSize, nodes: m.Nodes, dom };
|
||||
};
|
||||
const line = () => page.$eval('[data-key="lib-progress"]', (e) => (e.textContent ?? '').trim()).catch(() => '');
|
||||
const stored = () =>
|
||||
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 new Promise((resolve, reject) => {
|
||||
const req = db.transaction('photos', 'readonly').objectStore('photos').count();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
})
|
||||
.catch(() => -1);
|
||||
|
||||
await page.goto(`${base}/library`);
|
||||
await page.evaluate((s) => localStorage.setItem('check.slow', String(s)), SLOW);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector('[data-key="lib-node-RamRoll"]', { timeout: 60_000 });
|
||||
if (Number(process.env.PROFILE ?? 0)) await cdp.send('HeapProfiler.startSampling', { samplingInterval: 4096 });
|
||||
|
||||
const series = [];
|
||||
const t0 = Date.now();
|
||||
let queued = false;
|
||||
while (Date.now() - t0 < BUDGET_MS) {
|
||||
const s = await sample();
|
||||
s.t = Math.round((Date.now() - t0) / 100) / 10;
|
||||
s.line = await line();
|
||||
series.push(s);
|
||||
// The reader right-clicks a few folders under the roll: each is a reading
|
||||
// queued behind the one running, which is what the report is about.
|
||||
if (!queued && series.length >= QUEUE_AT) {
|
||||
queued = true;
|
||||
const keys = await page.evaluate(() => [...document.querySelectorAll('[data-key^="lib-node-RamRoll/D"]')].map((e) => e.dataset.key));
|
||||
for (const key of keys.slice(0, QUEUE_N)) {
|
||||
await page.click(`[data-key="${key}"]`, { button: 'right' }).catch(() => {});
|
||||
await page.click('[data-key="lib-update-RamRoll"]', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
console.log(`queued ${Math.min(QUEUE_N, keys.length)} folders`);
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
const rest = await sample();
|
||||
const first = series[0] ?? { heap: 0 };
|
||||
const peak = series.reduce((a, b) => (b.heap > a.heap ? b : a), first);
|
||||
const last = series[series.length - 1] ?? first;
|
||||
console.log(`samples ${series.length} over ${last.t}s rows ${await stored()}`);
|
||||
console.log(`heap first ${Math.round(first.heap / 1e6)}MB peak ${Math.round(peak.heap / 1e6)}MB@${peak.t}s last ${Math.round(last.heap / 1e6)}MB resting ${Math.round(rest.heap / 1e6)}MB`);
|
||||
console.log(`nodes first ${first.nodes} dom ${first.dom} -> nodes ${last.nodes} dom ${last.dom}`);
|
||||
if (Number(process.env.SNAPSHOT ?? 0)) {
|
||||
const chunks = [];
|
||||
cdp.on('HeapProfiler.addHeapSnapshotChunk', (e) => chunks.push(e.chunk));
|
||||
await cdp.send('HeapProfiler.enable');
|
||||
await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
|
||||
const snap = JSON.parse(chunks.join(''));
|
||||
const fields = snap.snapshot.meta.node_fields;
|
||||
const types = snap.snapshot.meta.node_types[fields.indexOf('type')];
|
||||
const stride = fields.length;
|
||||
const at = (i, f) => snap.nodes[i * stride + fields.indexOf(f)];
|
||||
const byKey = new Map();
|
||||
for (let i = 0; i < snap.nodes.length / stride; i++) {
|
||||
const key = `${types[at(i, 'type')]} ${snap.strings[at(i, 'name')]}`;
|
||||
const cur = byKey.get(key) ?? { n: 0, bytes: 0 };
|
||||
cur.n++;
|
||||
cur.bytes += at(i, 'self_size');
|
||||
byKey.set(key, cur);
|
||||
}
|
||||
console.log('--- who is alive (self_size) ---');
|
||||
for (const [key, v] of [...byKey.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 30)) {
|
||||
console.log(` ${String(Math.round(v.bytes / 1024)).padStart(8)}KB ${String(v.n).padStart(7)}x ${key}`);
|
||||
}
|
||||
if (Number(process.env.RETAIN ?? 0)) {
|
||||
// Who keeps the named object alive: walk the edges into the first node of a
|
||||
// given name and print the path back to a root the way the panel does.
|
||||
const nodeCount = snap.nodes.length / stride;
|
||||
const eStride = snap.snapshot.meta.edge_fields.length;
|
||||
let maxTo = 0;
|
||||
for (let i = 0; i < snap.edges.length / eStride; i++) maxTo = Math.max(maxTo, snap.edges[i * eStride + 2]);
|
||||
const scale = maxTo >= nodeCount ? eStride : 1;
|
||||
console.log(` nodes ${nodeCount} edges ${snap.edges.length / eStride} maxTo ${maxTo} -> to_node scale ${scale}`);
|
||||
const firstEdge = new Int32Array(nodeCount + 1);
|
||||
{
|
||||
let run = 0;
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
firstEdge[i] = run;
|
||||
run += at(i, 'edge_count');
|
||||
}
|
||||
firstEdge[nodeCount] = run;
|
||||
}
|
||||
const want = process.env.RETAIN;
|
||||
const typesOf = (i) => types[at(i, 'type')];
|
||||
const nameOf = (i) => snap.strings[at(i, 'name')];
|
||||
const targets = [];
|
||||
for (let i = 0; i < nodeCount && targets.length < 5; i++) if (typesOf(i) === 'object' && nameOf(i) === want) targets.push(i);
|
||||
const parent = new Map();
|
||||
const queue = [0];
|
||||
let head = 0;
|
||||
const seen = new Uint8Array(nodeCount);
|
||||
seen[0] = 1;
|
||||
const wantSet = new Set(targets);
|
||||
let hit = -1;
|
||||
while (head < queue.length) {
|
||||
const n0 = queue[head++];
|
||||
if (wantSet.has(n0)) {
|
||||
hit = n0;
|
||||
break;
|
||||
}
|
||||
for (let e = firstEdge[n0]; e < firstEdge[n0 + 1]; e++) {
|
||||
const child = snap.edges[e * eStride + 2] / scale;
|
||||
if (!seen[child]) {
|
||||
seen[child] = 1;
|
||||
parent.set(child, [n0, snap.strings[snap.edges[e * eStride]]]);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`--- retained path to "${want}" (${targets.length} found) ---`);
|
||||
if (hit >= 0) {
|
||||
const path = [];
|
||||
let cur = hit;
|
||||
while (cur !== undefined && cur !== 0) {
|
||||
const p = parent.get(cur);
|
||||
if (!p) break;
|
||||
path.push(`${nameOf(cur)}(${typesOf(cur)}) -[${p[1]}]-`);
|
||||
cur = p[0];
|
||||
}
|
||||
console.log(` ${path.reverse().join(' ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Number(process.env.PROFILE ?? 0)) {
|
||||
const { profile } = await cdp.send('HeapProfiler.stopSampling');
|
||||
const rows = [];
|
||||
const walk = (node, stack) => {
|
||||
const f = node.callFrame ?? {};
|
||||
const here = stack.concat([`${f.functionName || '(anonymous)'}@${(f.url ?? '').split('/').pop()}:${(f.lineNumber ?? 0) + 1}`]);
|
||||
if (node.selfSize) rows.push({ name: f.functionName || '(anonymous)', url: (f.url ?? '').split('/').pop(), size: node.selfSize, stack: here.slice(-6).join(' <- ') });
|
||||
for (const kid of node.children ?? []) walk(kid, here);
|
||||
};
|
||||
walk(profile.head, []);
|
||||
console.log('--- who allocates (selfSize, leaf) ---');
|
||||
for (const r of [...rows].sort((a, b) => b.size - a.size).slice(0, 25)) console.log(` ${String(Math.round(r.size / 1024)).padStart(8)}KB ${r.name} ${r.url}`);
|
||||
const byStack = new Map();
|
||||
for (const r of rows) byStack.set(r.stack, (byStack.get(r.stack) ?? 0) + r.size);
|
||||
console.log('--- where it comes from (stack, selfSize summed) ---');
|
||||
for (const [stack, size] of [...byStack.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
|
||||
console.log(` ${String(Math.round(size / 1024)).padStart(8)}KB ${stack}`);
|
||||
}
|
||||
}
|
||||
for (const s of series) console.log(` ${String(s.t).padStart(6)}s ${String(Math.round(s.heap / 1e6)).padStart(5)}MB nodes ${String(s.nodes).padStart(7)} dom ${String(s.dom).padStart(7)} ${s.line}`);
|
||||
await browser.close();
|
||||
app?.kill('SIGTERM');
|
||||
@@ -36,9 +36,12 @@ assert.match(code, /float3 lin = n \* mul\.rgb;/);
|
||||
// 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.
|
||||
// The gate opens below the white level, not at it: 0.82 against 0.98 was measured
|
||||
// after the pairs above, so the tint reads on the way up rather than only once the
|
||||
// frame has already run out of range.
|
||||
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\)\);/
|
||||
/float hi = max\(max\(n\.r, n\.g\), n\.b\);\s*hi = max\(hi, max\(max\(lin\.r, lin\.g\), lin\.b\)\);\s*float blown = smoothstep\(0\.82, 0\.98, hi\);\s*rgb = mix\(rgb, float3\(mx\), blown\);/
|
||||
);
|
||||
|
||||
// The inverse is built on the CPU side and has to be the first uniform of the
|
||||
@@ -46,7 +49,8 @@ assert.match(
|
||||
// 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, /^ *const white = sensorWhite\(data, cd\.maximum, cd\.black\);$/m);
|
||||
assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ white;$/m);
|
||||
assert.match(src, /^import \{ sensorWhite \} from '\.\/sensorWhite';$/m);
|
||||
|
||||
// On the FX30 that inverse is 2.065 (65535 / (2 (16380 - 512))), against the
|
||||
@@ -68,50 +72,83 @@ assert.match(
|
||||
// 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.match(src, /extractEmbeddedJpeg\(bytes\);/);
|
||||
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.doesNotMatch(src, /fitMatch/, 'the 3x3 preview match is back');
|
||||
assert.doesNotMatch(code, /uniform float4 f0;/, 'the 3x3 preview match is back 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;/);
|
||||
assert.match(src, /const jpeg = finalShot\.encodeToBytes\(Skia\.ImageFormat\.JPEG, 92\);/);
|
||||
assert.match(src, /preview && preview\.length > 10000/);
|
||||
|
||||
// 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.
|
||||
// The balance the frame opens with is read off the file's own preview — the same
|
||||
// reference the tone curve is fitted to — and only falls back to LibRaw's tags when
|
||||
// the preview has nothing neutral and bright left to read. The tags alone were not
|
||||
// the balance: on the Fujifilm RAF this was reported on, `pre_mul` was short of blue
|
||||
// twice over (R/G 2.296 against the frame's 1.795, B/G 1.307 against 1.626) and left
|
||||
// every white pink, `cam_mul` came closer, and the pair read off the preview matched
|
||||
// it: block a* -1.9 against the preview's -2.1 where the tags gave 0.0 and -1.0,
|
||||
// dE00 4.93 where they gave 6.73 and 5.27. And it holds across bodies for the same
|
||||
// reason — it is read on the frame, not on the tags.
|
||||
assert.ok(
|
||||
src.indexOf('return cd.cam_mul;') < src.indexOf('return cd.pre_mul;'),
|
||||
'pre_mul is preferred over the frame\'s own balance again'
|
||||
);
|
||||
assert.match(src, /^ *const refGrid = preview \? previewGrid\(preview, w, h, MATCH_GRID\) : null;$/m);
|
||||
assert.match(
|
||||
src,
|
||||
/const rawMul = \(refGrid && previewWhiteBalance\(data, w, h, refGrid, MATCH_GRID, white\)\)\s*\?\? getBrandWbMultipliers\(brand, cd\);/
|
||||
);
|
||||
assert.match(src, /^export function previewWhiteBalance\($/m);
|
||||
// ...and a balance read off too little of the frame is no balance: a frame that
|
||||
// offers fewer than this many sensor pixels across its neutral bright blocks keeps
|
||||
// the tags rather than a ratio read off noise.
|
||||
assert.match(src, /if \(count < 4096 \|\| sums\[0\] <= 0 \|\| sums\[1\] <= 0 \|\| sums\[2\] <= 0\) return null;/);
|
||||
|
||||
// What a RAW opens as is the colour the camera wrote into it. Not fitted: the
|
||||
// develop carries the difference between the file's own preview and this develop's
|
||||
// rendering of the same frame, at the preview's resolution, and adds it back on
|
||||
// the value it left. So there is no curve to come back folded, no body table, and
|
||||
// nothing that can disagree with the file it is opening.
|
||||
//
|
||||
// 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);
|
||||
// Measured against the file's own preview: dE00 6.04 for the per-channel cubic
|
||||
// this replaced, 0.54 for the residual, and 0.00 on the two bodies (a Nikon NEF
|
||||
// and a DNG) that write a full-size preview — where the two frames agree block for
|
||||
// block and the residual is nothing.
|
||||
const res = src.match(/const RESIDUAL_SKSL = `([\s\S]*?)`;/)?.[1];
|
||||
assert.ok(res, 'RESIDUAL_SKSL not found');
|
||||
const resCode = res.replace(/\/\/[^\n]*/g, '');
|
||||
assert.match(
|
||||
resCode,
|
||||
/return half4\(half3\(clamp\(ref\.eval\(p\)\.rgb - dev\.eval\(p\)\.rgb \+ 0\.5, 0\.0, 1\.0\)\), 1\.0\);/
|
||||
);
|
||||
assert.match(code, /uniform shader res;/);
|
||||
assert.match(code, /uniform float2 rscl;/);
|
||||
// The 0.5 that keeps a negative difference inside a byte, taken back off on the
|
||||
// way out, and read at the frame's own coordinates — `pos` is local to this band.
|
||||
assert.match(code, /float2 rp = pos \* rscl;/);
|
||||
assert.match(code, /o = clamp\(o \+ \(res\.eval\(rp\)\.rgb - 0\.5\), 0\.0, 1\.0\);/);
|
||||
// The residual is read at the develop's own resolution, so it is filtered up
|
||||
// rather than tapped: one byte a channel, which is finer than the shading it
|
||||
// carries, is the whole difference between two renderings.
|
||||
assert.match(src, /const resChild = \(residual\?\.img \?\? noResidual\(\)\)\.makeShaderOptions\(/);
|
||||
assert.match(src, /const shader = effect\.makeShaderWithChildren\(uniforms, \[child, resChild\]\);/);
|
||||
// A file with nothing to carry opens on a residual that says nothing — 0.5 in
|
||||
// half — rather than down a branch of its own in the shader.
|
||||
assert.match(src, /NO_RESIDUAL = Skia\.Image\.MakeImage\(/);
|
||||
assert.match(src, /new Uint16Array\(\[0x3800, 0x3800, 0x3800, 0x3c00\]\)/);
|
||||
// The develop runs twice, not once: the residual is read off the first frame and
|
||||
// carried by the second.
|
||||
assert.match(src, /^ *const second = develop\(residual\);$/m);
|
||||
// The per-channel curve this replaced is gone, and so is the buffer slot it rode
|
||||
// in: 33 floats became 23, the last two of them the residual's own scale.
|
||||
assert.doesNotMatch(code, /float tone\(float4 w, float x\) \{/, 'the per-channel curve is back');
|
||||
assert.doesNotMatch(code, /uniform float4 t0;/, 'the per-channel curve is back in the shader');
|
||||
assert.doesNotMatch(src, /FLAT_TONE/, 'the flat tone is back');
|
||||
assert.doesNotMatch(src, /toneMatch/, 'the tone fit is back');
|
||||
assert.match(src, /const uniforms = new Float32Array\(23\);/);
|
||||
assert.match(src, /^ *uniforms\[21\] = residual \? residual\.sx : 0;$/m);
|
||||
assert.match(src, /^import \{ MATCH_GRID \} from '\.\/previewMatch';$/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
|
||||
@@ -119,4 +156,115 @@ assert.match(src, /^ *uniforms\[21\] = f\[0\]; uniforms\[22\] = f\[1\]; uniforms
|
||||
// 6020x4024, and every one of the eight bodies checked doubled its frame.
|
||||
assert.match(src, /^ *halfSize: false,$/m, 'the develop is half-size again');
|
||||
|
||||
// The matrix the develop applies is the camera->sRGB one LibRaw already hands
|
||||
// back, not one derived from `cam_xyz` beside it. They disagree on the Olympus
|
||||
// ORF this was reported on: rgb_cam x cam_mul lands on R/G 1.021 B/G 0.920
|
||||
// against the file's own preview's 1.013 / 0.841, the cam_xyz chain on
|
||||
// 0.921 / 0.886 — green, and blue for a scene the camera left neutral, which is
|
||||
// the cast the frame opened with. And the matrix must not be divided by `pre_mul`
|
||||
// again: that undoes the row-sum-to-one normalisation dcraw built into rgb_cam.
|
||||
{
|
||||
const xyz = src.match(/^const XYZ_TO_SRGB = \[[\s\S]*?\n\];$/m)?.[0];
|
||||
const fn = src.match(/^export function getCamToSrgbMatrix\([\s\S]*?\n\}\n/m)?.[0];
|
||||
assert.ok(xyz && fn, 'getCamToSrgbMatrix not found');
|
||||
// The file is TypeScript; the annotations in this block are all that stop
|
||||
// `new Function` from reading it.
|
||||
const js = (xyz + '\n' + fn)
|
||||
.replace(/^export /m, '')
|
||||
.replace(/: any/g, '')
|
||||
.replace(/: number\[\]\[\]/g, '')
|
||||
.replace(/\?: CameraBrand/g, '');
|
||||
const getCamToSrgbMatrix = new Function(`${js}; return getCamToSrgbMatrix;`)();
|
||||
const rgb_cam = [
|
||||
[1.631760597229004, -0.39896339178085327, -0.23279713094234467],
|
||||
[-0.298429936170578, 1.7056032419204712, -0.4071732759475708],
|
||||
[0.02502557262778282, -0.45518237352371216, 1.4301568269729614],
|
||||
];
|
||||
const cam_xyz = [
|
||||
[0.9422, -0.3258, -0.0711],
|
||||
[-0.2655, 1.0898, 0.2015],
|
||||
[-0.0512, 0.1354, 0.5512],
|
||||
];
|
||||
const pre_mul = [2.0312116146087646, 0.9462084174156189, 1.4558180570602417];
|
||||
assert.deepEqual(
|
||||
getCamToSrgbMatrix({ rgb_cam, cam_xyz, pre_mul }),
|
||||
rgb_cam.map((row) => row.slice(0, 3)),
|
||||
'rgb_cam is no longer applied as LibRaw handed it back'
|
||||
);
|
||||
// A neutral camera triple stays neutral through what is left: every row of the
|
||||
// matrix has to sum to one, or the frame opens with a cast of its own.
|
||||
for (const [name, cd] of [
|
||||
['rgb_cam', { rgb_cam }],
|
||||
['cam_xyz', { cam_xyz }],
|
||||
['nothing', {}],
|
||||
]) {
|
||||
const rows = getCamToSrgbMatrix(cd);
|
||||
for (const row of rows) {
|
||||
assert.ok(Math.abs(row[0] + row[1] + row[2] - 1) < 1e-6, `${name}: row sums to what white is not`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The balance read off the preview is arithmetic on the plane and the preview
|
||||
// map, so both can be handed to it here. A plane whose red stands 1.5x its green
|
||||
// and whose blue 1.2x, under a map the camera left bright and neutral, has one
|
||||
// balance that would equalise those blocks, and it is the one that comes back.
|
||||
{
|
||||
const fn = src.match(/^export function previewWhiteBalance\([\s\S]*?\n\}\n/m)?.[0];
|
||||
assert.ok(fn, 'previewWhiteBalance not found');
|
||||
const previewWhiteBalance = new Function(
|
||||
`${fn.replace(/^export /m, '').replace(/:\s*(ArrayLike<number>|Uint8Array|number\[\] \| null|number)/g, '')}; return previewWhiteBalance;`
|
||||
)();
|
||||
|
||||
const N = 4;
|
||||
const W = 128;
|
||||
const H = 128;
|
||||
const WHITE = 10000;
|
||||
const plane = (r, g, b) => {
|
||||
const d = new Uint16Array(W * H * 3);
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
d[i * 3] = r;
|
||||
d[i * 3 + 1] = g;
|
||||
d[i * 3 + 2] = b;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
const block = (g, bx, by, r, gg, b) => {
|
||||
const i = (by * N + bx) * 4;
|
||||
g[i] = r;
|
||||
g[i + 1] = gg;
|
||||
g[i + 2] = b;
|
||||
g[i + 3] = 255;
|
||||
};
|
||||
const map = (f) => {
|
||||
const g = new Uint8Array(N * N * 4);
|
||||
for (let by = 0; by < N; by++) for (let bx = 0; bx < N; bx++) f(g, bx, by);
|
||||
return g;
|
||||
};
|
||||
const allWhite = map((g, bx, by) => block(g, bx, by, 200, 200, 200));
|
||||
|
||||
const mul = previewWhiteBalance(plane(3000, 2000, 2400), W, H, allWhite, N, WHITE);
|
||||
assert.ok(mul, 'a frame of bright neutral blocks has to read a balance');
|
||||
assert.ok(
|
||||
Math.abs(mul[0] - 2 / 3) < 1e-6 && mul[1] === 1 && Math.abs(mul[2] - 5 / 6) < 1e-6,
|
||||
`read ${mul} off a 1.5x red / 1.2x blue plane`
|
||||
);
|
||||
|
||||
// A block the camera did not leave neutral is not read, however bright it is:
|
||||
// it carries the scene's colour, not the frame's balance.
|
||||
const tinted = previewWhiteBalance(
|
||||
plane(3000, 2000, 2400),
|
||||
W,
|
||||
H,
|
||||
map((g, bx, by) => (bx ? block(g, bx, by, 200, 200, 200) : block(g, bx, by, 200, 120, 200))),
|
||||
N,
|
||||
WHITE
|
||||
);
|
||||
assert.deepEqual(tinted, mul, 'a tinted block moved the balance');
|
||||
|
||||
// Too little of the frame left to read is no balance at all — the caller keeps
|
||||
// the tags rather than a ratio read off a handful of pixels.
|
||||
assert.equal(previewWhiteBalance(plane(3000, 2000, 2400), 30, 30, allWhite, N, WHITE), null);
|
||||
}
|
||||
|
||||
console.log('raw-develop-check ok');
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Develops a real ORF through the app's own pipeline in Node — libraw-wasm on a
|
||||
// worker_threads shim, CanvasKit on the raster backend, the shipped
|
||||
// RAW_DEVELOP_SKSL and the shipped toneMatch — and measures the frame's
|
||||
// highlights against the preview the camera wrote inside the file. A cast that
|
||||
// lives only in the highlights is invisible to a source-regex check and to the
|
||||
// eye of anyone looking at the moonlit part of the frame.
|
||||
//
|
||||
// node scripts/raw-orf-check.mjs [file.ORF]
|
||||
//
|
||||
// Writes the develop next to the file for eyeballing.
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, 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 },
|
||||
}).outputText;
|
||||
|
||||
// libraw-wasm drives an Emscripten worker written for the browser: it speaks
|
||||
// `self.onmessage` / `self.postMessage`, where Node has parentPort. So the worker
|
||||
// is booted behind a shim that renames one into the other, and the package's own
|
||||
// message protocol ({ id, fn, args } / { id, out, error }) is spoken here.
|
||||
// The file bytes are handed over in the transfer list exactly as the package
|
||||
// does — so opening detaches them, and this check fails on any develop that
|
||||
// forgets to keep the caller's array intact.
|
||||
const LIBRAW_NODE_SRC = `
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
const SHIM = \`
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
globalThis.self = globalThis;
|
||||
globalThis.postMessage = (m, t) => parentPort.postMessage(m, t);
|
||||
Object.defineProperty(globalThis, 'onmessage', {
|
||||
configurable: true,
|
||||
set(fn) { parentPort.on('message', (data) => fn({ data })); },
|
||||
});
|
||||
// Emscripten asks for the wasm by the bare name it was built with ('libraw.wasm')
|
||||
// and Node's fetch has no base to resolve that against.
|
||||
const base = new URL('.', workerData.url);
|
||||
globalThis.fetch = async (u) =>
|
||||
new Response(await readFile(new URL(u, base)), { headers: { 'content-type': 'application/wasm' } });
|
||||
await import(workerData.url);
|
||||
\`;
|
||||
|
||||
export default class LibRaw {
|
||||
constructor() {
|
||||
const url = new URL('worker.js', ${JSON.stringify(import.meta.resolve('libraw-wasm/dist/index.js'))}).href;
|
||||
this.worker = new Worker(SHIM, { eval: true, workerData: { url } });
|
||||
this.pending = new Map();
|
||||
this.next = 0;
|
||||
this.worker.on('message', ({ id, out, error }) => {
|
||||
const p = this.pending.get(id);
|
||||
if (!p) return;
|
||||
this.pending.delete(id);
|
||||
if (error) p.reject(new Error(error));
|
||||
else p.resolve(out);
|
||||
});
|
||||
}
|
||||
run(fn, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.next++;
|
||||
this.pending.set(id, { resolve, reject });
|
||||
const transfer = args.filter((a) => ArrayBuffer.isView(a)).map((a) => a.buffer);
|
||||
this.worker.postMessage({ id, fn, args }, transfer);
|
||||
});
|
||||
}
|
||||
open(bytes, settings) { return this.run('open', bytes, settings); }
|
||||
metadata(full) { return this.run('metadata', !!full); }
|
||||
imageData() { return this.run('imageData'); }
|
||||
thumbnailData() { return this.run('thumbnailData'); }
|
||||
dispose() { return this.worker.terminate(); }
|
||||
}
|
||||
`;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'raw-orf-check-'));
|
||||
const ckEntry = import.meta.resolve('canvaskit-wasm/full');
|
||||
writeFileSync(
|
||||
join(dir, 'skiaShim.mjs'),
|
||||
transpile('../src/engine/skiaShim.ts').replace(
|
||||
/^import CanvasKitInit from ['"]canvaskit-wasm\/full['"];$/m,
|
||||
`import CanvasKitInit from ${JSON.stringify(ckEntry)};`,
|
||||
),
|
||||
);
|
||||
writeFileSync(join(dir, 'sensorWhite.mjs'), transpile('../src/engine/sensorWhite.ts'));
|
||||
writeFileSync(join(dir, 'halfFloat.mjs'), transpile('../src/engine/halfFloat.ts'));
|
||||
writeFileSync(join(dir, 'previewMatch.mjs'), transpile('../src/engine/previewMatch.ts'));
|
||||
writeFileSync(
|
||||
join(dir, 'rawDevelop.mjs'),
|
||||
transpile('../src/engine/rawDevelop.ts')
|
||||
.replace(/^import LibRaw from ['"]libraw-wasm['"];$/m, 'import LibRaw from "./librawNode.mjs";')
|
||||
.replace(/['"]\.\/sensorWhite['"]/, '"./sensorWhite.mjs"')
|
||||
.replace(/['"]\.\/halfFloat['"]/, '"./halfFloat.mjs"')
|
||||
.replace(/['"]\.\/previewMatch['"]/, '"./previewMatch.mjs"')
|
||||
.replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"'),
|
||||
);
|
||||
writeFileSync(join(dir, 'librawNode.mjs'), LIBRAW_NODE_SRC);
|
||||
|
||||
const { developRaw, extractEmbeddedJpeg } = await import(pathToFileURL(join(dir, 'rawDevelop.mjs')).href);
|
||||
// `Skia` is a live binding filled in by initSkia, so read it after the call.
|
||||
const Skia = await (await import(pathToFileURL(join(dir, 'skiaShim.mjs')).href)).initSkia();
|
||||
|
||||
// --- measure -----------------------------------------------------------------
|
||||
const file = process.argv[2] ?? '/home/locpham/RecipesCam/docker/_3279791.ORF';
|
||||
assert.ok(existsSync(file), `no such file: ${file}`);
|
||||
const bytes = new Uint8Array(readFileSync(file));
|
||||
|
||||
// The preview the curve is fitted to is the one the develop itself reads —
|
||||
// `extractEmbeddedJpeg`, the same call the app makes. The byte-scanning copy that
|
||||
// stood here found a JPEG in an ORF and nothing in a DNG or a RAF (their previews
|
||||
// live behind a container this scanner's marker walk does not follow), so the
|
||||
// reference came back undecodable and the check died on those two bodies.
|
||||
const jpeg = await developRaw(bytes);
|
||||
const out = file.replace(/\.\w+$/, '') + '-develop-check.jpg';
|
||||
writeFileSync(out, jpeg);
|
||||
console.log(`develop ${jpeg.length} bytes -> ${out}`);
|
||||
|
||||
function readPixels(jpegBytes, w, h) {
|
||||
const img = Skia.Image.MakeImageFromEncoded(jpegBytes);
|
||||
assert.ok(img, 'decode failed');
|
||||
const surface = Skia.Surface.Make(w, h);
|
||||
const paint = Skia.Paint();
|
||||
paint.setAntiAlias(false);
|
||||
surface.getCanvas().drawImageRectOptions(
|
||||
img, Skia.XYWHRect(0, 0, img.width(), img.height()), Skia.XYWHRect(0, 0, w, h),
|
||||
Skia.FilterMode.Linear, Skia.MipmapMode.None, paint,
|
||||
);
|
||||
surface.flush();
|
||||
const shot = surface.makeImageSnapshot();
|
||||
const px = shot.readPixels(0, 0, {
|
||||
width: w, height: h, colorType: Skia.ColorType.RGBA_8888,
|
||||
alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB,
|
||||
});
|
||||
img.delete();
|
||||
shot.delete();
|
||||
surface.dispose();
|
||||
paint.delete();
|
||||
return px;
|
||||
}
|
||||
|
||||
const N = 256;
|
||||
const dev = readPixels(jpeg, N, N);
|
||||
const ref = readPixels(extractEmbeddedJpeg(bytes), N, N);
|
||||
|
||||
// Bands by how bright the *camera's own* rendering says the block is: a neutral
|
||||
// render has both ratios at 1 there, a green/yellow veil pulls them apart.
|
||||
const rows = [];
|
||||
for (let i = 0; i < N * N; i++) {
|
||||
const r = ref[i * 4], g = ref[i * 4 + 1], b = ref[i * 4 + 2];
|
||||
rows.push({
|
||||
l: (r + g + b) / 3,
|
||||
r, g, b,
|
||||
d: [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]],
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => b.l - a.l);
|
||||
const report = (label, sel) => {
|
||||
const m = (f) => sel.reduce((s, x) => s + f(x), 0) / sel.length;
|
||||
const [r, g, b] = [m((x) => x.d[0]), m((x) => x.d[1]), m((x) => x.d[2])];
|
||||
const [rr, rg, rb] = [m((x) => x.r), m((x) => x.g), m((x) => x.b)];
|
||||
console.log(
|
||||
`${label} n=${sel.length}` +
|
||||
` | develop R/G ${(r / g).toFixed(3)} B/G ${(b / g).toFixed(3)}` +
|
||||
` | preview R/G ${(rr / rg).toFixed(3)} B/G ${(rb / rg).toFixed(3)}` +
|
||||
` | dRGB ${(r - rr).toFixed(1)},${(g - rg).toFixed(1)},${(b - rb).toFixed(1)}`,
|
||||
);
|
||||
};
|
||||
report('all ', rows);
|
||||
report('bright top 5% ', rows.slice(0, Math.round(N * N * 0.05)));
|
||||
report('bright top 20% ', rows.slice(0, Math.round(N * N * 0.2)));
|
||||
report('shadows bot 50% ', rows.slice(Math.round(N * N * 0.5)));
|
||||
@@ -0,0 +1,146 @@
|
||||
// The walk that reads a picked folder (src/engine/rollWalk.ts) is the part of a
|
||||
// scan that can run for minutes, and its order is what the reader sees: the
|
||||
// picked folder with its own frames, then every folder one layer down with
|
||||
// theirs, then the layer below that — and the folder they just clicked in front
|
||||
// of everything. None of that can be watched in a browser, so it is walked here
|
||||
// over a stand-in tree.
|
||||
//
|
||||
// node scripts/roll-walk-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const src = readFileSync(new URL('../src/engine/rollWalk.ts', import.meta.url), 'utf8');
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const { DIRS_PER_PASS, jumpQueue, walkPass } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
|
||||
|
||||
// A directory handle is only ever read for `values()` and `getDirectoryHandle()`,
|
||||
// so a stand-in tree needs nothing else: `{ name: { … } }` for folders and
|
||||
// `name: null` for a file. `@eaDir` and `.thumbnails` are the folders a real roll
|
||||
// drags along, and the recycle bins below are the ones an external volume puts at
|
||||
// its root — each holds a frame-named file, so only the folder rule can be what
|
||||
// keeps them out.
|
||||
const dir = (name, children) => ({
|
||||
kind: 'directory',
|
||||
name,
|
||||
values: async function* () {
|
||||
for (const [child, kids] of Object.entries(children)) {
|
||||
yield kids === null ? { kind: 'file', name: child } : dir(child, kids);
|
||||
}
|
||||
},
|
||||
getDirectoryHandle: async (part) => {
|
||||
if (!(part in children) || children[part] === null) throw new DOMException('not a folder', 'NotFoundError');
|
||||
return dir(part, children[part]);
|
||||
},
|
||||
});
|
||||
const keep = (name) => /\.(jpe?g|rw2)$/i.test(name);
|
||||
// A walk is its root, its queue, and the folders it has read.
|
||||
const at = (root, pending) => ({ root, pending, walked: new Set() });
|
||||
|
||||
const roll = dir('CheckRoll', {
|
||||
'.thumbnails': { hidden: null },
|
||||
'@eaDir': { junk: null },
|
||||
'P1.JPG': null,
|
||||
'Empty': { 'notes.txt': null },
|
||||
'2026': { 'P2.JPG': null, '04': { 'P3.RW2': null, 'notes.txt': null } },
|
||||
'2025': { 'P4.JPG': null },
|
||||
'$RECYCLE.BIN': { 'deleted.JPG': null },
|
||||
'$Recycle.Bin': { 'also-deleted.JPG': null },
|
||||
'RECYCLER': { 'older.JPG': null },
|
||||
'System Volume Information': { 'W.JPG': null },
|
||||
'#recycle': { 'synology.JPG': null },
|
||||
'lost+found': { 'linux.JPG': null },
|
||||
});
|
||||
|
||||
// Walks to the end, one click per pass, which is what the browser does between
|
||||
// passes: whatever the reader clicked last is what `jump` names. `seen` is the
|
||||
// folders each pass named, which is what the column draws.
|
||||
const walk = async (jumps = []) => {
|
||||
const tree = at(roll, [{ dir: roll, rel: '' }]);
|
||||
const out = [];
|
||||
let clicked = 0;
|
||||
let passes = 0;
|
||||
let walked = false;
|
||||
let seen = null;
|
||||
while (!walked) {
|
||||
const names = [];
|
||||
walked = await walkPass(tree, out, names, keep, () => jumps[clicked++] ?? null);
|
||||
passes++;
|
||||
seen ??= names;
|
||||
}
|
||||
return { out, names: seen, passes };
|
||||
};
|
||||
|
||||
// Nothing clicked: the picked folder's own frames, then a layer down, then the
|
||||
// deepest — a folder is never read before the folder that holds it. Every folder
|
||||
// is named in the pass that finds it, which is how a row exists before its own
|
||||
// frames do.
|
||||
const { out, names, passes } = await walk();
|
||||
const rels = out.map((f) => f.rel);
|
||||
assert.deepEqual(rels, ['P1.JPG', '2025/P4.JPG', '2026/P2.JPG', '2026/04/P3.RW2'], 'layer by layer from the parent down');
|
||||
assert.ok(
|
||||
rels.every((rel, i) => i === 0 || rel.split('/').length >= rels[i - 1].split('/').length),
|
||||
'no deeper frame is read before a shallower one'
|
||||
);
|
||||
assert.ok(!rels.some((rel) => /\.thumbnails|@eaDir|notes\.txt/.test(rel)), 'hidden and unsupported names are skipped');
|
||||
// The frames inside the recycle bins are named like frames, so nothing about the
|
||||
// files keeps them out: the folders themselves are what the walk refuses.
|
||||
const SYSTEM = /\$Recycle\.Bin|RECYCLER|System Volume Information|#recycle|lost\+found/i;
|
||||
assert.ok(!rels.some((rel) => SYSTEM.test(rel)), 'a recycle bin or system folder yields no frame');
|
||||
assert.ok(!names.some((rel) => SYSTEM.test(rel)), 'and never becomes a row either');
|
||||
assert.deepEqual(
|
||||
names,
|
||||
['2025/', '2026/', 'Empty/', '2026/04/'],
|
||||
'the first pass names every folder it walked into, frames or no frames'
|
||||
);
|
||||
assert.ok(names.includes('Empty/'), 'a folder holding no frame at all is still a row');
|
||||
assert.equal(passes, 1, 'a roll this size is one pass, and the names are in it');
|
||||
|
||||
// A click on a folder one layer down, while that layer is still to be read, puts
|
||||
// it first: the frames the reader is looking at are read before the branch beside
|
||||
// them.
|
||||
const clicked = await walk([null, '2026/']);
|
||||
assert.deepEqual(
|
||||
clicked.out.map((f) => f.rel),
|
||||
['P1.JPG', '2026/P2.JPG', '2025/P4.JPG', '2026/04/P3.RW2'],
|
||||
'the clicked folder jumps the queue'
|
||||
);
|
||||
|
||||
// A folder the walk has not reached yet — a row on screen from the parent pass —
|
||||
// is opened from the picked folder and read next: `getDirectoryHandle` walks the
|
||||
// path, and a path that will not open is left where it was.
|
||||
const deep = at(roll, [{ dir: dir('Other', { 'X.JPG': null }), rel: 'other/' }]);
|
||||
const deepOut = [];
|
||||
await walkPass(deep, deepOut, [], keep, () => '2026/04/');
|
||||
assert.deepEqual(
|
||||
deepOut.map((f) => f.rel),
|
||||
['2026/04/P3.RW2', 'other/X.JPG'],
|
||||
'a folder the queue never held is opened and read first'
|
||||
);
|
||||
const missing = at(roll, [{ dir: roll, rel: '' }]);
|
||||
await jumpQueue(missing, '2026/nope/');
|
||||
assert.equal(missing.pending.length, 1, 'a path that does not open leaves the queue alone');
|
||||
|
||||
// And the rule without a browser around it: a pass reads the queue in its own
|
||||
// order unless the click says otherwise.
|
||||
const wide = dir('Wide', { a: { 'A.JPG': null }, b: { 'B.JPG': null } });
|
||||
const before = [];
|
||||
await walkPass(at(wide, [{ dir: wide, rel: '' }]), before, [], keep, () => null);
|
||||
assert.deepEqual(before.map((f) => f.rel), ['a/A.JPG', 'b/B.JPG'], 'untouched, the folders are read in the order they were found');
|
||||
const after = [];
|
||||
await walkPass(at(wide, [{ dir: wide, rel: '' }]), after, [], keep, () => 'b/');
|
||||
assert.deepEqual(after.map((f) => f.rel), ['b/B.JPG', 'a/A.JPG'], 'the folder the reader clicked is read first');
|
||||
|
||||
// A pass is bounded: a library wider than the budget is read over several passes,
|
||||
// and the rest waits in the queue rather than in a loop that never yields.
|
||||
const children = {};
|
||||
for (let i = 0; i < DIRS_PER_PASS + 5; i++) children[`d${String(i).padStart(3, '0')}`] = { 'x.jpg': null };
|
||||
const many = at(dir('Many', children), [{ dir: dir('Many', children), rel: '' }]);
|
||||
const found = [];
|
||||
assert.equal(await walkPass(many, found, [], keep, () => null), false, 'the queue is not empty after one pass');
|
||||
assert.equal(found.length, DIRS_PER_PASS - 1, `one pass read the picked folder and ${DIRS_PER_PASS - 1} below it`);
|
||||
assert.equal(many.pending.length, 6, 'the rest is left in the queue, for the next pass');
|
||||
|
||||
console.log(`ok roll walk — ${rels.length} frames layer by layer, recycle bins and system folders skipped, names a pass ahead, click jumps the queue, ${DIRS_PER_PASS} folders a pass`);
|
||||
@@ -0,0 +1,302 @@
|
||||
// TEMPORARY — the bug this proves: reading a roll used to die the moment the
|
||||
// catalogue handed the visitor to the studio, because every internal link was a
|
||||
// full page load and the scan lived in that page's memory. Run it, then delete
|
||||
// it.
|
||||
//
|
||||
// node scripts/scan-nav-check.mjs
|
||||
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 ?? 4186);
|
||||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4320);
|
||||
const DELAY = Number(process.env.SCAN_DELAY ?? 900);
|
||||
const JPG = 'P1010256.JPG';
|
||||
const RAW = 'P1010256.RW2';
|
||||
|
||||
// Twelve frames: four at the top, four a folder down, four two folders down.
|
||||
const names = (prefix) => Array.from({ length: 4 }, (_, i) => `${prefix}${i + 1}.JPG`);
|
||||
const ROLL = {
|
||||
files: names('a'),
|
||||
dirs: { '2026': { files: names('b'), dirs: { '04': { files: names('c'), dirs: {} } } } },
|
||||
};
|
||||
const TOTAL = 12;
|
||||
|
||||
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');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// One frame at a time, however many the page asks for at once: a roll that reads
|
||||
// itself in parallel is over before a handover can be timed against it.
|
||||
let turn = Promise.resolve();
|
||||
const waitTurn = () => {
|
||||
const mine = turn.then(() => sleep(DELAY));
|
||||
turn = mine;
|
||||
return mine;
|
||||
};
|
||||
|
||||
const sampleServer = createServer(async (req, res) => {
|
||||
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
|
||||
if (!/\.(jpg|rw2)$/i.test(name)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const bytes = await readFile(path.join(SAMPLES, /\.rw2$/i.test(name) ? RAW : JPG));
|
||||
await waitTurn();
|
||||
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));
|
||||
|
||||
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 sleep(500);
|
||||
}
|
||||
if (!up) {
|
||||
app.kill('SIGTERM');
|
||||
sampleServer.close();
|
||||
console.log(`FAIL no preview on ${base}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
|
||||
// Documents this tab has loaded, across navigations: the count only moves when a
|
||||
// click threw the page away and started a new one.
|
||||
await context.addInitScript(() => {
|
||||
const n = Number(sessionStorage.getItem('docLoads') ?? '0') + 1;
|
||||
sessionStorage.setItem('docLoads', String(n));
|
||||
});
|
||||
|
||||
await context.addInitScript(
|
||||
({ origin, roll, 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);
|
||||
};
|
||||
// A second scan of the same roll reads the same names again; dropping what the
|
||||
// stand-in already holds makes it go back to the server for each of them, so
|
||||
// a re-reading roll has the same minutes in it a first one does.
|
||||
window.__forget = () => bytes.clear();
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => new File([await load(name)], name, { type: 'image/jpeg' }),
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
// `__fake` on a folder too: the catalogue reads its folders back out of the
|
||||
// store after every remount, and a handle that does not say what it is comes
|
||||
// back as a plain object with no `queryPermission` — a folder it cannot ask
|
||||
// about is a folder it offers to reconnect.
|
||||
const handle = { kind: 'directory', name: label, __fake: true };
|
||||
Object.defineProperties(handle, {
|
||||
values: {
|
||||
value: () =>
|
||||
[
|
||||
...(node.files ?? []).map(fileHandle),
|
||||
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
||||
][Symbol.iterator](),
|
||||
},
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const root = () => dirHandle(folder, roll);
|
||||
window.showDirectoryPicker = async () => root();
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
row.handle = row.handle.kind === 'directory' ? root() : 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}`, roll: ROLL, folder: 'SlowRoll' }
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
const indexed = () =>
|
||||
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 new Promise((resolve, reject) => {
|
||||
const req = db.transaction('photos').objectStore('photos').count();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 });
|
||||
step('the scan is running', true);
|
||||
|
||||
// The rows only reach IndexedDB in one batch at the end of the scan, so the
|
||||
// toolbar's own line is what says how far the reading is.
|
||||
const progressText = (timeout) =>
|
||||
page
|
||||
.waitForFunction(() => document.querySelector('[data-key="lib-progress"]')?.textContent?.trim() || null, null, {
|
||||
timeout,
|
||||
polling: 100,
|
||||
})
|
||||
.then((h) => h.jsonValue())
|
||||
.catch(() => null);
|
||||
|
||||
const docs = () => page.evaluate(() => Number(sessionStorage.getItem('docLoads') ?? '0'));
|
||||
const tiles = () => page.$$eval('[data-key^="lib-thumb-"]', (els) => els.length);
|
||||
// The count of documents standing when the page was last settled: every step that
|
||||
// follows a link or a frame asserts against this, because a link followed with no
|
||||
// scan in flight is *meant* to load the page again.
|
||||
let seen = await docs();
|
||||
const noReload = async () => (await docs()) === seen;
|
||||
|
||||
// Hand over to the studio while the roll is still being read, which is the whole
|
||||
// point: part of it in, the rest still to come.
|
||||
const midway = await progressText(15_000);
|
||||
step('the roll is half read when the studio is opened', !!midway, String(midway));
|
||||
|
||||
await page.click('[data-key="lib-studio"]');
|
||||
await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 });
|
||||
step('the handover stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`);
|
||||
step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url());
|
||||
// The reading belongs to the tab, so the header of the screen that did not start
|
||||
// it says it is still going, beside the way back into the catalogue.
|
||||
const badge = await page
|
||||
.waitForSelector('[data-key="studio-scan-count"]', { timeout: 15_000 })
|
||||
.then((el) => el.textContent())
|
||||
.catch(() => null);
|
||||
step('the studio header shows the reading the tab is doing', /^\d+\/\d+$/.test(badge ?? ''), String(badge));
|
||||
|
||||
// Back to the catalogue mid-scan: the screen that started the scan is gone and
|
||||
// back, and the roll is still being read — the reading belongs to the tab.
|
||||
await page.click('[data-key="nav-library"]');
|
||||
await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 });
|
||||
step('coming back stays in the page too', await noReload(), `${await docs()} document load(s)`);
|
||||
const progress = await progressText(15_000);
|
||||
step('the catalogue comes back showing the scan it missed', !!progress, String(progress));
|
||||
await page.waitForSelector('[data-key^="lib-node-"]', { timeout: 15_000 });
|
||||
const tree = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key));
|
||||
step('the tree is there, from the scan that outlived its screen', tree.includes('lib-node-SlowRoll'), JSON.stringify(tree));
|
||||
|
||||
// Now leave it alone in the studio and let it finish: nothing up there reads the
|
||||
// roll, so every frame that lands from here on landed for a screen that is gone.
|
||||
await page.click('[data-key="lib-studio"]');
|
||||
await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 });
|
||||
let indexed_to = 0;
|
||||
for (let i = 0; i < 300 && indexed_to < TOTAL; i++) {
|
||||
indexed_to = await indexed().catch(() => indexed_to);
|
||||
if (indexed_to < TOTAL) await sleep(200);
|
||||
}
|
||||
step('the whole roll is read with the studio up', indexed_to === TOTAL, `${indexed_to}/${TOTAL} indexed`);
|
||||
step('and the studio is still the page it was', await noReload(), `${await docs()} document load(s)`);
|
||||
|
||||
await page.click('[data-key="nav-library"]');
|
||||
await page.waitForFunction(
|
||||
(n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n,
|
||||
TOTAL,
|
||||
{ timeout: 60_000, polling: 200 }
|
||||
).catch(() => {});
|
||||
step('every frame ends up as a thumbnail', (await tiles()) === TOTAL, `${await tiles()} tiles`);
|
||||
// The tree opens folded, so the folder two down is a row only once the rows above
|
||||
// it are opened: the walk goes down it and comes back, which leaves the screen on
|
||||
// the roll the steps below read it as.
|
||||
await page.click('[data-key="lib-node-SlowRoll"]');
|
||||
await page.waitForSelector('[data-key="lib-node-SlowRoll/2026"]', { timeout: 15_000 });
|
||||
await page.click('[data-key="lib-node-SlowRoll/2026"]');
|
||||
await page.waitForSelector('[data-key="lib-node-SlowRoll/2026/04"]', { timeout: 15_000 });
|
||||
const grown = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key));
|
||||
step('the tree ends up holding the deepest folder of the roll', grown.includes('lib-node-SlowRoll/2026/04'), JSON.stringify(grown));
|
||||
await page.click('[data-key="lib-node-SlowRoll/2026"]');
|
||||
await page.click('[data-key="lib-node-SlowRoll"]');
|
||||
// Nothing was scanning at that last click, so that one did load the page again —
|
||||
// which is the whole point of the guard. Everything below counts from here.
|
||||
seen = await docs();
|
||||
|
||||
// Now the reader's own way into the studio, and the noisiest one for this page: a
|
||||
// frame opened from a roll that is being read again. The rows are drawn from the
|
||||
// catalogue it already has, so this is the one route out of here that the browser
|
||||
// could take with no link anywhere in sight.
|
||||
const rows = () => page.$eval('[data-key="lib-node-SlowRoll"] .adm-album-count', (el) => Number(el.textContent));
|
||||
await page.evaluate(() => window.__forget());
|
||||
await page.click('[data-key="lib-node-SlowRoll"]', { button: 'right' });
|
||||
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
|
||||
const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
|
||||
const rescan = menu.find((k) => /^lib-(rescan|reconnect)-/.test(k));
|
||||
step('the roll can be read again from its own menu', !!rescan, JSON.stringify(menu));
|
||||
await page.click(`[data-key="${rescan}"]`);
|
||||
await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 });
|
||||
await sleep(DELAY);
|
||||
const early = await rows();
|
||||
await sleep(DELAY * 2);
|
||||
const later = await rows();
|
||||
step('the row counts the reading as it comes, not the catalogue standing still', later !== early, `${early} → ${later} of ${TOTAL}`);
|
||||
step('the strip is still the catalogue the roll left behind', (await tiles()) === TOTAL, `${await tiles()} tiles`);
|
||||
|
||||
await page.click('[data-key="lib-open"]');
|
||||
await page.waitForSelector('[data-key="nav-library"]', { timeout: 30_000 });
|
||||
step('opening a frame stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`);
|
||||
// The studio takes the frame out of the address as soon as it has it, so the
|
||||
// route is what is left to say the handover arrived.
|
||||
step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url());
|
||||
|
||||
await page.click('[data-key="nav-library"]');
|
||||
await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 });
|
||||
step('and the scan it interrupted is still the scan in hand', await noReload(), `${await docs()} document load(s)`);
|
||||
const steady = await progressText(15_000);
|
||||
step('the catalogue is still reading the roll it was reading', !!steady, String(steady));
|
||||
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
sampleServer.close();
|
||||
console.log(failures ? `\n${failures} step(s) failed` : '\nall steps passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,139 @@
|
||||
// SHARPENING is an unsharp mask MASKED BY AN EDGE DETECTOR and CORED —
|
||||
// thay_doi_thong_so_giong_lightroom.md §4.2, "Chỉ có viền mắt, lông mi, sợi vải,
|
||||
// kẽ lá nhận Mask = 1; bầu trời phẳng và làn da mịn màng nhận Mask = 0".
|
||||
//
|
||||
// The knob used to be the same 3x3 kernel with Mask = 1 everywhere and no
|
||||
// coring, so a flat field of grain took the same gain an eyelash did and a
|
||||
// rising SHARPENING raised the frame's noise with it. This check pins the three
|
||||
// things the doc asks for and that the old kernel could not do:
|
||||
//
|
||||
// 1. a flat field comes back untouched (no gain on noise),
|
||||
// 2. a soft step below the edge threshold comes back untouched (Mask = 0),
|
||||
// 3. a hard step gains contrast on both sides of it (Mask = 1).
|
||||
//
|
||||
// node scripts/sharpen-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 { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
||||
|
||||
const transpile = (path) =>
|
||||
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sharpen-check-'));
|
||||
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
writeFileSync(
|
||||
join(dir, 'toneShader.mjs'),
|
||||
transpile('../shared/utils/toneShader.ts').replace(
|
||||
/^import .*from ['"]\.\/colorUtils['"];$/m,
|
||||
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
|
||||
),
|
||||
);
|
||||
const { SHARPEN_SKSL, SHARPEN_CORE, SHARPEN_MASK_LO, SHARPEN_MASK_HI } = await import(
|
||||
pathToFileURL(join(dir, 'toneShader.mjs')).href
|
||||
);
|
||||
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(engine, /Skia\.RuntimeEffect\.Make\(SHARPEN_SKSL\)/, 'SHARPEN_SKSL is not compiled any more');
|
||||
assert.doesNotMatch(engine, /CLARITY_SKSL/, 'the unmasked kernel is still wired in somewhere');
|
||||
assert.ok(
|
||||
SHARPEN_MASK_LO > 0 && SHARPEN_MASK_HI > SHARPEN_MASK_LO,
|
||||
`the doc's smoothstep(T, T + 0.1, G) is not a ramp (${SHARPEN_MASK_LO}..${SHARPEN_MASK_HI})`,
|
||||
);
|
||||
assert.ok(SHARPEN_CORE > 0, 'Detail coring is off, so a speckle is amplified again');
|
||||
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
const W = 16;
|
||||
const H = 16;
|
||||
const AMOUNT = 0.8; // the engine's own gain at SHARPENING 100: (100 / 10) * 0.8
|
||||
const near = (a, b, why) => assert.ok(Math.abs(a - b) <= 2 / 255, `${why} (${a} vs ${b})`);
|
||||
|
||||
// One 8-bit RGBA image from a per-pixel grey level.
|
||||
function imaged(grey) {
|
||||
const px = new Uint8Array(W * H * 4);
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const v = Math.round(Math.max(0, Math.min(1, grey(x, y))) * 255);
|
||||
const i = (y * W + x) * 4;
|
||||
px[i] = px[i + 1] = px[i + 2] = v;
|
||||
px[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return CanvasKit.MakeImage(
|
||||
{
|
||||
width: W, height: H,
|
||||
colorType: CanvasKit.ColorType.RGBA_8888,
|
||||
alphaType: CanvasKit.AlphaType.Unpremul,
|
||||
colorSpace: CanvasKit.ColorSpace.SRGB,
|
||||
},
|
||||
px,
|
||||
W * 4,
|
||||
);
|
||||
}
|
||||
|
||||
function render(grey) {
|
||||
const effect = CanvasKit.RuntimeEffect.Make(SHARPEN_SKSL);
|
||||
assert.ok(effect, 'SHARPEN_SKSL does not compile');
|
||||
const src = imaged(grey);
|
||||
const child = src.makeShaderOptions(
|
||||
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
|
||||
);
|
||||
const shader = effect.makeShaderWithChildren([AMOUNT, 1, 1], [child]); // px = 1,1: 1:1 export canvas
|
||||
assert.ok(shader, 'the pass did not take the frame as its child');
|
||||
const out = CanvasKit.MakeSurface(W, H);
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setShader(shader);
|
||||
out.getCanvas().drawPaint(paint);
|
||||
const bytes = out.makeImageSnapshot().readPixels(0, 0, {
|
||||
width: W, height: H, colorType: CanvasKit.ColorType.RGBA_8888,
|
||||
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
|
||||
});
|
||||
const at = (x, y) => bytes[(y * W + x) * 4] / 255;
|
||||
const values = [];
|
||||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) values.push(at(x, y));
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const std = Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length);
|
||||
return { at, mean, std };
|
||||
}
|
||||
|
||||
// 1. A flat field: the Sobel magnitude is 0 and the high-pass is 0, so the whole
|
||||
// Mask is 0 and the frame is handed back exactly. The old kernel gained here.
|
||||
const flat = render(() => 0.5);
|
||||
near(flat.mean, 0.5, 'a flat field moved — Mask = 0 is not being honoured');
|
||||
assert.ok(flat.std <= 2 / 255, `a flat field gained texture (std ${flat.std})`);
|
||||
|
||||
// 2. The same picture with grain on it: the noise is under the edge threshold, so
|
||||
// SHARPENING must not raise the frame's noise — the doc's §1 "sạn hạt cát".
|
||||
let seed = 12345;
|
||||
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff - 0.5);
|
||||
const grain = new Float64Array(W * H);
|
||||
for (let i = 0; i < grain.length; i++) grain[i] = 0.5 + rnd() * 0.04;
|
||||
const grainy = render((x, y) => grain[y * W + x]);
|
||||
const noiseIn = Math.sqrt(grain.reduce((a, b) => a + (b - 0.5) ** 2, 0) / grain.length);
|
||||
assert.ok(
|
||||
grainy.std <= noiseIn + 2 / 255,
|
||||
`grain was amplified by SHARPENING (std ${grainy.std} for a noise of ${noiseIn})`,
|
||||
);
|
||||
|
||||
// 3. A soft step, well under the edge threshold: Mask = 0, so it is not touched.
|
||||
const soft = render((x) => (x < W / 2 ? 0.5 : 0.52));
|
||||
near(soft.at(6, 8), 0.5, 'a sub-threshold step was sharpened on its dark side');
|
||||
near(soft.at(9, 8), 0.52, 'a sub-threshold step was sharpened on its light side');
|
||||
|
||||
// 4. A hard step: Sobel saturates the mask, the high-pass is coring-open, and
|
||||
// both sides move apart. A pass that changed nothing would fail here.
|
||||
const hard = render((x) => (x < W / 2 ? 0.25 : 0.75));
|
||||
assert.ok(hard.at(7, 8) < 0.25 - 2 / 255, `the dark side of an edge did not move (${hard.at(7, 8)})`);
|
||||
assert.ok(hard.at(8, 8) > 0.75 + 2 / 255, `the light side of an edge did not move (${hard.at(8, 8)})`);
|
||||
// ...and only the two columns beside the edge: the flat halves keep Mask = 0.
|
||||
near(hard.at(1, 8), 0.25, 'a flat half of the frame was sharpened');
|
||||
near(hard.at(14, 8), 0.75, 'a flat half of the frame was sharpened');
|
||||
|
||||
console.log('sharpen ok: edges move, flat fields and grain do not');
|
||||
@@ -0,0 +1,173 @@
|
||||
import { readFileSync, readdirSync, 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 },
|
||||
}).outputText;
|
||||
|
||||
const LIBRAW_NODE_SRC = `
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
const SHIM = \`
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
globalThis.self = globalThis;
|
||||
globalThis.postMessage = (m, t) => parentPort.postMessage(m, t);
|
||||
Object.defineProperty(globalThis, 'onmessage', {
|
||||
configurable: true,
|
||||
set(fn) { parentPort.on('message', (data) => fn({ data })); },
|
||||
});
|
||||
const base = new URL('.', workerData.url);
|
||||
globalThis.fetch = async (u) =>
|
||||
new Response(await readFile(new URL(u, base)), { headers: { 'content-type': 'application/wasm' } });
|
||||
await import(workerData.url);
|
||||
\`;
|
||||
|
||||
export default class LibRaw {
|
||||
constructor() {
|
||||
const url = new URL('worker.js', ${JSON.stringify(import.meta.resolve('libraw-wasm/dist/index.js'))}).href;
|
||||
this.worker = new Worker(SHIM, { eval: true, workerData: { url } });
|
||||
this.pending = new Map();
|
||||
this.next = 0;
|
||||
this.worker.on('message', ({ id, out, error }) => {
|
||||
const p = this.pending.get(id);
|
||||
if (!p) return;
|
||||
this.pending.delete(id);
|
||||
if (error) p.reject(new Error(error));
|
||||
else p.resolve(out);
|
||||
});
|
||||
}
|
||||
run(fn, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.next++;
|
||||
this.pending.set(id, { resolve, reject });
|
||||
const transfer = args.filter((a) => ArrayBuffer.isView(a)).map((a) => a.buffer);
|
||||
this.worker.postMessage({ id, fn, args }, transfer);
|
||||
});
|
||||
}
|
||||
open(bytes, settings) {
|
||||
const copy = Uint8Array.from(bytes);
|
||||
return this.run('open', copy, settings);
|
||||
}
|
||||
metadata(full) { return this.run('metadata', !!full); }
|
||||
imageData() { return this.run('imageData'); }
|
||||
thumbnailData() { return this.run('thumbnailData'); }
|
||||
dispose() { return this.worker.terminate(); }
|
||||
}
|
||||
`;
|
||||
|
||||
const dir = join(tmpdir(), 'test-all-raws-acc');
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
const testDir = mkdtempSync(dir);
|
||||
|
||||
const ckEntry = import.meta.resolve('canvaskit-wasm/full');
|
||||
writeFileSync(
|
||||
join(testDir, 'skiaShim.mjs'),
|
||||
transpile('../src/engine/skiaShim.ts').replace(
|
||||
/^import CanvasKitInit from ['"]canvaskit-wasm\/full['"];$/m,
|
||||
`import CanvasKitInit from ${JSON.stringify(ckEntry)};`,
|
||||
),
|
||||
);
|
||||
writeFileSync(join(testDir, 'sensorWhite.mjs'), transpile('../src/engine/sensorWhite.ts'));
|
||||
writeFileSync(join(testDir, 'halfFloat.mjs'), transpile('../src/engine/halfFloat.ts'));
|
||||
writeFileSync(join(testDir, 'previewMatch.mjs'), transpile('../src/engine/previewMatch.ts'));
|
||||
writeFileSync(
|
||||
join(testDir, 'tiffDecode.mjs'),
|
||||
transpile('../src/engine/tiffDecode.ts').replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"')
|
||||
);
|
||||
|
||||
// Transpile rawDevelop.ts with fix
|
||||
let rawDevTs = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
|
||||
|
||||
// Fix WB multiplier order in rawDevelop.ts code before transpiling
|
||||
rawDevTs = rawDevTs.replace(
|
||||
` if (cd?.cam_mul && Array.isArray(cd.cam_mul) && cd.cam_mul.some((v: number) => v > 0 && v !== 1)) {
|
||||
return cd.cam_mul;
|
||||
}
|
||||
|
||||
// 2. Try pre-calculated WB multipliers from LibRaw (pre_mul)
|
||||
if (cd?.pre_mul && Array.isArray(cd.pre_mul) && cd.pre_mul.some((v: number) => v > 0 && v !== 1)) {
|
||||
return cd.pre_mul;
|
||||
}`,
|
||||
` if (cd?.pre_mul && Array.isArray(cd.pre_mul) && cd.pre_mul.some((v: number) => v > 0 && v !== 1)) {
|
||||
return cd.pre_mul;
|
||||
}
|
||||
|
||||
// 2. Try camera WB multipliers from LibRaw (cam_mul)
|
||||
if (cd?.cam_mul && Array.isArray(cd.cam_mul) && cd.cam_mul.some((v: number) => v > 0 && v !== 1)) {
|
||||
return cd.cam_mul;
|
||||
}`
|
||||
);
|
||||
|
||||
const rawDevelopCode = ts.transpileModule(rawDevTs, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
.replace(/^import LibRaw from ['"]libraw-wasm['"];$/m, 'import LibRaw from "./librawNode.mjs";')
|
||||
.replace(/['"]\.\/sensorWhite['"]/, '"./sensorWhite.mjs"')
|
||||
.replace(/['"]\.\/halfFloat['"]/, '"./halfFloat.mjs"')
|
||||
.replace(/['"]\.\/previewMatch['"]/, '"./previewMatch.mjs"')
|
||||
.replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"')
|
||||
.replace(/['"]\.\/tiffDecode['"]/, '"./tiffDecode.mjs"');
|
||||
|
||||
writeFileSync(join(testDir, 'rawDevelop.mjs'), rawDevelopCode);
|
||||
writeFileSync(join(testDir, 'librawNode.mjs'), LIBRAW_NODE_SRC);
|
||||
|
||||
const { developRaw, extractEmbeddedJpeg } = await import(pathToFileURL(join(testDir, 'rawDevelop.mjs')).href);
|
||||
const Skia = await (await import(pathToFileURL(join(testDir, 'skiaShim.mjs')).href)).initSkia();
|
||||
|
||||
const getAvgRgb = (img) => {
|
||||
const w = 64, h = 64;
|
||||
const surface = Skia.Surface.MakeOffscreen(w, h);
|
||||
const canvas = surface.getCanvas();
|
||||
canvas.drawImageRectCubic(img, Skia.XYWHRect(0, 0, img.width(), img.height()), Skia.XYWHRect(0, 0, w, h), 1/3, 1/3);
|
||||
surface.flush();
|
||||
const px = canvas.readPixels(0, 0, { width: w, height: h, colorType: Skia.ColorType.RGBA_8888, alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB });
|
||||
surface.dispose();
|
||||
let r = 0, g = 0, b = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
r += px[i]; g += px[i+1]; b += px[i+2];
|
||||
}
|
||||
const count = px.length / 4;
|
||||
return [ (r/count).toFixed(2), (g/count).toFixed(2), (b/count).toFixed(2) ];
|
||||
};
|
||||
|
||||
const sampleDir = '../sample';
|
||||
const files = readdirSync(sampleDir).filter(f => /\.(raf|nef|dng|rw2|orf|arw|cr2|cr3)$/i.test(f));
|
||||
|
||||
for (const f of files) {
|
||||
const filePath = join(sampleDir, f);
|
||||
const bytes = new Uint8Array(readFileSync(filePath));
|
||||
console.log(`\n=== Testing ${f} ===`);
|
||||
try {
|
||||
const devJpeg = await developRaw(bytes, f);
|
||||
const embJpeg = extractEmbeddedJpeg(bytes);
|
||||
|
||||
const devImg = Skia.Image.MakeImageFromEncoded(devJpeg);
|
||||
const embImg = embJpeg ? Skia.Image.MakeImageFromEncoded(embJpeg) : null;
|
||||
|
||||
const devRgb = getAvgRgb(devImg);
|
||||
console.log(`Dev dim: ${devImg.width()}x${devImg.height()} RGB: [ ${devRgb.join(', ')} ]`);
|
||||
|
||||
if (embImg) {
|
||||
const embRgb = getAvgRgb(embImg);
|
||||
console.log(`Emb dim: ${embImg.width()}x${embImg.height()} RGB: [ ${embRgb.join(', ')} ]`);
|
||||
|
||||
const rDiff = Math.abs(parseFloat(devRgb[0]) - parseFloat(embRgb[0]));
|
||||
const gDiff = Math.abs(parseFloat(devRgb[1]) - parseFloat(embRgb[1]));
|
||||
const bDiff = Math.abs(parseFloat(devRgb[2]) - parseFloat(embRgb[2]));
|
||||
console.log(`RGB Abs Diffs: R=${rDiff.toFixed(2)}, G=${gDiff.toFixed(2)}, B=${bDiff.toFixed(2)}`);
|
||||
|
||||
const pass = rDiff < 15 && gDiff < 15 && bDiff < 15;
|
||||
console.log(`COLOR ACCURACY PASS: ${pass ? 'YES ✅' : 'NO ❌'}`);
|
||||
}
|
||||
|
||||
devImg.delete();
|
||||
if (embImg) embImg.delete();
|
||||
} catch (err) {
|
||||
console.error(`FAILED ${f}:`, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import LibRaw from 'libraw-wasm';
|
||||
|
||||
const files = [
|
||||
'D0004128.DNG',
|
||||
'DSCF1701.RAF',
|
||||
'P1010256.RW2',
|
||||
'_3279791.ORF',
|
||||
'APACHAI-20160215-357.NEF'
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const bytes = readFileSync('../' + file);
|
||||
const raw = new LibRaw();
|
||||
await raw.open(bytes, { halfSize: true, noAutoScale: true });
|
||||
const meta = await raw.metadata(true);
|
||||
const cd = meta?.color_data;
|
||||
console.log(`=== ${file} ===`);
|
||||
console.log('cam_mul:', cd?.cam_mul);
|
||||
console.log('pre_mul:', cd?.pre_mul);
|
||||
console.log('rgb_cam:', cd?.rgb_cam);
|
||||
console.log('cam_xyz:', cd?.cam_xyz);
|
||||
raw.dispose();
|
||||
} catch (err) {
|
||||
console.log(`Error reading ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// The tile for a `.tif` is read out of the file by `src/engine/tiffDecode.ts`,
|
||||
// because nothing else in the app draws a TIFF at all: `createImageBitmap`
|
||||
// answers InvalidStateError to a baseline 5472x3648 scan, CanvasKit's
|
||||
// `MakeImageFromEncoded` answers null, and LibRaw opens the file only to report a
|
||||
// 0x0 frame. So the reader is the only thing standing between a scan and a blank
|
||||
// cell in the wall, and it is checked here against a reference it had no hand in:
|
||||
// one 8x8 crop of the sample frame, written by libvips to a PPM and to four
|
||||
// TIFFs that differ only in how the strip is compressed — none, PackBits, LZW
|
||||
// with horizontal differencing, and JPEG — the first three of which therefore
|
||||
// all have to come back as the PPM's own pixels, exactly.
|
||||
//
|
||||
// cd docker/frontend && node scripts/tiff-decode-check.mjs
|
||||
//
|
||||
// The same crop through the real path (rawThumbnail -> a JPEG tile) is the
|
||||
// browser's job: `scripts/ram-check.mjs` and the wall itself, where the JPEG
|
||||
// decode needs CanvasKit.
|
||||
import { build } from 'esbuild';
|
||||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
// `vips crop ../sample/P1010256.JPG out.v 1200 1600 8 8`, then
|
||||
// `vips copy out.v out.{ppm,tif}`, `[compression=none|packbits|lzw]` — and once
|
||||
// more with `[compression=jpeg,Q=100]`, which is the other TIFF a scan comes as
|
||||
// and the only one whose strip is a JPEG: that one is lossy, so its pixels are
|
||||
// near the PPM's rather than equal to them.
|
||||
const FIXTURES = {
|
||||
'none.tif':
|
||||
'SUkqAMgAAABTR0lRQ0JuYViajoKsoJKvo5esoJShlIxMQEJURkZzZl6Ie3KWinyckIKZjYGShXxHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXhHPEBQREZjV1dlWlhzaGKQhX+YjoWRh35IPUFRRkpbUVJeVFV0amiSiIaUioiMgoBJQEFNREVUS0xdVFV9c3KUiomPhoeKgYJPRkdQR0hTSktaUVJ9dXOUi4ySiYqMg4ZTTU9US05VTE9jWl2Hf32Ui4yIgoKFf4EPAAABAwABAAAACAAAAAEBAwABAAAACAAAAAIBAwADAAAAkgEAAAMBAwABAAAAAQAAAAYBAwABAAAAAgAAABEBBAABAAAACAAAABIBAwABAAAACAAAABUBAwABAAAAAwAAABYBAwABAAAAgAAAABcBBAABAAAAwAAAABoBBQABAAAAggEAABsBBQABAAAAigEAABwBAwABAAAAAQAAACgBAwABAAAAAgAAAFMBAwADAAAAmAEAAAAAAAC0AAAAAQAAALQAAAABAAAACAAIAAgAAQABAAEA',
|
||||
'pb.tif':
|
||||
'SUkqANAAAAAXU0dJUUNCbmFYmo6CrKCSr6OXrKCUoZSMF0xAQlRGRnNmXoh7cpaKfJyQgpmNgZKFfBdHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXgXRzxAUERGY1dXZVpYc2hikIV/mI6FkYd+F0g9QVFGSltRUl5UVXRqaJKIhpSKiIyCgBdJQEFNREVUS0xdVFV9c3KUiomPhoeKgYIXT0ZHUEdIU0pLWlFSfXVzlIuMkomKjIOGF1NNT1RLTlVMT2NaXYd/fZSLjIiCgoV/gQ8AAAEDAAEAAAAIAAAAAQEDAAEAAAAIAAAAAgEDAAMAAACaAQAAAwEDAAEAAAAFgAAABgEDAAEAAAACAAAAEQEEAAEAAAAIAAAAEgEDAAEAAAAIAAAAFQEDAAEAAAADAAAAFgEDAAEAAACAAAAAFwEEAAEAAADIAAAAGgEFAAEAAACKAQAAGwEFAAEAAACSAQAAHAEDAAEAAAABAAAAKAEDAAEAAAACAAAAUwEDAAMAAACgAQAAAAAAALQAAAABAAAAtAAAAAEAAAAIAAgACAABAAEAAQA=',
|
||||
'lzw.tif':
|
||||
'SUkqAMQAAACAFMjkl/Px8h0PBYWC0VBIJBABgMCv2KPV6PgmEAhAgDAQPiAMBUKhQHA8FAaURR/vl8Psjj0fAMAgAQB4NgwGggIhEIA0HAsAUF9Pl+EceEAExwJhMIgKZA4HAoO1MEAkDPmsEgekEE10FAsEREBhYLBMPWcBWl8WskkAggS4Ae5V0EiAPh0L3l9vx/Pu/E8jEcA4OxXIDiMSCELhYMv7HPp9Pwpk0ngF/P/BgGoA4SCUQA0GA96Pd7SqAhAAAAEDAAEAAAAIAAAAAQEDAAEAAAAIAAAAAgEDAAMAAACaAQAAAwEDAAEAAAAFAAAABgEDAAEAAAACAAAAEQEEAAEAAAAIAAAAEgEDAAEAAAAIAAAAFQEDAAEAAAADAAAAFgEDAAEAAACAAAAAFwEEAAEAAAC8AAAAGgEFAAEAAACKAQAAGwEFAAEAAACSAQAAHAEDAAEAAAABAAAAKAEDAAEAAAACAAAAPQEDAAEAAAACAAAAUwEDAAMAAACgAQAAAAAAALQAAAABAAAAtAAAAAEAAAAIAAgACAABAAEAAQA=',
|
||||
'crop.ppm':
|
||||
'UDYKI3ZpcHMycHBtIC0gMjAyNi0xMC0wOVQxNTowMjozNi41MDg0MDIrMDcKOCA4CjI1NQpTR0lRQ0JuYViajoKsoJKvo5esoJShlIxMQEJURkZzZl6Ie3KWinyckIKZjYGShXxHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXhHPEBQREZjV1dlWlhzaGKQhX+YjoWRh35IPUFRRkpbUVJeVFV0amiSiIaUioiMgoBJQEFNREVUS0xdVFV9c3KUiomPhoeKgYJPRkdQR0hTSktaUVJ9dXOUi4ySiYqMg4ZTTU9US05VTE9jWl2Hf32Ui4yIgoKFf4E=',
|
||||
'jpeg.tif':
|
||||
'SUkqALwAAAD/2P/AABEIAAgACANSEQBHEQBCEQD/2gAMA1IARwBCAAA/APLf2HvHvimy8KeI/DQvbqy0bS7E3d4Ec2r3GqajdSBWaZCtu37v5G8zdM6IgySeflj9h3xf4qs/CviXwyL67s9G0yye7vUic2rzapqN1LtJmULC37vKN5geZ0RBz3+Qf2MdR8SnQfEWgxajf6bpmn2b3l4FY2rXGqX90wUvKmyB/wB3iNvM3zOqRkYJYn//2QAQAAABAwABAAAACAAAAAEBAwABAAAACAAAAAIBAwADAAAAkgEAAAMBAwABAAAABwAAAAYBAwABAAAAAgAAABEBBAABAAAACAAAABIBAwABAAAACAAAABUBAwABAAAAAwAAABYBAwABAAAAgAAAABcBBAABAAAAswAAABoBBQABAAAAggEAABsBBQABAAAAigEAABwBAwABAAAAAQAAACgBAwABAAAAAgAAAFMBAwADAAAAmAEAAFsBBwAhAQAAngEAAAAAAAC0AAAAAQAAALQAAAABAAAACAAIAAgAAQABAAEA/9j/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/2Q==',
|
||||
};
|
||||
|
||||
function fixture(name) {
|
||||
return new Uint8Array(Buffer.from(FIXTURES[name], 'base64'));
|
||||
}
|
||||
|
||||
// P6, with libvips' own comment line, which is the reason this is not a
|
||||
// `split('\n')`.
|
||||
function readPpm(bytes) {
|
||||
let at = 0;
|
||||
const token = () => {
|
||||
for (;;) {
|
||||
if (at >= bytes.length) return '';
|
||||
const c = bytes[at];
|
||||
if (c === 0x23) {
|
||||
while (at < bytes.length && bytes[at] !== 0x0a) at++;
|
||||
} else if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) {
|
||||
at++;
|
||||
} else break;
|
||||
}
|
||||
const from = at;
|
||||
while (at < bytes.length && ![0x20, 0x09, 0x0a, 0x0d].includes(bytes[at])) at++;
|
||||
return String.fromCharCode(...bytes.subarray(from, at));
|
||||
};
|
||||
if (token() !== 'P6') throw new Error('not a PPM');
|
||||
const width = Number(token());
|
||||
const height = Number(token());
|
||||
if (Number(token()) !== 255) throw new Error('not 8-bit');
|
||||
at++;
|
||||
return { width, height, pixels: bytes.subarray(at, at + width * height * 3) };
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
const check = (what, ok, detail = '') => {
|
||||
if (!ok) failures.push(`${what}${detail ? `: ${detail}` : ''}`);
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const cache = path.join(FRONTEND, 'node_modules', '.cache');
|
||||
await mkdir(cache, { recursive: true });
|
||||
const bundle = path.join(cache, 'tiff-decode-check.mjs');
|
||||
// `jpeg.tif` is the one fixture whose strip is a JPEG, and the reader decodes
|
||||
// that with the very CanvasKit the app boots — so the two have to come out of
|
||||
// ONE module instance, or the shim's `Skia` stays null inside the reader. Hence
|
||||
// the single stdin entry that re-exports both, rather than two bundles.
|
||||
await build({
|
||||
stdin: {
|
||||
contents:
|
||||
"export { decodeTiff } from './src/engine/tiffDecode';\n" +
|
||||
"export { initSkia } from './src/engine/skiaShim';",
|
||||
resolveDir: FRONTEND,
|
||||
loader: 'ts',
|
||||
},
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'es2022',
|
||||
external: ['canvaskit-wasm/full'],
|
||||
outfile: bundle,
|
||||
});
|
||||
|
||||
let decodeTiff, initSkia;
|
||||
try {
|
||||
({ decodeTiff, initSkia } = await import(pathToFileURL(bundle).href));
|
||||
} finally {
|
||||
await rm(bundle, { force: true });
|
||||
}
|
||||
|
||||
await initSkia({
|
||||
locateFile: () =>
|
||||
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
|
||||
const reference = readPpm(fixture('crop.ppm'));
|
||||
|
||||
for (const name of ['none.tif', 'pb.tif', 'lzw.tif']) {
|
||||
const frame = decodeTiff(fixture(name), 640);
|
||||
if (!frame) {
|
||||
check(name, false, 'no frame');
|
||||
continue;
|
||||
}
|
||||
if (frame.width !== reference.width || frame.height !== reference.height) {
|
||||
check(name, false, `${frame.width}x${frame.height} against ${reference.width}x${reference.height}`);
|
||||
continue;
|
||||
}
|
||||
let wrong = 0;
|
||||
let opaque = true;
|
||||
for (let i = 0; i < reference.width * reference.height; i++) {
|
||||
if (
|
||||
frame.rgba[i * 4] !== reference.pixels[i * 3] ||
|
||||
frame.rgba[i * 4 + 1] !== reference.pixels[i * 3 + 1] ||
|
||||
frame.rgba[i * 4 + 2] !== reference.pixels[i * 3 + 2]
|
||||
) wrong++;
|
||||
if (frame.rgba[i * 4 + 3] !== 255) opaque = false;
|
||||
}
|
||||
check(name, wrong === 0 && opaque, `${frame.width}x${frame.height}, ${wrong} wrong pixels`);
|
||||
}
|
||||
|
||||
// The JPEG strip is lossy — Q=100 is close to the PPM, not equal to it — so this
|
||||
// one is checked by how far a channel strays rather than by equality. It is also
|
||||
// the fixture that fails loudly when CanvasKit is not up (its `Skia` is null) or
|
||||
// when the pixels are read out of the decoded image the wrong way (all black).
|
||||
{
|
||||
const frame = decodeTiff(fixture('jpeg.tif'), 640);
|
||||
if (!frame) {
|
||||
check('jpeg.tif', false, 'no frame');
|
||||
} else if (frame.width !== reference.width || frame.height !== reference.height) {
|
||||
check('jpeg.tif', false, `${frame.width}x${frame.height} against ${reference.width}x${reference.height}`);
|
||||
} else {
|
||||
let worst = 0;
|
||||
let opaque = true;
|
||||
for (let i = 0; i < reference.width * reference.height; i++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
worst = Math.max(worst, Math.abs(frame.rgba[i * 4 + c] - reference.pixels[i * 3 + c]));
|
||||
}
|
||||
if (frame.rgba[i * 4 + 3] !== 255) opaque = false;
|
||||
}
|
||||
check('jpeg.tif', worst <= 12 && opaque, `${frame.width}x${frame.height}, worst channel ${worst}`);
|
||||
}
|
||||
}
|
||||
|
||||
check('a file that is not a TIFF', decodeTiff(fixture('crop.ppm'), 640) === null);
|
||||
check('a TIFF cut short', decodeTiff(fixture('none.tif').subarray(0, 40), 640) === null);
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nall tiff decode checks passed');
|
||||
@@ -0,0 +1,166 @@
|
||||
// The tone ramp is drawn through a BASE layer, and that base is now a blurred
|
||||
// CHILD of the pass rather than a ring of point samples inside it — see
|
||||
// TONE_BASE_RADIUS in toneShader.ts for the mottle that ring caused. Two things
|
||||
// can go wrong with that and neither is a crash: the shader stops compiling once
|
||||
// it takes a second child (SkSL is only checked at runtime, and no other check
|
||||
// here compiles TONE_SKSL as a whole), or the second child is wired to the wrong
|
||||
// slot and the pass quietly reads the sharp image as its own base — which is
|
||||
// exactly the identity the default (bx = 0) used to give, so a regression would
|
||||
// look like nothing happening. So the pass is compiled and rendered for real:
|
||||
// the frame and the base are held at two different flat values, and the pixel
|
||||
// has to land on the somewhere-between value the ramp over THAT base predicts.
|
||||
//
|
||||
// node scripts/tone-base-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 { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
||||
|
||||
const transpile = (path) =>
|
||||
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'tone-base-check-'));
|
||||
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
writeFileSync(
|
||||
join(dir, 'toneShader.mjs'),
|
||||
transpile('../shared/utils/toneShader.ts').replace(
|
||||
/^import .*from ['"]\.\/colorUtils['"];$/m,
|
||||
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
|
||||
),
|
||||
);
|
||||
const {
|
||||
TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive,
|
||||
TONE_KNOBS,
|
||||
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
|
||||
|
||||
// The shader has to NAME a base child — a ring of taps would not need one.
|
||||
assert.match(TONE_SKSL, /uniform shader base;/);
|
||||
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
const SIZE = 8;
|
||||
|
||||
function flat(value) {
|
||||
const surf = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setColor(CanvasKit.Color(value, value, value));
|
||||
surf.getCanvas().drawPaint(paint);
|
||||
return surf.makeImageSnapshot();
|
||||
}
|
||||
|
||||
const asChild = (image) =>
|
||||
image.makeShaderOptions(
|
||||
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
|
||||
);
|
||||
|
||||
// SHADOW full, everything else off — the knob the mottle was reported on.
|
||||
const adjustments = { shadow: 10 };
|
||||
const uniforms = getToneUniforms(adjustments);
|
||||
assert.ok(toneIsActive(uniforms), 'SHADOW +100 no longer turns the pass on');
|
||||
|
||||
function render(srcValue, baseValue) {
|
||||
const effect = CanvasKit.RuntimeEffect.Make(TONE_SKSL);
|
||||
assert.ok(effect, 'TONE_SKSL does not compile');
|
||||
const src = flat(srcValue);
|
||||
const base = flat(baseValue);
|
||||
const shader = effect.makeShaderWithChildren(uniforms ? toneUniformArray(uniforms) : [], [
|
||||
asChild(src),
|
||||
asChild(base),
|
||||
]);
|
||||
assert.ok(shader, 'the pass did not take two children — the base is not wired in');
|
||||
const out = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setShader(shader);
|
||||
out.getCanvas().drawPaint(paint);
|
||||
const pixels = out.makeImageSnapshot().readPixels(0, 0, {
|
||||
width: SIZE, height: SIZE, colorType: CanvasKit.ColorType.RGBA_8888,
|
||||
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
|
||||
});
|
||||
return pixels[0];
|
||||
}
|
||||
|
||||
// The ramp, in the same arithmetic the shader runs: ONE KNOB is the measured
|
||||
// knot table (TONE_KNOBS in toneShader.ts) drawn STRAIGHT between the knots,
|
||||
// scaled by the amount, added to the luma it reads and clamped — the minus side
|
||||
// of zero reads the minus table — and the four are applied ONE AFTER THE OTHER
|
||||
// and clamped on the way to the next, so the four moves compose instead of
|
||||
// summing. SHADOW carries the whole knob on its own, so SHADOW +100 reads
|
||||
// toneCurve(b, 0, 1, 0, 0).
|
||||
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
const poly = (c, L) => {
|
||||
let v = c.v[0];
|
||||
for (let i = 1; i < c.p.length; i++)
|
||||
v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1);
|
||||
return v;
|
||||
};
|
||||
const knob = (L, k, amt) =>
|
||||
clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L)));
|
||||
// toneCurve, move for move, with the clamp the shader puts after each one.
|
||||
// `afterBlack` is the L the SHADOW move reads.
|
||||
function toneCurve(L, bl, sh, hl, wh) {
|
||||
const afterBlack = knob(L, 'bl', bl);
|
||||
return {
|
||||
o: knob(knob(knob(afterBlack, 'sh', sh), 'hl', hl), 'wh', wh),
|
||||
afterBlack,
|
||||
};
|
||||
}
|
||||
const ramp = (b, k = {}) => toneCurve(b, k.bl ?? 0, k.sh ?? 0, k.hl ?? 0, k.wh ?? 0);
|
||||
const srcValue = 128; // the pixel: 0.501961 encoded
|
||||
const baseValue = 76; // its neighbourhood, darker: 0.298039
|
||||
const t = srcValue / 255;
|
||||
const b = baseValue / 255;
|
||||
// The neighbourhood's new luma, plus the pixel's own DIFFERENCE from the base —
|
||||
// Base' + Detail, the reconstruction the shader emits (see the note in
|
||||
// toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and
|
||||
// it is what took the texture out of the frame at BLACK -100.
|
||||
const diff = Math.abs(t - b);
|
||||
const smooth = (x) => x * x * (3 - 2 * x);
|
||||
const expected = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o));
|
||||
|
||||
const got = render(srcValue, baseValue);
|
||||
assert.ok(
|
||||
Math.abs(got - expected) <= 2,
|
||||
`a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp predicts ${expected}`,
|
||||
);
|
||||
// With the base handed in as the sharp image the difference is exactly zero and
|
||||
// the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no
|
||||
// neighbourhood of its own) needs it to be. It is NOT the identity any more:
|
||||
// the shadow window spans the middle, so the same knobs move a mid-grey.
|
||||
const globalMove = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o));
|
||||
const selfBase = render(srcValue, srcValue);
|
||||
assert.ok(
|
||||
Math.abs(selfBase - globalMove) <= 2,
|
||||
`a base equal to the pixel must be the ramp at the pixel (${globalMove}), got ${selfBase} for ${srcValue}`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(selfBase - srcValue) > 2,
|
||||
`the pass returned the pixel (${selfBase}) — the base is the sharp image again`,
|
||||
);
|
||||
|
||||
// There is no guard any more, and nothing to hold under one: the four moves are
|
||||
// each monotone for any amount in [-1, 1] and clamped on the way to the next, so
|
||||
// BLACK and SHADOW both at +100 compose into a ramp that still rises — measured
|
||||
// on a 1/400 grid, where the two used to sum past a slope of 1 and carry the
|
||||
// curve backwards — and BLACK's own travel no longer depends on the stock's
|
||||
// SHADOW, because BLACK runs FIRST: the value SHADOW reads is the same whatever
|
||||
// SHADOW says, which is the "kéo theo sự thay đổi của thông số khác" report.
|
||||
const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean);
|
||||
assert.ok(rises((b) => ramp(b, { bl: 1, sh: 1 }).o), 'BLACK and SHADOW both at +100 fold the ramp back on itself');
|
||||
assert.equal(
|
||||
ramp(0.05, { bl: -1 }).afterBlack,
|
||||
ramp(0.05, { bl: -1, sh: -0.2 }).afterBlack,
|
||||
'SHADOW drags BLACK’s own travel with it again',
|
||||
);
|
||||
|
||||
console.log(
|
||||
`tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` +
|
||||
`(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` +
|
||||
`radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`,
|
||||
);
|
||||
@@ -0,0 +1,751 @@
|
||||
// The three things the reader reported, checked in a browser: the counts beside
|
||||
// the folder names while a reading is running, what the column holds after the
|
||||
// tab comes back — every folder the walk went into, the empty ones included, with
|
||||
// an interrupted reading picking itself up — and a long roll's tiles, every frame
|
||||
// the wall draws read out of the reading that brought them.
|
||||
//
|
||||
// The app is behind an account and answers `/api` on its own origin, so the check
|
||||
// stands in for the API the way the container's nginx does; without it the page
|
||||
// is a guest and there is no folder to pick. The catalogue is read back out of
|
||||
// IndexedDB rather than out of the strip, which draws one node's own frames.
|
||||
//
|
||||
// npm run build && node scripts/tree-scan-check.mjs
|
||||
// APP_PORT=4395 SAMPLE_PORT=4396 SAMPLE_DIR=<folder of sample frames> \
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> CHROME=<path to chrome> \
|
||||
// node scripts/tree-scan-check.mjs
|
||||
import { spawn } from 'node:child_process';
|
||||
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, '..', 'sample');
|
||||
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';
|
||||
// What the frames of the long roll are served: a small JPEG, because a hundred
|
||||
// copies of an eight-megabyte one is eight hundred megabytes to prove a point
|
||||
// about tiles.
|
||||
const SMALL = 'Main.jpg';
|
||||
const LONG = 60;
|
||||
const LONG_NAMES = Array.from({ length: LONG }, (_, i) => `B${String(i).padStart(3, '0')}.JPG`);
|
||||
|
||||
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');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Every frame the page asks for, by extension: one real JPEG and one real RAW — see
|
||||
// the route that hands them over, below.
|
||||
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');
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext();
|
||||
// The sample frames are handed over inside the browser rather than off a socket of
|
||||
// its own: a page is given six sockets to a host, and the walk's sixty reads fill
|
||||
// all six, so the shelf's own reads would queue behind the reading they are meant
|
||||
// to keep up with — a queue no real disk puts in the way.
|
||||
await context.route(`http://127.0.0.1:${SAMPLE_PORT}/**`, async (route) => {
|
||||
const name = path.basename(decodeURIComponent(new URL(route.request().url()).pathname));
|
||||
const file = /^B\d+\.JPG$/.test(name) ? SMALL : /\.jpe?g$/i.test(name) ? JPG : /\.rw2$/i.test(name) ? RAW : null;
|
||||
if (!file) return route.fulfill({ status: 404, body: '' });
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/octet-stream',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: await readFile(path.join(SAMPLES, file)),
|
||||
});
|
||||
});
|
||||
|
||||
// The catalogue is behind an account, and the header reads the account's own
|
||||
// folder: the API answers for both, the way the container beside the page does.
|
||||
await context.route('**/api/**', (route) => {
|
||||
const url = route.request().url();
|
||||
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check' } });
|
||||
if (url.includes('/photos')) return json({ photos: [] });
|
||||
if (url.includes('/recipes')) return json({ recipes: [] });
|
||||
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'no' }) });
|
||||
});
|
||||
|
||||
await context.addInitScript(
|
||||
({ origin, folder, rolls, small }) => {
|
||||
const bytes = new Map();
|
||||
// One fetch per real file whatever the handle is called: the frames of the
|
||||
// long roll are one small JPEG served under sixty names.
|
||||
const load = async (name) => {
|
||||
const file = /^B\d+\.JPG$/.test(name) ? small : name;
|
||||
if (!bytes.has(file)) bytes.set(file, fetch(`${origin}/${file}`).then((r) => r.arrayBuffer()));
|
||||
return bytes.get(file);
|
||||
};
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => {
|
||||
const gets = JSON.parse(sessionStorage.getItem('check.gets') ?? '{}');
|
||||
gets[name] = (gets[name] ?? 0) + 1;
|
||||
sessionStorage.setItem('check.gets', JSON.stringify(gets));
|
||||
while (localStorage.getItem('check.hold') === '1') await new Promise((r) => setTimeout(r, 25));
|
||||
// A read of a frame is nearly all waiting on a real disk, and a reading
|
||||
// that is over in a blink is one the wall's own work cannot be watched
|
||||
// against. `check.slow` is that wait, in milliseconds.
|
||||
const slow = Number(localStorage.getItem('check.slow') ?? 0);
|
||||
if (slow) await new Promise((r) => setTimeout(r, slow));
|
||||
return new File([await load(name)], name, { type: 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1) });
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
const handle = { kind: 'directory', name: label, __fake: true };
|
||||
Object.defineProperties(handle, {
|
||||
values: {
|
||||
value: () => {
|
||||
const lists = JSON.parse(sessionStorage.getItem('check.lists') ?? '{}');
|
||||
lists[label] = (lists[label] ?? 0) + 1;
|
||||
sessionStorage.setItem('check.lists', JSON.stringify(lists));
|
||||
if (node.fail && localStorage.getItem('check.broken') !== 'open') throw new Error(`${label} will not open`);
|
||||
return [
|
||||
...(node.files ?? []).map(fileHandle),
|
||||
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
||||
][Symbol.iterator]();
|
||||
},
|
||||
},
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
getDirectoryHandle: {
|
||||
value: async (name) => {
|
||||
const kid = node.dirs?.[name];
|
||||
if (!kid) throw new Error(`no folder named ${name}`);
|
||||
return dirHandle(name, kid);
|
||||
},
|
||||
},
|
||||
getFileHandle: {
|
||||
value: async (name) => {
|
||||
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
|
||||
return fileHandle(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const pick = (name) => dirHandle(name, { files: rolls[name].files, dirs: rolls[name].dirs });
|
||||
// The roll in play, not the one this script was handed: a handle that comes
|
||||
// back out of the catalogue belongs to the folder being read, and the reading
|
||||
// a reload takes up is of that one.
|
||||
const root = () => pick(localStorage.getItem('check.roll') ?? folder);
|
||||
window.showDirectoryPicker = async () => root();
|
||||
// What the disk holds, for the check to change behind the app's back: a frame
|
||||
// taken off the disk is a folder the walk meets one frame fewer in.
|
||||
window.__rolls = rolls;
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
row.handle = row.handle.kind === 'directory' ? root() : 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}`,
|
||||
folder: 'CheckRoll',
|
||||
small: SMALL,
|
||||
rolls: {
|
||||
// One frame at the top, one a folder down, one two folders down, one in a
|
||||
// second branch — and a folder holding no frame at all.
|
||||
CheckRoll: {
|
||||
files: [JPG],
|
||||
dirs: {
|
||||
2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } },
|
||||
2025: { files: [JPG], dirs: {} },
|
||||
Empty: { files: ['notes.txt'], dirs: {} },
|
||||
},
|
||||
},
|
||||
// A roll with a folder that will not open: a permission taken back, a
|
||||
// volume pulled out — a folder the walk meets and cannot list.
|
||||
BrokenRoll: {
|
||||
files: [JPG],
|
||||
dirs: { Broken: { fail: true, files: [JPG], dirs: {} } },
|
||||
},
|
||||
// A roll long enough that its reading outlasts the wall's own work: sixty
|
||||
// frames read at a walking pace, so the wall has time to tile them while
|
||||
// the reading is still going.
|
||||
BigRoll: {
|
||||
files: LONG_NAMES,
|
||||
dirs: {},
|
||||
},
|
||||
// The same roll read for the first time, on a tab that goes away.
|
||||
CutRoll: {
|
||||
files: [JPG],
|
||||
dirs: { Broken: { fail: true, files: [JPG], dirs: {} } },
|
||||
},
|
||||
// A roll picked at a drive root, which Chromium hands over named `\` — the
|
||||
// one name a folder under it is spelled from and that is itself no folder
|
||||
// name at all. See the check at the foot of this file.
|
||||
'\\': {
|
||||
files: [JPG],
|
||||
dirs: { Roll: { files: [RAW], dirs: {} } },
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
const rowStates = () =>
|
||||
page.$$eval('[data-key^="lib-node-"]', (els) =>
|
||||
els.map((e) => `${e.dataset.key.replace('lib-node-', '')}:${(e.querySelector('.adm-album-count')?.textContent ?? '').trim()}`)
|
||||
);
|
||||
const progress = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
|
||||
const counts = async () => {
|
||||
const out = {};
|
||||
for (const row of await rowStates()) {
|
||||
const [key, n] = row.split(':');
|
||||
out[key] = n;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
// Row order is the tree's path order, not the order of this file's expectations.
|
||||
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
|
||||
// What the reading has stored, asked of the catalogue rather than of the strip:
|
||||
// the strip draws the open node's own frames, and three of these four are one
|
||||
// folder down.
|
||||
const stored = () =>
|
||||
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 new Promise((resolve, reject) => {
|
||||
const req = db.transaction('photos', 'readonly').objectStore('photos').count();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
})
|
||||
.catch(() => -1);
|
||||
// What the walk found, which is the whole of what the column is asked to say.
|
||||
const WANT = {
|
||||
CheckRoll: '4',
|
||||
'CheckRoll/2025': '1',
|
||||
'CheckRoll/2026': '2',
|
||||
'CheckRoll/2026/04': '1',
|
||||
'CheckRoll/Empty': '0',
|
||||
};
|
||||
|
||||
// --- a reading, watched while it runs ---------------------------------------
|
||||
await page.goto(`${base}/library`);
|
||||
// Held on the first frame it reads, so the walk is through its first pass — the
|
||||
// names and their numbers are the reading's, and the reading is not done.
|
||||
await page.evaluate(() => localStorage.setItem('check.hold', '1'));
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 });
|
||||
// Every row of the pass is a row now, and a count beside each name is what the
|
||||
// walk met — the reading itself has read nothing. The whole tree is drawn in one
|
||||
// go and never a row at a time, so the wait is for all of it: a count in between
|
||||
// is a state this screen does not have, and waiting for one is a wait that runs
|
||||
// out its clock and then measures whatever the reading has done since. The
|
||||
// reading is held on the frame it is reading, and how long a held frame stays
|
||||
// held is the engine's to decide (`READ_MS`), not this file's.
|
||||
await page
|
||||
.waitForFunction(() => {
|
||||
const rows = [...document.querySelectorAll('[data-key^="lib-node-"]')];
|
||||
return rows.length >= 5 && /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '');
|
||||
}, null, { timeout: 60_000, polling: 50 })
|
||||
.catch(() => {});
|
||||
const midFloat = await counts();
|
||||
const midLine = (await progress()).replace(/\s+/g, ' ').trim();
|
||||
const midStored = await stored();
|
||||
// The whole roll is one pass of four folders, so the numbers are the walk's the
|
||||
// moment the reading has listed the tree — before a single frame is read.
|
||||
step(
|
||||
'the counts beside the folder names are up while the reading runs',
|
||||
same(midFloat, WANT),
|
||||
`${JSON.stringify(midFloat)} at "${midLine}"`
|
||||
);
|
||||
step(
|
||||
'and they are the walk’s own, not the catalogue’s: nothing has been read yet',
|
||||
midStored === 0,
|
||||
`${midStored} rows stored`
|
||||
);
|
||||
step('and the reading is in flight while the column already says so', Boolean(await page.$('[data-key="lib-progress"]')), `"${midLine}"`);
|
||||
|
||||
// --- the reading runs out ----------------------------------------------------
|
||||
await page.evaluate(() => localStorage.removeItem('check.hold'));
|
||||
// The progress span is in the page whether or not a reading is, and its text is
|
||||
// empty until the first pass has said what the roll is: silence is the reading
|
||||
// being through.
|
||||
await page
|
||||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||||
timeout: 120_000,
|
||||
polling: 50,
|
||||
})
|
||||
.catch(() => {});
|
||||
const done = await counts();
|
||||
step(
|
||||
'a reading that ran out leaves the roll counted, empty folders and all',
|
||||
same(done, WANT),
|
||||
`${JSON.stringify(done)}, ${await stored()} rows stored`
|
||||
);
|
||||
|
||||
// --- the tab comes back ------------------------------------------------------
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 60_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
const back = await counts();
|
||||
step(
|
||||
'and the next visit draws the same column with nothing right-clicked',
|
||||
same(back, done),
|
||||
JSON.stringify(back)
|
||||
);
|
||||
step(
|
||||
'a folder the walk went into and met no frame in keeps its row',
|
||||
typeof back['CheckRoll/Empty'] === 'string',
|
||||
`Empty: ${back['CheckRoll/Empty']}`
|
||||
);
|
||||
// Nothing is left to read, so this visit offers nothing and starts nothing: the
|
||||
// catalogue comes back off the disk and the screen waits to be asked.
|
||||
step(
|
||||
'and a visit with nothing left to read reads nothing at all',
|
||||
!(await page.$('[data-key="lib-resume"]')) && (await progress()).trim() === '',
|
||||
`resume ${Boolean(await page.$('[data-key="lib-resume"]'))}, line "${(await progress()).trim()}"`
|
||||
);
|
||||
|
||||
// --- a reading cut off -------------------------------------------------------
|
||||
// The catalogue is emptied first, so every frame is one this reading has to read
|
||||
// itself: a reading that does not go on by itself leaves the strip at nothing.
|
||||
await page.evaluate(async () => {
|
||||
sessionStorage.clear();
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('photos', 'readwrite');
|
||||
tx.objectStore('photos').clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
localStorage.setItem('check.hold', '1');
|
||||
});
|
||||
const counting = () =>
|
||||
page
|
||||
.waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, {
|
||||
timeout: 60_000,
|
||||
polling: 50,
|
||||
})
|
||||
.then(() => progress())
|
||||
.then((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.catch(() => '');
|
||||
// A reading of the whole roll, held on the first frame it reads: what a tab that
|
||||
// is closed mid-reading leaves behind is exactly this.
|
||||
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
|
||||
await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 });
|
||||
await page.click('[data-key="lib-rescan-CheckRoll"]');
|
||||
const cut = await counting();
|
||||
// The walk writes its own position out at most once a second, and the reload is
|
||||
// what has to be cut off mid-reading rather than before it started.
|
||||
await page.waitForTimeout(2000);
|
||||
// Nothing here asks for another reading, and neither does the visit that comes
|
||||
// back: the roll the reload cut off is put on offer beside the progress line, and
|
||||
// the press of that offer is the only thing that starts the second reading. The
|
||||
// first frame is still held, so a visit that went on by itself would hang on it
|
||||
// rather than pass.
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 });
|
||||
const offered = (await progress()).trim();
|
||||
step(
|
||||
'a reading a reload cut off is offered by the next visit, not run by it',
|
||||
/\d+\/\d+/.test(cut) && offered === '',
|
||||
`"${cut}" then idle "${offered}"`
|
||||
);
|
||||
await page.click('[data-key="lib-resume"]');
|
||||
const cutAgain = await counting();
|
||||
step('and the offer is that reading taken up where it stopped', /\d+\/\d+/.test(cutAgain), `"${cutAgain}"`);
|
||||
|
||||
await page.evaluate(() => localStorage.removeItem('check.hold'));
|
||||
// The progress span is in the page whether or not a reading is, and its text is
|
||||
// empty until the first pass has said what the roll is: silence is the reading
|
||||
// being through.
|
||||
await page
|
||||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||||
timeout: 120_000,
|
||||
polling: 50,
|
||||
})
|
||||
.catch(() => {});
|
||||
const resumed = await stored();
|
||||
const lists = await page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
|
||||
step(
|
||||
'and the visit after it finishes the roll on its own, without listing a folder twice',
|
||||
resumed === 4 && lists.CheckRoll === 1 && lists['2026'] === 1 && lists.Empty === 1 && lists['2025'] === 1,
|
||||
`${resumed} rows stored from ${JSON.stringify(lists)}`
|
||||
);
|
||||
|
||||
const keys = (store) =>
|
||||
page
|
||||
.evaluate(
|
||||
(store) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const get = db.transaction(store, 'readonly').objectStore(store).getAllKeys();
|
||||
get.onsuccess = () => resolve(get.result);
|
||||
get.onerror = () => reject(get.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
}),
|
||||
store
|
||||
)
|
||||
.catch(() => []);
|
||||
const quiet = () =>
|
||||
page
|
||||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||||
timeout: 120_000,
|
||||
polling: 50,
|
||||
})
|
||||
.catch(() => {});
|
||||
// A reading, watched out: the line is up once it has told the column what the
|
||||
// roll is, and silence is the reading being through. Waiting for silence alone
|
||||
// would read the line before the reading has written it.
|
||||
const readOut = async () => {
|
||||
await page
|
||||
.waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, {
|
||||
timeout: 20_000,
|
||||
polling: 50,
|
||||
})
|
||||
.catch(() => {});
|
||||
await quiet();
|
||||
};
|
||||
// A row for a frame the disk has not got, with the tile and the marks that go
|
||||
// with it — the state a catalogue is left in when a frame is taken off the disk.
|
||||
const seed = (ids) =>
|
||||
page.evaluate(
|
||||
(ids) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction(['photos', 'thumbs', 'edits'], 'readwrite');
|
||||
for (const id of ids) {
|
||||
const parts = id.split('/');
|
||||
tx.objectStore('photos').put({
|
||||
id,
|
||||
folder: parts[0],
|
||||
dir: parts.slice(1, -1).join('/'),
|
||||
name: parts[parts.length - 1],
|
||||
handle: { kind: 'file', name: parts[parts.length - 1], __fake: true },
|
||||
taken: 1,
|
||||
size: 1,
|
||||
mtime: 1,
|
||||
addedAt: 1,
|
||||
});
|
||||
tx.objectStore('thumbs').put({ id, thumb: new Blob(['x']) });
|
||||
tx.objectStore('edits').put({ photoId: id, recipe: {}, updatedAt: 1 });
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
}),
|
||||
ids
|
||||
);
|
||||
|
||||
// --- the catalogue brought to the disk --------------------------------------
|
||||
// The reader's report: a folder's frames and the number kept for it disagree, and
|
||||
// a rescan of the parent does not put them right. Both halves are here — a frame
|
||||
// taken off the disk while its row stayed in the catalogue, and a row for a frame
|
||||
// that was never on the disk at all — and one rescan of the roll has to settle
|
||||
// both, the numbers under the subfolders included.
|
||||
await seed(['CheckRoll/2025/Gone.JPG', 'BrokenRoll/Broken/x.JPG']);
|
||||
await page.evaluate(() => {
|
||||
window.__rolls.CheckRoll.dirs['2025'].files.length = 0;
|
||||
});
|
||||
step(
|
||||
'the catalogue is holding two rows the disk has nothing for',
|
||||
(await keys('photos')).includes('CheckRoll/2025/Gone.JPG') &&
|
||||
(await keys('photos')).includes('CheckRoll/2025/P1010256.JPG'),
|
||||
JSON.stringify(await keys('photos'))
|
||||
);
|
||||
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
|
||||
await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 });
|
||||
await page.click('[data-key="lib-rescan-CheckRoll"]');
|
||||
await readOut();
|
||||
const repaired = await counts();
|
||||
const rows = (await keys('photos')).filter((k) => k.startsWith('CheckRoll/'));
|
||||
step(
|
||||
'a rescan of the parent brings every number under it to the disk',
|
||||
same(repaired, {
|
||||
...WANT,
|
||||
CheckRoll: '3',
|
||||
'CheckRoll/2025': '0',
|
||||
}),
|
||||
JSON.stringify(repaired)
|
||||
);
|
||||
step(
|
||||
'and the frames the reading did not meet are gone from the catalogue',
|
||||
rows.length === 3 && !rows.includes('CheckRoll/2025/P1010256.JPG') && !rows.includes('CheckRoll/2025/Gone.JPG'),
|
||||
JSON.stringify(rows)
|
||||
);
|
||||
step(
|
||||
'with their tiles and their marks',
|
||||
!(await keys('thumbs')).includes('CheckRoll/2025/Gone.JPG') &&
|
||||
!(await keys('edits')).includes('CheckRoll/2025/Gone.JPG'),
|
||||
JSON.stringify(await keys('edits'))
|
||||
);
|
||||
|
||||
// --- a folder the walk could not open ---------------------------------------
|
||||
// The other side of the same rule: the walk met nothing under `Broken`, and a
|
||||
// reading that read silence for absence would empty a folder it never listed.
|
||||
// The number beside its name is the catalogue's, which is the last reading that
|
||||
// could open it: a reading that could not open a folder writes no nought there —
|
||||
// a nought takes the place of the frames the catalogue holds and reads as a
|
||||
// folder gone empty until the reader rescans the roll.
|
||||
await page.evaluate(() => localStorage.setItem('check.roll', 'BrokenRoll'));
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector('[data-key="lib-node-BrokenRoll"]', { timeout: 60_000 });
|
||||
await readOut();
|
||||
const broken = await counts();
|
||||
step('a folder the walk could not open keeps its row', broken['BrokenRoll/Broken'] === '1', JSON.stringify(broken));
|
||||
step(
|
||||
'and nothing under it is dropped: silence is not absence',
|
||||
(await keys('photos')).includes('BrokenRoll/Broken/x.JPG'),
|
||||
JSON.stringify(await keys('photos'))
|
||||
);
|
||||
step(
|
||||
'while the frame the walk did meet is stored',
|
||||
(await keys('photos')).includes('BrokenRoll/P1010256.JPG'),
|
||||
JSON.stringify(await keys('photos'))
|
||||
);
|
||||
|
||||
// --- a folder the reading could not open, and a tab that goes away -----------
|
||||
// Why the number beside a folder stays at nothing through update after update,
|
||||
// which is the state the reader photographed. A roll is read for the first time
|
||||
// and the tab goes while the reading is in it: the position it leaves behind must
|
||||
// not say it walked a folder it could not open, because an update takes that
|
||||
// position up — a folder filed as read is a folder no update opens again, and its
|
||||
// number stays at nothing however many times the reader asks. The folder opens on
|
||||
// the visit that takes the reading up, and the reading is there to open it.
|
||||
const opens = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
|
||||
const opened = (m, name) => m[name] ?? 0;
|
||||
await page.evaluate(async () => {
|
||||
sessionStorage.removeItem('check.lists');
|
||||
localStorage.setItem('check.roll', 'CutRoll');
|
||||
localStorage.setItem('check.hold', '1');
|
||||
// Nothing in hand: the frame is one this reading has to read itself, which is
|
||||
// what the hold is holding — and a reading held on the frame it is reading is
|
||||
// the reading a closed tab leaves behind.
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('photos', 'readwrite');
|
||||
tx.objectStore('photos').clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
});
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector('[data-key="lib-node-CutRoll"]', { timeout: 60_000 });
|
||||
await counting();
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const askedBroken = opened(await opens(), 'Broken');
|
||||
const held = await page.evaluate(async () => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
try {
|
||||
const dir = await root.getDirectoryHandle('walk', { create: false });
|
||||
const fh = await dir.getFileHandle(`${encodeURIComponent('CutRoll')}.json`, { create: false });
|
||||
return JSON.parse(await (await fh.getFile()).text());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
step(
|
||||
'the position a closed tab leaves files no folder it could not open as walked',
|
||||
!!held && !held.walked.some((r) => r.startsWith('Broken')) && held.pending.includes('Broken'),
|
||||
JSON.stringify(held && { walked: held.walked, pending: held.pending })
|
||||
);
|
||||
// The tab goes, and the roll it cut off is offered on the visit that comes back:
|
||||
// the offer is pressed, and the reading it takes up is the reading that opens the
|
||||
// folder the last one could not.
|
||||
await page.evaluate(() => localStorage.setItem('check.broken', 'open'));
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 });
|
||||
await page.click('[data-key="lib-resume"]');
|
||||
const again = await counting();
|
||||
await page.evaluate(() => localStorage.removeItem('check.hold'));
|
||||
await readOut();
|
||||
const restarted = await counts();
|
||||
step(
|
||||
'and the visit that takes the reading up is the visit that opens it',
|
||||
opened(await opens(), 'Broken') > askedBroken,
|
||||
`Broken asked ${askedBroken} times, then ${opened(await opens(), 'Broken')}`
|
||||
);
|
||||
step(
|
||||
'so the folder is counted on the disk, not left at nothing',
|
||||
restarted['CutRoll'] === '2' && restarted['CutRoll/Broken'] === '1',
|
||||
JSON.stringify(restarted)
|
||||
);
|
||||
|
||||
// --- a roll picked at a drive root ------------------------------------------
|
||||
// The reader picks a whole drive and every number beside a folder name stands at
|
||||
// nought. The picker hands that folder over named `\`, and a row's number is
|
||||
// looked up by the path that name spells: swapped for a bare backslash the roll
|
||||
// is `/` and its folders `//Roll`, while the walk files its frames — and writes
|
||||
// its counts — under the empty root a `normPath` fold leaves behind. Two
|
||||
// spellings of one folder, and a column of noughts over a roll full of frames.
|
||||
await page.evaluate(async () => {
|
||||
sessionStorage.removeItem('check.lists');
|
||||
localStorage.removeItem('check.hold');
|
||||
localStorage.setItem('check.roll', '\\');
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('photos', 'readwrite');
|
||||
tx.objectStore('photos').clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
});
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector('[data-key="lib-node-/Roll"]', { timeout: 60_000 }).catch(() => {});
|
||||
// The reading runs out on its own, and silence is the reading being through.
|
||||
await page
|
||||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||||
timeout: 120_000,
|
||||
polling: 50,
|
||||
})
|
||||
.catch(() => {});
|
||||
const drive = await counts();
|
||||
step(
|
||||
'a roll picked at a drive root counts its frames beside its own name',
|
||||
drive[''] === '2' && drive['/Roll'] === '1',
|
||||
JSON.stringify(drive)
|
||||
);
|
||||
|
||||
// --- the shelf, out of a reading ---------------------------------------------
|
||||
// The reading hands the screen the frames it found a batch at a time, and a batch
|
||||
// used to be handed on only when it was full: the reader's folder of 26 RAWs is
|
||||
// under one batch, so the screen stood empty for the whole of the reading and then
|
||||
// filled at once, and the tiles had nothing to be drawn from until the reading was
|
||||
// over. Frames go down on the clock as well now, so the count climbs and the first
|
||||
// tiles appear while the roll is still being read. Sixty frames read at a walking
|
||||
// pace is a long roll read the way the reader read theirs.
|
||||
await page.evaluate(async () => {
|
||||
sessionStorage.removeItem('check.lists');
|
||||
localStorage.removeItem('check.hold');
|
||||
localStorage.setItem('check.slow', '900');
|
||||
localStorage.setItem('check.roll', 'BigRoll');
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(['photos', 'thumbs'], 'readwrite');
|
||||
tx.objectStore('photos').clear();
|
||||
tx.objectStore('thumbs').clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
});
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector('[data-key="lib-node-BigRoll"]', { timeout: 60_000 });
|
||||
await page
|
||||
.waitForFunction(() => (document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim().length > 0, null, {
|
||||
timeout: 60_000,
|
||||
polling: 25,
|
||||
})
|
||||
.catch(() => {});
|
||||
// The reading is in flight and the frames are being read: what is asked of it here
|
||||
// is whether the screen is told of them as they land. A roll under one `BATCH`
|
||||
// reached the screen in a single lump at the end of the reading, however long the
|
||||
// reading was — the reader's folder of 26 RAWs was one batch, and so one empty
|
||||
// screen for the whole of it. Every count between none and all is a reading that
|
||||
// said something on the way past.
|
||||
const landed = [];
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const reading = await page
|
||||
.$eval('[data-key="lib-progress"]', (e) => (e.textContent ?? '').trim())
|
||||
.catch(() => '');
|
||||
const listed = Number(
|
||||
(await page.$eval('[data-key="lib-listed"]', (e) => e.textContent ?? '').catch(() => '0')).replace(/\D+/g, '')
|
||||
);
|
||||
if (reading && listed > 0) landed.push(listed);
|
||||
if (!reading) break;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
const seen = [...new Set(landed)];
|
||||
step(
|
||||
'the frames the reading lands are on screen as it lands them',
|
||||
seen.length >= 2,
|
||||
`${seen.length} counts while the reading ran: ${seen.join(', ') || 'none'}`
|
||||
);
|
||||
await page
|
||||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||||
timeout: 120_000,
|
||||
polling: 25,
|
||||
})
|
||||
.catch(() => {});
|
||||
// The frames the wall draws are read one behind another and the tiles land in
|
||||
// starts and stops, so the count is taken once it has stood still for a while.
|
||||
let atEnd = 0;
|
||||
for (let i = 0, same = 0; i < 160 && same < 10; i++) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const n = (await keys('thumbs')).length;
|
||||
same = n === atEnd ? same + 1 : 0;
|
||||
atEnd = n;
|
||||
}
|
||||
step('the wall tiles the frames a reading brought it', atEnd >= 10, `${atEnd} tiles of ${LONG} drawn`);
|
||||
await page.evaluate(() => localStorage.removeItem('check.slow'));
|
||||
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,218 @@
|
||||
// Where a reading had got to is written down for the whole origin, not for the
|
||||
// tab. The tab that reads a roll for minutes is often not the one that finishes
|
||||
// it: the app is closed and the installed one opens, or the tab is thrown away
|
||||
// and the reader comes back. The position used to live in sessionStorage, which
|
||||
// is the tab's own, so the second context found nothing, walked the roll from the
|
||||
// top and listed every folder again — the frame counter starting over while the
|
||||
// frames it already held were read again only to be skipped.
|
||||
//
|
||||
// Two pages of one browser context are two contexts of one origin: the first
|
||||
// picks the stand-in roll and is closed mid-walk, the second opens and takes the
|
||||
// reading up. What it costs is what says whether it did — a reading that took the
|
||||
// position up lists nothing, since the folders behind it came back with it, where
|
||||
// one that started at the top lists the whole roll a second time.
|
||||
//
|
||||
// npm run build && node scripts/walk-share-check.mjs
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/walk-share-check.mjs
|
||||
//
|
||||
// Playwright is not a dependency of this package, so a run without it says SKIP
|
||||
// and exits 0. APP_PORT says where the preview goes, CHROME says which browser.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
const PORT = Number(process.env.APP_PORT ?? 4197);
|
||||
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);
|
||||
}
|
||||
|
||||
// `--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(PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||||
cwd: FRONTEND,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const base = `http://127.0.0.1:${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');
|
||||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Dated folders of five frames each: a roll large enough that the reading is
|
||||
// still going when the first page is taken away, and small enough to finish.
|
||||
const DIRS = {};
|
||||
for (let i = 0; i < 40; i++) {
|
||||
DIRS[`d${String(i).padStart(2, '0')}`] = { files: [1, 2, 3, 4, 5].map((n) => `F${i}_${n}.JPG`), t: 1_700_000_000_000 };
|
||||
}
|
||||
|
||||
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 picker stands in for the person: a folder of handles over frames made in
|
||||
// the page (a beat apiece, so the reading can be caught in the middle of the
|
||||
// roll), and the same handles put back on the rows IndexedDB hands out — a cloned
|
||||
// handle is a plain object with no `getFile` on it.
|
||||
await context.addInitScript(
|
||||
({ dirs }) => {
|
||||
let bytes = null;
|
||||
const jpeg = () =>
|
||||
(bytes ??= new Promise((resolve) => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 24;
|
||||
c.height = 16;
|
||||
const g = c.getContext('2d');
|
||||
g.fillStyle = '#b3412a';
|
||||
g.fillRect(0, 0, 24, 16);
|
||||
c.toBlob(async (b) => resolve(new Uint8Array(await b.arrayBuffer())), 'image/jpeg', 0.8);
|
||||
}));
|
||||
const fileHandle = (name, t) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
return new File([await jpeg()], name, { type: 'image/jpeg', lastModified: t });
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
const handle = { kind: 'directory', name: label, __fake: true };
|
||||
Object.defineProperties(handle, {
|
||||
values: {
|
||||
value: () => {
|
||||
// Every listing a reading asks for, counted for the whole origin: a
|
||||
// reading that starts over lists the same folder a second time, and
|
||||
// that second listing is the whole of what the position saves.
|
||||
localStorage.setItem('walkcheck.lists', String(Number(localStorage.getItem('walkcheck.lists') ?? 0) + 1));
|
||||
return [
|
||||
...(node.files ?? []).map((f) => fileHandle(f, node.t)),
|
||||
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
||||
][Symbol.iterator]();
|
||||
},
|
||||
},
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
getDirectoryHandle: {
|
||||
value: async (name) => {
|
||||
const kid = node.dirs?.[name];
|
||||
if (!kid) throw new Error(`no folder named ${name}`);
|
||||
return dirHandle(name, kid);
|
||||
},
|
||||
},
|
||||
getFileHandle: {
|
||||
value: async (name) => {
|
||||
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
|
||||
return fileHandle(name, node.t);
|
||||
},
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const root = () => dirHandle('BigRoll', { files: [], dirs });
|
||||
window.showDirectoryPicker = async () => root();
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name, 1_700_000_000_000);
|
||||
}
|
||||
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;
|
||||
};
|
||||
}
|
||||
},
|
||||
{ dirs: DIRS }
|
||||
);
|
||||
|
||||
const progress = (p) => p.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
|
||||
const at = (text) => {
|
||||
const m = /(\d+)\/(\d+)/.exec(text);
|
||||
return m ? { n: Number(m[1]), t: Number(m[2]) } : null;
|
||||
};
|
||||
const listed = (p) => p.evaluate(() => Number(localStorage.getItem('walkcheck.lists') ?? 0));
|
||||
const held = (p) =>
|
||||
p.evaluate(() => {
|
||||
const raw = localStorage.getItem('recipescam.library.walk.BigRoll');
|
||||
if (!raw) return null;
|
||||
const s = JSON.parse(raw);
|
||||
return { done: s.progress?.done, walked: s.walked?.length, frames: s.frames?.length };
|
||||
});
|
||||
|
||||
// --- the first context: it starts the reading and is taken away -------------
|
||||
const first = await context.newPage();
|
||||
await first.goto(`${base}/library`);
|
||||
await first.evaluate(() => {
|
||||
localStorage.removeItem('walkcheck.lists');
|
||||
localStorage.removeItem('recipescam.library.walk.BigRoll');
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await first.click('[data-key="lib-add"]');
|
||||
let stopped = null;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const now = at(await progress(first));
|
||||
if (now && now.n >= 40 && now.t >= 200) {
|
||||
stopped = now;
|
||||
break;
|
||||
}
|
||||
await first.waitForTimeout(50);
|
||||
}
|
||||
const wrote = await held(first);
|
||||
const listsA = await listed(first);
|
||||
await first.close();
|
||||
if (!stopped || !wrote) {
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(`FAIL the first reading never got going — ${stopped ? `${stopped.n}/${stopped.t}` : 'no counter'}, position ${JSON.stringify(wrote)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- the next context: it opens the app and reads where it starts -----------
|
||||
const second = await context.newPage();
|
||||
await second.goto(`${base}/library`);
|
||||
let opened = null;
|
||||
for (let i = 0; i < 1200; i++) {
|
||||
const now = at(await progress(second));
|
||||
if (now) {
|
||||
opened = now;
|
||||
break;
|
||||
}
|
||||
await second.waitForTimeout(25);
|
||||
}
|
||||
// Let it work for a moment, then look at what it was: a reading that took the
|
||||
// position up has nothing to walk — the queue and the folders behind it came
|
||||
// back with it — where one that started at the top lists the whole roll again.
|
||||
await second.waitForTimeout(2500);
|
||||
const listsB = await listed(second);
|
||||
const session = await second.evaluate(() => sessionStorage.getItem('recipescam.library.walk.BigRoll') !== null);
|
||||
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
|
||||
const ok = opened !== null && listsB === listsA && !session;
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} walk share — a reading another context left behind carries on`);
|
||||
console.log(` first context stopped at ${stopped.n}/${stopped.t}, ${listsA} folders listed, wrote ${JSON.stringify(wrote)}`);
|
||||
console.log(` next context opened at ${opened ? `${opened.n}/${opened.t}` : 'nothing — it never started'}, ${listsB} folders listed, position under the tab's own key=${session}`);
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,245 @@
|
||||
// The wall, checked where it can actually go wrong: in a browser, on a shelf long
|
||||
// enough that the wall is drawing a few of its rows and standing in for the rest.
|
||||
//
|
||||
// One failure is what this guards, and it is the one that has already cost a
|
||||
// white screen: a window written from the window before it. The wall measures
|
||||
// itself after every render, writes what it measured, and renders again — so an
|
||||
// input the measure takes off the window it just wrote feeds itself, pass after
|
||||
// pass, until React gives up with #185, unmounts the tree, and leaves the reader
|
||||
// with nothing (there is no error boundary under it).
|
||||
//
|
||||
// The trigger is not spelled out — no browser reproduces the reader's own,
|
||||
// which is the point — so the check drives the *class*: the wall's own box
|
||||
// reported at two different heights on every pass, plus one scroll to start it.
|
||||
// That is what any read-back is, and the wall must survive it.
|
||||
//
|
||||
// npm run build && node scripts/wall-check.mjs
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/wall-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 SAMPLE_FILE = process.env.SAMPLE_FILE ?? path.resolve(FRONTEND, '../sample/_3279791-develop-check.jpg');
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 5033);
|
||||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034);
|
||||
const FRAMES = Number(process.env.FRAMES ?? 300);
|
||||
const FOLDER = 'WallRoll';
|
||||
// Every frame is the same bytes under a different name: what is being checked is
|
||||
// how many cards the wall will draw, not what is on them.
|
||||
const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`);
|
||||
|
||||
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 roll, off a local server (the page fetches it by CORS) --------------
|
||||
const jpg = await readFile(SAMPLE_FILE);
|
||||
const sampleServer = createServer((req, res) => {
|
||||
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
|
||||
if (!names.includes(name)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': jpg.length,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.end(jpg);
|
||||
});
|
||||
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.
|
||||
// `BASE_URL` points the check at a build already being served instead of at
|
||||
// `dist/`, which is how the bundle on the box is checked as it stands.
|
||||
// `detached` so the whole group can be signalled at the end: `npx` spawns a shell
|
||||
// which spawns vite, and killing only the wrapper leaves vite holding the port.
|
||||
const app = process.env.BASE_URL
|
||||
? null
|
||||
: spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||||
cwd: FRONTEND,
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
const stopApp = () => {
|
||||
if (!app?.pid) return;
|
||||
try {
|
||||
process.kill(-app.pid, 'SIGTERM');
|
||||
} catch {}
|
||||
};
|
||||
const base = process.env.BASE_URL ?? `http://127.0.0.1:${APP_PORT}`;
|
||||
let up = !app;
|
||||
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) {
|
||||
stopApp();
|
||||
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({
|
||||
executablePath: process.env.CHROME || undefined,
|
||||
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1500, height: 950 } });
|
||||
|
||||
// The account the shelf belongs to. `vite preview` has no backend, and the app
|
||||
// reads a shape off every one of these calls, so the check answers them: an
|
||||
// empty account is all the wall needs.
|
||||
await context.route('**/api/**', (route) => {
|
||||
const url = route.request().url();
|
||||
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check', verified: true, pro: true, admin: false } });
|
||||
if (url.includes('/photos')) return json({ photos: [] });
|
||||
if (url.includes('/place')) return json({ place: 'Hanoi' });
|
||||
if (url.includes('/ratings')) return json({ ratings: [] });
|
||||
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
|
||||
if (url.includes('/recipes')) return json({ recipes: [] });
|
||||
return json({});
|
||||
});
|
||||
|
||||
// Only a person can answer a folder picker; the check stands in for one. The
|
||||
// stand-in handles carry their methods as *non-enumerable* properties, which is
|
||||
// what lets them through IndexedDB's structured clone at all (a clone copies
|
||||
// enumerable own keys, and a function anywhere else is a DataCloneError), and
|
||||
// they are put back where the catalogue reads them, because a cloned handle is a
|
||||
// plain object with no `getFile`.
|
||||
await context.addInitScript(
|
||||
({ origin, names, folder }) => {
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () =>
|
||||
new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.UTC(2026, 0, 1),
|
||||
}),
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
const handle = { kind: 'directory', name: label, __fake: true };
|
||||
Object.defineProperties(handle, {
|
||||
values: { value: () => [...node.files.map(fileHandle)][Symbol.iterator]() },
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
getDirectoryHandle: { value: async () => { throw new Error('no such folder'); } },
|
||||
getFileHandle: {
|
||||
value: async (name) => {
|
||||
if (!node.files.includes(name)) throw new Error(`no frame named ${name}`);
|
||||
return fileHandle(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const root = () => dirHandle(folder, { files: names });
|
||||
window.showDirectoryPicker = async () => root();
|
||||
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
row.handle = row.handle.kind === 'directory' ? root() : 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, folder: FOLDER }
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200)));
|
||||
const read = () =>
|
||||
page.evaluate(() => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
return {
|
||||
cards: el ? el.querySelectorAll('.adm-card').length : 0,
|
||||
wall: el ? el.scrollHeight > el.clientHeight : false,
|
||||
// The white screen itself: no rail, no words, nothing drawn.
|
||||
blank: document.body.innerText.trim().length < 40 && !document.querySelector('nav.rail'),
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
await page.waitForSelector(`[data-key="lib-node-${FOLDER}"]`, { timeout: 60_000 });
|
||||
await page
|
||||
.waitForFunction(
|
||||
(n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)),
|
||||
FRAMES,
|
||||
{ timeout: 240_000, polling: 250 }
|
||||
)
|
||||
.catch(() => {});
|
||||
const counted = await page.$eval('[data-key="lib-count"]', (e) => e.textContent?.trim() ?? '');
|
||||
step('the roll is read whole', counted.includes(String(FRAMES)), counted);
|
||||
|
||||
await page.click('[data-key="lib-view-grid"]');
|
||||
await page.waitForSelector('.adm-grid', { timeout: 15_000 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
const wall = await read();
|
||||
step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
|
||||
step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls');
|
||||
|
||||
// The class of trigger, made to happen: one input the wall reads back off itself,
|
||||
// moving on every pass.
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
const h = el.clientHeight;
|
||||
let n = 0;
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => (n++ % 2 ? h : h + 300) });
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
await page.waitForTimeout(8000);
|
||||
const after = await read();
|
||||
const looped = errors.filter((e) => e.includes('185') || e.includes('update depth'));
|
||||
step(
|
||||
'a wall fed its own window does not take the screen down',
|
||||
!after.blank && after.cards > 0 && looped.length === 0,
|
||||
`blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}`
|
||||
);
|
||||
step('the wall is still the wall', after.cards > 0, `${after.cards} cards after the feed`);
|
||||
|
||||
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
|
||||
if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // '));
|
||||
|
||||
await browser.close();
|
||||
stopApp();
|
||||
sampleServer.close();
|
||||
console.log(failures ? `\n${failures} FAILED` : '\nall ok');
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,79 @@
|
||||
// The WB preset table's two colour decisions, checked where they are made.
|
||||
//
|
||||
// A preset button is filled with temperatureSwatch(kelvin) and labelled in
|
||||
// readableInk(fill). Both are one formula over the whole range, and both fail
|
||||
// quietly in the direction nobody notices: a swatch that stops moving with K
|
||||
// still looks like a swatch, and an ink that is merely the theme's text colour
|
||||
// on a mid-grey fill is legible to whoever picked the theme and unreadable to
|
||||
// everyone on the other one. So this walks the ruler and holds every swatch to
|
||||
// WCAG's 4.5:1 against the ink it was given — plus the direction of the cast,
|
||||
// which is the whole reason the fill is a colour rather than a name.
|
||||
//
|
||||
// node scripts/wb-table-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 { fileURLToPath, 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 },
|
||||
}).outputText;
|
||||
|
||||
// colorUtils imports types only, so the compiler drops that line itself.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wb-table-check-'));
|
||||
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
const { readableInk, temperatureSwatch } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href);
|
||||
|
||||
// WCAG contrast ratio between two #rrggbb colours.
|
||||
const channel = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
||||
const rel = (hex) => {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return 0.2126 * channel(((n >> 16) & 255) / 255) + 0.7152 * channel(((n >> 8) & 255) / 255) + 0.0722 * channel((n & 255) / 255);
|
||||
};
|
||||
const ratio = (a, b) => {
|
||||
const [hi, lo] = [rel(a), rel(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
const PRESETS = [5500, 5500, 5500, 6500, 7500, 3200, 4000]; // AUTO..FLUOR, WB_PRESETS' kelvins
|
||||
const RULER = [...Array(151).keys()].map((i) => 2500 + i * 50);
|
||||
|
||||
// 1. The ink is always one of the two, and always the one that reads better.
|
||||
const inkOf = (k) => readableInk(temperatureSwatch(k));
|
||||
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#808080')));
|
||||
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#000000')));
|
||||
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#ffffff')));
|
||||
assert.equal(readableInk('#ffffff'), '#0b0e12', 'a white fill carries dark ink');
|
||||
assert.equal(readableInk('#000000'), '#ffffff', 'a black fill carries light ink');
|
||||
for (const fill of ['#7f7f7f', '#808080', '#517eff', '#947d61', '#e8e8e8', '#101010']) {
|
||||
const ink = readableInk(fill);
|
||||
const other = ink === '#ffffff' ? '#0b0e12' : '#ffffff';
|
||||
assert.ok(ratio(fill, ink) >= ratio(fill, other), `${fill}: ${ink} is not the better of the two`);
|
||||
}
|
||||
|
||||
// 2. Every swatch on the ruler, and every preset's, clears 4.5:1 under its ink.
|
||||
for (const k of [...RULER, ...PRESETS]) {
|
||||
const bg = temperatureSwatch(k);
|
||||
assert.match(bg, /^#[0-9a-f]{6}$/, `${k}K paints ${bg}`);
|
||||
const ink = readableInk(bg);
|
||||
const r = ratio(bg, ink);
|
||||
assert.ok(r >= 4.5, `${k}K: ${ink} on ${bg} is ${r.toFixed(2)}:1`);
|
||||
}
|
||||
|
||||
// 3. The fill runs the way the ruler does: warmer as K rises, i.e. red up and
|
||||
// blue down, so the table reads as a scale rather than seven greys.
|
||||
const low = temperatureSwatch(2500);
|
||||
const high = temperatureSwatch(10000);
|
||||
const chan = (hex, shift) => (parseInt(hex.slice(1), 16) >> shift) & 255;
|
||||
assert.ok(chan(low, 16) < chan(high, 16), `red does not rise: ${low} -> ${high}`);
|
||||
assert.ok(chan(low, 0) > chan(high, 0), `blue does not fall: ${low} -> ${high}`);
|
||||
|
||||
// 4. And the fills collapse the way the kelvins do — five over the seven, since
|
||||
// the three 5500K presets differ by TINT and the swatch is the temperature's.
|
||||
const fills = new Set(PRESETS.map(temperatureSwatch));
|
||||
assert.equal(fills.size, 5, `the presets paint ${fills.size} different fills`);
|
||||
|
||||
console.log(`wb table: ${RULER.length} swatches >= 4.5:1 under readableInk, ${low} -> ${high} across the ruler, ${fills.size} fills over 7 presets`);
|
||||
@@ -4,7 +4,10 @@ export type FrameId =
|
||||
| 'classic-white'
|
||||
| 'cinematic'
|
||||
| 'wallframe' // WALL FRAME PORTRAIT — the artwork hung 90° CW
|
||||
| 'wallframe-landscape'; // the same artwork upright: WALL FRAME LANDSCAPE
|
||||
| 'wallframe-landscape' // the same artwork upright: WALL FRAME LANDSCAPE
|
||||
| 'old-film' // OLD FILM LANDSCAPE — the distressed sheet as it is
|
||||
| 'old-film-portrait' // OLD FILM PORTRAIT — the same sheet turned 90° CW
|
||||
| 'custom'; // CUSTOM IMPORTED FRAME — transparent PNG/WebP overlay
|
||||
|
||||
// Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT
|
||||
// (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing
|
||||
@@ -164,8 +167,20 @@ export interface GradientMask {
|
||||
shadows?: number;
|
||||
whites?: number;
|
||||
blacks?: number;
|
||||
highlight?: number;
|
||||
shadow?: number;
|
||||
white?: number;
|
||||
black?: number;
|
||||
clarity?: number;
|
||||
dehaze?: number;
|
||||
vibrance?: number;
|
||||
// The LIGHT column's WB pair on the mask's own pixels: the same two rulers,
|
||||
// the same units (kelvin, and the store's ±10 tint), the same gain — a local
|
||||
// white balance, which is what a mask that sits under a mixed light is for.
|
||||
// Absent on a mask stored before they existed, and then the gain is exactly 1
|
||||
// (5500K / 0), so nothing moves and the recipe reads the same.
|
||||
temperature?: number;
|
||||
tint?: number;
|
||||
}
|
||||
|
||||
export interface ColorAdjustments {
|
||||
@@ -231,6 +246,7 @@ export interface Recipe {
|
||||
baseFilter: BaseFilter;
|
||||
adjustments: ColorAdjustments;
|
||||
frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'
|
||||
framePanX?: number; // horizontal pan offset (-0.5 to 0.5) when landscape photo is in portrait frame
|
||||
cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional)
|
||||
useGeotag: boolean;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,40 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number }
|
||||
]);
|
||||
}
|
||||
|
||||
// The whole white balance in one place — Kelvin AND tint — because it is now
|
||||
// asked for twice: once frame-wide by the colour matrix below, once per gradient
|
||||
// mask by gradientMask.ts, where a mask's WB is meant to be the same move on a
|
||||
// smaller area rather than a second opinion about what the two knobs mean.
|
||||
//
|
||||
// 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. `tint` is the store's own -10..+10.
|
||||
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
return normalizeGainLuma([
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
}
|
||||
|
||||
// Kelvin -> the CAST a WB swatch prints. Not the colour of a blackbody at that
|
||||
// K: the number names the light the scene was shot under, so the engine
|
||||
// compensates for it and WARMS the picture as K rises (kelvinToRGB). A swatch
|
||||
// painted with the light's own colour would therefore run the other way from the
|
||||
// picture — orange under a knob that blue-d the frame. Measuring the ruler means
|
||||
// painting what the render does: the engine's own gains on a mid grey, which is
|
||||
// the same matrix step the preview puts on every pixel, so this can never drift
|
||||
// from it. Halved, so the swatch sits mid-grey in tone whatever the cast.
|
||||
export function temperatureSwatch(kelvin: number): string {
|
||||
const g = kelvinToRGB(kelvin);
|
||||
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v * 255))).toString(16).padStart(2, '0');
|
||||
return `#${chan(0.5 * g.r)}${chan(0.5 * g.g)}${chan(0.5 * g.b)}`;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -159,6 +193,25 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
|
||||
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
|
||||
}
|
||||
|
||||
// The text colour a #rrggbb fill can carry. A white-balance swatch is a mid grey
|
||||
// by construction — the engine's gains halved — so a label left at the theme's
|
||||
// own text colour sits on it at whatever contrast the two happen to have, and
|
||||
// the pale end of the ruler leaves it unreadable. The pair is chosen by the one
|
||||
// rule that answers "which of the two reads better here": the WCAG relative
|
||||
// luminance of the fill against black and against white, whichever is the larger
|
||||
// ratio — the smaller of the two can fall under 4.5:1, the larger cannot.
|
||||
// ponytail: two inks, not a per-theme palette; a third is a design decision, not
|
||||
// a bug.
|
||||
export function readableInk(bg: string): string {
|
||||
const n = parseInt(bg.slice(1), 16);
|
||||
const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||
const rel =
|
||||
0.2126 * lin(((n >> 16) & 255) / 255) + 0.7152 * lin(((n >> 8) & 255) / 255) + 0.0722 * lin((n & 255) / 255);
|
||||
const withBlack = (rel + 0.05) / 0.05;
|
||||
const withWhite = 1.05 / (rel + 0.05);
|
||||
return withBlack >= withWhite ? '#0b0e12' : '#ffffff';
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -178,7 +231,7 @@ const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = {
|
||||
// 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;
|
||||
export 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
|
||||
@@ -413,18 +466,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
|
||||
// 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 wbGain = whiteBalanceGain(temperature, tint);
|
||||
|
||||
const wbMat = [
|
||||
wbGain.r, 0, 0, 0, 0,
|
||||
|
||||
@@ -95,6 +95,12 @@ export function filmSimToRecipe(sim: FilmSim): Recipe {
|
||||
};
|
||||
}
|
||||
|
||||
// The shipped recipes. HIGHLIGHT and SHADOW here are written in the KNOB's unit,
|
||||
// and both knobs ride HALF an anchor per unit (see a1/a3 in toneShader.ts), so a
|
||||
// recipe that wants a given roll-off asks for double what the knot move reads —
|
||||
// the same note FILM_TONE carries. The look is the knot, not the number, which is
|
||||
// why these were doubled when the two knobs' reach was halved: a recipe that had
|
||||
// its highlights rolled to 0.70 still rolls them there.
|
||||
export const DEFAULT_RECIPES: Recipe[] = [
|
||||
{
|
||||
id: 'classic-neg-default',
|
||||
@@ -106,8 +112,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
saturation: -1,
|
||||
temperature: 6300,
|
||||
tint: 2,
|
||||
highlight: -2,
|
||||
shadow: 1,
|
||||
highlight: -4,
|
||||
shadow: 2,
|
||||
denoise: 1,
|
||||
clarity: 2,
|
||||
grain: 4,
|
||||
@@ -130,8 +136,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
saturation: 3,
|
||||
temperature: 5500,
|
||||
tint: 0,
|
||||
highlight: 1,
|
||||
shadow: -1,
|
||||
highlight: 2,
|
||||
shadow: -2,
|
||||
denoise: 2,
|
||||
clarity: 4,
|
||||
grain: 1,
|
||||
@@ -208,8 +214,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
saturation: 1,
|
||||
temperature: 7500,
|
||||
tint: -2,
|
||||
highlight: -3,
|
||||
shadow: 2,
|
||||
highlight: -6,
|
||||
shadow: 4,
|
||||
denoise: 3,
|
||||
clarity: -2,
|
||||
grain: 5,
|
||||
@@ -233,8 +239,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
saturation: 1,
|
||||
temperature: 5800,
|
||||
tint: 0,
|
||||
highlight: -1,
|
||||
shadow: 1,
|
||||
highlight: -2,
|
||||
shadow: 2,
|
||||
denoise: 2,
|
||||
clarity: 1,
|
||||
grain: 3,
|
||||
@@ -258,8 +264,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
saturation: 2,
|
||||
temperature: 6000,
|
||||
tint: -1,
|
||||
highlight: 1,
|
||||
shadow: -1,
|
||||
highlight: 2,
|
||||
shadow: -2,
|
||||
denoise: 1,
|
||||
clarity: 2,
|
||||
grain: 2,
|
||||
@@ -283,8 +289,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
saturation: 2,
|
||||
temperature: 7200,
|
||||
tint: -2,
|
||||
highlight: -2,
|
||||
shadow: 1,
|
||||
highlight: -4,
|
||||
shadow: 2,
|
||||
denoise: 2,
|
||||
clarity: 0,
|
||||
grain: 4,
|
||||
@@ -308,8 +314,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
saturation: -2,
|
||||
temperature: 4500,
|
||||
tint: 2,
|
||||
highlight: -2,
|
||||
shadow: 2,
|
||||
highlight: -4,
|
||||
shadow: 4,
|
||||
denoise: 3,
|
||||
clarity: -2,
|
||||
grain: 3,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { GradientMask } from '../types';
|
||||
import { whiteBalanceGain } from './colorUtils';
|
||||
import {
|
||||
CLARITY_GAIN,
|
||||
DEHAZE_FLOOR_T,
|
||||
DEHAZE_MAX_OMEGA,
|
||||
DEHAZE_PATCH_STEP,
|
||||
DEHAZE_PATCH_TAPS,
|
||||
TONE_MATH_SKSL,
|
||||
} from './toneShader';
|
||||
|
||||
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
|
||||
@@ -30,17 +32,18 @@ import {
|
||||
//
|
||||
// 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.
|
||||
// four tonal-range knobs (the frame-wide ramp, moved on the mask's own pixels),
|
||||
// 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.
|
||||
// what `exp2(e)` spends on the light (toneShader.EXPOSURE_SKSL), 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
|
||||
@@ -57,6 +60,10 @@ const num = (v: unknown, fallback: number) => {
|
||||
};
|
||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const clampA = (v: number) => (v < -10 ? -10 : v > 10 ? 10 : v);
|
||||
// The frame-wide WB ruler's own two ends, and its own neutral: a mask that never
|
||||
// touched WB carries 5500K / 0, whose gain is exactly 1.
|
||||
const NEUTRAL_K = 5500;
|
||||
const clampK = (v: number) => (v < 2500 ? 2500 : v > 10000 ? 10000 : 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
|
||||
@@ -75,6 +82,9 @@ export type ReadMask = GradientMask & {
|
||||
blacks: number;
|
||||
clarity: number;
|
||||
dehaze: number;
|
||||
vibrance: number;
|
||||
temperature: number;
|
||||
tint: number;
|
||||
};
|
||||
|
||||
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
||||
@@ -91,18 +101,24 @@ export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
||||
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)),
|
||||
exposure: clampEV(num(m.exposure ?? (m as any).ev, 0)),
|
||||
contrast: clampA(num(m.contrast, 0)),
|
||||
saturation: clampA(num(m.saturation, 0)),
|
||||
saturation: clampA(num(m.saturation ?? (m as any).color, 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)),
|
||||
highlights: clampA(num(m.highlights ?? m.highlight, 0)),
|
||||
shadows: clampA(num(m.shadows ?? m.shadow, 0)),
|
||||
whites: clampA(num(m.whites ?? m.white, 0)),
|
||||
blacks: clampA(num(m.blacks ?? m.black, 0)),
|
||||
clarity: clampA(num(m.clarity, 0)),
|
||||
dehaze: clampA(num(m.dehaze, 0)),
|
||||
vibrance: clampA(num(m.vibrance, 0)),
|
||||
// The WB pair, the two rulers the LIGHT column already carries, read on the
|
||||
// mask's own pixels: kelvin on its own scale, tint in the store's ±10. Both
|
||||
// absent on a mask stored before they existed, and then the gain is 1.
|
||||
temperature: clampK(num(m.temperature, NEUTRAL_K)),
|
||||
tint: clampA(num(m.tint, 0)),
|
||||
}))
|
||||
.filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0));
|
||||
}
|
||||
@@ -119,11 +135,12 @@ export function masksHaveSpatial(masks: GradientMask[]): boolean {
|
||||
|
||||
// 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.
|
||||
// the white balance gain, 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,
|
||||
@@ -132,7 +149,7 @@ export function maskUniforms(
|
||||
): Float32Array {
|
||||
const list = readMasks(masks);
|
||||
const n = list.length;
|
||||
const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4);
|
||||
const u = new Float32Array((6 * 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);
|
||||
@@ -143,28 +160,33 @@ export function maskUniforms(
|
||||
[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.
|
||||
// The tone soft masks and the two ends, then the spatial pair & vibrance.
|
||||
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([m.clarity / 10, m.dehaze / 10, m.vibrance / 10, 0], (4 * n + i) * 4);
|
||||
// The mask's own white balance, resolved here and not in the shader: the
|
||||
// Kelvin fit is a curve, and a curve in SkSL would be a second copy of it
|
||||
// (colorUtils.whiteBalanceGain is the one). Three gains and a zero pad, so
|
||||
// one array of the same shape as the rest.
|
||||
const wb = whiteBalanceGain(m.temperature, m.tint);
|
||||
u.set([wb.r, wb.g, wb.b, 0], (5 * 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);
|
||||
u.set([width, height, 0, 0], 6 * n * 4);
|
||||
if (air) u.set([air[0], air[1], air[2], 0], (6 * 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.
|
||||
// The colour inside a mask, in the spec's own order and, for every knob the app
|
||||
// also has frame-wide, in the app's own formulas: EXPOSURE first (one stop of
|
||||
// LIGHT, through exposureMove), then contrast about the middle, then saturation
|
||||
// as a mix away from the pixel's own REC-709 luma. Then the four tonal-range
|
||||
// knobs and the two spatial ones — the four through the frame-wide ramp
|
||||
// (TONE_MATH_SKSL), CLARITY and DEHAZE against the blurred reference the caller
|
||||
// hands in. Every one of them means inside the mask what it means on the whole
|
||||
// frame, because the same name on the same knob must not be two different moves;
|
||||
// toneShader.ts is the one copy all of them are read from. 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
|
||||
@@ -173,31 +195,34 @@ export function maskUniforms(
|
||||
// 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));
|
||||
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
|
||||
// EXPOSURE through the frame-wide function, on the mask's pixels: one stop of
|
||||
// LIGHT (a linear-light move, so 2^ev is what it multiplies) and a move of the
|
||||
// luma rather than of the three channels, so the knob brightens a colour
|
||||
// instead of shifting its hue when a channel reaches the ceiling.
|
||||
c = half3(exposureMove(vec3(c), a.x));
|
||||
// The mask's own white balance, the frame-wide WB gain on the mask's pixels: a
|
||||
// gain on each channel, hoisted to the JS side because the Kelvin fit is a
|
||||
// curve (colorUtils.whiteBalanceGain). 5500K / 0 hands over (1,1,1), so a mask
|
||||
// that never touched the pair costs three multiplies by one and nothing else.
|
||||
c = clamp(c * wb, half3(0.0), half3(1.0));
|
||||
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.
|
||||
// The tonal four, through the frame-wide ramp (TONE_MATH_SKSL): a mask's
|
||||
// HIGHLIGHT is the same move as the whole-frame HIGHLIGHT on a smaller area,
|
||||
// not a second opinion about what the name means — the four knots, their
|
||||
// ordering clamp, the 0.50 midpoint no knob reaches, and the luma-preserving
|
||||
// rebuild are all the frame's. DR is the one knob the ramp also carries that a
|
||||
// mask does not have, so it is spent as 0 here.
|
||||
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));
|
||||
c = half3(toneRamp(vec3(c), lf, lf, tone.w, tone.y, tone.x, tone.z, 0.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));
|
||||
// Saturation & Vibrance matching toneShader's exact chroma-masked formula
|
||||
half mx = max(max(c.r, c.g), c.b);
|
||||
half mn = min(min(c.r, c.g), c.b);
|
||||
half chroma = mx > half(0.0001) ? (mx - mn) / mx : half(0.0);
|
||||
half kv = half(1.0) + half(fx.z) * half(0.75) * (half(1.0) - chroma);
|
||||
c = clamp(mix(half3(nl), c, half(1.0 + a.z) * kv), half3(0.0), half3(1.0));
|
||||
${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) {
|
||||
@@ -270,7 +295,7 @@ ${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PA
|
||||
}
|
||||
}
|
||||
}` : ' 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));
|
||||
c.rgb = mix(c.rgb, maskAdjust(c.rgb, wb[${i}].rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -293,8 +318,10 @@ uniform float4 rads[${count}];
|
||||
uniform float4 adj[${count}];
|
||||
uniform float4 tone[${count}];
|
||||
uniform float4 fx[${count}];
|
||||
uniform float4 wb[${count}];
|
||||
uniform float4 size;
|
||||
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
|
||||
${TONE_MATH_SKSL}
|
||||
${adjustFn(spatial)}
|
||||
half4 main(float2 pos) {
|
||||
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ColorAdjustments } from '../types';
|
||||
import { EV_PER_UNIT } from './colorUtils';
|
||||
|
||||
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
|
||||
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
|
||||
@@ -21,30 +22,54 @@ export interface ContinuousParamDef {
|
||||
|
||||
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
||||
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
|
||||
// The spec reads EXPOSURE to two decimals, so "+0.50" and "-0.25" and a plain
|
||||
// "0.00" — the EV unit is already on the row's own label, not in its readout.
|
||||
const twoStops = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
|
||||
// The bipolar develop knobs read -100..+100: the spec's own numbers (the panel
|
||||
// draws every TONE and PRESENCE slider from -100 to 100, and the tone-mapping doc
|
||||
// normalises that /100 on the way to its uniforms). The recipe keeps the ±10 unit
|
||||
// every look has always been filed in, so the ×10 belongs to the slider and not
|
||||
// to the store: a look saved, shared or shipped in DEFAULT_RECIPES before this
|
||||
// still means exactly what it meant. A knob that is not bipolar — a temperature
|
||||
// in kelvin, an amount that only goes up — keeps its own scale, because -100 of
|
||||
// a percentage is nothing. EXPOSURE is the odd one out: its slider is in stops
|
||||
// (-5..+5, two decimals, the spec's own numbers) while the store still holds the
|
||||
// ±10 units EV_PER_UNIT converts, so its accessors translate instead of scaling
|
||||
// and every recipe's brightness is untouched.
|
||||
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites', 'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
|
||||
const deepen = (def: ContinuousParamDef): ContinuousParamDef =>
|
||||
HUNDRED.has(def.key)
|
||||
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
|
||||
: def;
|
||||
|
||||
export const PARAM_DEFS: {
|
||||
iq: ContinuousParamDef[];
|
||||
wb: ContinuousParamDef[];
|
||||
filters: ContinuousParamDef[];
|
||||
} = {
|
||||
iq: [
|
||||
iq: ([
|
||||
{
|
||||
key: 'exposure',
|
||||
label: 'EXPOSURE',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
min: -5,
|
||||
max: 5,
|
||||
step: 0.01,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.exposure,
|
||||
set: (v) => ({ exposure: v }),
|
||||
display: twoStops,
|
||||
// Stops out, units in: the store has carried ±10 units since the first
|
||||
// recipe, so the knob translates rather than replacing the scale. The
|
||||
// /4 lands on a whole 0.04 for every step the slider can emit, which keeps
|
||||
// the stored number readable in a recipe file.
|
||||
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
|
||||
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }),
|
||||
},
|
||||
{
|
||||
key: 'ev',
|
||||
label: 'EV',
|
||||
min: -3,
|
||||
max: 3,
|
||||
step: 0.5,
|
||||
step: 0.1,
|
||||
defaultValue: 0,
|
||||
display: oneEv,
|
||||
get: (a) => a.exposureCompensation ?? 0,
|
||||
@@ -105,8 +130,35 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.shadow ?? 0,
|
||||
set: (v) => ({ shadow: v }),
|
||||
},
|
||||
],
|
||||
wb: [
|
||||
{
|
||||
// The two ends of the same ramp the four tone sliders move together (see
|
||||
// TONE_SKSL): WHITE is the knot on 1.00 and BLACK the one on 0.00, each
|
||||
// pulled toward the middle as the knob comes down. Not a white-balance
|
||||
// move any more, so they sit with the other two on the TONE panel. Same
|
||||
// range as the CREATE form's rows, so a look round-trips.
|
||||
key: 'whites',
|
||||
label: 'WHITE',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.whites ?? 0,
|
||||
set: (v) => ({ whites: v }),
|
||||
},
|
||||
{
|
||||
key: 'blacks',
|
||||
label: 'BLACK',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.blacks ?? 0,
|
||||
set: (v) => ({ blacks: v }),
|
||||
},
|
||||
] as ContinuousParamDef[]).map(deepen),
|
||||
wb: ([
|
||||
{
|
||||
key: 'temperature',
|
||||
label: 'COLOR TEMP',
|
||||
@@ -129,34 +181,8 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.tint ?? 0,
|
||||
set: (v) => ({ tint: v }),
|
||||
},
|
||||
{
|
||||
// The two points live on WB, not on LIGHT: they shift each channel's own
|
||||
// end of the ramp, which balances a cast at the toe and the shoulder
|
||||
// rather than adding another tone knob. Same range as the CREATE form's
|
||||
// rows, so a look round-trips.
|
||||
key: 'whites',
|
||||
label: 'WHITE',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.whites ?? 0,
|
||||
set: (v) => ({ whites: v }),
|
||||
},
|
||||
{
|
||||
key: 'blacks',
|
||||
label: 'BLACK',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.blacks ?? 0,
|
||||
set: (v) => ({ blacks: v }),
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
] as ContinuousParamDef[]).map(deepen),
|
||||
filters: ([
|
||||
{
|
||||
key: 'denoise',
|
||||
label: 'NOISE REDUCTION',
|
||||
@@ -256,5 +282,5 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.vignette ?? 0,
|
||||
set: (v) => ({ vignette: v }),
|
||||
},
|
||||
],
|
||||
] as ContinuousParamDef[]).map(deepen),
|
||||
};
|
||||
|
||||
@@ -13,7 +13,10 @@ const KEY = 'RecipesCam::recipe-share::v1';
|
||||
const ALGORITHM = 'xor16-v1';
|
||||
|
||||
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none'];
|
||||
const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe', 'wallframe-landscape'];
|
||||
const FRAMES: FrameId[] = [
|
||||
'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe', 'wallframe-landscape',
|
||||
'old-film', 'old-film-portrait',
|
||||
];
|
||||
|
||||
const hash = (s: string): number => {
|
||||
let h = 0x811c9dc5;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -183,9 +183,10 @@ export function Admin() {
|
||||
setAlbum(userId);
|
||||
};
|
||||
|
||||
// Block/unblock and remove/restore share one route; the list is reloaded
|
||||
// because either flag also changes what the built-in viewer would show.
|
||||
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
|
||||
// Block/unblock, remove/restore and the PRO grant share one route; the list
|
||||
// is reloaded because any of the flags also changes what the built-in viewer
|
||||
// would show.
|
||||
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: boolean }) =>
|
||||
run(async () => {
|
||||
await api.adminSetUser(id, patch);
|
||||
await refreshPhotos();
|
||||
@@ -604,6 +605,7 @@ export function Admin() {
|
||||
<th>{t('adm.userName')}</th>
|
||||
<th>{t('adm.userEmail')}</th>
|
||||
<th>{t('adm.userRole')}</th>
|
||||
<th className="adm-pro">{t('adm.userPro')}</th>
|
||||
<th>{t('adm.userPhotos')}</th>
|
||||
<th>{t('adm.userJoined')}</th>
|
||||
<th />
|
||||
@@ -644,6 +646,21 @@ export function Admin() {
|
||||
{u.admin ? t('adm.roleAdmin') : t('adm.roleMember')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-pro">
|
||||
{/* The PRO grant. Ticking it hands this account the
|
||||
studio's PRO tier on its next request; the API
|
||||
reads the row, not the session, so nothing has
|
||||
to be signed in again. An admin account is PRO
|
||||
by allowlist, so its box only reports that. */}
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key={`adm-user-pro-${u.id}`}
|
||||
aria-label={t('adm.userPro')}
|
||||
checked={u.pro}
|
||||
disabled={busy || u.admin}
|
||||
onChange={() => void setUserState(u.id, { pro: !u.pro })}
|
||||
/>
|
||||
</td>
|
||||
<td>{u.photos}</td>
|
||||
<td>{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
|
||||
+1492
-282
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, type Highlights, type Photo, type PhotoSlot, type Rating, type User } from './api';
|
||||
import { refreshAuth } from './auth';
|
||||
import { clearSession } from './engine/session';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
|
||||
@@ -29,6 +30,14 @@ type Txt = { en: string; vi: string };
|
||||
// hand (see the effect).
|
||||
const AWARD_MS = 5000;
|
||||
const AWARD_FADE_MS = 1000;
|
||||
// What a frame's votes won it. The first two are windows of the clock; `ever`
|
||||
// is the fallback the API sends when neither window has a vote in it, and it
|
||||
// claims no window — a quiet week must not dress a month-old best as today's.
|
||||
const AWARD_LABEL = {
|
||||
day: { en: 'Recipe of the day', vi: 'Công thức của ngày' },
|
||||
week: { en: 'Recipe of the week', vi: 'Công thức của tuần' },
|
||||
ever: { en: 'Most-loved recipe', vi: 'Công thức được yêu nhất' },
|
||||
} as const;
|
||||
|
||||
// No page can really stop a save — the bytes are already on the machine. What it
|
||||
// can do is refuse to hand over more pixels than it draws: the photo is decoded,
|
||||
@@ -284,7 +293,7 @@ export function Landing() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [ratings, setRatings] = useState<Record<string, Rating>>({});
|
||||
const [highlights, setHighlights] = useState<Highlights>({ day: [], week: [] });
|
||||
const [highlights, setHighlights] = useState<Highlights>({ day: [], week: [], ever: [] });
|
||||
// Which award frame the hero shows. It steps forward forever and wraps modulo
|
||||
// the list, so a reload that changes the list cannot land it out of range.
|
||||
const [awardIdx, setAwardIdx] = useState(0);
|
||||
@@ -319,10 +328,10 @@ export function Landing() {
|
||||
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
|
||||
api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined);
|
||||
api.highlights().then((r) => setHighlights(r.highlights)).catch(() => undefined);
|
||||
return api.me().then((r) => {
|
||||
setUser(r.user);
|
||||
return r.user;
|
||||
}).catch(() => null);
|
||||
return refreshAuth().then((u) => {
|
||||
setUser(u);
|
||||
return u;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -443,14 +452,20 @@ export function Landing() {
|
||||
}, [photos]);
|
||||
|
||||
// The hero's award column: today's best-rated contributions first, then this
|
||||
// week's, each frame carrying the award its own votes won — a frame that took
|
||||
// both is the day's. The labels and the file come from the public listing, so
|
||||
// a tally whose photo is no longer in it is dropped rather than shown bare.
|
||||
// week's, then — only when neither window has a vote — the all-time bests.
|
||||
// Each frame carries the award its own votes won, and a frame that took more
|
||||
// than one keeps the tighter claim: the day's before the week's, both before
|
||||
// ever. The labels and the file come from the public listing, so a tally whose
|
||||
// photo is no longer in it is dropped rather than shown bare.
|
||||
const awards = useMemo(() => {
|
||||
const by = new Map(photos.map((p) => [p.id, p]));
|
||||
const seen = new Set<number>();
|
||||
const out: { photo: Photo; avg: number; n: number; kind: 'day' | 'week' }[] = [];
|
||||
for (const [kind, list] of [['day', highlights.day], ['week', highlights.week]] as const) {
|
||||
const out: { photo: Photo; avg: number; n: number; kind: keyof typeof AWARD_LABEL }[] = [];
|
||||
for (const [kind, list] of [
|
||||
['day', highlights.day],
|
||||
['week', highlights.week],
|
||||
['ever', highlights.ever],
|
||||
] as const) {
|
||||
for (const tally of list) {
|
||||
const photo = by.get(tally.id);
|
||||
if (!photo || seen.has(tally.id)) continue;
|
||||
@@ -599,14 +614,14 @@ export function Landing() {
|
||||
className={`chip${mode === 'light' ? ' on' : ''}`}
|
||||
onClick={() => setMode('light')}
|
||||
>
|
||||
Light
|
||||
<span className="chip-label">Light</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip${mode === 'dark' ? ' on' : ''}`}
|
||||
onClick={() => setMode('dark')}
|
||||
>
|
||||
Dark
|
||||
<span className="chip-label">Dark</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -614,7 +629,7 @@ export function Landing() {
|
||||
data-key="theme-auto"
|
||||
onClick={() => setMode('auto')}
|
||||
>
|
||||
Auto
|
||||
<span className="chip-label">Auto</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* The same colour groups the workspace offers; the page's own
|
||||
@@ -628,7 +643,7 @@ export function Landing() {
|
||||
className={`chip${accent === entry.id ? ' on' : ''}`}
|
||||
onClick={() => setAccent(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
<span className="chip-label">{entry.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -653,7 +668,16 @@ export function Landing() {
|
||||
aria-label={t('nav.language')}
|
||||
onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
{/* On a phone the button wears a globe; on a wide screen it wears
|
||||
the code, where there is room to say which language is up.
|
||||
Both live in the DOM — neither is the button's name (`aria-label`
|
||||
above is), so which one is drawn is a stylesheet's business. */}
|
||||
<svg className="lp-tool-globe" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<ellipse cx="12" cy="12" rx="4.2" ry="9" />
|
||||
<path d="M3.6 9h16.8M3.6 15h16.8" />
|
||||
</svg>
|
||||
<span className="lp-tool-code">{lang.toUpperCase()}</span>
|
||||
</button>
|
||||
</span>
|
||||
{/* Sign in is the default: the dialog opens here, on the page the
|
||||
@@ -706,6 +730,16 @@ export function Landing() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* The trial notice: the last shelf of the bar, under the menu line. It
|
||||
scrolls because it is one long sentence that would otherwise wrap on
|
||||
a phone and stand as a paragraph. One copy only, and it walks the
|
||||
whole way — in past the right edge, out past the left — so exactly
|
||||
one sentence is in flight at any moment, phone included. */}
|
||||
<div className="lp-notice" role="status" data-key="trial-notice">
|
||||
<div className="lp-notice-track">
|
||||
<span>{t('notice.trial')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{menu ? (
|
||||
<div className="lp-sheet">
|
||||
{nav.map((entry) => <a key={entry.href} href={entry.href} onClick={() => setMenu(false)}>{c(entry.label)}</a>)}
|
||||
@@ -767,11 +801,10 @@ export function Landing() {
|
||||
{award ? (
|
||||
<aside className="lp-award" data-key="lp-award">
|
||||
{/* The award is the frame's own: today's winner wears the day's,
|
||||
a frame only this week's votes name wears the week's. */}
|
||||
a frame only this week's votes name wears the week's, and one
|
||||
no window named wears the all-time label. */}
|
||||
<span className="lp-award-kind" data-key="lp-award-kind">
|
||||
{c(award.kind === 'day'
|
||||
? { en: 'Recipe of the day', vi: 'Công thức của ngày' }
|
||||
: { en: 'Recipe of the week', vi: 'Công thức của tuần' })}
|
||||
{c(AWARD_LABEL[award.kind])}
|
||||
</span>
|
||||
{/* Every frame of the column is drawn and only the one standing is
|
||||
opaque, so the swap IS the two fades — no state machine on top
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+53
-12
@@ -2,6 +2,8 @@
|
||||
// /api), the Vite dev server proxies it too — so no base URL, no CORS.
|
||||
import type { Recipe } from '../shared/types';
|
||||
import { shrinkForUpload, resizedJpeg } from './engine/imageOps';
|
||||
import { forgetStale, hold, keepStale, serverAnswered, serverGone, stale } from './pwa/offline';
|
||||
import { t } from './i18n/I18nProvider';
|
||||
|
||||
// Upload caps. The API + nginx both refuse oversized bodies, so shrink in the
|
||||
// browser first; the server then sniffs the bytes and requires the declared
|
||||
@@ -19,6 +21,9 @@ export interface User {
|
||||
// Signed-in accounts only unlock the PRO tier once their address is proven;
|
||||
// an unverified one is served exactly like a guest. Admins count as verified.
|
||||
verified?: boolean;
|
||||
// The PRO tier itself: the proof above, or a grant the operator ticked for
|
||||
// this account in the admin users table.
|
||||
pro?: boolean;
|
||||
// A ready-to-use picture URL (`/api/users/<id>/avatar?v=<file>`), or null.
|
||||
// The version segment is the file's own name, so a replacement is never
|
||||
// served from cache.
|
||||
@@ -102,12 +107,15 @@ export interface PhotoTally {
|
||||
at: string;
|
||||
}
|
||||
|
||||
// The two windows the award column reads: `day` is today's votes, `week` this
|
||||
// ISO week's — so today's winners appear in both, and the column shows them
|
||||
// once, as the day's.
|
||||
// The windows the award column reads: `day` is today's votes, `week` this ISO
|
||||
// week's — so today's winners appear in both, and the column shows them once,
|
||||
// as the day's. `ever` is every vote ever cast, and it is filled only when both
|
||||
// windows came back empty: with nothing to name a frame today or this week, the
|
||||
// best of all time is the only honest answer the column has left.
|
||||
export interface Highlights {
|
||||
day: PhotoTally[];
|
||||
week: PhotoTally[];
|
||||
ever: PhotoTally[];
|
||||
}
|
||||
|
||||
// One account as /api/admin/users reports it. `avatar` is the ready-made URL
|
||||
@@ -123,6 +131,8 @@ export interface AdminUser {
|
||||
// but restorable (a hard delete drops the row entirely).
|
||||
blocked: boolean;
|
||||
removed: boolean;
|
||||
// The "Activated Pro" box: true hands this account the studio's PRO tier.
|
||||
pro: boolean;
|
||||
}
|
||||
|
||||
// One row of a grouped count on the traffic screen. The API sorts them, largest
|
||||
@@ -149,14 +159,38 @@ export interface Stats {
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
|
||||
...init,
|
||||
});
|
||||
// A read is answered from what the server last said. A write is not answered
|
||||
// at all when no server is there — pretending it landed is a lie the screen
|
||||
// would act on — but it is not lost either: it is held, in order, and sent
|
||||
// when one answers. See `hold`/`flush` in pwa/offline.ts.
|
||||
const read = !init?.method || init.method === 'GET';
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
|
||||
...init,
|
||||
});
|
||||
} catch (err) {
|
||||
// No answer at all: the server is gone. Serve the last one it gave for this
|
||||
// read, or fail as before — the caller has its own empty state.
|
||||
serverGone();
|
||||
// A write goes on the queue instead. The one exception is the sole call that
|
||||
// is not about the server's own data: `open-explorer` asks it to pop a
|
||||
// folder on this machine, and popping it minutes later is not popping it.
|
||||
if (!read && path !== '/open-explorer') {
|
||||
hold(path, init?.method ?? 'POST', typeof init?.body === 'string' ? init.body : null);
|
||||
throw new Error(t('offline.queued'));
|
||||
}
|
||||
const staleOne = read ? await stale<T>(path) : null;
|
||||
if (staleOne !== null) return staleOne;
|
||||
throw err;
|
||||
}
|
||||
serverAnswered();
|
||||
if (res.status === 204) return undefined as T;
|
||||
const body = await readJson(res);
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
if (read) keepStale(path, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
@@ -214,10 +248,14 @@ export const api = {
|
||||
// null user = signed out; the API answers 200 either way.
|
||||
me: () => call<{ user: User | null }>('/auth/me'),
|
||||
signup: (email: string, password: string) =>
|
||||
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) })
|
||||
// The answers held offline are the previous account's and the next one must
|
||||
// not be shown them; this browser is not an account until it has one.
|
||||
.finally(forgetStale),
|
||||
login: (email: string, password: string) =>
|
||||
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
logout: () => call<void>('/auth/logout', { method: 'POST' }),
|
||||
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) })
|
||||
.finally(forgetStale),
|
||||
logout: () => call<void>('/auth/logout', { method: 'POST' }).finally(forgetStale),
|
||||
// Mail the verification link to the signed-in address again. Works while
|
||||
// unverified (that is the whole point); 429 once the hourly cap is spent.
|
||||
resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }),
|
||||
@@ -310,7 +348,7 @@ export const api = {
|
||||
|
||||
// Moderation of an account. Both flags are reversible; `adminDeleteUser` is
|
||||
// the final act and takes the account's photos and recipes with it.
|
||||
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
|
||||
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: boolean }) =>
|
||||
call<{ user: AdminUser }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
adminDeleteUser: (id: number) => call<void>(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -345,4 +383,7 @@ export const api = {
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as unknown as { user: User };
|
||||
},
|
||||
|
||||
// Open photo location in native system file manager (Windows Explorer / Finder / Linux file manager)
|
||||
openExplorer: (path: string) => call<{ ok: boolean }>('/open-explorer', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Who this browser is signed in as, in one place.
|
||||
//
|
||||
// Three screens ask the same question — the studio (its account menu and its
|
||||
// tier), the library (whether to open at all) and the top bar (whether the
|
||||
// LIBRARY link is a link). Read separately, each of them answers it once and then
|
||||
// keeps that answer for the life of the page — the library is mounted once and
|
||||
// never asked again — so a visitor who signs in at the studio left the library
|
||||
// behind holding "guest"
|
||||
// — the next click on LIBRARY handed them a panel asking for a login they had just
|
||||
// given. One record, one reader.
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { api, type User } from './api';
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
// The API has answered once: the account is known, guest or not. Before that
|
||||
// `user` is null because nobody has said — which is not the same as "guest", and
|
||||
// is why the LIBRARY link is held back rather than switched off, and why opening
|
||||
// `/library` on a record nobody has answered for yet is not a reason to send the
|
||||
// visitor home.
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
let state: AuthState = { user: null, known: false };
|
||||
let inFlight: Promise<User | null> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function authState(): AuthState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeAuth(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// The snapshot is a value the store replaces only when it changes, so React can
|
||||
// compare it by identity — one object per answer, not one per render.
|
||||
export function useAuth(): AuthState {
|
||||
return useSyncExternalStore(subscribeAuth, authState);
|
||||
}
|
||||
|
||||
// Take the answer the caller already has — a login, a logout, `/me` on the studio's
|
||||
// own mount — so every subscribed screen moves at once.
|
||||
export function setAuthUser(user: User | null): void {
|
||||
if (state.known && state.user === user) return;
|
||||
state = { user, known: true };
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
// Ask the API again. One request at a time: the studio and the library both ask as
|
||||
// they mount, and one page load is one round trip. A call that fails leaves the
|
||||
// record as it was — the API being away is not the visitor's account ending.
|
||||
export function refreshAuth(): Promise<User | null> {
|
||||
if (!inFlight) {
|
||||
inFlight = api
|
||||
.me()
|
||||
.then((me) => {
|
||||
setAuthUser(me?.user ?? null);
|
||||
return me?.user ?? null;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
// One table for the guest/user split, so the policy can change without touching
|
||||
// the UI. Guest = the landing + a capped, watermarked demo; user = full tools,
|
||||
// full-resolution export, saved recipes.
|
||||
// One table for the three tiers, so the policy can change without touching the
|
||||
// UI. guest = the landing + a capped, watermarked demo; member = any signed-in
|
||||
// account, which is the basic tier: recipes, the photo folder and a clean export
|
||||
// of the photo it is editing; pro = the operator's Activated Pro box, which adds
|
||||
// the PRO tools (HSL, TOOLS and GRADIENT MASK, RAW) and lets the export run past
|
||||
// the photo's own pixels.
|
||||
//
|
||||
// `maxExportDim` here is the tier's own ceiling. The basic tier's ceiling is the
|
||||
// photo being edited, which only the caller knows — see `exportCap` in App.
|
||||
|
||||
export type TierId = 'guest' | 'user';
|
||||
export type TierId = 'guest' | 'member' | 'pro';
|
||||
|
||||
export interface Tier {
|
||||
id: TierId;
|
||||
/** Longest edge of an exported photo, or null for the original size. */
|
||||
/** Longest edge of an exported photo, or null for the caller's own ceiling. */
|
||||
maxExportDim: number | null;
|
||||
/** Text burned into every export, or null for a clean file. */
|
||||
watermark: string | null;
|
||||
@@ -15,5 +21,6 @@ export interface Tier {
|
||||
|
||||
export const TIERS: Record<TierId, Tier> = {
|
||||
guest: { id: 'guest', maxExportDim: 2048, watermark: 'RecipesCam demo', batchExport: false },
|
||||
user: { id: 'user', maxExportDim: null, watermark: null, batchExport: true },
|
||||
member: { id: 'member', maxExportDim: null, watermark: null, batchExport: false },
|
||||
pro: { id: 'pro', maxExportDim: null, watermark: null, batchExport: true },
|
||||
};
|
||||
|
||||
@@ -10,14 +10,37 @@ let assets: RenderAssets | null = null;
|
||||
// font service in a browser, so the list is exactly what the bundle carries —
|
||||
// and each family is ONE file, because drawText has no glyph fallback: a name
|
||||
// mapped to only the latin subset would print a Vietnamese place name as tofu.
|
||||
// The two here are the site's own self-hosted faces (styles/fonts.css, SIL OFL),
|
||||
// The first two are the site's own self-hosted faces (styles/fonts.css, SIL OFL),
|
||||
// with their latin / latin-ext / vietnamese woff2 subsets decompressed, pinned
|
||||
// to weight 400 @ opsz 14 and merged into one TTF per family (fontTools).
|
||||
// The rest come from google/fonts (SIL OFL), subset to the same latin /
|
||||
// latin-ext / vietnamese ranges with pyftsubset (fontTools); the variable ones
|
||||
// (Hahmlet, Dancing Script @ wght 400, Tilt Warp @ XROT 0 YROT 0) are pinned to
|
||||
// that one named instance first. Every file keeps whatever glyph its source had
|
||||
// inside those ranges — no invented ones: Tilt Warp has no U+0111, the rest do.
|
||||
export const MARK_FACES: { family: string; file: string }[] = [
|
||||
{ family: 'Inter', file: '/assets/Inter-Regular.ttf' },
|
||||
{ family: 'Fraunces', file: '/assets/Fraunces-Regular.ttf' },
|
||||
{ family: 'VT323', file: '/assets/VT323-Regular.ttf' },
|
||||
{ family: 'Alex Brush', file: '/assets/AlexBrush-Regular.ttf' },
|
||||
{ family: 'Tilt Warp', file: '/assets/TiltWarp-Regular.ttf' },
|
||||
{ family: 'Hahmlet', file: '/assets/Hahmlet-Regular.ttf' },
|
||||
{ family: 'Pinyon Script', file: '/assets/PinyonScript-Regular.ttf' },
|
||||
{ family: 'Allura', file: '/assets/Allura-Regular.ttf' },
|
||||
{ family: 'Dancing Script', file: '/assets/DancingScript-Regular.ttf' },
|
||||
{ family: 'Amatic SC', file: '/assets/AmaticSC-Regular.ttf' },
|
||||
];
|
||||
|
||||
// The two frame sheets are drawn by hand and dropped over the file of the same
|
||||
// name, and that name is the one thing neither cache lets go of: nginx answers
|
||||
// /assets/ immutable for 30 days (nginx.conf) and the service worker serves the
|
||||
// path cache-first (sw.js) — both stored for the sheet that was there the day the
|
||||
// visitor first loaded the app, so a sheet swapped in today arrives as yesterday's.
|
||||
// The build stamp in the query is what makes the URL move with the build; the old
|
||||
// entry stays in both caches, unasked for, and ages out on its own.
|
||||
const ART = `?v=${import.meta.env.VITE_VERSION ?? ''}`;
|
||||
|
||||
// The fonts never change under a build, so they keep the plain URL.
|
||||
async function buf(url: string): Promise<ArrayBuffer> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`);
|
||||
@@ -34,13 +57,20 @@ export function probeGpu(): boolean {
|
||||
export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> {
|
||||
await initSkia({ locateFile: (file) => `/wasm/${file}` });
|
||||
if (!assets) {
|
||||
const [textFont, emojiFont, wallframePng, markFonts] = await Promise.all([
|
||||
const [textFont, emojiFont, wallframePng, oldFilmPng, markFonts] = await Promise.all([
|
||||
buf('/assets/Cousine-Regular.ttf'),
|
||||
buf('/assets/NotoEmoji-GPS.ttf'),
|
||||
buf('/assets/wallframe.png'),
|
||||
buf(`/assets/wallframe.png${ART}`),
|
||||
buf(`/assets/old_film.png${ART}`),
|
||||
Promise.all(MARK_FACES.map(async (face) => ({ family: face.family, bytes: await buf(face.file) }))),
|
||||
]);
|
||||
assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng), markFonts };
|
||||
assets = {
|
||||
textFont,
|
||||
emojiFont,
|
||||
wallframe: new Uint8Array(wallframePng),
|
||||
oldFilm: new Uint8Array(oldFilmPng),
|
||||
markFonts,
|
||||
};
|
||||
}
|
||||
return { assets, gpu: probeGpu() };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Custom imported transparent photo frame engine and storage
|
||||
|
||||
export interface CustomFrameHole {
|
||||
x: number; // 0..1 fraction of frame width
|
||||
y: number; // 0..1 fraction of frame height
|
||||
w: number; // 0..1 fraction of frame width
|
||||
h: number; // 0..1 fraction of frame height
|
||||
}
|
||||
|
||||
export interface CustomFrameData {
|
||||
id: string;
|
||||
name: string;
|
||||
bytes: Uint8Array;
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hole: CustomFrameHole;
|
||||
scale: number; // default 1.0 (cover/fill hole)
|
||||
panX: number; // offset in frame pixels
|
||||
panY: number; // offset in frame pixels
|
||||
}
|
||||
|
||||
const DB_NAME = 'recipescam-studio';
|
||||
const STORE = 'custom_frames';
|
||||
const FRAME_KEY = 'active_custom_frame';
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 2);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains('session')) {
|
||||
db.createObjectStore('session');
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCustomFrame(frame: CustomFrameData): Promise<void> {
|
||||
try {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(frame, FRAME_KEY);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
} catch (err) {
|
||||
console.warn('Failed to save custom frame to IndexedDB', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCustomFrame(): Promise<CustomFrameData | null> {
|
||||
try {
|
||||
const db = await openDb();
|
||||
const row = await new Promise<CustomFrameData | undefined>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readonly');
|
||||
const req = tx.objectStore(STORE).get(FRAME_KEY);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
db.close();
|
||||
if (row && row.bytes instanceof Uint8Array && row.hole) {
|
||||
return row;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCustomFrame(): Promise<void> {
|
||||
try {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).delete(FRAME_KEY);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes an imported frame file (PNG, WebP, SVG, etc.),
|
||||
* decodes it, and detects the transparent opening/hole via alpha channel analysis.
|
||||
*/
|
||||
export async function analyzeCustomFrame(file: File): Promise<CustomFrameData> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const blob = new Blob([bytes], { type: file.type || 'image/png' });
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('Cannot decode frame image'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
|
||||
const w = img.naturalWidth || img.width;
|
||||
const h = img.naturalHeight || img.height;
|
||||
if (!(w > 0 && h > 0)) {
|
||||
throw new Error('Invalid image dimensions');
|
||||
}
|
||||
|
||||
// Downscale slightly for fast and accurate pixel analysis (max 600px)
|
||||
const maxDim = 600;
|
||||
const sampleScale = Math.min(1, maxDim / Math.max(w, h));
|
||||
const sw = Math.max(10, Math.round(w * sampleScale));
|
||||
const sh = Math.max(10, Math.round(h * sampleScale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = sw;
|
||||
canvas.height = sh;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context not available');
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, sw, sh);
|
||||
const imgData = ctx.getImageData(0, 0, sw, sh);
|
||||
const data = imgData.data;
|
||||
|
||||
// Transparent threshold: alpha < 70 (out of 255)
|
||||
const isAlphaLow = (x: number, y: number) => {
|
||||
return data[(y * sw + x) * 4 + 3] < 70;
|
||||
};
|
||||
|
||||
// 1. Flood-fill from outer edges to distinguish outer transparent padding from the inner transparent hole
|
||||
const visited = new Uint8Array(sw * sh);
|
||||
const queue: number[] = [];
|
||||
|
||||
for (let x = 0; x < sw; x++) {
|
||||
if (isAlphaLow(x, 0)) {
|
||||
visited[0 * sw + x] = 1;
|
||||
queue.push(x, 0);
|
||||
}
|
||||
if (isAlphaLow(x, sh - 1)) {
|
||||
visited[(sh - 1) * sw + x] = 1;
|
||||
queue.push(x, sh - 1);
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < sh; y++) {
|
||||
if (isAlphaLow(0, y) && !visited[y * sw + 0]) {
|
||||
visited[y * sw + 0] = 1;
|
||||
queue.push(0, y);
|
||||
}
|
||||
if (isAlphaLow(sw - 1, y) && !visited[y * sw + (sw - 1)]) {
|
||||
visited[y * sw + (sw - 1)] = 1;
|
||||
queue.push(sw - 1, y);
|
||||
}
|
||||
}
|
||||
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const qx = queue[head++];
|
||||
const qy = queue[head++];
|
||||
const neighbors = [
|
||||
[qx + 1, qy],
|
||||
[qx - 1, qy],
|
||||
[qx, qy + 1],
|
||||
[qx, qy - 1],
|
||||
];
|
||||
for (const [nx, ny] of neighbors) {
|
||||
if (nx >= 0 && nx < sw && ny >= 0 && ny < sh) {
|
||||
const idx = ny * sw + nx;
|
||||
if (!visited[idx] && isAlphaLow(nx, ny)) {
|
||||
visited[idx] = 1;
|
||||
queue.push(nx, ny);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Find bounding box of inner transparent pixels (not reached by flood-fill from border)
|
||||
let minX = sw;
|
||||
let minY = sh;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
let innerTransparentCount = 0;
|
||||
|
||||
for (let y = 0; y < sh; y++) {
|
||||
for (let x = 0; x < sw; x++) {
|
||||
const idx = y * sw + x;
|
||||
if (isAlphaLow(x, y) && !visited[idx]) {
|
||||
innerTransparentCount++;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: if no inner transparent pixel was isolated, search for any transparent pixels
|
||||
if (innerTransparentCount < 20 || minX > maxX) {
|
||||
minX = sw;
|
||||
minY = sh;
|
||||
maxX = -1;
|
||||
maxY = -1;
|
||||
let anyCount = 0;
|
||||
for (let y = 0; y < sh; y++) {
|
||||
for (let x = 0; x < sw; x++) {
|
||||
if (isAlphaLow(x, y)) {
|
||||
anyCount++;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
innerTransparentCount = anyCount;
|
||||
}
|
||||
|
||||
// 4. Compute normalized hole geometry
|
||||
let hole: CustomFrameHole = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 };
|
||||
if (innerTransparentCount > 20 && minX <= maxX && minY <= maxY) {
|
||||
const holeW = Math.max(0.05, (maxX - minX + 1) / sw);
|
||||
const holeH = Math.max(0.05, (maxY - minY + 1) / sh);
|
||||
hole = {
|
||||
x: minX / sw,
|
||||
y: minY / sh,
|
||||
w: holeW,
|
||||
h: holeH,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: file.name,
|
||||
bytes,
|
||||
dataUrl,
|
||||
width: w,
|
||||
height: h,
|
||||
hole,
|
||||
scale: 1.0,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
};
|
||||
}
|
||||
@@ -6,15 +6,17 @@
|
||||
// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes
|
||||
// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is
|
||||
// gone (the browser downloads a Blob instead).
|
||||
// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution
|
||||
// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from
|
||||
// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated
|
||||
// as a shader pass. `convolve` replaces the pixels outright (the shader's
|
||||
// output is opaque and covers the same rect), so no clearing pass is needed.
|
||||
// - `MakeMatrixConvolution` does not exist in CanvasKit. The output sharpen
|
||||
// (and CLARITY's own kernel) run through shader passes instead —
|
||||
// `SHARPEN_SKSL` for the knob, the same kernel with the doc's Sobel edge
|
||||
// mask and detail coring on it. `convolve` replaces the pixels outright (the
|
||||
// shader's output is opaque and covers the same rect), so no clearing pass
|
||||
// is needed.
|
||||
// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
|
||||
// be composited over the un-blurred content, so those passes snapshot, clear
|
||||
// and redraw. Order matches RN's nested filter chain: denoise → clarity →
|
||||
// mist → soften.
|
||||
// mist → soften. NOISE REDUCTION's chroma reference is one of those blurs,
|
||||
// and only its chroma (NR_SKSL) — the knob used to smear all three channels.
|
||||
// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
|
||||
// alone keeps the print size metadata.
|
||||
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
|
||||
@@ -27,14 +29,16 @@ import {
|
||||
TONE_SKSL,
|
||||
EXPOSURE_SKSL,
|
||||
GLOW_SKSL,
|
||||
CLARITY_SKSL,
|
||||
SHARPEN_SKSL,
|
||||
CLARITY_BLUR_SKSL,
|
||||
CLARITY_BLEND_SKSL,
|
||||
CLARITY_GAIN,
|
||||
NR_SKSL,
|
||||
NR_CHROMA_SPAN,
|
||||
DEHAZE_SKSL,
|
||||
dehazeUniformArray,
|
||||
dehazePatchStep,
|
||||
getToneUniforms,
|
||||
TONE_BASE_RADIUS,
|
||||
TONE_BASE_SIGMA,
|
||||
toneIsActive,
|
||||
toneUniformArray,
|
||||
glowUniformArray,
|
||||
@@ -72,6 +76,7 @@ export interface RenderAssets {
|
||||
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
|
||||
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
|
||||
wallframe: Uint8Array; // wallframe.png
|
||||
oldFilm: Uint8Array; // old_film.png
|
||||
// The FONT option's faces beside DEFAULT (see assets.ts MARK_FACES).
|
||||
markFonts: { family: string; bytes: ArrayBuffer }[];
|
||||
}
|
||||
@@ -95,6 +100,13 @@ export interface RenderOptions {
|
||||
// quarter turn and the fine angle (they ride on top of both).
|
||||
photoFlipH?: boolean;
|
||||
photoFlipV?: boolean;
|
||||
customFrame?: {
|
||||
bytes: Uint8Array;
|
||||
hole: { x: number; y: number; w: number; h: number };
|
||||
scale?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
} | null;
|
||||
// The strip the FILE carries: the amber `#TAG` over the photo and a caption
|
||||
// band appended below it, mirroring the landing page's `.lp-shot` +
|
||||
// `.lp-frame-meta`. Left off the live preview, which must not show it.
|
||||
@@ -166,10 +178,11 @@ let halationEffect: any = null;
|
||||
let curveEffect: any = null;
|
||||
let clarityBlurEffect: any = null;
|
||||
let clarityBlendEffect: any = null;
|
||||
let nrEffect: any = null;
|
||||
let dehazeEffect: any = null;
|
||||
|
||||
function effects() {
|
||||
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
||||
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(SHARPEN_SKSL);
|
||||
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
if (!exposureEffect) exposureEffect = Skia.RuntimeEffect.Make(EXPOSURE_SKSL);
|
||||
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
||||
@@ -178,6 +191,7 @@ function effects() {
|
||||
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
|
||||
if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL);
|
||||
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
|
||||
if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL);
|
||||
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
|
||||
return {
|
||||
sharpenEffect,
|
||||
@@ -189,6 +203,7 @@ function effects() {
|
||||
curveEffect,
|
||||
clarityBlurEffect,
|
||||
clarityBlendEffect,
|
||||
nrEffect,
|
||||
dehazeEffect,
|
||||
};
|
||||
}
|
||||
@@ -236,7 +251,7 @@ function maskEffectFor(count: number, spatial: boolean): any {
|
||||
return effect;
|
||||
}
|
||||
|
||||
// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
|
||||
// SHARPEN_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
|
||||
// a 1:1 export canvas, so the radius matches what the preview tuned.
|
||||
function convolvePaint(srcImage: any, amount: number): any {
|
||||
const { sharpenEffect: effect } = effects();
|
||||
@@ -310,6 +325,68 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num
|
||||
disposeAll([paint, filter, snap]);
|
||||
}
|
||||
|
||||
// The BASE layer of the tone ramp: the frame blurred out to TONE_BASE_RADIUS, as
|
||||
// an image the tone shader takes as its second child and reads once per pixel.
|
||||
// Blurred with Skia's own MakeBlur — the same draw drawBlurred makes for the
|
||||
// sharpening pass — because what the ramp needs is an AVERAGE of the
|
||||
// neighbourhood and a ring of point samples is not one: sampled on a fine ring
|
||||
// the luma aliases, the gain o(base)/base inherits the alias, and the
|
||||
// reconstruction paints it back as mottle (a 1160x774 frame at SHADOW +100 came
|
||||
// out with 0.063 of high-frequency gain against 0.005 for this blur — see
|
||||
// TONE_BASE_RADIUS in toneShader.ts). `shaderOf` is left owned by the caller: the
|
||||
// tone pass reads the same shader as its first child.
|
||||
function blurredBase(
|
||||
shaderOf: () => any,
|
||||
w: number,
|
||||
h: number,
|
||||
sigma: number
|
||||
): any | null {
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf) return null;
|
||||
try {
|
||||
const shader = shaderOf();
|
||||
if (!shader) return null;
|
||||
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
|
||||
const paint = Skia.Paint();
|
||||
paint.setImageFilter(filter);
|
||||
paint.setShader(shader);
|
||||
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
|
||||
flush(surf);
|
||||
disposeAll([paint, filter]);
|
||||
return surf.makeImageSnapshot() ?? null;
|
||||
} finally {
|
||||
surf.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// A blurred copy of what the surface holds right now, as an image a pass can
|
||||
// take as a child. Same `MakeBlur` as the ramp's base and the negative blurs,
|
||||
// but built from a SNAPSHOT rather than from a shader the caller still holds —
|
||||
// so the snapshot's own shader is freed here, and NOISE REDUCTION can read the
|
||||
// graded pixels of the frame without re-deriving the draw that made them.
|
||||
function blurredFrame(surface: any, w: number, h: number, sigma: number): any | null {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
if (!snap) return null;
|
||||
const surf = createSurface(w, h);
|
||||
const child = surf ? imageShaderChild(snap) : null;
|
||||
try {
|
||||
if (!surf || !child) return null;
|
||||
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
|
||||
const paint = Skia.Paint();
|
||||
paint.setImageFilter(filter);
|
||||
paint.setShader(child);
|
||||
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
|
||||
flush(surf);
|
||||
disposeAll([paint, filter]);
|
||||
return surf.makeImageSnapshot() ?? null;
|
||||
} finally {
|
||||
if (child) child.dispose();
|
||||
surf?.dispose();
|
||||
snap.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// --- spatial passes: CLARITY and DEHAZE -------------------------------------
|
||||
//
|
||||
// Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and
|
||||
@@ -381,6 +458,11 @@ function spatialReference(surface: any, w: number, h: number): any | null {
|
||||
// answer is the pixel whose DARKEST channel is brightest, so the 4096-pixel copy
|
||||
// only has to be searched for that one. Null when the readback is unavailable —
|
||||
// the caller then leaves the pass out rather than guessing a value.
|
||||
// Where a pixel stops being evidence of A, and the share of the survivors that
|
||||
// speaks for it (see the note inside).
|
||||
const DEHAZE_AIR_BLOWN = 0.9;
|
||||
const DEHAZE_AIR_FRACTION = 0.02;
|
||||
|
||||
function atmosphericLight(surface: any, w: number, h: number): [number, number, number] | null {
|
||||
const n = 32;
|
||||
const small = createSurface(n, n);
|
||||
@@ -407,19 +489,39 @@ function atmosphericLight(surface: any, w: number, h: number): [number, number,
|
||||
colorSpace: Skia.ColorSpace.SRGB,
|
||||
});
|
||||
if (!px) return null;
|
||||
let best = -1;
|
||||
let air: [number, number, number] = [1, 1, 1];
|
||||
// A blown pixel's dark channel is 1 whatever the haze is — white cloth the
|
||||
// camera clipped reads exactly like white haze — so a frame with a white
|
||||
// anything in it answered A = [1, 1, 1]. That is the worst A to hand
|
||||
// `J = (I - A)/t + A`: every pixel below 1 goes negative on some channel and
|
||||
// is clipped back channel by channel, which is the lilac cast on white cloth
|
||||
// and the bright rim around every dark detail (one pixel over, the patch
|
||||
// holds a shadow and t is 1 again).
|
||||
//
|
||||
// So the pixels that can be evidence of the light are the ones short of
|
||||
// white, and A is the mean of the brightest of those — the doc's 0.1% is one
|
||||
// pixel of this 32x32 copy, which a single specular dot would own.
|
||||
const evidence: Array<[number, number, number]> = [];
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
const r = px[i * 4] / 255;
|
||||
const g = px[i * 4 + 1] / 255;
|
||||
const b = px[i * 4 + 2] / 255;
|
||||
const dark = Math.min(r, g, b);
|
||||
if (dark > best) {
|
||||
best = dark;
|
||||
air = [r, g, b];
|
||||
}
|
||||
const rgb: [number, number, number] = [
|
||||
px[i * 4] / 255,
|
||||
px[i * 4 + 1] / 255,
|
||||
px[i * 4 + 2] / 255,
|
||||
];
|
||||
if (Math.min(rgb[0], rgb[1], rgb[2]) < DEHAZE_AIR_BLOWN) evidence.push(rgb);
|
||||
}
|
||||
return air;
|
||||
// A frame with nothing short of white in it is all haze: the doc's answer
|
||||
// would be A = 1 and the inversion would be all clipping, so DEHAZE stays off
|
||||
// instead (the same null the readback failure returns).
|
||||
if (evidence.length < 2) return null;
|
||||
evidence.sort((l, r) => Math.min(r[0], r[1], r[2]) - Math.min(l[0], l[1], l[2]));
|
||||
const keep = Math.max(1, Math.round(evidence.length * DEHAZE_AIR_FRACTION));
|
||||
const air: [number, number, number] = [0, 0, 0];
|
||||
for (let i = 0; i < keep; i++) {
|
||||
air[0] += evidence[i][0];
|
||||
air[1] += evidence[i][1];
|
||||
air[2] += evidence[i][2];
|
||||
}
|
||||
return [air[0] / keep, air[1] / keep, air[2] / keep];
|
||||
} catch {
|
||||
// No readback (a surface that refuses one, a shader that failed): DEHAZE
|
||||
// stays off for this render.
|
||||
@@ -430,6 +532,132 @@ function atmosphericLight(surface: any, w: number, h: number): [number, number,
|
||||
}
|
||||
}
|
||||
|
||||
// DEHAZE's dark channel, the prior's own quantity, built as an image for
|
||||
// DEHAZE_SKSL to sample — the pass itself turns it into t.
|
||||
//
|
||||
// The pass used to take the dark channel's minimum inline, over five taps spaced
|
||||
// 0.625% of the frame's width apart — one tap every 7 pixels of a 1067-pixel
|
||||
// preview. A dark detail thinner than that spacing sat between two taps on one
|
||||
// row of the frame and under a tap on the next, so t swung between "this patch
|
||||
// holds a shadow, leave it alone" and "this patch is all haze, divide hard" with
|
||||
// a 7-pixel period around every dark thing in the photo. A rising DEHAZE drew
|
||||
// that period as bright copies of the detail, stacked beside it.
|
||||
//
|
||||
// Reading the dark channel off a small copy of the frame is what removes it: one
|
||||
// cell of that copy already covers a whole patch, so the neighbourhood is
|
||||
// covered rather than sampled, and the bilinear upscale interpolates it back with
|
||||
// no period left in it. The box passes after that are the doc's soft matting in
|
||||
// the one form that is free here — the map is smoothed, not the pixels.
|
||||
//
|
||||
// The dark channel and not t is what goes into that image, because a channel is
|
||||
// eight bits and only carries 0..1: t is 1 + 0.95 at the negative end of the
|
||||
// knob, and would arrive clipped to 1, turning "scatter the light back in" into a
|
||||
// pass that does nothing at all. The dark channel is 0..1 by construction, and
|
||||
// the signed amount stays a uniform, where it costs no range.
|
||||
const DEHAZE_MAP_SIZE = 64;
|
||||
const DEHAZE_MAP_TAPS = 1; // patch reach, in cells of that copy
|
||||
const DEHAZE_MAP_BLUR = 1; // box radius, in cells of that copy
|
||||
|
||||
// One separable box pass over the copy's own grid.
|
||||
function boxBlurMap(src: Float32Array, n: number, r: number): Float32Array {
|
||||
const mid = new Float32Array(n * n);
|
||||
const out = new Float32Array(n * n);
|
||||
const edge = (v: number) => Math.min(n - 1, Math.max(0, v));
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
let sum = 0;
|
||||
for (let i = -r; i <= r; i++) sum += src[y * n + edge(x + i)];
|
||||
mid[y * n + x] = sum / (2 * r + 1);
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
let sum = 0;
|
||||
for (let j = -r; j <= r; j++) sum += mid[edge(y + j) * n + x];
|
||||
out[y * n + x] = sum / (2 * r + 1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Null when the copy cannot be read: the caller then leaves DEHAZE out for this
|
||||
// render rather than correcting with a t it made up (atmosphericLight's own
|
||||
// answer to the same failure).
|
||||
function dehazeDarkChannel(
|
||||
surface: any,
|
||||
w: number,
|
||||
h: number,
|
||||
air: [number, number, number]
|
||||
): any | null {
|
||||
const n = DEHAZE_MAP_SIZE;
|
||||
const small = createSurface(n, n);
|
||||
if (!small) return null;
|
||||
let snap: any = null;
|
||||
let paint: any = null;
|
||||
try {
|
||||
flush(surface);
|
||||
snap = surface.makeImageSnapshot();
|
||||
if (!snap) return null;
|
||||
paint = Skia.Paint();
|
||||
small.getCanvas().drawImageRect(
|
||||
snap,
|
||||
Skia.XYWHRect(0, 0, w, h),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
paint
|
||||
);
|
||||
flush(small);
|
||||
const px = small.getCanvas().readPixels(0, 0, {
|
||||
width: n,
|
||||
height: n,
|
||||
colorType: Skia.ColorType.RGBA_8888,
|
||||
alphaType: Skia.AlphaType.Unpremul,
|
||||
colorSpace: Skia.ColorSpace.SRGB,
|
||||
});
|
||||
if (!px) return null;
|
||||
const a = [Math.max(air[0], 0.05), Math.max(air[1], 0.05), Math.max(air[2], 0.05)];
|
||||
const dark = new Float32Array(n * n);
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
let d = 1;
|
||||
for (let j = -DEHAZE_MAP_TAPS; j <= DEHAZE_MAP_TAPS; j++) {
|
||||
const cy = Math.min(n - 1, Math.max(0, y + j));
|
||||
for (let i = -DEHAZE_MAP_TAPS; i <= DEHAZE_MAP_TAPS; i++) {
|
||||
const o = (cy * n + Math.min(n - 1, Math.max(0, x + i))) * 4;
|
||||
d = Math.min(d, px[o] / 255 / a[0], px[o + 1] / 255 / a[1], px[o + 2] / 255 / a[2]);
|
||||
}
|
||||
}
|
||||
dark[y * n + x] = Math.min(1, Math.max(0, d));
|
||||
}
|
||||
}
|
||||
const smooth = boxBlurMap(boxBlurMap(dark, n, DEHAZE_MAP_BLUR), n, DEHAZE_MAP_BLUR);
|
||||
// The dark channel rides the red channel and every byte is opaque, so the
|
||||
// image is its own premultiplied answer and needs no range the shader decodes.
|
||||
const bytes = new Uint8Array(n * n * 4);
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
const v = Math.min(255, Math.max(0, Math.round(smooth[i] * 255)));
|
||||
bytes[i * 4] = v;
|
||||
bytes[i * 4 + 1] = v;
|
||||
bytes[i * 4 + 2] = v;
|
||||
bytes[i * 4 + 3] = 255;
|
||||
}
|
||||
return Skia.Image.MakeImage(
|
||||
{
|
||||
width: n,
|
||||
height: n,
|
||||
colorType: Skia.ColorType.RGBA_8888,
|
||||
alphaType: Skia.AlphaType.Premul,
|
||||
},
|
||||
bytes,
|
||||
n * 4
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
disposeAll([paint, snap].filter(Boolean) as SkDisposable[]);
|
||||
small.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// An image as a child shader — what every effect here wants instead of a paint.
|
||||
// The caller owns the result (the renderer's `own`).
|
||||
function imageShaderChild(image: any): any {
|
||||
@@ -470,6 +698,7 @@ function replaceThrough(
|
||||
|
||||
let faces: { typeface: any; iconTypeface: any } | null = null;
|
||||
let wallframeImage: any = null;
|
||||
let oldFilmImage: any = null;
|
||||
|
||||
function loadFaces(assets: RenderAssets) {
|
||||
if (faces) return faces;
|
||||
@@ -542,6 +771,21 @@ function loadWallframe(assets: RenderAssets) {
|
||||
return wallframeImage;
|
||||
}
|
||||
|
||||
// Both films are the same sheet of distressed paper: `old-film` lays it down as
|
||||
// it is, `old-film-portrait` turns it 90° CW, so the pair is one frame standing
|
||||
// and one lying — the same two shapes the walls offer, in a sheet whose middle
|
||||
// is a transparent window a couple of percent of paper wide around it.
|
||||
const isOldFilm = (id: FrameId) => id === 'old-film' || id === 'old-film-portrait';
|
||||
// A frame that carries its own opening: the aspect chip has nothing to say to
|
||||
// it (both walls, both films, custom frames).
|
||||
const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id) || id === 'custom';
|
||||
|
||||
function loadOldFilm(assets: RenderAssets) {
|
||||
if (oldFilmImage) return oldFilmImage;
|
||||
oldFilmImage = Skia.Image.MakeImageFromEncoded(assets.oldFilm);
|
||||
return oldFilmImage;
|
||||
}
|
||||
|
||||
export async function renderPhoto(input: RenderInput): Promise<RenderResult | null> {
|
||||
const { recipe, frameId, options } = input;
|
||||
const owned: SkDisposable[] = [];
|
||||
@@ -577,10 +821,10 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
|
||||
const cropRect = options?.cropRect ?? null;
|
||||
const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH;
|
||||
// A wall is an artwork with its own opening, so the aspect chip (ratioWH)
|
||||
// has nothing to say there — but the visitor's own crop does, or a photo
|
||||
// that was cropped and applied comes back whole behind the frame.
|
||||
if (cropRect || (ratioWH && !isWallframe(frameId))) {
|
||||
// A wall or a film is an artwork with its own opening, so the aspect chip
|
||||
// (ratioWH) has nothing to say there — but the visitor's own crop does, or a
|
||||
// photo that was cropped and applied comes back whole behind the frame.
|
||||
if (cropRect || (ratioWH && !hasOwnAspect(frameId))) {
|
||||
const curAspect = width / height;
|
||||
let cropW = width;
|
||||
let cropH = height;
|
||||
@@ -646,6 +890,13 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
const matrixIsIdentity = matrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
|
||||
|
||||
// 3b. Tone shader.
|
||||
// TONE_BASE_RADIUS of the FRAME is the neighbourhood the ramp is drawn
|
||||
// through, so the preview and the file look at the same size of one — this is
|
||||
// the one place that knows how big the frame is. The base itself is a blurred
|
||||
// copy of the tone shader's own child (blurredBase), not a ring of samples
|
||||
// inside it: see TONE_BASE_RADIUS for why a ring was the mottle bug. A frame
|
||||
// too small or a surface that will not come up leaves the sharp child as the
|
||||
// base, which lands the base on the pixel and keeps the global ramp.
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
let toneShader: any = null;
|
||||
// 3c. Cinema seasonal grade (cinema → tone → image).
|
||||
@@ -687,7 +938,16 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
if (toneIsActive(tone)) {
|
||||
const { toneEffect: effect } = effects();
|
||||
if (effect) {
|
||||
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [baseShaderOf()]));
|
||||
// The base is built HERE and not earlier: it draws the same shader the
|
||||
// tone pass reads, and both have to see the pixels after the exposure.
|
||||
const sigma = width * TONE_BASE_RADIUS * TONE_BASE_SIGMA;
|
||||
const base = own(blurredBase(baseShaderOf, width, height, sigma));
|
||||
toneShader = own(
|
||||
effect.makeShaderWithChildren(toneUniformArray(tone), [
|
||||
baseShaderOf(),
|
||||
base ? own(imageShaderChild(base)) : baseShaderOf(),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cinemaOn) {
|
||||
@@ -742,12 +1002,6 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
if (paintShader) paint.setShader(paintShader);
|
||||
else if (!graded) paint.setColorFilter(colorFilter);
|
||||
|
||||
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
|
||||
if (adjustments.denoise > 0) {
|
||||
const sigma = (adjustments.denoise / 10) * 0.6;
|
||||
paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
|
||||
}
|
||||
|
||||
// 5. Draw the graded photo.
|
||||
if (paintShader) {
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
|
||||
@@ -755,6 +1009,33 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
canvas.drawImage(skImage, 0, 0, paint);
|
||||
}
|
||||
|
||||
// 4. NOISE REDUCTION — thay_doi_thong_so_giong_lightroom.md §4.1, the colour
|
||||
// half of it (NR_SKSL). It runs HERE and not on the paint, because it needs
|
||||
// the finished pixels as one child and a blurred copy of them as the other,
|
||||
// and a `setImageFilter` on the draw can only offer the shader's input. The
|
||||
// knob WAS that filter — one sigma over all three channels — so the pass that
|
||||
// took a colour speckle out took the picture's edges with it. Only the chroma
|
||||
// is blurred now; the luma is the frame's own, so a strand of hair comes back
|
||||
// where it was whatever the knob says.
|
||||
const denoiseKnob = adjustments.denoise ?? 0;
|
||||
if (denoiseKnob > 0) {
|
||||
const { nrEffect: denoiseEffect } = effects();
|
||||
const sigma = (denoiseKnob / 10) * NR_CHROMA_SPAN * width;
|
||||
// The blur is built before the redraw because the redraw overwrites the
|
||||
// frame the reference is read out of (the same reason DEHAZE reads its dark
|
||||
// channel and its light first).
|
||||
const blurred = denoiseEffect ? own(blurredFrame(surface, width, height, sigma)) : null;
|
||||
if (denoiseEffect && blurred) {
|
||||
replaceThrough(canvas, surface, width, height, (snap) => {
|
||||
const shader = denoiseEffect.makeShaderWithChildren(
|
||||
[Math.min(1, denoiseKnob / 10)],
|
||||
[own(imageShaderChild(snap)), own(imageShaderChild(blurred))]
|
||||
);
|
||||
return shader ? own(shader) : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. DEHAZE and CLARITY — the frame-wide half of the two docs
|
||||
// (raw_parameter_processing... §3, ki_n_tr_c_multiple_passes... in full). Both
|
||||
// compare a pixel against a blurred reference of its own surroundings, so the
|
||||
@@ -778,22 +1059,64 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
return air;
|
||||
};
|
||||
// DEHAZE is signed: positive takes scattered light out, negative puts it
|
||||
// back (DEHAZE_SKSL's own t moves either side of 1). Either way it reads the
|
||||
// dark channel from the frame itself — a minimum over a patch, not the
|
||||
// bilateral reference — so the pass needs no second child and the reference
|
||||
// is built only for CLARITY and the masks that ask for it.
|
||||
// back (the transmission it is handed moves either side of 1, DEHAZE_SKSL's
|
||||
// own note). Its second child is not the bilateral reference CLARITY reads —
|
||||
// the prior wants a MINIMUM over a patch, not an average — but the smoothed
|
||||
// transmission built below, so the reference is still built only for CLARITY
|
||||
// and the masks that ask for it.
|
||||
//
|
||||
// The knob is left to over-correct at the top of its range on purpose: a
|
||||
// hazy sky goes white and a saturated colour beside a dark edge deepens.
|
||||
// That is `(c - a)/t + a` with an airlight near 0.93 and a plain clamp, the
|
||||
// arithmetic the doc asks for, and it is smooth across the frame — 6x zoom
|
||||
// panels of a hazy frame at DEHAZE 100 show one wide gradient, no band
|
||||
// repeating at any period. The bands that used to be drawn beside dark
|
||||
// detail were the patch above, and hiding them behind a knee in the shader
|
||||
// would soften the correction everywhere to answer a bug that is gone.
|
||||
// ponytail: no knee; if the over-correction ever needs taming the one number
|
||||
// that does it is DEHAZE_MAX_OMEGA.
|
||||
const dehazeAmount = (adjustments.dehaze ?? 0) / 10;
|
||||
// Through `effects()`, not the module variable: nothing above this pass has
|
||||
// asked for DEHAZE, so on the first render the raw variable is still null and
|
||||
// the knob did nothing until some later render happened to fill it in.
|
||||
const { dehazeEffect: effect } = effects();
|
||||
|
||||
if (dehazeAmount !== 0 && dehazeEffect) {
|
||||
replaceThrough(canvas, surface, width, height, (snap) => {
|
||||
const a = airOf();
|
||||
if (!a) return null;
|
||||
const shader = dehazeEffect.makeShaderWithChildren(
|
||||
dehazeUniformArray(a, dehazeAmount, dehazePatchStep(width)),
|
||||
[own(imageShaderChild(snap))]
|
||||
);
|
||||
return shader ? own(shader) : null;
|
||||
});
|
||||
if (dehazeAmount !== 0 && effect) {
|
||||
// The dark channel is read off the frame the pass is about to overwrite,
|
||||
// like the light and for the same reason: after the pass the pixels are the
|
||||
// corrected ones and the dark channel they would give is not the haze's.
|
||||
const a = airOf();
|
||||
const darkMap = a ? own(dehazeDarkChannel(surface, width, height, a)) : null;
|
||||
if (a && darkMap) {
|
||||
replaceThrough(canvas, surface, width, height, (snap) => {
|
||||
const shader = dehazeEffect.makeShaderWithChildren(
|
||||
dehazeUniformArray(a, dehazeAmount),
|
||||
[
|
||||
own(imageShaderChild(snap)),
|
||||
own(
|
||||
darkMap.makeShaderOptions(
|
||||
Skia.TileMode.Clamp,
|
||||
Skia.TileMode.Clamp,
|
||||
Skia.FilterMode.Linear,
|
||||
Skia.MipmapMode.None,
|
||||
// A map this small only covers the frame if it is told to. With
|
||||
// no matrix the image's own texels ARE the shader's coordinates,
|
||||
// so `dark.eval` reads the map 1:1: the top-left 64 pixels of
|
||||
// the frame get the real dark channel and every pixel past them
|
||||
// clamps onto the map's last texel — one constant t over the
|
||||
// whole photo, i.e. a global inversion instead of a dehaze.
|
||||
// CanvasKit reads this matrix as the map's own pixels to the
|
||||
// frame's (measured: `scaled(n/w)` clamps the same way, the
|
||||
// reciprocal lands on the ideal ramp), hence frame over map,
|
||||
// as a plain 3x3 in SkMatrix's own order.
|
||||
[width / DEHAZE_MAP_SIZE, 0, 0, 0, height / DEHAZE_MAP_SIZE, 0, 0, 0, 1]
|
||||
)
|
||||
),
|
||||
]
|
||||
);
|
||||
return shader ? own(shader) : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CLARITY is one move in two directions: the frame against its own blurred
|
||||
@@ -808,7 +1131,11 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
if (!reference) return null;
|
||||
own(reference);
|
||||
const shader = clarityBlendEffect.makeShaderWithChildren(
|
||||
[(clarityKnob / 10) * (clarityKnob > 0 ? CLARITY_GAIN : 1)],
|
||||
// Amount in the blend's own [-1, 1] units: the positive side's gain
|
||||
// (CLARITY_DETAIL_GAIN) lives INSIDE the shader now, so the mask's
|
||||
// CLARITY_GAIN — a different quantity on a different child — is not
|
||||
// borrowed for this one and the two knobs stay independent.
|
||||
[clarityKnob / 10],
|
||||
[own(imageShaderChild(snap)), own(imageShaderChild(reference))]
|
||||
);
|
||||
return shader ? own(shader) : null;
|
||||
@@ -1141,6 +1468,133 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
} else if (isOldFilm(frameId)) {
|
||||
// The sheet has the frame's SHAPE, like the walls: `old-film-portrait` is
|
||||
// the PNG turned 90° CW, so the pair is one standing frame and one lying.
|
||||
// The sheet's own 914x609 is far too small to hold a photo, so the frame
|
||||
// is scaled UP to the photo instead of the photo down to the frame: the
|
||||
// deckle stays a couple of percent of the print at any export size, which
|
||||
// is also why the photo is cover-cropped into the whole sheet and not
|
||||
// into a measured window — the sheet's edge is torn, so it has no straight
|
||||
// sides to cut against; the photo simply runs under all of it.
|
||||
flush(surface);
|
||||
const photoImg = own(surface.makeImageSnapshot());
|
||||
cardSource = photoImg;
|
||||
const film = loadOldFilm(input.assets);
|
||||
if (!film) {
|
||||
console.error('Failed to decode old_film.png');
|
||||
return null;
|
||||
}
|
||||
const portrait = frameId === 'old-film-portrait';
|
||||
const sheetW = portrait ? film.height() : film.width();
|
||||
const sheetH = portrait ? film.width() : film.height();
|
||||
const scale = Math.max(width / sheetW, height / sheetH);
|
||||
const outW = Math.round(sheetW * scale);
|
||||
const outH = Math.round(sheetH * scale);
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) return null;
|
||||
own(cardSurface);
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const plainPaint = own(Skia.Paint());
|
||||
const cover = Math.max(outW / width, outH / height);
|
||||
const coverW = outW / cover;
|
||||
const coverH = outH / cover;
|
||||
cardCanvas.drawImageRectOptions(
|
||||
photoImg,
|
||||
Skia.XYWHRect((width - coverW) / 2, (height - coverH) / 2, coverW, coverH),
|
||||
Skia.XYWHRect(0, 0, outW, outH),
|
||||
Skia.FilterMode.Linear,
|
||||
Skia.MipmapMode.None,
|
||||
plainPaint
|
||||
);
|
||||
cardCanvas.save();
|
||||
if (portrait) {
|
||||
// The wall-frame portrait's own turn: origin to the top-right corner,
|
||||
// then 90° CW, so the sheet's width runs down the frame and the drawn
|
||||
// rect is the frame's height by its width.
|
||||
cardCanvas.translate(outW, 0);
|
||||
cardCanvas.rotate(90, 0, 0);
|
||||
}
|
||||
cardCanvas.drawImageRectOptions(
|
||||
film,
|
||||
Skia.XYWHRect(0, 0, film.width(), film.height()),
|
||||
portrait ? Skia.XYWHRect(0, 0, outH, outW) : Skia.XYWHRect(0, 0, outW, outH),
|
||||
Skia.FilterMode.Linear,
|
||||
Skia.MipmapMode.None,
|
||||
plainPaint
|
||||
);
|
||||
cardCanvas.restore();
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
} else if (frameId === 'custom' && options?.customFrame) {
|
||||
flush(surface);
|
||||
const photoImg = own(surface.makeImageSnapshot());
|
||||
cardSource = photoImg;
|
||||
const pw = photoImg.width();
|
||||
const ph = photoImg.height();
|
||||
|
||||
const customImg = own(Skia.Image.MakeImageFromEncoded(options.customFrame.bytes));
|
||||
if (!customImg) {
|
||||
console.error('Failed to decode custom frame');
|
||||
return null;
|
||||
}
|
||||
const fw = customImg.width();
|
||||
const fh = customImg.height();
|
||||
|
||||
const cardSurface = createSurface(fw, fh);
|
||||
if (!cardSurface) return null;
|
||||
own(cardSurface);
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const plainPaint = own(Skia.Paint());
|
||||
|
||||
const hole = options.customFrame.hole;
|
||||
const holeX = hole.x * fw;
|
||||
const holeY = hole.y * fh;
|
||||
const holeW = hole.w * fw;
|
||||
const holeH = hole.h * fh;
|
||||
const holeCenterX = holeX + holeW / 2;
|
||||
const holeCenterY = holeY + holeH / 2;
|
||||
|
||||
// Base cover scale to fill the transparent hole
|
||||
const baseScale = Math.max(holeW / pw, holeH / ph);
|
||||
const zoom = options.customFrame.scale ?? 1.0;
|
||||
const finalScale = baseScale * zoom;
|
||||
|
||||
const drawW = pw * finalScale;
|
||||
const drawH = ph * finalScale;
|
||||
|
||||
const panX = options.customFrame.panX ?? 0;
|
||||
const panY = options.customFrame.panY ?? 0;
|
||||
|
||||
const drawX = holeCenterX + panX - drawW / 2;
|
||||
const drawY = holeCenterY + panY - drawH / 2;
|
||||
|
||||
// 1. Draw photo underneath
|
||||
cardCanvas.drawImageRectOptions(
|
||||
photoImg,
|
||||
Skia.XYWHRect(0, 0, pw, ph),
|
||||
Skia.XYWHRect(drawX, drawY, drawW, drawH),
|
||||
Skia.FilterMode.Linear,
|
||||
Skia.MipmapMode.None,
|
||||
plainPaint
|
||||
);
|
||||
|
||||
// 2. Draw custom frame on top so transparent area displays the photo
|
||||
cardCanvas.drawImageRectOptions(
|
||||
customImg,
|
||||
Skia.XYWHRect(0, 0, fw, fh),
|
||||
Skia.XYWHRect(0, 0, fw, fh),
|
||||
Skia.FilterMode.Linear,
|
||||
Skia.MipmapMode.None,
|
||||
plainPaint
|
||||
);
|
||||
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = fw;
|
||||
stampH = fh;
|
||||
} else {
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// HEIC → JPEG on the client, the way rawDevelop.ts does it for a camera RAW: the
|
||||
// file is opened by the studio as the picture it holds, so a phone's own file
|
||||
// needs no converter in the middle either.
|
||||
//
|
||||
// Unlike a RAW there is nothing to fit: a HEIC is already a rendered 8-bit sRGB
|
||||
// image that the phone made, not sensor data with the camera's picture style to
|
||||
// be matched, so this decode is a decode — no curve, no matrix, no white
|
||||
// balance, and nothing here touches the RAW develop's colour.
|
||||
//
|
||||
// The reading itself is the worker's (see heicWorker.ts): this file is the queue
|
||||
// in front of it, one request at a time, so a folder being read does not start
|
||||
// four wasm decoders.
|
||||
import type { HeicAsk, HeicReply } from './heicWorker';
|
||||
|
||||
// What a picker is told to offer beside the images it knows: iOS file names are
|
||||
// the only sign of the format, and the MIME type for one is not registered
|
||||
// everywhere a file dialog reads.
|
||||
export const HEIC_ACCEPT = '.heic,.heif';
|
||||
|
||||
export function isHeicName(name: string): boolean {
|
||||
return /\.(heic|heif)$/i.test(name);
|
||||
}
|
||||
|
||||
// The answer a request that never got one resolves to: no JPEG, no error worth
|
||||
// printing — the caller has the same nothing in hand as it has for a frame whose
|
||||
// own preview will not decode.
|
||||
const NOTHING: HeicReply = { id: 0 };
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let seq = 0;
|
||||
const waiting = new Map<number, (reply: HeicReply) => void>();
|
||||
|
||||
function answer(reply: HeicReply): void {
|
||||
const done = waiting.get(reply.id);
|
||||
waiting.delete(reply.id);
|
||||
done?.(reply);
|
||||
}
|
||||
|
||||
// The one worker, made when the first HEIC is asked for and kept for the visit.
|
||||
// A worker that dies takes its pending requests with it rather than leaving a
|
||||
// reader on a promise that will never answer, and the next file gets a new one.
|
||||
function lane(): Worker | null {
|
||||
if (worker) return worker;
|
||||
try {
|
||||
const made = new Worker(new URL('./heicWorker.ts', import.meta.url), { type: 'module' });
|
||||
made.onmessage = (event: MessageEvent<HeicReply>) => answer(event.data);
|
||||
made.onerror = () => {
|
||||
for (const id of [...waiting.keys()]) answer({ ...NOTHING, id });
|
||||
made.terminate();
|
||||
if (worker === made) worker = null;
|
||||
};
|
||||
worker = made;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
function ask(bytes: Uint8Array, thumb: boolean, max: number): Promise<HeicReply> {
|
||||
const target = lane();
|
||||
if (!target) return Promise.resolve(NOTHING);
|
||||
const id = ++seq;
|
||||
return new Promise((resolve) => {
|
||||
waiting.set(id, resolve);
|
||||
// A copy, because the bytes are read again by whoever asked: the catalogue
|
||||
// reads the frame's own date and position off the same slice it just asked
|
||||
// for a tile from.
|
||||
const ask: HeicAsk = { id, bytes: bytes.slice(), thumb, max };
|
||||
target.postMessage(ask, [ask.bytes.buffer]);
|
||||
});
|
||||
}
|
||||
|
||||
// The picture a frame is, as a JPEG — what the studio opens and what a develop
|
||||
// hands it for a RAW. `max` caps the long edge; 0 keeps the file's own size.
|
||||
export async function heicToJpeg(bytes: Uint8Array, max = 0): Promise<Uint8Array> {
|
||||
const reply = await ask(bytes, false, max);
|
||||
if (!reply.jpeg) throw new Error(reply.error ?? 'heic');
|
||||
return reply.jpeg;
|
||||
}
|
||||
|
||||
// The phone's own small copy of the frame, for the catalogue's tiles: the item it
|
||||
// keeps for exactly this, decoded in milliseconds where the frame is a second.
|
||||
// Null when the file carries no such item, which is a frame with no tile rather
|
||||
// than a frame that failed.
|
||||
export async function heicThumbnail(bytes: Uint8Array): Promise<Uint8Array | null> {
|
||||
const reply = await ask(bytes, true, 0);
|
||||
return reply.jpeg ?? null;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// The HEIC decode, off the main thread.
|
||||
//
|
||||
// This is the one reading of a phone's file the app has to do in software: no
|
||||
// browser engine on this platform decodes HEVC stills for `createImageBitmap`
|
||||
// (Chromium answers `InvalidStateError`, and its WebCodecs will not take an hvc1
|
||||
// configuration either), and a HEIC carries no JPEG preview to lift the way a
|
||||
// camera RAW does — the frame inside it is one HEVC image, in grid tiles. So the
|
||||
// file is decoded here, by libheif compiled to wasm, and what leaves this worker
|
||||
// is a JPEG — the same thing `developRaw` and `rawThumbnail` hand their callers.
|
||||
// Nothing downstream of this file knows which of the two it has.
|
||||
//
|
||||
// It runs in a worker for the reason the RAW develop runs in its own: this is a
|
||||
// second of wasm for a 12MP frame (measured 1065ms on an iPhone 8 Plus file,
|
||||
// 4032x3024), and a tab that stops painting for a second while it opens a folder
|
||||
// is not an app. One worker is also one decoder — libheif is single-threaded and
|
||||
// this heap is not shared — so the requests queue here instead of racing.
|
||||
//
|
||||
// A tile does not pay that second: a phone writes a small HEVC thumbnail of the
|
||||
// frame as an item of its own (`thmb` on an iPhone), 320x240 on that same file,
|
||||
// and it decodes in 11ms. It is not a top-level image, so libheif's own reader
|
||||
// never returns it and the container is walked by hand below. A file with no
|
||||
// such item gets no tile — the row still lists the frame by name, which is what
|
||||
// a DNG with no preview gets too.
|
||||
import createHeif, { type HeifImage, type HeifImageHandle, type HeifModule } from 'libheif-js/libheif-wasm/libheif-bundle.mjs';
|
||||
|
||||
// The develop's JPEG: the studio works on what a RAW develop gives it, so this
|
||||
// is full size and encoded to be worked on rather than to be looked at.
|
||||
const QUALITY = 0.92;
|
||||
|
||||
export interface HeicAsk {
|
||||
id: number;
|
||||
// The file — whole for a develop, the head of it for a tile: the thumbnail is
|
||||
// in the first kilobytes (measured: 64KB is enough on that same frame, so the
|
||||
// 256KB the catalogue already reads for a header covers it four times over).
|
||||
bytes: Uint8Array;
|
||||
// The file's own thumbnail item when set, the frame itself otherwise.
|
||||
thumb: boolean;
|
||||
// The long edge to encode at; 0 leaves the decoded size alone.
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface HeicReply {
|
||||
id: number;
|
||||
jpeg?: Uint8Array;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// One module per worker, made once and kept: it carries a wasm heap, and the
|
||||
// second HEIC of a roll must not pay for the first one's instantiation.
|
||||
let loading: Promise<HeifModule> | null = null;
|
||||
const heif = (): Promise<HeifModule> => (loading ??= createHeif());
|
||||
|
||||
interface Rgba {
|
||||
data: Uint8ClampedArray;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// libheif's own display call, wrapped: it answers on a later turn of the event
|
||||
// loop, so the promise is what makes the buffer readable, and the handle has to
|
||||
// outlive the call that reads it — every caller here frees the container after
|
||||
// this has resolved, never before.
|
||||
function pixels(image: HeifImage): Promise<Rgba | null> {
|
||||
const width = image.get_width();
|
||||
const height = image.get_height();
|
||||
if (!width || !height) return Promise.resolve(null);
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
return new Promise((resolve) =>
|
||||
image.display({ data, width, height }, (decoded) => {
|
||||
image.free();
|
||||
resolve(decoded ? { data, width, height } : null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Whether a call came back with an error. The status these calls carry is
|
||||
// emscripten's enum object, not a number — a successful one is `{value: 0}`,
|
||||
// which is truthy — so the value inside it is what says yes or no. A handle that
|
||||
// came back well has no `code` at all.
|
||||
function failed(result: { code?: { value: number } }): boolean {
|
||||
return !!result.code?.value;
|
||||
}
|
||||
|
||||
// The small HEVC image a phone keeps of its own frame. Its item id is reached
|
||||
// through the primary image's references, then the container is asked for a
|
||||
// handle to that id — the same high-level call the top-level reader uses, so the
|
||||
// colour conversion and the frame's own transforms come out of libheif either way.
|
||||
async function thumbnailItem(m: HeifModule, bytes: Uint8Array): Promise<Rgba | null> {
|
||||
const ctx = m.heif_context_alloc();
|
||||
try {
|
||||
if (failed(m.heif_context_read_from_memory(ctx, bytes))) return null;
|
||||
const primary = m.heif_context_get_primary_image_handle(ctx);
|
||||
if (failed(primary)) return null;
|
||||
|
||||
let id: number;
|
||||
try {
|
||||
const count = m._heif_image_handle_get_number_of_thumbnails(primary.$$.ptr);
|
||||
if (!count) return null;
|
||||
const list = m._malloc(4 * count);
|
||||
try {
|
||||
m._heif_image_handle_get_list_of_thumbnail_IDs(primary.$$.ptr, list, count);
|
||||
id = m.HEAPU32[list >> 2];
|
||||
} finally {
|
||||
m._free(list);
|
||||
}
|
||||
} finally {
|
||||
m.heif_image_handle_release(primary);
|
||||
}
|
||||
|
||||
const handle: HeifImageHandle = m.heif_js_context_get_image_handle(ctx, id);
|
||||
if (failed(handle)) return null;
|
||||
return await pixels(new m.HeifImage(handle));
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
m.heif_context_free(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// The frame itself, which is the primary image of the container.
|
||||
async function primaryImage(m: HeifModule, bytes: Uint8Array): Promise<Rgba | null> {
|
||||
const images = new m.HeifDecoder().decode(bytes);
|
||||
const image = images.find((i) => i.is_primary()) ?? images[0];
|
||||
return image ? pixels(image) : null;
|
||||
}
|
||||
|
||||
// The pixels as a JPEG, at most `max` on the long edge. The encode happens here
|
||||
// rather than on the page for the usual reason: a 12MP frame is a 48MB readback
|
||||
// and a canvas large enough to hold it, and neither belongs on the thread that
|
||||
// paints.
|
||||
async function encode(rgba: Rgba, max: number): Promise<Uint8Array> {
|
||||
const scale = max > 0 ? Math.min(1, max / Math.max(rgba.width, rgba.height)) : 1;
|
||||
const width = Math.max(1, Math.round(rgba.width * scale));
|
||||
const height = Math.max(1, Math.round(rgba.height * scale));
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('no 2d context');
|
||||
|
||||
const imgData = new ImageData(rgba.data as Uint8ClampedArray<ArrayBuffer>, rgba.width, rgba.height);
|
||||
|
||||
try {
|
||||
if (scale === 1) {
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
} else {
|
||||
const bitmap = await createImageBitmap(imgData);
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
}
|
||||
} catch {
|
||||
const fullCanvas = new OffscreenCanvas(rgba.width, rgba.height);
|
||||
const fullCtx = fullCanvas.getContext('2d');
|
||||
if (fullCtx) {
|
||||
fullCtx.putImageData(imgData, 0, 0);
|
||||
ctx.drawImage(fullCanvas, 0, 0, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: QUALITY });
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
|
||||
// The worker's own surface. `self` is typed as the page's window by the DOM lib
|
||||
// the rest of the app compiles against, so the two calls this file makes are
|
||||
// named here instead of pulling a second, conflicting lib in.
|
||||
const scope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<HeicAsk>) => void) | null;
|
||||
postMessage: (message: HeicReply, transfer?: Transferable[]) => void;
|
||||
};
|
||||
|
||||
scope.onmessage = (event) => {
|
||||
const { id, bytes, thumb, max } = event.data;
|
||||
void (async (): Promise<Rgba | null> => {
|
||||
const m = await heif();
|
||||
return thumb ? thumbnailItem(m, bytes) : primaryImage(m, bytes);
|
||||
})()
|
||||
.then(async (rgba) => {
|
||||
if (!rgba) throw new Error('no image');
|
||||
const jpeg = await encode(rgba, max);
|
||||
scope.postMessage({ id, jpeg }, [jpeg.buffer]);
|
||||
})
|
||||
.catch((err: unknown) => scope.postMessage({ id, error: err instanceof Error ? err.message : String(err) }));
|
||||
};
|
||||
@@ -151,11 +151,31 @@ async function shrinkOrKeep(bytes: Uint8Array, maxDim: number, maxBytes: number)
|
||||
// hands it back as a Date in ours — which is what a camera prints on a stamp, and
|
||||
// what someone opening a 2019 photo today expects to read under the coordinates.
|
||||
// Null when the file carries no date at all; the caller falls back to its clock.
|
||||
// `exifr` keeps a private cache of the parsed options it is handed, keyed by the
|
||||
// options object itself, and every entry in it lives as long as the page. A
|
||||
// fresh literal per frame is therefore a frame's worth of tag sets held forever:
|
||||
// reading a roll of a hundred thousand frames is what fills the tab. So the
|
||||
// options are written once here and the same object is handed over every time —
|
||||
// which is also what the cache is for.
|
||||
const DATE_OPTIONS = { pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'] };
|
||||
const SPEC_OPTIONS = {
|
||||
pick: [
|
||||
'Make',
|
||||
'Model',
|
||||
'ISO',
|
||||
'ISOSpeedRatings',
|
||||
'FocalLength',
|
||||
'FNumber',
|
||||
'ExposureTime',
|
||||
'ExifImageWidth',
|
||||
'ExifImageHeight',
|
||||
],
|
||||
};
|
||||
const ISO_OPTIONS = { pick: ['ISO', 'ISOSpeedRatings'] };
|
||||
|
||||
export async function readCapturedAt(bytes: Uint8Array): Promise<number | null> {
|
||||
try {
|
||||
const tags = (await exifr.parse(bytes, {
|
||||
pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'],
|
||||
})) as Record<string, unknown> | undefined;
|
||||
const tags = (await exifr.parse(bytes, DATE_OPTIONS)) as Record<string, unknown> | undefined;
|
||||
for (const tag of ['DateTimeOriginal', 'CreateDate', 'ModifyDate']) {
|
||||
const value = tags?.[tag];
|
||||
if (value instanceof Date && Number.isFinite(value.getTime())) return value.getTime();
|
||||
@@ -166,6 +186,78 @@ export async function readCapturedAt(bytes: Uint8Array): Promise<number | null>
|
||||
return null;
|
||||
}
|
||||
|
||||
// What the camera recorded about the shutter, the glass and the frame itself —
|
||||
// the numbers a photographer reads first. All of them sit in the EXIF segment at
|
||||
// the front of the file, so this costs the same few hundred kilobytes the date
|
||||
// does. Each one is null where the file does not carry it, which is what a
|
||||
// screenshot, a scan or a Fuji RAF answers: `exifr` reads the formats it knows
|
||||
// and nothing about a file it does not know is invented here.
|
||||
export interface ShotSpecs {
|
||||
camera: string | null;
|
||||
iso: number | null;
|
||||
// A focal length in millimetres, an f-number, and an exposure in seconds.
|
||||
focal: number | null;
|
||||
aperture: number | null;
|
||||
exposure: number | null;
|
||||
// The frame's own size, which only a JPEG states outright. A RAW leaves it to
|
||||
// the decoder, and half a number is worse than none.
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export async function readSpecs(bytes: Uint8Array): Promise<ShotSpecs | null> {
|
||||
try {
|
||||
const tags = (await exifr.parse(bytes, SPEC_OPTIONS)) as Record<string, unknown> | undefined;
|
||||
if (!tags) return null;
|
||||
const num = (v: unknown): number | null => {
|
||||
const n = Number(Array.isArray(v) ? v[0] : v);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
const make = tags.Make ? String(tags.Make).trim() : '';
|
||||
const model = tags.Model ? String(tags.Model).trim() : '';
|
||||
let camera: string | null = null;
|
||||
if (model) {
|
||||
camera = make && !model.toLowerCase().startsWith(make.toLowerCase()) ? `${make} ${model}` : model;
|
||||
} else if (make) {
|
||||
camera = make;
|
||||
}
|
||||
return {
|
||||
camera,
|
||||
iso: num(tags.ISO ?? tags.ISOSpeedRatings),
|
||||
focal: num(tags.FocalLength),
|
||||
aperture: num(tags.FNumber),
|
||||
exposure: num(tags.ExposureTime),
|
||||
// Null together: a frame with one edge and not the other has no size.
|
||||
width: tags.ExifImageWidth && tags.ExifImageHeight ? num(tags.ExifImageWidth) : null,
|
||||
height: tags.ExifImageWidth && tags.ExifImageHeight ? num(tags.ExifImageHeight) : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The rounded number as it is printed: one decimal, and none where there is
|
||||
// none — `f/2.8` rather than `f/2.8000000000000003`.
|
||||
const round = (n: number) => String(Math.round(n * 10) / 10);
|
||||
|
||||
// Those numbers as the line under a frame. A shutter faster than a second is the
|
||||
// fraction a camera prints (`1/320s`), and everything the file does not have is
|
||||
// left out rather than stood in for — an empty line is what a file with no EXIF
|
||||
// gets, and the caller prints nothing.
|
||||
export function specsLine(specs: ShotSpecs | null, place?: string | null, dims?: { w: number; h: number } | null): string {
|
||||
if (!specs && !place && !dims) return '';
|
||||
const parts = [
|
||||
specs?.camera ? specs.camera : '',
|
||||
specs?.aperture ? `f/${round(specs.aperture)}` : '',
|
||||
specs?.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '',
|
||||
specs?.iso ? `ISO ${Math.round(specs.iso)}` : '',
|
||||
specs?.focal ? `${round(specs.focal)}mm` : '',
|
||||
dims ? `${dims.w} × ${dims.h}` : (specs?.width && specs?.height ? `${specs.width} × ${specs.height}` : ''),
|
||||
place ? place : '',
|
||||
];
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect.
|
||||
// Returns null when the photo has none — the caller then offers manual entry.
|
||||
export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
|
||||
@@ -189,7 +281,7 @@ export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
|
||||
// the caller then prints "AUTO" instead of inventing a number.
|
||||
export async function readIso(bytes: Uint8Array): Promise<number | null> {
|
||||
try {
|
||||
const tags = (await exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })) as
|
||||
const tags = (await exifr.parse(bytes, ISO_OPTIONS)) as
|
||||
| { ISO?: unknown; ISOSpeedRatings?: unknown }
|
||||
| undefined;
|
||||
const raw = tags?.ISO ?? tags?.ISOSpeedRatings;
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
// Immich, as a second photo source beside the folders on disk — the page's half.
|
||||
//
|
||||
// Nothing here talks to Immich: the key never reaches the browser, and the
|
||||
// address never leaves the backend, so every call below is one of the routes in
|
||||
// `backend/src/immich.ts` on this page's own origin. What this file does is turn
|
||||
// a server's answer into the two shapes the LIBRARY already knows — a row in the
|
||||
// `photos` store and a name in the column — so the tree, the wall and the strip
|
||||
// draw a server's frames with the code they already had for a folder's.
|
||||
import {
|
||||
breathe,
|
||||
missingThumbs,
|
||||
normPath,
|
||||
prunePhotos,
|
||||
putFolder,
|
||||
putPhotoThumb,
|
||||
putPhotos,
|
||||
putPhotosFast,
|
||||
remoteUrl,
|
||||
saveWalkedDirs,
|
||||
type LibraryDir,
|
||||
type LibraryFolder,
|
||||
type LibraryPhoto,
|
||||
} from './library';
|
||||
|
||||
// Immich answers 200 items at most for one page of a search (the backend caps it
|
||||
// there), so this is the size the sync loops at: one round trip per two hundred
|
||||
// frames rather than one per frame.
|
||||
const PAGE = 200;
|
||||
// How many times one page of a server's frames is asked for again before the
|
||||
// reading gives up on it. A page in flight is a call to a box the reader's own
|
||||
// network may be between, and the second answer is usually the first answer.
|
||||
const ASSET_RETRIES = 2;
|
||||
|
||||
// The nodes one reading is walking through. The screen asks for a sync when it
|
||||
// opens and whenever the node it shows changes, and two readings of one node at
|
||||
// the same time are two readings asking the server for the same pages and writing
|
||||
// one place over the other: the second is turned away, and the first keeps the
|
||||
// place for both.
|
||||
const walking = new Set<string>();
|
||||
// Tiles are drawn after the rows have landed rather than before: the column and
|
||||
// the count are what the reader sees first, and a tile is the wall's own business.
|
||||
const TILE_LANES = 3;
|
||||
|
||||
export interface ImmichAlbum {
|
||||
id: string;
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
coverId: string | null;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
export interface ImmichAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
takenAt: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface ImmichServer {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'api' | 'share';
|
||||
version: string | null;
|
||||
canDownload: boolean | null;
|
||||
albums: string[];
|
||||
keyMasked: string;
|
||||
}
|
||||
|
||||
// The three refusals the dialog can say something about, plus the one it can only
|
||||
// say as a shape: the key is wrong or too narrow, the box cannot be reached, it
|
||||
// answered with something unusable, or what was typed is not an address and a key.
|
||||
export type ImmichError = 'auth' | 'unreachable' | 'server' | 'invalid';
|
||||
|
||||
export type ImmichProbe =
|
||||
| { ok: true; version: string | null; albums: ImmichAlbum[]; canDownload: boolean | null }
|
||||
| { ok: false; error: ImmichError };
|
||||
|
||||
export type ImmichSaved = { ok: true; server: ImmichServer } | { ok: false; error: ImmichError };
|
||||
|
||||
// One place reads a JSON answer, so a route that answered with a page of HTML —
|
||||
// a proxy in the way, a box that is not Immich — is a refusal rather than a crash
|
||||
// somewhere further in.
|
||||
async function json<T>(res: Response): Promise<T | null> {
|
||||
try {
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
return await json<T>(await fetch(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The saved servers. One call per session: the tree has to draw on the first
|
||||
// paint, and a server list that is asked for on every render is a round trip per
|
||||
// render.
|
||||
let configOnce: Promise<{ servers: ImmichServer[] }> | null = null;
|
||||
|
||||
export function immichConfig(fresh = false): Promise<{ servers: ImmichServer[] }> {
|
||||
if (fresh || !configOnce) {
|
||||
configOnce = get<{ servers?: ImmichServer[] }>('/api/immich/config').then((body) => ({
|
||||
servers: body?.servers ?? [],
|
||||
}));
|
||||
}
|
||||
return configOnce;
|
||||
}
|
||||
|
||||
export function forgetImmichConfig(): void {
|
||||
configOnce = null;
|
||||
}
|
||||
|
||||
// "Check this key" — nothing is stored, so a typo never becomes a row.
|
||||
export async function probeImmich(url: string, key: string, type: 'api' | 'share'): Promise<ImmichProbe> {
|
||||
try {
|
||||
const res = await fetch('/api/immich/probe', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ url, key, type }),
|
||||
});
|
||||
const body = await json<ImmichProbe>(res);
|
||||
return body ?? { ok: false, error: 'server' };
|
||||
} catch {
|
||||
return { ok: false, error: 'unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
// Add a server, or change one. The backend probes before it stores, so a server
|
||||
// that comes back saved is one that answered — `albums` is the ticked set, sent
|
||||
// on the same call that saves the key so a fresh server is never briefly "all".
|
||||
export async function saveImmichServer(input: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
key?: string;
|
||||
type?: 'api' | 'share';
|
||||
albums?: string[];
|
||||
}): Promise<ImmichSaved> {
|
||||
try {
|
||||
const res = await fetch('/api/immich/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await json<ImmichSaved>(res);
|
||||
if (body?.ok) forgetImmichConfig();
|
||||
return body ?? { ok: false, error: 'server' };
|
||||
} catch {
|
||||
return { ok: false, error: 'unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function forgetImmichServer(id: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/immich/config?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (res.ok) forgetImmichConfig();
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload an edited photo to Immich Cloud
|
||||
export async function uploadToImmich(
|
||||
bytes: Uint8Array,
|
||||
name: string,
|
||||
serverId?: string,
|
||||
albumId?: string
|
||||
): Promise<{ ok: boolean; id?: string; error?: string }> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (serverId) params.set('server', serverId);
|
||||
if (name) params.set('name', name);
|
||||
if (albumId) params.set('album', albumId);
|
||||
|
||||
const res = await fetch(`/api/immich/upload?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'image/jpeg',
|
||||
},
|
||||
body: bytes as BodyInit,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: (err as any)?.error || (err as any)?.details || `HTTP ${res.status}` };
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: true, id: (data as any)?.id };
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// The albums this key can see, asked for live: an album added on Immich has to be
|
||||
// there the next time the dialog is opened, so nothing here is cached.
|
||||
export async function listImmichAlbums(server: string): Promise<ImmichAlbum[]> {
|
||||
const body = await get<{ ok?: boolean; albums?: ImmichAlbum[] }>(
|
||||
`/api/immich/albums?id=${encodeURIComponent(server)}`
|
||||
);
|
||||
return body?.albums ?? [];
|
||||
}
|
||||
|
||||
export async function saveImmichAlbums(server: string, selected: string[]): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch('/api/immich/albums', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: server, selected }),
|
||||
});
|
||||
const body = await json<{ albums?: string[] }>(res);
|
||||
return body?.albums ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// One page of a server's frames. `album` is an Immich id or null for everything
|
||||
// the key can see — the album field's empty state.
|
||||
export async function listAssets(
|
||||
server: string,
|
||||
album: string | null,
|
||||
page: number,
|
||||
search = '',
|
||||
takenBefore?: string | null,
|
||||
takenAfter?: string | null
|
||||
): Promise<{ items: ImmichAsset[]; hasMore: boolean; ok: boolean }> {
|
||||
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
|
||||
if (album) query.set('album', album);
|
||||
if (search) query.set('q', search);
|
||||
if (takenBefore) query.set('takenBefore', takenBefore);
|
||||
if (takenAfter) query.set('takenAfter', takenAfter);
|
||||
// A page that could not be read is not a page with nothing on it. Both are one
|
||||
// shape to a loop that counts items, and the loop that takes the one for the
|
||||
// other reads the end of the roll at the first timeout and drops every row it
|
||||
// had not met yet. `ok` is the word that says which of the two this was, and an
|
||||
// unanswered page is asked for again here rather than in every caller.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/api/immich/assets?${query.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await json<{ items?: ImmichAsset[]; hasMore?: boolean }>(res);
|
||||
if (!body) throw new Error('unreadable answer');
|
||||
return { items: body.items ?? [], hasMore: body.hasMore === true, ok: true };
|
||||
} catch {
|
||||
if (attempt >= ASSET_RETRIES) return { items: [], hasMore: false, ok: false };
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch timeline buckets (months/years) for an Immich server in one fast call
|
||||
export async function fetchImmichBuckets(
|
||||
server: string,
|
||||
size: 'YEAR' | 'MONTH' = 'MONTH'
|
||||
): Promise<{ timeBucket: string; count: number }[]> {
|
||||
const query = new URLSearchParams({ server, size });
|
||||
const body = await get<{ buckets?: { timeBucket: string; count: number }[] }>(
|
||||
`/api/immich/buckets?${query.toString()}`
|
||||
);
|
||||
return Array.isArray(body?.buckets) ? body.buckets : [];
|
||||
}
|
||||
|
||||
// What the tree paints, and the name an album's frames are filed under. An album
|
||||
// with no name — a key too narrow to read one, an album the reader never titled —
|
||||
// is known by the first of its id, which is what the plan asks for.
|
||||
function albumLabels(albums: ImmichAlbum[]): Map<string, string> {
|
||||
const taken = new Set<string>();
|
||||
const out = new Map<string, string>();
|
||||
for (const album of albums) {
|
||||
let label = album.albumName.trim() || album.id.slice(0, 8);
|
||||
// Two albums may carry one name — Immich allows it — and a column is drawn
|
||||
// off the path, so a second of that name would be a row the first one already
|
||||
// is. The uuid is what tells them apart, and eight of it is enough to read.
|
||||
if (taken.has(label)) label = `${label} ~${album.id.slice(0, 8)}`;
|
||||
taken.add(label);
|
||||
out.set(album.id, label);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The `~` suffix a frame takes when an album holds two files of one name: the id
|
||||
// is the name, so one of them has to move rather than one of them disappear.
|
||||
function frameName(name: string, id: string, taken: Set<string>): string {
|
||||
if (!taken.has(name)) return name;
|
||||
return `${name} ~${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export interface ImmichSyncResult {
|
||||
total: number;
|
||||
albums: number;
|
||||
// False while the node is still being read — the answer above comes back at the
|
||||
// foreground window, and the rest of it is read behind it. The screen says so
|
||||
// rather than a number that would have the reader believe the node holds six
|
||||
// hundred frames.
|
||||
done: boolean;
|
||||
// The album rows this read named, spelled the way the column spells a path. The
|
||||
// screen puts these in the place of the node's old ones: an album the reader
|
||||
// unticked is a row that has to go, and a record that is only ever added to
|
||||
// would keep drawing it.
|
||||
dirs: LibraryDir[];
|
||||
}
|
||||
|
||||
export type ImmichSyncCallback = (batch: LibraryPhoto[], progress: { loaded: number; hasMore: boolean }) => void;
|
||||
|
||||
// Read a server's albums into the catalogue: the rows and the column first, so
|
||||
// the count and the tree are right the moment this returns, and the tiles after,
|
||||
// a lane at a time, so a reader who opens the node while it is still fetching
|
||||
// sees frames rather than a blank wall.
|
||||
//
|
||||
// Streams batches to IndexedDB and UI immediately (in < 200ms per batch).
|
||||
// Resolves the foreground pass in 3 pages (up to 600 newest photos) so the UI
|
||||
// unblocks instantly, and continues syncing historical photos in the background.
|
||||
export async function syncImmich(
|
||||
folder: LibraryFolder,
|
||||
onBatch?: ImmichSyncCallback
|
||||
): Promise<ImmichSyncResult> {
|
||||
const server = folder.server ?? '';
|
||||
const search = folder.kind === 'search' ? (folder.query ?? '').trim() : '';
|
||||
const chosen = folder.albums ?? [];
|
||||
const root = normPath(folder.name);
|
||||
|
||||
const rows: LibraryPhoto[] = [];
|
||||
const dirs: LibraryDir[] = [];
|
||||
const counts: Record<string, number> = {};
|
||||
// The frames this reading has already filed. The keyset read stops on a frame's
|
||||
// own time and `takenBefore` is inclusive, so every page opens on the frame the
|
||||
// page before it ended with: filed as it came, that frame is a row the wall
|
||||
// draws twice. `frameName` answers the name a second *file* takes; the id is
|
||||
// what says there is no second file.
|
||||
const seen = new Set<string>();
|
||||
|
||||
// 1. ALWAYS read ALL photos of the Immich API (filed under root of folder: dir = '')
|
||||
// Keyset pagination with takenBefore prevents slow SQL offsets and timeouts!
|
||||
const rootTaken = new Set<string>();
|
||||
// Where the reading before this one stopped, when it did not reach the end. A
|
||||
// node of a hundred thousand frames is not one visit's to read, and a place only
|
||||
// ever held in memory is a place every reload began without: the whole of what
|
||||
// the catalogue was given was then the newest three pages of it, once a visit,
|
||||
// for good. The node's own record keeps the place instead — the frame it stopped
|
||||
// on, and the page within that frame's own instant.
|
||||
const resume = folder.cursor && !folder.cursor.done ? folder.cursor : null;
|
||||
let lastTakenBefore: string | null = resume?.before ?? null;
|
||||
let page = resume?.page ?? 1;
|
||||
let pages = 0;
|
||||
let rootCount = 0;
|
||||
let complete = false;
|
||||
let failed = false;
|
||||
const FOREGROUND_PAGES = 3; // The newest three pages answer the call so the screen unblocks
|
||||
let hasMoreRemaining = false;
|
||||
|
||||
// The place, written down where the visit after this one reads it. Every page
|
||||
// rather than every minute: a page is two hundred frames, and a visit may end
|
||||
// at any of them.
|
||||
const remember = async (before: string | null, at: number, done: boolean) => {
|
||||
try {
|
||||
await putFolder({ ...folder, cursor: { before, page: at, done } });
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Where a page leaves the reading. The bound is a frame's own instant and the
|
||||
// call that asks for it keeps the frame it stopped on — `takenBefore` is
|
||||
// inclusive — so a page whose oldest frame is older than the bound has moved it,
|
||||
// and the page after it starts at that very frame. A page that is one instant
|
||||
// from end to end cannot move it at all: Immich keeps a frame's time to the
|
||||
// millisecond, and a roll imported in one go is hundreds of frames at the same
|
||||
// one, which is the whole of the oldest days of this library. Asked for with the
|
||||
// bound alone, that page is the same page for ever — the reading never ends, and
|
||||
// the frames under that instant are never read. So the rest of the instant is
|
||||
// walked by offset, and the bound is left where it is until the pages under it
|
||||
// run out.
|
||||
// ponytail: that offset walk is over everything older than the bound, and a deep
|
||||
// offset is a slow answer — an Immich that offers a cursor for it, or a page
|
||||
// asked per instant out of the timeline buckets, is the way out of it if a roll
|
||||
// of thousands at one instant ever sits in the middle of a library.
|
||||
const step = (items: ImmichAsset[]): void => {
|
||||
const at = items[items.length - 1]?.takenAt ?? null;
|
||||
if (page === 1 && at && at !== lastTakenBefore) lastTakenBefore = at;
|
||||
else page++;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { items, hasMore, ok } = await listAssets(server, null, page, search, lastTakenBefore);
|
||||
// A page that could not be read is not the end of the node, and only a
|
||||
// reading that met the end of it may say what the node no longer holds.
|
||||
if (!ok) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
const batchRows: LibraryPhoto[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.id || !item.name || seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
const name = frameName(item.name, item.id, rootTaken);
|
||||
rootTaken.add(item.name);
|
||||
rootTaken.add(name);
|
||||
const row: LibraryPhoto = {
|
||||
id: [root, name].join('/'),
|
||||
folder: folder.name,
|
||||
dir: '',
|
||||
name,
|
||||
server,
|
||||
remoteId: item.id,
|
||||
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
|
||||
size: item.size ?? 0,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
rows.push(row);
|
||||
batchRows.push(row);
|
||||
rootCount++;
|
||||
}
|
||||
|
||||
// Save batch to IndexedDB and push to UI immediately
|
||||
if (batchRows.length) {
|
||||
await putPhotosFast(batchRows);
|
||||
onBatch?.(batchRows, { loaded: rootCount, hasMore });
|
||||
}
|
||||
|
||||
pages++;
|
||||
if (!hasMore || !items.length) {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
step(items);
|
||||
await remember(lastTakenBefore, page, false);
|
||||
|
||||
// After foreground window, resolve foreground sync promptly so UI is responsive
|
||||
if (pages >= FOREGROUND_PAGES) {
|
||||
hasMoreRemaining = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
counts[root] = rootCount;
|
||||
|
||||
// 2. ALBUMS of Immich ONLY display in the ALBUM section if the user chooses specific albums (chosen.length > 0).
|
||||
if (!search && chosen.length > 0) {
|
||||
const albums = await listImmichAlbums(server);
|
||||
const labels = albumLabels(albums);
|
||||
const chosenAlbums = albums.filter((a) => chosen.includes(a.id));
|
||||
|
||||
for (const album of chosenAlbums) {
|
||||
const label = labels.get(album.id) ?? album.id.slice(0, 8);
|
||||
const albumTaken = new Set<string>();
|
||||
let albPage = 1;
|
||||
let albCount = 0;
|
||||
for (;;) {
|
||||
const { items, hasMore, ok } = await listAssets(server, album.id, albPage, search);
|
||||
// An album that could not be read is one this reading has no word on: the
|
||||
// rows it holds are left where they are, and nothing below is pruned
|
||||
// against a reading that did not meet them.
|
||||
if (!ok) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
const albBatch: LibraryPhoto[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.id || !item.name) continue;
|
||||
const name = frameName(item.name, item.id, albumTaken);
|
||||
albumTaken.add(item.name);
|
||||
albumTaken.add(name);
|
||||
const row: LibraryPhoto = {
|
||||
id: [root, label, name].join('/'),
|
||||
folder: folder.name,
|
||||
dir: label,
|
||||
name,
|
||||
server,
|
||||
remoteId: item.id,
|
||||
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
|
||||
size: item.size ?? 0,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
rows.push(row);
|
||||
albBatch.push(row);
|
||||
albCount++;
|
||||
}
|
||||
if (albBatch.length) {
|
||||
await putPhotosFast(albBatch);
|
||||
onBatch?.(albBatch, { loaded: rootCount + albCount, hasMore });
|
||||
}
|
||||
if (!hasMore || !items.length) break;
|
||||
albPage++;
|
||||
}
|
||||
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
|
||||
counts[`${root}/${label}`] = albCount;
|
||||
}
|
||||
}
|
||||
|
||||
// The record carries the reading's whole word or none of it: only a reading
|
||||
// that met the end of the node may say what the node holds, because a record
|
||||
// that says otherwise is the record the column reads its noughts off. A reading
|
||||
// stopped at the foreground window, or cut short by a page that would not come
|
||||
// back, folds its half into what was already there instead.
|
||||
await saveWalkedDirs(root, dirs, counts, complete && !failed);
|
||||
|
||||
// A row the reading did not meet is dropped only against a reading that met all
|
||||
// of them — and only one that began at the top. A reading that carried on from
|
||||
// where the last one stopped holds the newest pages in its own list and no older
|
||||
// row in it at all, and pruned to that list it would take back every frame it
|
||||
// was picking up.
|
||||
// ponytail: a resumed reading therefore stops one prune short — a frame dropped
|
||||
// on the server keeps its row until some visit reads the node from the top. Read
|
||||
// `met` out of the catalogue rather than out of `rows` if that ever shows.
|
||||
if (complete && !failed && !resume) {
|
||||
const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase()));
|
||||
await prunePhotos(root, '', met);
|
||||
}
|
||||
|
||||
// The rest of the node, a page at a time, behind the answer above. Its place is
|
||||
// the record's exactly as the foreground's is, so a visit that ends in the
|
||||
// middle of it — a reload, a closed tab, a lid shut — costs the page in flight
|
||||
// and nothing else.
|
||||
if ((hasMoreRemaining || failed) && !walking.has(root)) {
|
||||
walking.add(root);
|
||||
void (async () => {
|
||||
let bgPage = page;
|
||||
let bgLastTakenBefore = lastTakenBefore;
|
||||
let reachedEnd = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { items, hasMore: bgMore, ok } = await listAssets(
|
||||
server,
|
||||
null,
|
||||
bgPage,
|
||||
search,
|
||||
bgLastTakenBefore
|
||||
);
|
||||
// A page that could not be read leaves the place where the last page
|
||||
// that could left it: the visit after this one asks for it again.
|
||||
if (!ok) break;
|
||||
if (!items.length) {
|
||||
reachedEnd = true;
|
||||
break;
|
||||
}
|
||||
const bgBatchRows: LibraryPhoto[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.id || !item.name || seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
const name = frameName(item.name, item.id, rootTaken);
|
||||
rootTaken.add(item.name);
|
||||
rootTaken.add(name);
|
||||
bgBatchRows.push({
|
||||
id: [root, name].join('/'),
|
||||
folder: folder.name,
|
||||
dir: '',
|
||||
name,
|
||||
server,
|
||||
remoteId: item.id,
|
||||
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
|
||||
size: item.size ?? 0,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
rootCount++;
|
||||
}
|
||||
if (bgBatchRows.length) {
|
||||
await putPhotosFast(bgBatchRows);
|
||||
onBatch?.(bgBatchRows, { loaded: rootCount, hasMore: bgMore });
|
||||
}
|
||||
if (!bgMore) {
|
||||
reachedEnd = true;
|
||||
break;
|
||||
}
|
||||
const at = items[items.length - 1]?.takenAt ?? null;
|
||||
if (bgPage === 1 && at && at !== bgLastTakenBefore) bgLastTakenBefore = at;
|
||||
else bgPage++;
|
||||
await remember(bgLastTakenBefore, bgPage, false);
|
||||
await breathe();
|
||||
}
|
||||
counts[root] = rootCount;
|
||||
await saveWalkedDirs(root, dirs, counts, reachedEnd);
|
||||
if (reachedEnd) {
|
||||
// The node is read through: the place is not a place any more, and the
|
||||
// reading that begins next begins at the top — which is where a frame
|
||||
// added since would be.
|
||||
await remember(null, 1, true);
|
||||
if (!resume) {
|
||||
const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase()));
|
||||
await prunePhotos(root, '', met);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
walking.delete(root);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return { total: rootCount, albums: dirs.length, dirs, done: complete && !failed };
|
||||
}
|
||||
|
||||
// The name a server's node takes: the first one is `Immich`, and a second is
|
||||
// numbered, because the name is the store's key and two keys cannot be one.
|
||||
export function immichFolderName(taken: Iterable<string>): string {
|
||||
const used = new Set(taken);
|
||||
let n = 1;
|
||||
while (used.has(n === 1 ? 'Immich' : `Immich ${n}`)) n++;
|
||||
return n === 1 ? 'Immich' : `Immich ${n}`;
|
||||
}
|
||||
|
||||
// What the column paints for that node: the source and the name its owner gave it.
|
||||
export function immichFolderLabel(serverName: string): string {
|
||||
return `Immich · ${serverName}`;
|
||||
}
|
||||
|
||||
// Put a saved server in the column as a folder. The row is the page's own — a
|
||||
// name, the server it reads, and the albums it keeps — and `taken` is what it must
|
||||
// not collide with, because the name is the store's key and two keys cannot be
|
||||
// one. The name is `Immich`, `Immich 2`, …; the label is what the tree paints.
|
||||
// The name a search node takes. The query is in it because the name is what the
|
||||
// node is: a column of `Immich · …` rows would say nothing about which search is
|
||||
// which, and the name is the store's key, so the same words are already the same
|
||||
// node. Only a clash with something that is not this query's own node — a disk
|
||||
// folder someone called the same — moves it on to a number.
|
||||
export function immichSearchName(q: string, taken: Iterable<string>): string {
|
||||
const used = new Set(taken);
|
||||
const base = `Immich · ${q}`;
|
||||
let name = base;
|
||||
for (let n = 2; used.has(name); n++) name = `${base} ${n}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
// Put a search in the column as a node of its own. The same words are the same
|
||||
// node read again rather than one more row for every time they are typed: the
|
||||
// frames are keyed by it, and a node per attempt would be a column of dead ends.
|
||||
export async function searchImmichFolder(
|
||||
server: ImmichServer,
|
||||
query: string,
|
||||
current: LibraryFolder[]
|
||||
): Promise<LibraryFolder> {
|
||||
// Bounded the way the backend bounds it, so the name and the query are the same
|
||||
// words: a row whose name is longer than the query that made it is a row nobody
|
||||
// can match up again.
|
||||
const q = query.trim().slice(0, 100);
|
||||
const found = current.find((f) => f.kind === 'search' && f.server === server.id && f.query === q);
|
||||
if (found) return found;
|
||||
const folder: LibraryFolder = {
|
||||
name: immichSearchName(q, current.map((f) => f.name)),
|
||||
label: `Immich · ${server.name} · “${q}”`,
|
||||
source: 'immich',
|
||||
server: server.id,
|
||||
kind: 'search',
|
||||
query: q,
|
||||
};
|
||||
await putFolder(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
export async function addImmichFolder(server: ImmichServer, taken: Iterable<string>): Promise<LibraryFolder> {
|
||||
const folder: LibraryFolder = {
|
||||
name: immichFolderName(taken),
|
||||
label: immichFolderLabel(server.name),
|
||||
source: 'immich',
|
||||
server: server.id,
|
||||
albums: server.albums,
|
||||
kind: server.type === 'share' ? 'share' : 'album',
|
||||
};
|
||||
await putFolder(folder);
|
||||
return folder;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,420 @@
|
||||
// The catalogue's own copy, kept in a folder on the visitor's own disk. The
|
||||
// catalogue lives in the browser's storage, which is the one place a cleared
|
||||
// profile, a new machine or a reinstall takes away — and the frames themselves
|
||||
// are not in it, so what is lost is the reading: every tile, every star, every
|
||||
// recipe. This writes that reading out to a folder the visitor picks, and reads
|
||||
// it back into a catalogue that has nothing.
|
||||
//
|
||||
// The folder holds one file of rows — thumbnail, star, recipe, the frame's size
|
||||
// and write time, and no handle, which is not a thing that can be written down —
|
||||
// and the tiles beside it, one file per frame under `thumbs/<frame id>` — a
|
||||
// component the browser refuses to write spelled out as `%XX` (see `spell`) so
|
||||
// the tiles of such a folder land too — so the
|
||||
// folder mirrors the library exactly and a tile can be looked at, copied or
|
||||
// rsynced by hand. A frame that is being restored is matched to a frame on the
|
||||
// disk by that size and write time, so putting a catalogue back costs the tiles
|
||||
// and not one frame read.
|
||||
//
|
||||
// ponytail: no incremental tree walk, no deletes. A tile is written when the
|
||||
// frame is new or its tile changed, and one left behind by a frame that was
|
||||
// dropped from the library stays there — it is a few dozen kilobytes in a folder
|
||||
// the visitor owns. Add a sweep when a library that is edited down to a fraction
|
||||
// of its size makes the leftovers worth the walk.
|
||||
import type { LibraryEdit, LibraryFolder } from './library';
|
||||
import {
|
||||
ensurePermission,
|
||||
forgetWalkedDirs,
|
||||
getPhotoThumb,
|
||||
listEdits,
|
||||
listFolders,
|
||||
listPhotos,
|
||||
normPath,
|
||||
renameFolder,
|
||||
restoreCatalogue,
|
||||
setBackupTileFetcher,
|
||||
type CatalogueRow,
|
||||
} from './library';
|
||||
|
||||
const DB_NAME = 'recipescam-backup';
|
||||
// Two stores of its own, not the library's: the library opens without a version
|
||||
// on purpose (see `openDb` there), and a version it has to reach for is one a
|
||||
// second tab can block. This one is nothing but a handle and a list, so it can
|
||||
// have a version and be upgraded.
|
||||
const WHERE = 'where';
|
||||
const WRITTEN = 'written';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
const CATALOGUE = 'recipescam-catalogue.json';
|
||||
const THUMBS = 'thumbs';
|
||||
// The catalogue is written whole however little changed: it is one file, and a
|
||||
// row that was left behind in it is a frame that will not come back.
|
||||
const VERSION = 1;
|
||||
|
||||
export async function readBackupTile(id: string): Promise<Blob | null> {
|
||||
try {
|
||||
const root = await folderHandle();
|
||||
if (!root) return null;
|
||||
// A frame id is `folder/dir/name`; a Windows-written one can say `\`, so it is
|
||||
// spelled the same way here as everywhere else before it is cut.
|
||||
const parts = normPath(id).split('/');
|
||||
const name = parts.pop() as string;
|
||||
const dir = await at(root, [THUMBS, ...parts].join('/'));
|
||||
const file = await dir.getFileHandle(spell(name));
|
||||
return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically register as lazy backup tile fetcher for library.ts
|
||||
setBackupTileFetcher(readBackupTile);
|
||||
|
||||
interface Meta {
|
||||
id: 'meta';
|
||||
at: number;
|
||||
photos: number;
|
||||
wrote: number;
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
// The folder's own name, or null when the visitor has not chosen one.
|
||||
folder: string | null;
|
||||
// Whether the browser has let the page write to it. It comes back `prompt`
|
||||
// after a restart — a permission outlives the tab only while the tab does, and
|
||||
// Chromium will not hand it back without a click — which is why the screen
|
||||
// says so rather than failing silently.
|
||||
granted: boolean;
|
||||
at: number;
|
||||
photos: number;
|
||||
wrote: number;
|
||||
}
|
||||
|
||||
export interface BackupProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(WHERE)) db.createObjectStore(WHERE, { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(WRITTEN)) db.createObjectStore(WRITTEN, { keyPath: 'id' });
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const req = run(db.transaction(store, mode).objectStore(store));
|
||||
req.onsuccess = () => resolve(req.result as T);
|
||||
req.onerror = () => reject(req.error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function keep(store: string, rows: object[]): Promise<void> {
|
||||
if (!rows.length) return;
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(store, 'readwrite');
|
||||
const s = tx.objectStore(store);
|
||||
for (const row of rows) s.put(row);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function folderHandle(): Promise<FileSystemDirectoryHandle | null> {
|
||||
try {
|
||||
const row = await ask<{ id: string; handle: FileSystemDirectoryHandle } | undefined>(WHERE, 'readonly', (s) =>
|
||||
s.get('folder')
|
||||
);
|
||||
return row?.handle ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The permission is asked for silently and never granted from here: a prompt
|
||||
// without a click behind it is a prompt the browser may refuse outright, and the
|
||||
// buttons on the screen are where the click is.
|
||||
async function granted(handle: FileSystemHandle, write: boolean): Promise<boolean> {
|
||||
if (!handle) return false;
|
||||
const askable = handle as FileSystemHandle & {
|
||||
queryPermission?: (o: { mode: string }) => Promise<PermissionState>;
|
||||
};
|
||||
try {
|
||||
return (await askable.queryPermission?.({ mode: write ? 'readwrite' : 'read' })) === 'granted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function backupStatus(): Promise<BackupStatus> {
|
||||
const handle = await folderHandle();
|
||||
const meta = await ask<Meta | undefined>(WHERE, 'readonly', (s) => s.get('meta')).catch(() => undefined);
|
||||
return {
|
||||
folder: handle?.name ?? null,
|
||||
granted: handle ? await granted(handle, true) : false,
|
||||
at: meta?.at ?? 0,
|
||||
photos: meta?.photos ?? 0,
|
||||
wrote: meta?.wrote ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// The catalogue's own header, read out of the file and not out of this browser's
|
||||
// memory of it. A folder the reader is pointing at may be a copy carried over on
|
||||
// a stick, written by another machine, at another time: it is its own catalogue
|
||||
// that says which backup it is, and its own count and date that the screen has
|
||||
// to show before anything is written to it or read from it.
|
||||
async function head(root: FileSystemDirectoryHandle): Promise<Meta | null> {
|
||||
try {
|
||||
const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text();
|
||||
const cat = JSON.parse(text) as { at?: number; photos?: unknown[] };
|
||||
return { id: 'meta', at: cat.at ?? 0, photos: (cat.photos ?? []).length, wrote: 0 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The picker's own dialog, the same way a folder of frames is picked — a second
|
||||
// `id` so the browser remembers this folder apart from the library's. It is the
|
||||
// only way a folder is ever chosen, and the only thing that hands a remembered
|
||||
// one its permission back: the browser takes that away when the tab closes, and
|
||||
// a permission asked for without a picker behind it is one it may refuse.
|
||||
export async function pickBackupFolder(): Promise<string> {
|
||||
if (!('showDirectoryPicker' in window)) throw new Error('no-folder-picker');
|
||||
const handle = await (
|
||||
window as unknown as {
|
||||
showDirectoryPicker: (o?: unknown) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
).showDirectoryPicker({ mode: 'readwrite', id: 'recipescam-backup' });
|
||||
const before = await folderHandle();
|
||||
await keep(WHERE, [{ id: 'folder', handle }]);
|
||||
// A folder that is not the one the last run wrote into starts empty, and the
|
||||
// list of tiles already written is about that folder — kept, it would say every
|
||||
// tile is there and the new folder would come out with none of them.
|
||||
if (before?.name !== handle.name) {
|
||||
await ask(WRITTEN, 'readwrite', (s) => s.clear());
|
||||
// And the row goes back to saying what this folder holds rather than what
|
||||
// the last one did: a count and a date from the folder next door is a count
|
||||
// and a date about a backup the reader is not looking at.
|
||||
const meta = await head(handle);
|
||||
await ask<Meta | undefined>(WHERE, 'readwrite', (s) => (meta ? s.put(meta) : s.delete('meta')));
|
||||
}
|
||||
return handle.name;
|
||||
}
|
||||
|
||||
// A handle comes back from IndexedDB without its permission, so every write goes
|
||||
// through this: the visitor's click is the one thing that can hand it back.
|
||||
async function writable(): Promise<FileSystemDirectoryHandle> {
|
||||
const handle = await folderHandle();
|
||||
if (!handle) throw new Error('no-backup-folder');
|
||||
if (!(await ensurePermission(handle, true))) throw new Error('no-backup-permission');
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Chrome will not spell a name it will not put on a disk: no `* ? " < > | : \ /`,
|
||||
// no control or format character, no space, dot or tilde at either end, no
|
||||
// `.lnk` `.scf` `.url`, and it has to be UTF-8 — the whole of what Chromium's
|
||||
// `IsSafePathComponent` refuses, and every one of those is a name a camera or a
|
||||
// person is free to give a folder. The library is read through a handle, which
|
||||
// lets every one of them through, so the refusal only ever surfaces here, on the
|
||||
// way out: `getDirectoryHandle` says `Name is not allowed` and the whole backup
|
||||
// stops. This spells such a name instead — the offending character as `%XX` of
|
||||
// its UTF-8 bytes — and it is the same function on the way back in, so a tile
|
||||
// under a folder called `2024 : ảnh.` still comes home. A name that was already
|
||||
// safe is handed back untouched, which is what keeps the folder a person can
|
||||
// read, copy or rsync by hand.
|
||||
const UNSAFE = /[<>:"|?*\\/]|\p{Cc}|\p{Cf}|[\uFDD0-\uFDEF\uFFFE\uFFFF]/u;
|
||||
const EDGE = /^[ .~]|[ .~]$/;
|
||||
const SHORTCUT = /\.(lnk|scf|url)$/i;
|
||||
|
||||
const hex = (ch: string) =>
|
||||
[...new TextEncoder().encode(ch)].map((b) => `%${b.toString(16).toUpperCase().padStart(2, '0')}`).join('');
|
||||
|
||||
function spell(name: string): string {
|
||||
// `%` spells here even though a disk takes it: a name that holds one would
|
||||
// otherwise spell the same as a name whose character was spelled out, and the
|
||||
// two tiles would land on each other's file.
|
||||
if (
|
||||
name &&
|
||||
name !== '.' &&
|
||||
name !== '..' &&
|
||||
!name.includes('%') &&
|
||||
!UNSAFE.test(name) &&
|
||||
!EDGE.test(name) &&
|
||||
!SHORTCUT.test(name)
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
const chars = [...name];
|
||||
return chars
|
||||
.map((ch, i) => {
|
||||
const edge = (i === 0 || i === chars.length - 1) && /[ .~]/.test(ch);
|
||||
return ch === '%' || edge || UNSAFE.test(ch) ? hex(ch) : ch;
|
||||
})
|
||||
.join('')
|
||||
.replace(SHORTCUT, (m) => `%2E${m.slice(1)}`);
|
||||
}
|
||||
|
||||
// The directories are asked for by the path in the frame id, and the id of every
|
||||
// frame in a folder starts with that folder's: a cache of what has been opened
|
||||
// turns a folder of 220 000 frames into a walk of the folders it is made of.
|
||||
const opened = new WeakMap<FileSystemDirectoryHandle, Map<string, FileSystemDirectoryHandle>>();
|
||||
|
||||
async function at(root: FileSystemDirectoryHandle, rel: string, create = false): Promise<FileSystemDirectoryHandle> {
|
||||
let cache = opened.get(root);
|
||||
if (!cache) opened.set(root, (cache = new Map()));
|
||||
let node = root;
|
||||
let path = '';
|
||||
// Both separators: a path handed in from a Windows-written id may say `\`.
|
||||
for (const part of rel.split(/[\\/]/)) {
|
||||
if (!part) continue;
|
||||
const name = spell(part);
|
||||
path = path ? `${path}/${name}` : name;
|
||||
const had = cache.get(path);
|
||||
if (had) {
|
||||
node = had;
|
||||
continue;
|
||||
}
|
||||
node = await node.getDirectoryHandle(name, { create });
|
||||
cache.set(path, node);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
async function put(root: FileSystemDirectoryHandle, rel: string, blob: Blob): Promise<void> {
|
||||
const cut = rel.lastIndexOf('/');
|
||||
const dir = cut < 0 ? root : await at(root, rel.slice(0, cut), true);
|
||||
const file = await dir.getFileHandle(spell(cut < 0 ? rel : rel.slice(cut + 1)), { create: true });
|
||||
// One write at a time and closed at once: a writable that is left open is a
|
||||
// file that is not on the disk yet, and a backup that is interrupted is one the
|
||||
// next run has to write again.
|
||||
const writer = await file.createWritable();
|
||||
await writer.write(blob);
|
||||
await writer.close();
|
||||
}
|
||||
|
||||
// What the last run left in the folder: the tile's own size, per frame. A tile
|
||||
// that has not changed is not written again, which is the whole of what makes a
|
||||
// second run cheap.
|
||||
async function already(): Promise<Map<string, number>> {
|
||||
const rows = await ask<{ id: string; size: number }[]>(WRITTEN, 'readonly', (s) => s.getAll()).catch(() => []);
|
||||
return new Map(rows.map((r) => [r.id, r.size]));
|
||||
}
|
||||
|
||||
export interface BackupResult extends BackupStatus {
|
||||
// The tiles this run actually wrote, and the frames it found.
|
||||
written: number;
|
||||
}
|
||||
|
||||
export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise<BackupResult> {
|
||||
const root = await writable();
|
||||
const [all, edits, allFolders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
|
||||
// The copy is the disk's: a node that is really an Immich server and the frames
|
||||
// read over the network from it are not on this machine, so they are not part of
|
||||
// what a folder on this machine can hold. The catalogue still keeps them — see
|
||||
// `immich.ts`, which reads them again on the next visit.
|
||||
const photos = all.filter((p) => !p.remoteId);
|
||||
const folders = allFolders.filter((f) => !f.source);
|
||||
const had = await already();
|
||||
const at0 = Date.now();
|
||||
const cat = {
|
||||
version: VERSION,
|
||||
at: at0,
|
||||
folders: folders.map(({ name, label }) => ({ name, label })),
|
||||
// The two things a file cannot hold: the handle, and the tile, which is
|
||||
// written beside this as a file of its own.
|
||||
photos: photos.map(({ handle, thumb, ...row }) => row) as CatalogueRow[],
|
||||
edits,
|
||||
};
|
||||
await put(root, CATALOGUE, new Blob([JSON.stringify(cat)], { type: 'application/json' }));
|
||||
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
let done = 0;
|
||||
const rows: { id: string; size: number }[] = [];
|
||||
for (const photo of photos) {
|
||||
if (++done % 64 === 0) onProgress?.({ done, total: photos.length });
|
||||
const blob = photo.thumb ?? (await getPhotoThumb(photo.id));
|
||||
if (!blob) continue;
|
||||
if (had.get(photo.id) === blob.size) continue;
|
||||
const parts = normPath(photo.id).split('/');
|
||||
parts.pop();
|
||||
const rel = [THUMBS, ...parts, photo.name].join('/');
|
||||
try {
|
||||
await put(root, rel, blob);
|
||||
} catch (err) {
|
||||
if (skipped < 5) console.warn('backup: could not write', rel, err);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
rows.push({ id: photo.id, size: blob.size });
|
||||
written++;
|
||||
if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length));
|
||||
}
|
||||
await keep(WRITTEN, rows);
|
||||
if (skipped) console.warn(`backup: ${skipped} of ${photos.length} tiles skipped`);
|
||||
onProgress?.({ done: photos.length, total: photos.length });
|
||||
const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written };
|
||||
await keep(WHERE, [meta]);
|
||||
return { folder: root.name, granted: true, at: at0, photos: photos.length, wrote: written, written };
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
photos: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
// Put a catalogue back. Nothing is deleted and nothing that is already there is
|
||||
// spared: the backup is the reading, and a frame it knows about is a frame the
|
||||
// catalogue should know about too. Folders are not in it — a folder is a handle,
|
||||
// and a handle only comes from the visitor's own picker — so the frames come back
|
||||
// without one and the folders are picked again afterwards, which is also what
|
||||
// gives every frame its handle back without reading a byte of it.
|
||||
export async function restoreNow(onProgress?: (p: BackupProgress) => void): Promise<RestoreResult> {
|
||||
const root = await folderHandle();
|
||||
if (!root) throw new Error('no-backup-folder');
|
||||
if (!(await ensurePermission(root, false))) throw new Error('no-backup-permission');
|
||||
const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text();
|
||||
const cat = JSON.parse(text) as {
|
||||
at?: number;
|
||||
folders?: { name: string; label?: string }[];
|
||||
photos: CatalogueRow[];
|
||||
edits: LibraryEdit[];
|
||||
};
|
||||
// The folders this catalogue still has keep the names the backup remembers:
|
||||
// a rename is the reader's own, and it is not in the frame's file anywhere.
|
||||
const here = new Map<string, LibraryFolder>((await listFolders()).map((f) => [f.name, f]));
|
||||
for (const f of cat.folders ?? []) {
|
||||
const folder = here.get(f.name);
|
||||
if (folder && f.label && folder.label !== f.label) await renameFolder(folder, f.label);
|
||||
}
|
||||
|
||||
const photoList = cat.photos ?? [];
|
||||
const editList = cat.edits ?? [];
|
||||
const total = photoList.length;
|
||||
|
||||
// Instant catalog metadata & edits restoration (< 100ms)
|
||||
const photos = await restoreCatalogue(photoList, editList);
|
||||
// And the walks are forgotten with it: the numbers a walk wrote down are off a
|
||||
// disk this backup does not describe, and a column drawn under them would say a
|
||||
// folder holds what it held the last time the reader looked at it.
|
||||
await forgetWalkedDirs();
|
||||
|
||||
// Activate backup tile fetcher for lazy rendering on screen
|
||||
setBackupTileFetcher(readBackupTile);
|
||||
|
||||
onProgress?.({ done: total, total });
|
||||
return { photos, at: cat.at ?? 0 };
|
||||
}
|
||||
@@ -1,186 +1,18 @@
|
||||
// The colour the camera itself wrote into the file.
|
||||
// The grid the frame's own rendering is read on.
|
||||
//
|
||||
// LibRaw hands back the sensor's own data and nothing about how the body chose
|
||||
// to render it: the white balance it locked, its picture style, its tone curve.
|
||||
// The file carries the answer anyway — the preview saved inside it, which is the
|
||||
// frame the photographer saw and the one a desktop viewer shows. So the develop
|
||||
// is matched to that preview: develop a grid of the frame, take the same grid out
|
||||
// of the embedded preview, and least-squares the 3x3 that takes the first to the
|
||||
// second — then, on what that 3x3 leaves, the tone curve a matrix cannot hold.
|
||||
// This file held the fit that used to relate a develop to the preview the camera
|
||||
// wrote into the file: a grid out of each, a cubic a channel between them, and a
|
||||
// 3x3 least squared ahead of that curve. None of it is the camera's rendering —
|
||||
// a picture style is a colour transform with a curve in it — and the best a
|
||||
// per-channel cubic could reach on the Fujifilm RAF this was reported on was
|
||||
// dE00 6.04 against the file's own preview.
|
||||
//
|
||||
// Measured on the A5100 frame the studio was reported on (dE00 against the
|
||||
// camera's own 24MP JPEG, blocks of a 64x64 grid): 12.0 as the develop left it,
|
||||
// 9.2 with this fit drawn in linear light, 6.4 painted through the 8-bit colour
|
||||
// filter this used to be, 6.6 with the 3x3 alone in float, 4.4 with the tone
|
||||
// curve under it. What is left either way is the camera's own local rendering —
|
||||
// its sharpening, the detail a 1.7MP preview never had — and no per-pixel
|
||||
// transform reaches that.
|
||||
// What replaced it is not a fit: the develop carries the difference itself, at
|
||||
// the preview's own resolution, and adds it back on the value it left (see
|
||||
// previewResidual in rawDevelop.ts). dE00 0.54 on that frame, 0.00 on every body
|
||||
// that writes a full-size preview.
|
||||
//
|
||||
// The fit is on the sRGB-encoded values, exposure included — matrix and curve
|
||||
// alike: the reference is the file's own frame, so matching its brightness is
|
||||
// part of matching its colour. Fitted in that encoded space, not in linear light,
|
||||
// because a least squares in linear light weighs the highlights and comes back
|
||||
// with the midtones wrong — the same frames scored dE00 6.4 fitted encoded
|
||||
// against 9.2 fitted linear. Any body, any picture style, and no fitted table to
|
||||
// go stale — a preview is the one profile every RAW already carries.
|
||||
// Row-major 3x3, on the sRGB-encoded develop: what the shader applies to a pixel.
|
||||
export type Mat3 = readonly [
|
||||
number, number, number,
|
||||
number, number, number,
|
||||
number, number, number,
|
||||
];
|
||||
|
||||
// The grid the fit reads, and the size of the preview map handed to it.
|
||||
// What is left here is the grid both the balance read and the develop's own maps
|
||||
// are taken on, which is still the size the preview is reduced to when something
|
||||
// only needs a statistic of the frame.
|
||||
export const MATCH_GRID = 64;
|
||||
|
||||
// A block at or past this has nothing left to say about a rendering.
|
||||
const CLIP = 0.97;
|
||||
const FLOOR = 0.03;
|
||||
|
||||
// The per-file colour fit: a 3x3, then the tone curve a 3x3 cannot hold — one
|
||||
// cubic a channel, on the encoded value the 3x3 leaves. `tone` is that curve in
|
||||
// the shader's own order: r a b c d, then g, then b.
|
||||
export interface Match {
|
||||
m: Mat3;
|
||||
tone: Float32Array;
|
||||
}
|
||||
|
||||
// What the second stage does when the frame has no curve the fit can trust.
|
||||
export const FLAT_TONE = new Float32Array([0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0]);
|
||||
|
||||
// Gaussian elimination on the one system this file fits: a channel is a row of
|
||||
// three, for the 3x3 and again for the 3x3's own error.
|
||||
function solve(C: number[][], d: number[]): number[] {
|
||||
const n = 3;
|
||||
const a = Array.from({ length: n }, (_, i) => [...C[i], d[i]]);
|
||||
for (const row of a) for (let c = 0; c < n; c++) row[c] += 1e-9;
|
||||
for (let c = 0; c < n; c++) {
|
||||
let p = c;
|
||||
for (let r = c + 1; r < n; r++) if (Math.abs(a[r][c]) > Math.abs(a[p][c])) p = r;
|
||||
[a[c], a[p]] = [a[p], a[c]];
|
||||
for (let r = 0; r < n; r++) {
|
||||
if (r === c) continue;
|
||||
const f = a[r][c] / a[c][c];
|
||||
for (let k = c; k <= n; k++) a[r][k] -= f * a[c][k];
|
||||
}
|
||||
}
|
||||
return a.map((_, i) => a[i][n] / a[i][i]);
|
||||
}
|
||||
|
||||
// The curve a channel needs to reach the camera's, least squared: what the 3x3
|
||||
// left for that channel, a cubic against the block the camera wrote there.
|
||||
//
|
||||
// The cubic is pinned at white, not free. Fitted free it slid its far end down to
|
||||
// meet the blocks it wanted — 0.96 on red — and every blown highlight the develop
|
||||
// had carried up to the white level came out off-white instead: a blown pixel
|
||||
// (1, 1, 1) into the fit left at 0.82 / 0.97 / 0.93, and 0.0% of the frame at
|
||||
// pure white against 2.3% before and 3.3% in the preview. So the fit solves the
|
||||
// three free terms of `tone = x^3 + a(1 - x^3) + b(x - x^3) + c(x^2 - x^3)`, whose
|
||||
// terms all vanish at 1 and which costs the fit almost nothing (rms 0.066 on red
|
||||
// against the free cubic's 0.063, and 0.076 on blue against 0.076, on the frame
|
||||
// this was measured on). Scaling the free curve onto its own end instead moved
|
||||
// the midtones by the whole 4% the end was short, and pinning both ends moved them
|
||||
// by 12% (rms 0.21 on green).
|
||||
function basis(i: number, x: number): number {
|
||||
const x3 = x * x * x;
|
||||
return i === 0 ? 1 - x3 : i === 1 ? x - x3 : x * x - x3;
|
||||
}
|
||||
|
||||
function fitTone(blocks: { t: number[]; q: number[] }[]): Float32Array {
|
||||
const out = new Float32Array(12);
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const A = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||
const d = [0, 0, 0];
|
||||
for (const { t, q } of blocks) {
|
||||
const x = q[c], b = [basis(0, x), basis(1, x), basis(2, x)];
|
||||
for (let p = 0; p < 3; p++) {
|
||||
for (let k = 0; k < 3; k++) A[p][k] += b[p] * b[k];
|
||||
d[p] += b[p] * (t[c] - x * x * x);
|
||||
}
|
||||
}
|
||||
const [a, b, cc] = solve(A, d);
|
||||
out.set([a, b, cc, 1 - a - b - cc], c * 4);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A curve that comes back folded, or wandered far from the value it was handed,
|
||||
// is a fit off blocks that fought each other, not a rendering: the caller keeps
|
||||
// the 3x3 alone rather than a look nobody's camera has. The floor sits below the
|
||||
// zero a curve is allowed to reach — the develop clamps there anyway — and the
|
||||
// slope is only asked not to turn back.
|
||||
function curveIsSane(tone: Float32Array): boolean {
|
||||
for (const v of tone) if (!Number.isFinite(v) || Math.abs(v) > 4) return false;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const [a, b, cc, dd] = [tone[c * 4], tone[c * 4 + 1], tone[c * 4 + 2], tone[c * 4 + 3]];
|
||||
let prev = a;
|
||||
for (let i = 1; i <= 16; i++) {
|
||||
const x = i / 16;
|
||||
const y = a + x * (b + x * (cc + x * dd));
|
||||
if (y < prev - 0.12 || y < -0.15 || y > 1.15) return false;
|
||||
prev = y;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// `dev` and `ref` are n x n RGBA maps of the same frame, one byte per channel.
|
||||
// The 3x3 comes back on those encoded values — the space the caller draws it in.
|
||||
// Null when the two cannot be related — too few blocks carry colour, or the
|
||||
// solution runs away — and the caller develops the frame as the sensor left it.
|
||||
export function fitMatch(dev: Uint8Array, ref: Uint8Array, n = MATCH_GRID): Match | null {
|
||||
const C = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||
const d = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||
const blocks: { e: number[]; t: number[] }[] = [];
|
||||
let used = 0;
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
const e = [dev[i * 4] / 255, dev[i * 4 + 1] / 255, dev[i * 4 + 2] / 255];
|
||||
const t = [ref[i * 4] / 255, ref[i * 4 + 1] / 255, ref[i * 4 + 2] / 255];
|
||||
// A block the camera clipped has no colour left to copy, one at the floor is
|
||||
// only the pedestal, and one the develop clipped has lost the ratio between
|
||||
// its channels — none of them say anything about the rendering.
|
||||
if (t[0] > CLIP && t[1] > CLIP && t[2] > CLIP) continue;
|
||||
if (e[0] > CLIP || e[1] > CLIP || e[2] > CLIP) continue;
|
||||
if (Math.max(t[0], t[1], t[2]) < FLOOR) continue;
|
||||
used++;
|
||||
blocks.push({ e, t });
|
||||
for (let x = 0; x < 3; x++) {
|
||||
for (let y = 0; y < 3; y++) C[x][y] += e[x] * e[y];
|
||||
for (let c = 0; c < 3; c++) d[x][c] += e[c] * t[x];
|
||||
}
|
||||
}
|
||||
// A fit off a handful of blocks is a fit off noise.
|
||||
if (used < (n * n) / 8) return null;
|
||||
// Ridge towards identity, scaled to the data. The three channels of a develop
|
||||
// are near-collinear — a camera's own rendering of a neutral scene is close to
|
||||
// neutral — so the plain normal equations come back singular-ish, and the frame's
|
||||
// own noise decides a matrix whose diagonal lands at or below zero. λ of 1e-3 of
|
||||
// the data's scale lifts that back to a definite system and moves the fitted
|
||||
// matrix only in the third decimal (measured: 0.001 kept the tone the same to
|
||||
// 0.3% where 0.1 began flattening it).
|
||||
const lambda = 1e-3 * ((C[0][0] + C[1][1] + C[2][2]) / 3);
|
||||
for (let r = 0; r < 3; r++) {
|
||||
C[r][r] += lambda;
|
||||
d[r][r] += lambda;
|
||||
}
|
||||
const m = [...solve(C, d[0]), ...solve(C, d[1]), ...solve(C, d[2])];
|
||||
// A row that ran off is a degenerate fit, not a look. A diagonal element at or
|
||||
// below zero is not one: what these matrices hold is the difference between two
|
||||
// near-identical channels (X-T3 -0.04, X100V -1.67 on a row that carries +1.05
|
||||
// and -1.67), and refusing those refused the whole fit — those bodies opened
|
||||
// byte-identical to an unfitted develop.
|
||||
for (const v of m) if (!Number.isFinite(v) || Math.abs(v) > 4) return null;
|
||||
// The 3x3 alone cannot bend a channel — it can only scale it — and what is left
|
||||
// between the two frames is mostly exactly that bend. So the residual after the
|
||||
// 3x3 is fitted a cubic a channel, on the value the 3x3 leaves.
|
||||
const q = blocks.map(({ e, t }) => {
|
||||
const v = [
|
||||
m[0] * e[0] + m[1] * e[1] + m[2] * e[2],
|
||||
m[3] * e[0] + m[4] * e[1] + m[5] * e[2],
|
||||
m[6] * e[0] + m[7] * e[1] + m[8] * e[2],
|
||||
];
|
||||
return { t, q: v.map((x) => Math.min(1, Math.max(0, x))) };
|
||||
});
|
||||
const tone = fitTone(q);
|
||||
return { m: m as unknown as Mat3, tone: curveIsSane(tone) ? tone : FLAT_TONE };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
// Walking a picked folder is the one part of the catalogue that can run for
|
||||
// minutes: a roll is dated folders inside dated folders, and a library can hold
|
||||
// thousands. The walk goes from the parent down, one layer at a time — the picked
|
||||
// folder and its own frames, then every folder one level down with theirs, then
|
||||
// the level below that — so the names in the column and the frames near the top
|
||||
// are on screen within the first pass. A folder the reader clicks is opened
|
||||
// straight away and put in front of the queue, so the branch in front of them is
|
||||
// read before the layer it sits in would have come round. A pass covers
|
||||
// `DIRS_PER_PASS` directories and then hands the thread back, which is what keeps
|
||||
// the page answering while a scan runs.
|
||||
//
|
||||
// Pure on purpose: it only reads `values()` and `getDirectoryHandle()` off a
|
||||
// directory handle, so a check can walk a stand-in tree without a browser
|
||||
// (scripts/roll-walk-check.mjs).
|
||||
|
||||
export interface WalkFile {
|
||||
handle: FileSystemFileHandle;
|
||||
rel: string;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
dir: FileSystemDirectoryHandle;
|
||||
rel: string;
|
||||
}
|
||||
|
||||
// What the walk is: the folder everything is measured from, the directories still
|
||||
// to read, and the ones already read. `rel` is how a folder is spelled here — ''
|
||||
// for the picked one, `2026/04/` under it, always with the trailing slash.
|
||||
export interface Walk {
|
||||
root: FileSystemDirectoryHandle;
|
||||
pending: Pending[];
|
||||
walked: Set<string>;
|
||||
}
|
||||
|
||||
// Directories one pass reads before it yields: small enough that a click is
|
||||
// answered within a second, large enough that a folder of a few hundred is one
|
||||
// pass rather than a hundred.
|
||||
export const DIRS_PER_PASS = 200;
|
||||
|
||||
// Folders a roll drags along that are never the reader's photographs. A leading
|
||||
// `.` or `@` is the `.thumbnails` a camera writes, `.git`, `.Trash`, and the
|
||||
// `@eaDir` a Synology puts beside every frame; the spelled-out names are the
|
||||
// recycle bins and system folders of Windows, Synology and Linux volumes, which
|
||||
// hold deleted or machine-written files rather than frames. Matching is
|
||||
// case-insensitive because the same folder is `$RECYCLE.BIN` on one volume and
|
||||
// `$Recycle.Bin` on the next.
|
||||
export const SYSTEM_DIR = /^[.@]|^\$RECYCLE\.BIN$|^RECYCLER$|^System Volume Information$|^#recycle$|^lost\+found$/i;
|
||||
|
||||
// The folder a path names, asked from the root one segment at a time. A queue
|
||||
// that was written down as paths comes back as handles this way, and a path that
|
||||
// will not open answers null rather than a folder that is not there.
|
||||
export async function openAt(root: FileSystemDirectoryHandle, rel: string): Promise<FileSystemDirectoryHandle | null> {
|
||||
let dir = root;
|
||||
try {
|
||||
// ponytail: a path written on Windows can arrive with `\`, so both separators
|
||||
// split it; a POSIX name holding a literal `\` is the price (normPath pays it already).
|
||||
for (const part of rel.split(/[\\/]/).filter(Boolean)) dir = await dir.getDirectoryHandle(part);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// The folder the reader clicked goes first. When it is not in the queue the walk
|
||||
// has not reached it yet — its row is on screen because the parent pass named it
|
||||
// — so it is opened from the picked folder and read next. A folder already read,
|
||||
// or one that will not open, changes nothing.
|
||||
export async function jumpQueue(walk: Walk, rel: string | null): Promise<void> {
|
||||
if (!rel || walk.walked.has(rel)) return;
|
||||
const at = walk.pending.findIndex((p) => p.rel === rel);
|
||||
if (at >= 0) {
|
||||
if (at > 0) walk.pending.unshift(walk.pending.splice(at, 1)[0]);
|
||||
return;
|
||||
}
|
||||
const dir = await openAt(walk.root, rel);
|
||||
if (!dir) return;
|
||||
// ponytail: the reader's folder jumps the whole queue, ancestors included —
|
||||
// the folders between it and the picked one are read when their layer comes.
|
||||
walk.pending.unshift({ dir, rel });
|
||||
}
|
||||
|
||||
// One pass over the queue. The queue is the walk's own state: what is behind has
|
||||
// been read, what is ahead is in the order it was found — parents before
|
||||
// children, shallower layers before deeper ones. `seen` collects the folders this
|
||||
// pass found, which is what the column draws before they hold a frame. Returns
|
||||
// true when nothing is left to walk.
|
||||
export async function walkPass(
|
||||
walk: Walk,
|
||||
out: WalkFile[],
|
||||
seen: string[],
|
||||
keep: (name: string) => boolean,
|
||||
jump: () => string | null,
|
||||
onDir?: (rel: string) => void,
|
||||
onFile?: (file: WalkFile) => void,
|
||||
onFail?: (rel: string) => void
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < DIRS_PER_PASS && walk.pending.length; i++) {
|
||||
await jumpQueue(walk, jump());
|
||||
const here = walk.pending.shift()!;
|
||||
onDir?.(here.rel);
|
||||
// A folder opened by a click is still in the queue its parent filled: it is
|
||||
// read once, in the place the click put it.
|
||||
if (walk.walked.has(here.rel)) continue;
|
||||
walk.walked.add(here.rel);
|
||||
try {
|
||||
const baseRel = here.rel ? (here.rel.endsWith('/') ? here.rel : `${here.rel}/`) : '';
|
||||
for await (const entry of here.dir.values()) {
|
||||
if (SYSTEM_DIR.test(entry.name)) continue;
|
||||
if (entry.kind === 'directory') {
|
||||
const rel = `${baseRel}${entry.name}/`;
|
||||
walk.pending.push({ dir: entry as FileSystemDirectoryHandle, rel });
|
||||
seen.push(rel);
|
||||
} else if (keep(entry.name)) {
|
||||
const file: WalkFile = { handle: entry as FileSystemFileHandle, rel: `${baseRel}${entry.name}` };
|
||||
out.push(file);
|
||||
onFile?.(file);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A subfolder that will not open is skipped; the rest of the roll stands.
|
||||
// It is also a folder this walk has no word on — what it holds was never
|
||||
// listed, so a caller about to take the walk's word for what the disk
|
||||
// holds is told here that this folder is not part of it.
|
||||
onFail?.(here.rel);
|
||||
}
|
||||
}
|
||||
return walk.pending.length === 0;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface StudioState {
|
||||
recipe?: Recipe;
|
||||
simId?: string | null;
|
||||
frameId?: FrameId;
|
||||
framePanX?: number;
|
||||
crop?: CropRatio;
|
||||
cropRect?: CropRect;
|
||||
// false = the frame is on screen but not accepted yet (still draggable).
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { download } from './imageOps';
|
||||
|
||||
// Posting the export to a social site. A page cannot reach into another
|
||||
// origin's composer: the cookie is the browser's to send, never ours to read,
|
||||
// and the window we open has no DOM we may touch — which is the same rule that
|
||||
// keeps any other site from posting as the visitor. So there is no way to drop
|
||||
// the picture into the site's own picker from here. Two things do work, and
|
||||
// this takes the best one the browser offers:
|
||||
//
|
||||
// sheet on a phone the OS share sheet opens and the target app —
|
||||
// Facebook, Instagram, Reddit — comes up with the picture already
|
||||
// in its composer. Only the caption is left to type.
|
||||
// clipboard the desktop path: the picture goes on the clipboard and the site
|
||||
// opens beside it, so one paste (Ctrl+V) puts it in the composer.
|
||||
// download no share sheet and no clipboard — an http origin is not a secure
|
||||
// context, so the API is not even there: the file is handed over the
|
||||
// old way and the site opens beside it.
|
||||
//
|
||||
// ponytail: the four sites are listed here rather than in a config file. Add a
|
||||
// network when someone asks for one; these three paths serve any site alike.
|
||||
export const SHARE_SITES = {
|
||||
facebook: 'https://www.facebook.com/',
|
||||
instagram: 'https://www.instagram.com/',
|
||||
reddit: 'https://www.reddit.com/submit',
|
||||
x: 'https://x.com/compose/post',
|
||||
} as const;
|
||||
|
||||
export type ShareSite = keyof typeof SHARE_SITES;
|
||||
|
||||
export type SharePath = 'sheet' | 'clipboard' | 'download';
|
||||
|
||||
// The clipboard takes PNG in every engine — Chromium silently refuses a JPEG —
|
||||
// so the file is drawn to a canvas once. No resample: the bytes handed in are
|
||||
// already the size the visitor chose.
|
||||
async function pngOf(file: File): Promise<Blob> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('no 2d context');
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
return await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
|
||||
);
|
||||
}
|
||||
|
||||
export async function sharePhoto(bytes: Uint8Array, filename: string, site: ShareSite): Promise<SharePath> {
|
||||
const file = new File([bytes as BlobPart], filename, { type: 'image/jpeg' });
|
||||
// A phone is the only place the picture reaches the target app on its own:
|
||||
// the sheet hands the file over and that app owns its own composer.
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({ files: [file] });
|
||||
} catch (err) {
|
||||
// The sheet was closed: a "not now", not a failure, and nothing else opens
|
||||
// on top of the choice the visitor just made.
|
||||
if (!(err instanceof DOMException && err.name === 'AbortError')) throw err;
|
||||
}
|
||||
return 'sheet';
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': await pngOf(file) })]);
|
||||
window.open(SHARE_SITES[site], '_blank', 'noopener');
|
||||
return 'clipboard';
|
||||
} catch {
|
||||
window.open(SHARE_SITES[site], '_blank', 'noopener');
|
||||
download(bytes, filename);
|
||||
return 'download';
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,16 @@ function rasterSurface(w: number, h: number) {
|
||||
let grContext: any = null;
|
||||
|
||||
function ensureGrContext(): any {
|
||||
if (grContext) return grContext;
|
||||
if (grContext) {
|
||||
if (typeof grContext.isContextLost === 'function' && grContext.isContextLost()) {
|
||||
try {
|
||||
if (typeof grContext.delete === 'function') grContext.delete();
|
||||
} catch {}
|
||||
grContext = null;
|
||||
} else {
|
||||
return grContext;
|
||||
}
|
||||
}
|
||||
if (typeof OffscreenCanvas === 'undefined') return null;
|
||||
try {
|
||||
grContext = CK.MakeWebGLContext(CK.GetWebGLContext(new OffscreenCanvas(8, 8)));
|
||||
|
||||
@@ -22,6 +22,10 @@ const MODEL_URL = '/models/realesr-general-x4v3.onnx';
|
||||
const MODEL_F16_URL = '/models/realesr-general-x4v3-f16.onnx';
|
||||
// The wasm dir, not the file: the runtime picks its own name inside it.
|
||||
const WASM_DIR = '/wasm/ort/';
|
||||
// What the export menu warms, named so the warming costs no runtime: the script
|
||||
// the runtime hands its threads, the binary they all load, and the two copies of
|
||||
// the model. See `preloadSuperRes`.
|
||||
const RUNTIME_FILES = ['ort-wasm-simd-threaded.jsep.mjs', 'ort-wasm-simd-threaded.jsep.wasm'];
|
||||
// The model's own factor is fixed at 4; the destination is whatever the export
|
||||
// asked for, so each finished tile is drawn to its place in the output rather
|
||||
// than assembled at 4x and shrunk (which would cost the memory of both).
|
||||
@@ -86,8 +90,14 @@ function load(): Promise<Loaded> {
|
||||
// check is for the case the headers ever go missing: asking for more than
|
||||
// one thread without isolation makes the runtime throw instead of falling
|
||||
// back, and a slow export beats a broken one.
|
||||
//
|
||||
// Four, not the processor's whole width. Every thread is a Web Worker of
|
||||
// its own holding a copy of that 27MB binary, and on a laptop the memory
|
||||
// and the bandwidth are both spent well before the eighth one: the tile is
|
||||
// 256 pixels square, and the threads that matter are here. A machine with
|
||||
// fewer cores asks for fewer.
|
||||
ort.env.wasm.numThreads = self.crossOriginIsolated
|
||||
? Math.min(8, navigator.hardwareConcurrency || 1)
|
||||
? Math.min(4, navigator.hardwareConcurrency || 1)
|
||||
: 1;
|
||||
// A laptop with two GPUs would otherwise hand this to the one built into
|
||||
// the processor. The model is the export, so ask for the fast one.
|
||||
@@ -117,10 +127,49 @@ function load(): Promise<Loaded> {
|
||||
|
||||
// Start fetching the 32MB of runtime and model before the export that needs
|
||||
// them, so the visitor waits for the pixels and not for the download. Called
|
||||
// when the export menu opens — the only door onto an upscale — and ignored
|
||||
// afterwards: every later call finds `load()` already in flight.
|
||||
// when the export menu opens — the only door onto an upscale.
|
||||
//
|
||||
// The bytes, and only the bytes: they are fetched by name rather than imported,
|
||||
// because importing the runtime is what starts its thread pool — one Web Worker
|
||||
// per thread, one copy of the 27MB binary each — and a pool that then belongs to
|
||||
// the page until it dies, whether or not an export is ever asked for. Opening a
|
||||
// menu is not the work. What the menu is worth is the download, and the download
|
||||
// is what the browser's cache keeps: the export's own `import` then pays a second
|
||||
// of instantiation it cannot avoid anyway, and finds the files already on disk.
|
||||
export function preloadSuperRes(): void {
|
||||
void load().catch(() => {});
|
||||
for (const file of [...RUNTIME_FILES.map((f) => WASM_DIR + f), MODEL_URL, MODEL_F16_URL]) {
|
||||
void fetch(file).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// The page is being put away — closed, reloaded, restored from the back/forward
|
||||
// cache — and this renderer is holding the session's own buffers and a thread
|
||||
// pool with it. Hand the session back rather than leaving the browser to reap it
|
||||
// with the page: a machine short of memory at the moment the studio closes is a
|
||||
// machine that hangs on the studio opening again, which is the one symptom this
|
||||
// can answer for.
|
||||
//
|
||||
// The pool is the runtime's and goes with the page; the session is ours. `loaded`
|
||||
// goes with it, so a page that comes back loads the runtime again instead of
|
||||
// calling into a released session — the files are cached, so that is a second.
|
||||
export async function releaseSuperRes(): Promise<void> {
|
||||
const pending = loaded;
|
||||
loaded = null;
|
||||
if (!pending) return;
|
||||
try {
|
||||
await (await pending).session.release();
|
||||
} catch {
|
||||
// A load that failed, or a runtime too old to have `release`: nothing to
|
||||
// give back, and nothing to say about it either.
|
||||
}
|
||||
}
|
||||
|
||||
// The last moment the studio is on screen. Nothing else in this module listens
|
||||
// for anything, so the listener goes with the one resource that needs it.
|
||||
if (typeof addEventListener === 'function') {
|
||||
addEventListener('pagehide', () => {
|
||||
void releaseSuperRes();
|
||||
});
|
||||
}
|
||||
|
||||
// The photo's bytes, enlarged so its longest edge is `targetLongest`. The caller
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
// TIFF → RGBA, for the catalogue's tiles.
|
||||
//
|
||||
// A `.tif` in an album had no tile, and none of the three decoders the app
|
||||
// already carries will make one. Measured on a baseline 5472x3648 scan written
|
||||
// by vips: `createImageBitmap` answers InvalidStateError, CanvasKit's
|
||||
// `MakeImageFromEncoded` answers null — so `tiffThumbnail` never returned
|
||||
// anything for a TIFF at all — and LibRaw opens the file only to report a 0x0
|
||||
// frame with no thumbnail inside it.
|
||||
//
|
||||
// So the frame is read here instead: the header, the strips or tiles with their
|
||||
// compression, the photometric, and one row in `step` out of the image. A 640px
|
||||
// tile shows no more of a 5472px row than that, which is why the full-size row
|
||||
// is never built — a scan album is hundreds of megabytes a frame and the tile
|
||||
// is the only thing anyone asked for.
|
||||
//
|
||||
// What is covered: little and big endian, strips and tiles, chunky (planar 1),
|
||||
// 1/8/16-bit, grey / RGB / palette, compression none, LZW (5), PackBits
|
||||
// (32773) and both JPEG-in-TIFF flavours (6 and 7, through CanvasKit, which is
|
||||
// the one thing in here that does decode a JPEG). YCbCr outside a JPEG and CMYK
|
||||
// are not decoded and answer null — the row still lists the frame by name.
|
||||
|
||||
import { Skia } from './skiaShim';
|
||||
|
||||
export interface TiffFrame {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array;
|
||||
}
|
||||
|
||||
// An IFD longer than this is not a camera file, it is something reading one.
|
||||
const MAX_ENTRIES = 4096;
|
||||
|
||||
interface Entry {
|
||||
type: number;
|
||||
count: number;
|
||||
/** Where the value's bytes are, inline in the entry or out at an offset. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
/** Position in the image this chunk's pixels start at. */
|
||||
x0: number;
|
||||
y0: number;
|
||||
/** Width of a stored row, which a tile pads out to its own tile width. */
|
||||
stride: number;
|
||||
width: number;
|
||||
height: number;
|
||||
offset: number;
|
||||
length: number;
|
||||
/** Set for the two JPEG flavours: the stream itself, tables already joined. */
|
||||
jpeg?: Uint8Array;
|
||||
}
|
||||
|
||||
interface Samples {
|
||||
kind: 'samples';
|
||||
width: number;
|
||||
height: number;
|
||||
spp: number;
|
||||
depth: number;
|
||||
data: Uint8Array | Uint16Array;
|
||||
}
|
||||
|
||||
interface Rgba {
|
||||
kind: 'rgba';
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
type Decoded = Samples | Rgba;
|
||||
|
||||
export function decodeTiff(bytes: Uint8Array, maxSide: number): TiffFrame | null {
|
||||
if (bytes.length < 8) return null;
|
||||
const little = bytes[0] === 0x49 && bytes[1] === 0x49;
|
||||
if (!little && !(bytes[0] === 0x4d && bytes[1] === 0x4d)) return null;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const u16 = (at: number) => view.getUint16(at, little);
|
||||
const u32 = (at: number) => view.getUint32(at, little);
|
||||
if (u16(2) !== 42) return null;
|
||||
|
||||
const valueSize = (type: number) => {
|
||||
if (type === 1 || type === 2 || type === 6 || type === 7) return 1;
|
||||
if (type === 3 || type === 8) return 2;
|
||||
if (type === 4 || type === 9 || type === 11) return 4;
|
||||
if (type === 5 || type === 10 || type === 12) return 8;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const readIfd = (offset: number): Map<number, Entry> | null => {
|
||||
if (offset < 8 || offset + 2 > bytes.length) return null;
|
||||
const count = u16(offset);
|
||||
if (count > MAX_ENTRIES || offset + 2 + count * 12 > bytes.length) return null;
|
||||
const tags = new Map<number, Entry>();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entry = offset + 2 + i * 12;
|
||||
const type = u16(entry + 2);
|
||||
const n = u32(entry + 4);
|
||||
const size = valueSize(type) * n;
|
||||
if (!size || n > 1 << 22) continue;
|
||||
const at = size <= 4 ? entry + 8 : u32(entry + 8);
|
||||
if (at < 0 || at + size > bytes.length) continue;
|
||||
tags.set(u16(entry), { type, count: n, at });
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
const num = (tags: Map<number, Entry>, tag: number, fallback = 0) => {
|
||||
const entry = tags.get(tag);
|
||||
if (!entry) return fallback;
|
||||
if (entry.type === 3) return u16(entry.at);
|
||||
if (entry.type === 4 || entry.type === 9) return u32(entry.at);
|
||||
if (entry.type === 1 || entry.type === 6 || entry.type === 7) return bytes[entry.at];
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const nums = (tags: Map<number, Entry>, tag: number) => {
|
||||
const entry = tags.get(tag);
|
||||
if (!entry) return [];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < entry.count; i++) {
|
||||
if (entry.type === 3) out.push(u16(entry.at + i * 2));
|
||||
else if (entry.type === 4) out.push(u32(entry.at + i * 4));
|
||||
else out.push(bytes[entry.at + i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const tags = readIfd(u32(4));
|
||||
if (!tags) return null;
|
||||
|
||||
const width = num(tags, 256);
|
||||
const height = num(tags, 257);
|
||||
if (width < 1 || height < 1) return null;
|
||||
|
||||
const compression = num(tags, 259, 1);
|
||||
const photometric = num(tags, 262, 2);
|
||||
const spp = num(tags, 277, 1);
|
||||
if (num(tags, 284, 1) !== 1) return null;
|
||||
if (spp < 1 || spp > 4) return null;
|
||||
if (photometric === 5) return null;
|
||||
if (photometric === 6 && compression !== 6 && compression !== 7) return null;
|
||||
if (photometric === 2 && spp < 3) return null;
|
||||
|
||||
let depths = nums(tags, 258);
|
||||
if (!depths.length) depths = [1];
|
||||
if (depths.length === 1 && spp > 1) depths = new Array(spp).fill(depths[0]);
|
||||
if (depths.length !== spp) return null;
|
||||
const depth = depths[0];
|
||||
if (depths.some((d) => d !== depth)) return null;
|
||||
if (depth !== 1 && depth !== 8 && depth !== 16) return null;
|
||||
|
||||
const predictor = num(tags, 317, 1);
|
||||
if (predictor !== 1 && predictor !== 2) return null;
|
||||
|
||||
const tileWidth = num(tags, 322);
|
||||
const tileHeight = num(tags, 323);
|
||||
const tiled = tileWidth > 0 && tileHeight > 0 && tags.has(324);
|
||||
const rowsPerStrip = Math.max(1, num(tags, 278, height) || height);
|
||||
|
||||
const chunks = new Map<number, Chunk>();
|
||||
const tables = tags.get(347);
|
||||
const jpegTables = tables ? bytes.subarray(tables.at, tables.at + tables.count) : null;
|
||||
|
||||
if (compression === 6) {
|
||||
const offsets = nums(tags, 273);
|
||||
const lengths = nums(tags, 279);
|
||||
const offset = num(tags, 513, offsets[0] ?? -1);
|
||||
const length = num(tags, 514, lengths[0] ?? 0);
|
||||
if (offset < 0 || offset + length > bytes.length || !length) return null;
|
||||
chunks.set(0, {
|
||||
x0: 0, y0: 0, stride: width, width, height, offset, length,
|
||||
jpeg: bytes.subarray(offset, offset + length),
|
||||
});
|
||||
} else if (tiled) {
|
||||
const offsets = nums(tags, 324);
|
||||
const lengths = nums(tags, 325);
|
||||
const across = Math.ceil(width / tileWidth);
|
||||
const down = Math.ceil(height / tileHeight);
|
||||
for (let ty = 0; ty < down; ty++) {
|
||||
for (let tx = 0; tx < across; tx++) {
|
||||
const key = ty * across + tx;
|
||||
const offset = offsets[key];
|
||||
const length = lengths[key];
|
||||
if (offset == null || !length || offset + length > bytes.length) continue;
|
||||
const x0 = tx * tileWidth;
|
||||
const y0 = ty * tileHeight;
|
||||
const chunk: Chunk = {
|
||||
x0, y0, stride: tileWidth,
|
||||
width: Math.min(tileWidth, width - x0),
|
||||
height: Math.min(tileHeight, height - y0),
|
||||
offset, length,
|
||||
};
|
||||
if (compression === 7) {
|
||||
const scan = bytes.subarray(offset, offset + length);
|
||||
chunk.jpeg = jpegTables ? jpegWithTables(jpegTables, scan) : scan;
|
||||
}
|
||||
chunks.set(key, chunk);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const offsets = nums(tags, 273);
|
||||
const lengths = nums(tags, 279);
|
||||
for (let i = 0; i < offsets.length; i++) {
|
||||
const y0 = i * rowsPerStrip;
|
||||
if (y0 >= height) break;
|
||||
const offset = offsets[i];
|
||||
const length = lengths[i] ?? 0;
|
||||
if (offset < 0 || !length || offset + length > bytes.length) continue;
|
||||
const chunk: Chunk = {
|
||||
x0: 0, y0, stride: width, width,
|
||||
height: Math.min(rowsPerStrip, height - y0),
|
||||
offset, length,
|
||||
};
|
||||
if (compression === 7) {
|
||||
const scan = bytes.subarray(offset, offset + length);
|
||||
chunk.jpeg = jpegTables ? jpegWithTables(jpegTables, scan) : scan;
|
||||
}
|
||||
chunks.set(i, chunk);
|
||||
}
|
||||
}
|
||||
if (!chunks.size) return null;
|
||||
|
||||
const keyOf = tiled
|
||||
? (sx: number, sy: number) => Math.floor(sy / tileHeight) * Math.ceil(width / tileWidth) + Math.floor(sx / tileWidth)
|
||||
: (_sx: number, sy: number) => Math.floor(sy / rowsPerStrip);
|
||||
|
||||
const value = (s: Samples, i: number) => {
|
||||
if (i < 0 || i >= s.data.length) return 0;
|
||||
const raw = s.data[i];
|
||||
return s.depth === 16 ? raw >> 8 : raw;
|
||||
};
|
||||
|
||||
const decode = (chunk: Chunk): Decoded | null => {
|
||||
if (chunk.jpeg) {
|
||||
const image = Skia.Image.MakeImageFromEncoded(chunk.jpeg);
|
||||
if (!image) return null;
|
||||
try {
|
||||
const w = image.width();
|
||||
const h = image.height();
|
||||
if (!w || !h) return null;
|
||||
// CanvasKit's image `readPixels` only takes its own buffer: handed one of
|
||||
// ours it throws, and the frame came back black to a check on its return
|
||||
// value (measured on a 5472x128 strip of a JPEG-compressed scan).
|
||||
const px = image.readPixels(0, 0, {
|
||||
width: w,
|
||||
height: h,
|
||||
colorType: Skia.ColorType.RGBA_8888,
|
||||
alphaType: Skia.AlphaType.Unpremul,
|
||||
colorSpace: Skia.ColorSpace.SRGB,
|
||||
});
|
||||
if (!px) return null;
|
||||
return {
|
||||
kind: 'rgba',
|
||||
width: w,
|
||||
height: h,
|
||||
data: new Uint8Array(px.buffer, px.byteOffset, px.byteLength),
|
||||
};
|
||||
} finally {
|
||||
image.delete();
|
||||
}
|
||||
}
|
||||
|
||||
const stride = Math.ceil((chunk.stride * spp * depth) / 8);
|
||||
const want = stride * chunk.height;
|
||||
let raw: Uint8Array;
|
||||
if (compression === 1) raw = bytes.subarray(chunk.offset, Math.min(bytes.length, chunk.offset + want));
|
||||
else if (compression === 5) raw = lzwDecode(bytes.subarray(chunk.offset, chunk.offset + chunk.length), want);
|
||||
else if (compression === 32773) raw = packBits(bytes.subarray(chunk.offset, chunk.offset + chunk.length), want);
|
||||
else return null;
|
||||
if (raw.length < want) return null;
|
||||
|
||||
if (depth === 1) {
|
||||
const data = new Uint8Array(chunk.height * chunk.width * spp);
|
||||
for (let y = 0; y < chunk.height; y++) {
|
||||
for (let x = 0; x < chunk.width; x++) {
|
||||
const at = y * stride + (x >> 3);
|
||||
const bit = at < raw.length ? (raw[at] >> (7 - (x & 7))) & 1 : 0;
|
||||
data[y * chunk.width * spp + x] = bit * 255;
|
||||
}
|
||||
}
|
||||
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 1, data };
|
||||
}
|
||||
|
||||
if (depth === 16) {
|
||||
const data = new Uint16Array(chunk.height * chunk.width * spp);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const at = i * 2;
|
||||
data[i] = little ? raw[at] | (raw[at + 1] << 8) : (raw[at] << 8) | raw[at + 1];
|
||||
}
|
||||
if (predictor === 2) differencing(data, chunk, spp);
|
||||
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 16, data };
|
||||
}
|
||||
|
||||
const data = new Uint8Array(want).fill(0);
|
||||
data.set(raw.subarray(0, want));
|
||||
if (predictor === 2) differencing(data, chunk, spp);
|
||||
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 8, data };
|
||||
};
|
||||
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height));
|
||||
const outW = Math.max(1, Math.round(width * scale));
|
||||
const outH = Math.max(1, Math.round(height * scale));
|
||||
const out = new Uint8Array(outW * outH * 4);
|
||||
|
||||
const palette = photometric === 3 ? nums(tags, 320) : null;
|
||||
const half = palette ? palette.length / 3 : 0;
|
||||
|
||||
let cachedKey = -1;
|
||||
let cached: Decoded | null = null;
|
||||
|
||||
for (let oy = 0; oy < outH; oy++) {
|
||||
const sy = Math.min(height - 1, Math.floor((oy * height) / outH));
|
||||
for (let ox = 0; ox < outW; ox++) {
|
||||
const sx = Math.min(width - 1, Math.floor((ox * width) / outW));
|
||||
const key = keyOf(sx, sy);
|
||||
if (key !== cachedKey) {
|
||||
cachedKey = key;
|
||||
const chunk = chunks.get(key);
|
||||
cached = chunk ? decode(chunk) : null;
|
||||
}
|
||||
const chunk = chunks.get(key);
|
||||
if (!cached || !chunk) continue;
|
||||
const chunkX = sx - chunk.x0;
|
||||
const chunkY = sy - chunk.y0;
|
||||
if (chunkX < 0 || chunkY < 0 || chunkX >= chunk.width || chunkY >= chunk.height) continue;
|
||||
|
||||
const at = (oy * outW + ox) * 4;
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
|
||||
if (cached.kind === 'rgba') {
|
||||
const i = (chunkY * cached.width + chunkX) * 4;
|
||||
if (i + 3 >= cached.data.length) continue;
|
||||
r = cached.data[i];
|
||||
g = cached.data[i + 1];
|
||||
b = cached.data[i + 2];
|
||||
} else {
|
||||
const i = chunkY * cached.width * spp + chunkX * spp;
|
||||
if (palette && half) {
|
||||
const index = value(cached, i);
|
||||
r = (palette[index] ?? 0) >> 8;
|
||||
g = (palette[half + index] ?? 0) >> 8;
|
||||
b = (palette[2 * half + index] ?? 0) >> 8;
|
||||
} else if (photometric === 2 || photometric === 6) {
|
||||
r = value(cached, i);
|
||||
g = value(cached, i + 1);
|
||||
b = value(cached, i + 2);
|
||||
} else {
|
||||
const gray = value(cached, i);
|
||||
r = g = b = photometric === 0 ? 255 - gray : gray;
|
||||
}
|
||||
}
|
||||
|
||||
out[at] = r;
|
||||
out[at + 1] = g;
|
||||
out[at + 2] = b;
|
||||
out[at + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return { width: outW, height: outH, rgba: out };
|
||||
}
|
||||
|
||||
// Horizontal differencing, undone in place: every sample is the one before it
|
||||
// plus the difference stored. Bounded by the type, which is the point — a 16-bit
|
||||
// sample wrapped at 65536 and an 8-bit one at 256, and the file wrapped them the
|
||||
// same way.
|
||||
function differencing(data: Uint8Array | Uint16Array, chunk: Chunk, spp: number): void {
|
||||
const row = chunk.width * spp;
|
||||
for (let y = 0; y < chunk.height; y++) {
|
||||
const base = y * row;
|
||||
for (let i = spp; i < row; i++) data[base + i] += data[base + i - spp];
|
||||
}
|
||||
}
|
||||
|
||||
// A JPEG-compressed strip carries the frame's own tables — the whole stream
|
||||
// minus the entropy-coded scan — and the strip is the scan. Joining the two is
|
||||
// the only way either decodes: a strip on its own is missing its tables, and the
|
||||
// tables on their own are not a picture.
|
||||
function jpegWithTables(tables: Uint8Array, strip: Uint8Array): Uint8Array {
|
||||
const tableFrom = tables[0] === 0xff && tables[1] === 0xd8 ? 2 : 0;
|
||||
const tableTo = tables.length > 4 && tables[tables.length - 2] === 0xff && tables[tables.length - 1] === 0xd9
|
||||
? tables.length - 2
|
||||
: tables.length;
|
||||
const scanFrom = strip[0] === 0xff && strip[1] === 0xd8 ? 2 : 0;
|
||||
const complete = strip.length > 4 && strip[strip.length - 2] === 0xff && strip[strip.length - 1] === 0xd9;
|
||||
const body = tableTo - tableFrom;
|
||||
const out = new Uint8Array(2 + body + (strip.length - scanFrom) + (complete ? 0 : 2));
|
||||
out[0] = 0xff;
|
||||
out[1] = 0xd8;
|
||||
out.set(tables.subarray(tableFrom, tableTo), 2);
|
||||
out.set(strip.subarray(scanFrom), 2 + body);
|
||||
if (!complete) {
|
||||
out[out.length - 2] = 0xff;
|
||||
out[out.length - 1] = 0xd9;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// TIFF's LZW: MSB-first codes, 9 to 12 bits, and the code width steps up one
|
||||
// code early — at 511, not 512 — which is the quirk every other implementation
|
||||
// trips on.
|
||||
function lzwDecode(src: Uint8Array, expected: number): Uint8Array {
|
||||
const out = new Uint8Array(expected);
|
||||
const prefix = new Int32Array(4096);
|
||||
const suffix = new Uint8Array(4096);
|
||||
const stack = new Uint8Array(4096);
|
||||
let taken = 0;
|
||||
let read = 0;
|
||||
let bitCount = 0;
|
||||
let bitBuffer = 0;
|
||||
let next = 258;
|
||||
let codeSize = 9;
|
||||
let previous = -1;
|
||||
let first = 0;
|
||||
|
||||
const code = () => {
|
||||
while (bitCount < codeSize) {
|
||||
if (read >= src.length) return -1;
|
||||
bitBuffer = (bitBuffer << 8) | src[read++];
|
||||
bitCount += 8;
|
||||
}
|
||||
bitCount -= codeSize;
|
||||
return (bitBuffer >> bitCount) & ((1 << codeSize) - 1);
|
||||
};
|
||||
|
||||
const emit = (at: number) => {
|
||||
let depth = 0;
|
||||
let walk = at;
|
||||
while (walk >= 256) {
|
||||
if (depth >= 4096 || prefix[walk] < 0) return;
|
||||
stack[depth++] = suffix[walk];
|
||||
walk = prefix[walk];
|
||||
}
|
||||
stack[depth++] = walk;
|
||||
first = walk;
|
||||
while (depth > 0) {
|
||||
if (taken >= out.length) return;
|
||||
out[taken++] = stack[--depth];
|
||||
}
|
||||
};
|
||||
|
||||
let current = code();
|
||||
while (current >= 0 && current !== 257) {
|
||||
if (current === 256) {
|
||||
next = 258;
|
||||
codeSize = 9;
|
||||
previous = -1;
|
||||
current = code();
|
||||
continue;
|
||||
}
|
||||
if (previous < 0) {
|
||||
if (current > 255) return out;
|
||||
emit(current);
|
||||
} else if (current < next) {
|
||||
emit(current);
|
||||
if (next < 4096) {
|
||||
prefix[next] = previous;
|
||||
suffix[next] = first;
|
||||
next++;
|
||||
}
|
||||
} else {
|
||||
if (next >= 4096) return out;
|
||||
prefix[next] = previous;
|
||||
suffix[next] = first;
|
||||
emit(next);
|
||||
next++;
|
||||
}
|
||||
previous = current;
|
||||
if (next === (1 << codeSize) - 1 && codeSize < 12) codeSize++;
|
||||
current = code();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function packBits(src: Uint8Array, expected: number): Uint8Array {
|
||||
const out = new Uint8Array(expected);
|
||||
let taken = 0;
|
||||
let read = 0;
|
||||
while (read < src.length && taken < expected) {
|
||||
let run = src[read++];
|
||||
if (run > 127) run -= 256;
|
||||
if (run >= 0) {
|
||||
const end = Math.min(src.length, read + run + 1);
|
||||
while (read < end && taken < expected) out[taken++] = src[read++];
|
||||
read = end;
|
||||
} else if (run !== -128) {
|
||||
const value = src[read++];
|
||||
const times = Math.min(1 - run, expected - taken);
|
||||
for (let i = 0; i < times; i++) out[taken++] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -25,6 +25,21 @@ interface I18nValue {
|
||||
|
||||
const Ctx = createContext<I18nValue | null>(null);
|
||||
|
||||
function translate(lang: Lang, key: MsgKey, params?: Record<string, string | number>): string {
|
||||
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
|
||||
if (params) {
|
||||
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// The same lookup, for the code outside React that has to write a line the
|
||||
// screen will show — the held write in api.ts, which throws its sentence out of
|
||||
// a catch block. It reads the language the way the provider does.
|
||||
export function t(key: MsgKey, params?: Record<string, string | number>): string {
|
||||
return translate(readLang(), key, params);
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(readLang);
|
||||
|
||||
@@ -40,13 +55,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const value = useMemo<I18nValue>(() => ({
|
||||
lang,
|
||||
setLang,
|
||||
t: (key, params) => {
|
||||
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
|
||||
if (params) {
|
||||
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
t: (key, params) => translate(lang, key, params),
|
||||
}), [lang, setLang]);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
||||
+248
-12
@@ -10,9 +10,18 @@ export const en: Dict = {
|
||||
'nav.account': 'Account',
|
||||
'nav.theme': 'Theme',
|
||||
'nav.language': 'Language',
|
||||
// The phone's way back from a tab's strip to the rail that opened it.
|
||||
'nav.tabs': 'TABS',
|
||||
'nav.back': 'Back to home',
|
||||
'nav.guest': 'Guest',
|
||||
|
||||
// The install offer (see vi.ts).
|
||||
'install.title': 'Install RecipesCam',
|
||||
'install.body': 'Add it to your home screen to open it faster — smoother, offline, full screen like an app.',
|
||||
'install.yes': 'INSTALL',
|
||||
'install.no': 'NO THANKS',
|
||||
'install.ios': 'Tap the Share button in the toolbar, then choose “Add to Home Screen”.',
|
||||
|
||||
'act.undo': 'UNDO',
|
||||
'act.redo': 'REDO',
|
||||
'act.reset': 'RESET',
|
||||
@@ -27,13 +36,13 @@ export const en: Dict = {
|
||||
'drop.clear': 'Clear',
|
||||
'drop.peeking': 'SHOWING THE ORIGINAL',
|
||||
'drop.peekHint': 'Hold right-click or the \\ key to compare with the original',
|
||||
'drop.compare': 'Compare with original picture',
|
||||
'drop.keep': 'SAVE ORIGINAL',
|
||||
'drop.compare': 'Compare',
|
||||
'drop.keep': 'Save original',
|
||||
'drop.keepHint': 'Save the untouched photo to the device photo library. A browser that cannot share files downloads it instead.',
|
||||
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
|
||||
|
||||
// The live viewfinder (see vi.ts for what the status line is for).
|
||||
'cam.open': 'OPEN CAMERA',
|
||||
'cam.open': 'Open camera',
|
||||
'cam.shot': 'SHOOT',
|
||||
'cam.flip': 'Switch front/back camera',
|
||||
'cam.starting': 'Opening the camera…',
|
||||
@@ -43,7 +52,7 @@ export const en: Dict = {
|
||||
'cam.fail': 'The camera did not open.',
|
||||
'cam.shotFail': 'The shot failed — try again.',
|
||||
|
||||
'hist.title': 'HISTOGRAM',
|
||||
'hist.title': 'Histogram',
|
||||
'hist.drag': 'Drag to move',
|
||||
'hist.hide': 'Hide the histogram',
|
||||
|
||||
@@ -51,8 +60,7 @@ export const en: Dict = {
|
||||
'tab.favorited': 'FAVORITED',
|
||||
'tab.photos': 'MY PHOTOS',
|
||||
'tab.light': 'LIGHT',
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.tools': 'TOOLS',
|
||||
'tab.hsl': 'HSL',
|
||||
'hsl.hint': 'PRESS PICK, THEN SAMPLE A COLOUR ON THE PHOTO',
|
||||
'tab.frame': 'FRAME',
|
||||
@@ -85,6 +93,21 @@ export const en: Dict = {
|
||||
'frame.cinematic': 'CINEMATIC',
|
||||
'frame.wall': 'WALL FRAME PORTRAIT',
|
||||
'frame.wallLand': 'WALL FRAME LANDSCAPE',
|
||||
'frame.oldFilm': 'OLD FILM LANDSCAPE',
|
||||
'frame.oldFilmPort': 'OLD FILM PORTRAIT',
|
||||
'frame.custom': 'CUSTOM FRAME',
|
||||
'frame.import': '+ IMPORT FRAME',
|
||||
'frame.customTitle': 'Custom Transparent Frame',
|
||||
'frame.zoom': 'Zoom / Scale',
|
||||
'frame.panX': 'Pan X',
|
||||
'frame.panY': 'Pan Y',
|
||||
'frame.fit': 'Fit Window',
|
||||
'frame.fill': 'Fill Window',
|
||||
'frame.center': 'Center',
|
||||
'frame.replace': 'Change Frame',
|
||||
'frame.remove': 'Remove Frame',
|
||||
'frame.dragHint': 'Drag on photo to pan • Scroll wheel to zoom',
|
||||
'frame.analyzing': 'Detecting frame transparency...',
|
||||
|
||||
'crop.none': 'NO CROP',
|
||||
'crop.1:1': '1:1',
|
||||
@@ -131,7 +154,7 @@ export const en: Dict = {
|
||||
'auth.loggedInAs': 'Signed in as {email}',
|
||||
'auth.verifyTitle': 'Verify your email',
|
||||
'auth.verifyBody': 'We sent a six-digit code to {email}. Type it below — or open the link in the mail — to unlock the PRO features.',
|
||||
'auth.verifyProHint': 'Until then the account works exactly like a guest: exports stay 2048px with a watermark, and nothing can be saved.',
|
||||
'auth.verifyProHint': 'Until then the account stays on the basic tier: recipes, saved photos (12 max), and an export inside the long edge of the photo being edited. PRO features (HSL, TOOLS, RAW, exports past it) need the account to be activated.',
|
||||
'auth.verifySent': 'A new code has been sent.',
|
||||
'auth.codePlaceholder': 'The six digits from the mail',
|
||||
'auth.verifyBtn': 'VERIFY',
|
||||
@@ -139,11 +162,19 @@ export const en: Dict = {
|
||||
'auth.codeLocked': 'Too many wrong tries — ask for a new code by email.',
|
||||
'auth.signupVerifyHint': 'After signing up we email you a six-digit code — type it in to unlock the PRO features.',
|
||||
|
||||
// The PRO gate. Signed in but unproven is served as a guest, so the studio
|
||||
// needs one place that says why and one way to ask for the letter again.
|
||||
'pro.verifyChip': 'VERIFY EMAIL TO USE PRO',
|
||||
// The trial notice under the header: which features are being tried out, on
|
||||
// whom, and until when.
|
||||
'notice.trial': 'Some features are on trial for registered users until 1 December; after that they open only in the Android app.',
|
||||
|
||||
// The PRO gate. A signed-in account the operator has not activated has the
|
||||
// basic tier, so the studio needs one place that says what that means (the
|
||||
// bar), one reason per locked tab (the chips), and one answer for a click on
|
||||
// either of them.
|
||||
'pro.hslChip': 'HSL IS A PRO FEATURE',
|
||||
'pro.unverifiedBar': 'Account {email} is not verified — it works like a guest.',
|
||||
'pro.fixChip': 'FIX IS A PRO FEATURE',
|
||||
'pro.gradientChip': 'GRADIENT MASK IS A PRO FEATURE',
|
||||
'pro.basicBar': 'Account {email} is on the basic tier — PRO features need an account the operator has activated.',
|
||||
'pro.locked': 'PRO feature — this account has not been activated. Ask the operator to tick "Activated Pro".',
|
||||
'pro.resend': 'RESEND VERIFICATION EMAIL',
|
||||
'pro.resent': 'SENT',
|
||||
|
||||
@@ -200,7 +231,10 @@ export const en: Dict = {
|
||||
'export.4k': 'EXPORT 4K (4096px)',
|
||||
'export.customLabel': 'Longest edge (px)',
|
||||
'export.custom': 'EXPORT',
|
||||
'export.cap': 'Guest exports are capped at {px}px.',
|
||||
'export.cap': 'This account can export up to {px}px.',
|
||||
'export.share': 'Share to',
|
||||
'export.shareHint':
|
||||
"On a phone the network's own app opens with the picture already in its composer. On a desktop the picture goes to the clipboard — paste it (Ctrl+V) into the composer that opened; where pasting is refused, the file downloads for you to drop in.",
|
||||
|
||||
'photo.uploading': 'UPLOADING…',
|
||||
|
||||
@@ -218,9 +252,184 @@ export const en: Dict = {
|
||||
'photos.failed': 'That did not work: {msg}',
|
||||
'photos.noLabels': 'No strip labels yet',
|
||||
|
||||
'lib.title': 'LIBRARY',
|
||||
'lib.locked': 'The library is for accounts. Log in to browse your photo folders.',
|
||||
// The LIBRARY button while there is no account: the link is not offered, and
|
||||
// this is what says why.
|
||||
'lib.needAccount': 'The library is for accounts — log in to open it.',
|
||||
'lib.hint':
|
||||
'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
|
||||
'lib.add': 'ADD FOLDER',
|
||||
'lib.stop': 'STOP SCAN',
|
||||
'lib.rename': 'RENAME FOLDER',
|
||||
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
|
||||
'lib.update': 'UPDATE...',
|
||||
'lib.rescan': 'RESCAN...',
|
||||
'lib.makeThumbs': 'MAKE THUMBS',
|
||||
'lib.thumbsMade': 'Made {n} thumbnails for {folder}.',
|
||||
'lib.resume': 'KEEP READING {folder}',
|
||||
'lib.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
|
||||
'lib.remove': 'REMOVE',
|
||||
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
||||
'lib.nodeHint': 'Right-click to rename, update, rescan, make the missing thumbnails, collapse or remove the folder.',
|
||||
'lib.collapseAll': 'COLLAPSE ALL',
|
||||
'lib.collapse': 'COLLAPSE',
|
||||
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
|
||||
'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)',
|
||||
'lib.viewPreview': 'SINGLE',
|
||||
'lib.viewGrid': 'ALL THUMBS',
|
||||
'lib.subsShow': 'SHOW SUBFOLDERS',
|
||||
'lib.subsHide': 'THIS FOLDER ONLY',
|
||||
'lib.subsHint': 'Frames in subfolders: the whole branch under the open folder, or only what that folder holds itself.',
|
||||
'lib.open': 'Open in the studio',
|
||||
'lib.openExplorer': 'Open in window explorer',
|
||||
'lib.openLocation': 'Open at location',
|
||||
'lib.openStudio': 'Open in studio',
|
||||
'lib.mergePanorama': 'Panorama Merge…',
|
||||
'lib.mergeHdr': 'HDR Merge (Anti-ghosting)…',
|
||||
'lib.copiedPath': 'Copied location to clipboard: {path}',
|
||||
'lib.edited': 'edited',
|
||||
'lib.reset': 'Reset to default',
|
||||
'lib.size': '{mb} MB',
|
||||
'lib.count': '{n} photos',
|
||||
// What the thumbnail view is read through. The score a frame is given is the
|
||||
// reader's own, so the words say so: "stars and up" is a floor, not a target.
|
||||
'lib.sort': 'SORT',
|
||||
'lib.sortNewest': 'Newest first',
|
||||
'lib.sortOldest': 'Oldest first',
|
||||
'lib.sortStars': 'Most stars',
|
||||
'lib.sortDateTaken': 'Date taken',
|
||||
'lib.sortDateEdited': 'Date edited',
|
||||
'lib.sortFileType': 'File type',
|
||||
'lib.sortName': 'File name',
|
||||
'lib.sortAsc': 'Ascending',
|
||||
'lib.sortDesc': 'Descending',
|
||||
'lib.rating': 'RATING',
|
||||
'lib.ratingAll': 'Any rating',
|
||||
'lib.ratingAtLeast': '{n}★ and up',
|
||||
'lib.search.label': 'Search Immich',
|
||||
'lib.search.placeholder': 'Name or tag…',
|
||||
'lib.search.hint': 'Immich: type a file name or a tag, then Enter — the answer becomes a node of its own in the tree.',
|
||||
'lib.search.noServer': 'No Immich server yet — add one first.',
|
||||
'lib.starHint': 'Give this frame {n} stars',
|
||||
'lib.rotateLeft': 'Turn this frame left',
|
||||
'lib.rotateRight': 'Turn this frame right',
|
||||
'lib.year': 'YEAR',
|
||||
'lib.yearAll': 'Any year',
|
||||
'lib.hourFrom': 'Shot from',
|
||||
'lib.hourFromAny': 'Any hour',
|
||||
'lib.hourTo': 'Shot until',
|
||||
'lib.hourToAny': 'Any hour',
|
||||
'lib.scanning': 'Scanning {done}/{total} — {added} new…',
|
||||
'lib.scanned': 'Scanned {folder}: {added}/{total} new frames.',
|
||||
'lib.empty': 'Nothing in this folder yet.',
|
||||
'lib.noFolders': 'No folder yet. Hit ADD FOLDER and pick a photo folder on this machine.',
|
||||
'lib.blocked':
|
||||
'Another tab of this app is holding the library open from an older build. Close that tab, or reload it, and this one picks the library up.',
|
||||
'lib.missing': 'Could not open {name} — the file has been moved or deleted.',
|
||||
'lib.offline':
|
||||
'Could not open {name} — the photo folder is not connected. Plug the drive back in and try again.',
|
||||
'lib.failed': 'That folder could not be read.',
|
||||
'lib.unsupported':
|
||||
'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.',
|
||||
// The catalogue's own copy: the browser's storage is the one place a cleared
|
||||
// profile takes the reading away from, and these two are what puts it back.
|
||||
'lib.backup': 'BACK UP',
|
||||
'lib.restore': 'RESTORE',
|
||||
'lib.backupNone': 'No backup folder yet — press to pick one and write the catalogue into it.',
|
||||
'lib.backupAt': 'Backup: {folder} · {n} frames · {time}',
|
||||
'lib.backupBlocked': 'The browser has not let this page write to {folder}. Press BACK UP and allow it.',
|
||||
'lib.backupPick': 'Pick the folder the catalogue is written into and read back from — the folder name is the backup name.',
|
||||
'lib.backupRun': 'Backing up {done}/{total}…',
|
||||
'lib.backupDone': 'Backed up {n} frames, {w} tiles written.',
|
||||
'lib.backupFailed': 'The backup folder could not be written to ({why}).',
|
||||
'lib.restoreConfirm': 'Restore {n} frames from {folder}, written {time}? Frames the library already holds are left as they are.',
|
||||
'lib.restoreRun': 'Restoring {done}/{total}…',
|
||||
'lib.restoreDone': 'Restored {n} frames. Add the folder again so every frame gets its file back.',
|
||||
'lib.restoreNothing': 'There is no catalogue of its own in {folder} — pick the folder a backup was written into.',
|
||||
'lib.restoreFailed': 'The backup folder could not be read.',
|
||||
|
||||
// The repair (Library.tsx, admin only): every roll read back to the disk, so
|
||||
// the catalogue and the disk say the same thing again.
|
||||
'lib.repair': 'REPAIR DATA',
|
||||
'lib.repairConfirm': 'Read all {n} folders from the top so the library matches the disk? Frames that are gone will be dropped from the catalogue.',
|
||||
'lib.repairRun': 'Repairing the library ({n} folders)…',
|
||||
'lib.repairDone': 'Library repaired: {n} folders read back.',
|
||||
|
||||
// Immich (src/engine/immich.ts, src/ui/ImmichDialog.tsx, Library.tsx): one more
|
||||
// source beside the folders on disk — a server the account keeps, read through
|
||||
// the backend's own routes. The key never reaches this page.
|
||||
'lib.immich.add': 'ADD IMMICH',
|
||||
'lib.immich.title': 'ADD AN IMMICH SERVER',
|
||||
'lib.immich.albumsChoose': 'CHOOSE ALBUMS',
|
||||
'lib.immich.name': 'Name shown',
|
||||
'lib.immich.nameHint': 'Leave blank to use the hostname',
|
||||
'lib.immich.url': 'Server address',
|
||||
'lib.immich.key': 'API key',
|
||||
'lib.immich.keyShare': 'This is a share link, not an API key',
|
||||
'lib.immich.keyCheck': 'CHECK THE KEY',
|
||||
'lib.immich.checking': 'CHECKING…',
|
||||
'lib.immich.perms':
|
||||
'The API key needs all six read permissions: album.read, album.statistics, asset.read, asset.view, asset.download and asset.statistics.',
|
||||
'lib.immich.ok': 'The key works.',
|
||||
'lib.immich.albumsPick': 'Tick the albums to keep in the library. Ticking none means all of them.',
|
||||
'lib.immich.albumIds': 'Or paste album IDs',
|
||||
'lib.immich.albumIdsHint': 'One ID per line, or a whole …/albums/<id> address',
|
||||
'lib.immich.badIds': '{n} line(s) with no album ID in them were skipped.',
|
||||
'lib.immich.unknownAlbum': 'This key sees no album {id}.',
|
||||
'lib.immich.newKey': 'New key (blank to keep the current one)',
|
||||
'lib.immich.noDownload': 'This key cannot download originals — frames can be viewed and graded from their previews only.',
|
||||
'lib.immich.sync': 'READ AGAIN',
|
||||
'lib.immich.disconnect': 'DISCONNECT',
|
||||
'lib.immich.keep': 'REMOVE FROM LIBRARY',
|
||||
'lib.immich.failed': 'This server could not be read.',
|
||||
'lib.immich.auth': 'The key is wrong or too narrow.',
|
||||
'lib.immich.unreachable': 'That address could not be reached.',
|
||||
'lib.immich.serverErr': 'The server answered with something unusable.',
|
||||
'lib.immich.invalid': 'An address and a key are both needed.',
|
||||
'lib.immich.empty': 'This key sees no album.',
|
||||
'lib.immich.added': 'Read {n} frames from {folder}.',
|
||||
'lib.immich.reading': 'Reading {folder}, still reading…',
|
||||
'lib.immich.hint': 'Right-click to read again, choose albums, disconnect or remove it from the library.',
|
||||
'lib.immich.links': 'Saved cloud links',
|
||||
'lib.immich.dup': 'same address — the library is read twice',
|
||||
'lib.immich.linkForget': 'REMOVE THE LINK',
|
||||
'immich.upload': 'Upload to Immich',
|
||||
'immich.uploadTitle': 'Upload edited photo to Immich Cloud',
|
||||
'immich.uploading': 'Uploading…',
|
||||
'immich.uploadSuccess': 'Successfully uploaded photo to Immich Cloud!',
|
||||
'immich.uploadFailed': 'Failed to upload to Immich Cloud: {msg}',
|
||||
'immich.uploadToCloud': 'Upload to Immich Cloud',
|
||||
'immich.noServer': 'No Immich Cloud server connected',
|
||||
|
||||
// Mobile Cloud Library
|
||||
'lib.mobile.photos': 'Photos',
|
||||
'lib.mobile.search': 'Search',
|
||||
'lib.mobile.albums': 'Albums',
|
||||
'lib.mobile.library': 'Library',
|
||||
'lib.mobile.allAlbums': 'All albums',
|
||||
'lib.mobile.allPhotos': 'All photos',
|
||||
'lib.mobile.addImmich': 'ADD IMMICH',
|
||||
'lib.mobile.addImmichCloud': 'ADD IMMICH CLOUD SERVER',
|
||||
'lib.mobile.cloudDesc': 'Mobile browsers do not support local file system management. Cloud library allows you to browse and edit photos directly from your Immich Cloud.',
|
||||
'lib.mobile.servers': 'CLOUD SERVERS',
|
||||
'lib.mobile.noServers': 'No Immich Cloud servers connected yet.',
|
||||
'lib.mobile.noPhotos': 'No photos in this cloud library yet.',
|
||||
'lib.mobile.noAlbums': 'No albums found.',
|
||||
'lib.mobile.connected': 'Connected',
|
||||
'lib.mobile.scroll': 'scroll',
|
||||
'lib.mobile.openStudio': 'OPEN IN STUDIO',
|
||||
'lib.mobile.preview': 'Preview',
|
||||
'lib.mobile.details': 'Details',
|
||||
'lib.mobile.rescan': 'RELOAD',
|
||||
'lib.mobile.chooseAlbums': 'CHOOSE ALBUMS',
|
||||
'lib.mobile.disconnect': 'DISCONNECT',
|
||||
'lib.mobile.searchPlaceholder': 'Search on Immich (filename, tag)...',
|
||||
|
||||
'nav.admin': 'Admin',
|
||||
'nav.photos': 'My photos',
|
||||
'nav.profile': 'Profile',
|
||||
'nav.library': 'LIBRARY',
|
||||
'nav.studio': 'Studio',
|
||||
'adm.title': 'Strip moderation',
|
||||
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
|
||||
@@ -266,6 +475,7 @@ export const en: Dict = {
|
||||
'adm.userName': 'Name',
|
||||
'adm.userEmail': 'Email',
|
||||
'adm.userRole': 'Role',
|
||||
'adm.userPro': 'Activated Pro',
|
||||
'adm.userPhotos': 'Photos',
|
||||
'adm.userJoined': 'Joined',
|
||||
'adm.roleAdmin': 'Admin',
|
||||
@@ -329,6 +539,32 @@ export const en: Dict = {
|
||||
'err.generic': 'Something went wrong: {msg}',
|
||||
'err.offline': 'Could not reach the server.',
|
||||
|
||||
// The offline bar (src/ui/OfflineBar.tsx).
|
||||
'offline.notice': 'No server — showing the copy saved on this machine.',
|
||||
// A write the server never got. It is held, not lost; the sentence rides
|
||||
// `err.generic`'s `{msg}`, so it reads as the second half of that one, and the
|
||||
// count is the bar's own line for the queue (`offline.pending`).
|
||||
'offline.queued': 'the server is out of reach, so this change is saved here and sent when it is back.',
|
||||
'offline.pending': 'Not sent yet: {n}',
|
||||
|
||||
// A folder the reader pointed at that is already in the library.
|
||||
'lib.dupFolder': '"{folder}" is already in the library.',
|
||||
// A repair the reader cut short, told apart from one that ran to its end.
|
||||
'lib.repairHalt': 'Repair stopped: {n} folders read back before it was stopped.',
|
||||
|
||||
// The crash screen (src/ui/ErrorBoundary.tsx).
|
||||
'crash.title': 'The app hit an error',
|
||||
'crash.body': 'Something went wrong. The details are below — send them along if it keeps happening.',
|
||||
'crash.reload': 'Reload',
|
||||
|
||||
// The one-window guard (src/ui/SingleTab.tsx).
|
||||
'tab.title': 'RecipesCam is open in another tab',
|
||||
'tab.body': 'The app runs one tab at a time. Use that one, or move it to this tab.',
|
||||
'tab.here': 'Use this tab',
|
||||
'tab.close': 'Close this tab',
|
||||
'tab.taken': 'Another tab just opened RecipesCam and took over.',
|
||||
'tab.retake': 'Take it back',
|
||||
|
||||
'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)',
|
||||
};
|
||||
|
||||
|
||||
+249
-12
@@ -13,13 +13,24 @@ export const vi = {
|
||||
'nav.account': 'Tài khoản',
|
||||
'nav.theme': 'Giao diện',
|
||||
'nav.language': 'Ngôn ngữ',
|
||||
'nav.tabs': 'TAB',
|
||||
'nav.back': 'Về trang chủ',
|
||||
'nav.admin': 'Quản trị',
|
||||
'nav.photos': 'Ảnh của tôi',
|
||||
'nav.profile': 'Hồ sơ',
|
||||
'nav.library': 'THƯ VIỆN',
|
||||
'nav.studio': 'Studio',
|
||||
'nav.guest': 'Khách',
|
||||
|
||||
// The install offer (src/ui/InstallPrompt.tsx). The only dialog that asks for
|
||||
// something the visitor did not come for, so the copy sells what they get out of
|
||||
// it and the refusal is one word away.
|
||||
'install.title': 'Cài RecipesCam lên màn hình chính',
|
||||
'install.body': 'Cài lên màn hình chính để mở nhanh hơn — mượt mà hơn, offline, toàn màn hình như một app.',
|
||||
'install.yes': 'ĐỒNG Ý',
|
||||
'install.no': 'KHÔNG CẢM ƠN',
|
||||
'install.ios': 'Bấm nút Chia sẻ trên thanh địa chỉ, rồi chọn "Thêm vào màn hình chính".',
|
||||
|
||||
'act.undo': 'HOÀN TÁC',
|
||||
'act.redo': 'LÀM LẠI',
|
||||
'act.reset': 'ĐẶT LẠI',
|
||||
@@ -34,15 +45,15 @@ export const vi = {
|
||||
'drop.clear': 'Xoá ảnh',
|
||||
'drop.peeking': 'ĐANG XEM ẢNH GỐC',
|
||||
'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc',
|
||||
'drop.compare': 'So với ảnh gốc',
|
||||
'drop.keep': 'LƯU ẢNH GỐC',
|
||||
'drop.compare': 'So sánh',
|
||||
'drop.keep': 'Lưu ảnh gốc',
|
||||
'drop.keepHint': 'Lưu nguyên ảnh gốc chưa chỉnh vào thư viện ảnh của máy. Trình duyệt không chia sẻ được tệp thì ảnh sẽ được tải về.',
|
||||
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
|
||||
|
||||
// The live viewfinder. The status line is the one fact the user cannot see
|
||||
// for themselves: the preview runs at its own resolution, and how fast the
|
||||
// device grades it is what tells them the phone is keeping up.
|
||||
'cam.open': 'MỞ CAMERA',
|
||||
'cam.open': 'Mở camera',
|
||||
'cam.shot': 'CHỤP',
|
||||
'cam.flip': 'Đổi camera trước/sau',
|
||||
'cam.starting': 'Đang mở camera…',
|
||||
@@ -53,7 +64,7 @@ export const vi = {
|
||||
'cam.shotFail': 'Chụp không thành công — thử lại.',
|
||||
|
||||
// The histogram overlay. 'HISTOGRAM' is the word Vietnamese photographers use.
|
||||
'hist.title': 'HISTOGRAM',
|
||||
'hist.title': 'Histogram',
|
||||
'hist.drag': 'Kéo để di chuyển',
|
||||
'hist.hide': 'Ẩn histogram',
|
||||
|
||||
@@ -61,8 +72,7 @@ export const vi = {
|
||||
'tab.favorited': 'FAVORITED',
|
||||
'tab.photos': 'ẢNH CỦA TÔI',
|
||||
'tab.light': 'LIGHT',
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.tools': 'TOOLS',
|
||||
'tab.hsl': 'HSL',
|
||||
'hsl.hint': 'BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH',
|
||||
'tab.frame': 'FRAME',
|
||||
@@ -95,6 +105,21 @@ export const vi = {
|
||||
'frame.cinematic': 'ĐIỆN ẢNH',
|
||||
'frame.wall': 'WALL FRAME PORTRAIT',
|
||||
'frame.wallLand': 'WALL FRAME LANDSCAPE',
|
||||
'frame.oldFilm': 'OLD FILM LANDSCAPE',
|
||||
'frame.oldFilmPort': 'OLD FILM PORTRAIT',
|
||||
'frame.custom': 'KHUNG TỰ CHỌN',
|
||||
'frame.import': '+ THÊM KHUNG',
|
||||
'frame.customTitle': 'Khung tự thêm (Transparent Frame)',
|
||||
'frame.zoom': 'Thu phóng',
|
||||
'frame.panX': 'Dịch ngang',
|
||||
'frame.panY': 'Dịch dọc',
|
||||
'frame.fit': 'Vừa khung',
|
||||
'frame.fill': 'Lấp đầy',
|
||||
'frame.center': 'Căn giữa',
|
||||
'frame.replace': 'Đổi khung',
|
||||
'frame.remove': 'Bỏ khung',
|
||||
'frame.dragHint': 'Kéo trên ảnh để di chuyển • Cuộn chuột để phóng to/thu nhỏ',
|
||||
'frame.analyzing': 'Đang phân tích vùng trong suốt của khung...',
|
||||
|
||||
'crop.none': 'KHÔNG CẮT',
|
||||
'crop.1:1': '1:1',
|
||||
@@ -141,7 +166,7 @@ export const vi = {
|
||||
'auth.loggedInAs': 'Đã đăng nhập: {email}',
|
||||
'auth.verifyTitle': 'Xác thực email',
|
||||
'auth.verifyBody': 'Đã gửi mã xác thực 6 số tới {email}. Nhập mã vào ô dưới đây — hoặc mở liên kết trong email — để dùng được các tính năng PRO.',
|
||||
'auth.verifyProHint': 'Khi chưa xác thực, tài khoản dùng y như khách: ảnh xuất ra tối đa 2048px kèm watermark và không lưu được gì.',
|
||||
'auth.verifyProHint': 'Khi chưa xác thực, tài khoản vẫn ở bản cơ bản: lưu công thức, lưu ảnh (tối đa 12) và xuất ảnh trong giới hạn cạnh dài của ảnh đang chỉnh sửa. Các tính năng PRO (HSL, TOOLS, RAW, xuất lớn hơn) cần tài khoản được kích hoạt.',
|
||||
'auth.verifySent': 'Đã gửi lại mã xác thực.',
|
||||
'auth.codePlaceholder': 'Mã 6 số trong email',
|
||||
'auth.verifyBtn': 'XÁC THỰC',
|
||||
@@ -149,11 +174,19 @@ export const vi = {
|
||||
'auth.codeLocked': 'Sai quá nhiều lần — hãy gửi lại email để nhận mã mới.',
|
||||
'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một mã xác thực 6 số tới email — nhập mã để bật các tính năng PRO.',
|
||||
|
||||
// The PRO gate. Signed in but unproven is served as a guest, so the studio
|
||||
// needs one place that says why and one way to ask for the letter again.
|
||||
'pro.verifyChip': 'XÁC THỰC EMAIL ĐỂ DÙNG PRO',
|
||||
// The trial notice under the header: which features are being tried out, on
|
||||
// whom, and until when.
|
||||
'notice.trial': 'Một vài tính năng sẽ được sử dụng thử với người đã đăng ký đến ngày 1/12, sau đó chỉ mở các tính năng này trên ứng dụng android',
|
||||
|
||||
// The PRO gate. A signed-in account the operator has not activated has the
|
||||
// basic tier, so the studio needs one place that says what that means (the
|
||||
// bar), one reason per locked tab (the chips), and one answer for a click on
|
||||
// either of them.
|
||||
'pro.hslChip': 'HSL LÀ TÍNH NĂNG PRO',
|
||||
'pro.unverifiedBar': 'Tài khoản {email} chưa xác thực email — đang dùng như khách.',
|
||||
'pro.fixChip': 'FIX LÀ TÍNH NĂNG PRO',
|
||||
'pro.gradientChip': 'GRADIENT MASK LÀ TÍNH NĂNG PRO',
|
||||
'pro.basicBar': 'Tài khoản {email} đang ở bản cơ bản — tính năng PRO cần tài khoản được kích hoạt.',
|
||||
'pro.locked': 'Tính năng PRO — tài khoản chưa được kích hoạt. Nhờ quản trị viên tích "Kích hoạt PRO".',
|
||||
'pro.resend': 'GỬI LẠI EMAIL XÁC THỰC',
|
||||
'pro.resent': 'ĐÃ GỬI',
|
||||
|
||||
@@ -210,7 +243,10 @@ export const vi = {
|
||||
'export.4k': 'XUẤT 4K (4096px)',
|
||||
'export.customLabel': 'Cạnh dài nhất (px)',
|
||||
'export.custom': 'XUẤT',
|
||||
'export.cap': 'Bản khách giới hạn ở {px}px.',
|
||||
'export.cap': 'Tài khoản này xuất tối đa {px}px.',
|
||||
'export.share': 'Chia sẻ lên',
|
||||
'export.shareHint':
|
||||
'Điện thoại: app của mạng mở ra với ảnh đã nằm trong ô đăng. Máy tính: ảnh được copy vào clipboard, dán (Ctrl+V) vào ô đăng vừa mở; nơi không cho dán thì ảnh được tải về để bạn kéo vào.',
|
||||
|
||||
'photo.uploading': 'ĐANG TẢI…',
|
||||
|
||||
@@ -228,6 +264,180 @@ export const vi = {
|
||||
'photos.failed': 'Không thực hiện được: {msg}',
|
||||
'photos.noLabels': 'Chưa có nhãn dải phim',
|
||||
|
||||
'lib.title': 'THƯ VIỆN',
|
||||
'lib.locked': 'Thư viện dành cho tài khoản. Đăng nhập để duyệt các thư mục ảnh của bạn.',
|
||||
// Nút LIBRARY khi chưa có tài khoản: liên kết không được mời, và đây là lý do.
|
||||
'lib.needAccount': 'Thư viện dành cho tài khoản — hãy đăng nhập để mở.',
|
||||
'lib.hint':
|
||||
'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
|
||||
'lib.add': 'THÊM THƯ MỤC',
|
||||
'lib.stop': 'DỪNG QUÉT',
|
||||
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
|
||||
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
|
||||
'lib.update': 'CẬP NHẬT...',
|
||||
'lib.rescan': 'QUÉT LẠI...',
|
||||
'lib.makeThumbs': 'TẠO ẢNH THU NHỎ',
|
||||
'lib.thumbsMade': 'Đã tạo {n} ảnh thu nhỏ cho {folder}.',
|
||||
'lib.resume': 'ĐỌC TIẾP {folder}',
|
||||
'lib.resumeHint': 'Lượt đọc thư mục này bị cắt ngang. Bấm để đọc tiếp từ chỗ đã dừng.',
|
||||
'lib.remove': 'BỎ',
|
||||
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
||||
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, tạo ảnh thu nhỏ còn thiếu, thu gọn hay bỏ thư mục.',
|
||||
'lib.collapseAll': 'THU GỌN TẤT CẢ',
|
||||
'lib.collapse': 'THU GỌN',
|
||||
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
|
||||
'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)',
|
||||
'lib.viewPreview': 'MỘT ẢNH',
|
||||
'lib.viewGrid': 'TOÀN BỘ',
|
||||
'lib.subsShow': 'HIỆN CẢ THƯ MỤC CON',
|
||||
'lib.subsHide': 'CHỈ THƯ MỤC ĐANG CHỌN',
|
||||
'lib.subsHint':
|
||||
'Ảnh trong thư mục con: hiện cả nhánh dưới thư mục đang chọn, hoặc chỉ ảnh của chính thư mục đó.',
|
||||
'lib.open': 'Mở trong studio',
|
||||
'lib.openExplorer': 'Open in window explorer',
|
||||
'lib.openLocation': 'Open at location',
|
||||
'lib.openStudio': 'Mở trong studio',
|
||||
'lib.mergePanorama': 'Ghép Panorama…',
|
||||
'lib.mergeHdr': 'Ghép HDR (Chống nhòe)…',
|
||||
'lib.copiedPath': 'Đã sao chép đường dẫn: {path}',
|
||||
'lib.edited': 'đã chỉnh',
|
||||
'lib.reset': 'Đặt lại ảnh về mặc định',
|
||||
'lib.size': '{mb} MB',
|
||||
'lib.count': '{n} ảnh',
|
||||
// Cách đọc bày ảnh: điểm sao là của người xem, nên chữ nói đúng thế — "từ n
|
||||
// sao" là mức sàn, không phải mức để nhắm tới.
|
||||
'lib.sort': 'SẮP XẾP',
|
||||
'lib.sortNewest': 'Mới chụp trước',
|
||||
'lib.sortOldest': 'Cũ nhất trước',
|
||||
'lib.sortStars': 'Nhiều sao nhất',
|
||||
'lib.sortDateTaken': 'Ngày chụp',
|
||||
'lib.sortDateEdited': 'Ngày sửa/lưu',
|
||||
'lib.sortFileType': 'Loại tệp',
|
||||
'lib.sortName': 'Tên tệp',
|
||||
'lib.sortAsc': 'Tăng dần',
|
||||
'lib.sortDesc': 'Giảm dần',
|
||||
'lib.rating': 'XẾP HẠNG',
|
||||
'lib.ratingAll': 'Mọi mức sao',
|
||||
'lib.ratingAtLeast': 'Từ {n} sao',
|
||||
'lib.search.label': 'Tìm trên Immich',
|
||||
'lib.search.placeholder': 'Tên ảnh hoặc tag…',
|
||||
'lib.search.hint': 'Immich: gõ tên ảnh hoặc tên tag rồi Enter — kết quả thành một mục riêng trong cây.',
|
||||
'lib.search.noServer': 'Chưa có máy chủ Immich nào — thêm một cái trước.',
|
||||
'lib.starHint': 'Cho ảnh này {n} sao',
|
||||
'lib.rotateLeft': 'Xoay ảnh sang trái',
|
||||
'lib.rotateRight': 'Xoay ảnh sang phải',
|
||||
'lib.year': 'NĂM',
|
||||
'lib.yearAll': 'Mọi năm',
|
||||
'lib.hourFrom': 'Chụp từ',
|
||||
'lib.hourFromAny': 'Mọi giờ',
|
||||
'lib.hourTo': 'Chụp đến',
|
||||
'lib.hourToAny': 'Mọi giờ',
|
||||
'lib.scanning': 'Đang quét {done}/{total} — thêm {added}…',
|
||||
'lib.scanned': 'Đã quét {folder}: {added}/{total} ảnh mới.',
|
||||
'lib.empty': 'Chưa có ảnh nào trong thư mục này.',
|
||||
'lib.noFolders': 'Chưa có thư mục nào. Bấm THÊM THƯ MỤC để chọn một thư mục ảnh trên máy.',
|
||||
'lib.blocked':
|
||||
'Một tab khác của ứng dụng đang giữ thư viện ở bản cũ hơn. Hãy đóng tab đó, hoặc tải lại nó, để tab này mở được thư viện.',
|
||||
'lib.missing': 'Không mở được {name} — tệp đã bị di chuyển hoặc xoá.',
|
||||
'lib.offline':
|
||||
'Không mở được {name} — thư mục ảnh chưa kết nối. Cắm lại ổ đĩa rồi thử lại.',
|
||||
'lib.failed': 'Không đọc được thư mục này.',
|
||||
'lib.unsupported':
|
||||
'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.',
|
||||
// Bản sao của danh mục: bộ nhớ trình duyệt là chỗ duy nhất mà một profile bị
|
||||
// xoá lấy mất phần đã đọc, và hai nút này là cách lấy lại.
|
||||
'lib.backup': 'SAO LƯU',
|
||||
'lib.restore': 'PHỤC HỒI',
|
||||
'lib.backupNone': 'Chưa có thư mục sao lưu — bấm để chọn một thư mục và ghi danh mục vào đó.',
|
||||
'lib.backupAt': 'Sao lưu: {folder} · {n} ảnh · {time}',
|
||||
'lib.backupBlocked': 'Trình duyệt chưa cho trang ghi vào {folder}. Bấm SAO LƯU và cho phép.',
|
||||
'lib.backupPick': 'Chọn thư mục mà danh mục được ghi vào và đọc lại từ đó — tên thư mục chính là tên bản sao lưu.',
|
||||
'lib.backupRun': 'Đang sao lưu {done}/{total}…',
|
||||
'lib.backupDone': 'Đã sao lưu {n} ảnh, ghi {w} thumbnail.',
|
||||
'lib.backupFailed': 'Không ghi được vào thư mục sao lưu ({why}).',
|
||||
'lib.restoreConfirm': 'Phục hồi {n} ảnh từ {folder}, ghi lúc {time}? Những ảnh thư viện đã có sẽ được giữ nguyên.',
|
||||
'lib.restoreRun': 'Đang phục hồi {done}/{total}…',
|
||||
'lib.restoreDone': 'Đã phục hồi {n} ảnh. Thêm lại thư mục ảnh để mỗi ảnh lấy lại được tệp của nó.',
|
||||
'lib.restoreNothing': 'Thư mục {folder} không có danh mục sao lưu nào — hãy chọn đúng thư mục đã ghi sao lưu.',
|
||||
'lib.restoreFailed': 'Không đọc được thư mục sao lưu.',
|
||||
|
||||
// Sửa dữ liệu (Library.tsx, chỉ quản trị): đọc lại từng cuộn ảnh về đúng đĩa,
|
||||
// để danh mục và đĩa nói cùng một điều.
|
||||
'lib.repair': 'SỬA DỮ LIỆU',
|
||||
'lib.repairConfirm': 'Đọc lại cả {n} thư mục từ đầu để thư viện khớp với đĩa? Ảnh đã mất sẽ bị gỡ khỏi danh mục.',
|
||||
'lib.repairRun': 'Đang sửa dữ liệu thư viện ({n} thư mục)…',
|
||||
'lib.repairDone': 'Đã sửa dữ liệu thư viện: đọc lại {n} thư mục.',
|
||||
|
||||
// Immich (src/engine/immich.ts, src/ui/ImmichDialog.tsx, Library.tsx): một
|
||||
// nguồn ảnh nữa bên cạnh các thư mục trên máy — một máy chủ mà tài khoản đã
|
||||
// lưu, đọc qua các route của backend. Khoá không bao giờ xuống trang này.
|
||||
'lib.immich.add': 'THÊM IMMICH',
|
||||
'lib.immich.title': 'THÊM MÁY CHỦ IMMICH',
|
||||
'lib.immich.albumsChoose': 'CHỌN ALBUM',
|
||||
'lib.immich.name': 'Tên hiển thị',
|
||||
'lib.immich.nameHint': 'Bỏ trống để lấy tên miền',
|
||||
'lib.immich.url': 'Địa chỉ máy chủ',
|
||||
'lib.immich.key': 'Khoá API',
|
||||
'lib.immich.keyShare': 'Đây là link chia sẻ, không phải API key',
|
||||
'lib.immich.keyCheck': 'KIỂM TRA KHOÁ',
|
||||
'lib.immich.checking': 'ĐANG KIỂM TRA…',
|
||||
'lib.immich.perms':
|
||||
'Khoá API cần đủ 6 quyền đọc: album.read, album.statistics, asset.read, asset.view, asset.download và asset.statistics.',
|
||||
'lib.immich.ok': 'Khoá dùng được.',
|
||||
'lib.immich.albumsPick': 'Tick những album đưa vào thư viện. Không tick gì nghĩa là lấy tất cả.',
|
||||
'lib.immich.albumIds': 'Hoặc dán ID album',
|
||||
'lib.immich.albumIdsHint': 'Mỗi dòng một ID, hoặc dán cả đường dẫn …/albums/<id>',
|
||||
'lib.immich.badIds': 'Bỏ qua {n} dòng không có ID album.',
|
||||
'lib.immich.unknownAlbum': 'Khoá này không thấy album {id}.',
|
||||
'lib.immich.newKey': 'Khoá mới (bỏ trống nếu giữ nguyên)',
|
||||
'lib.immich.noDownload': 'Khoá này không có quyền tải ảnh gốc — chỉ xem và chỉnh được bản xem trước.',
|
||||
'lib.immich.sync': 'ĐỌC LẠI',
|
||||
'lib.immich.disconnect': 'NGẮT KẾT NỐI',
|
||||
'lib.immich.keep': 'XOÁ KHỎI THƯ VIỆN',
|
||||
'lib.immich.failed': 'Không đọc được máy chủ này.',
|
||||
'lib.immich.auth': 'Khoá sai hoặc thiếu quyền đọc.',
|
||||
'lib.immich.unreachable': 'Không kết nối được tới địa chỉ này.',
|
||||
'lib.immich.serverErr': 'Máy chủ trả lời không đọc được.',
|
||||
'lib.immich.invalid': 'Cần có địa chỉ và khoá.',
|
||||
'lib.immich.empty': 'Khoá này không thấy album nào.',
|
||||
'lib.immich.added': 'Đã đọc {n} ảnh từ {folder}.',
|
||||
'lib.immich.reading': 'Đang đọc {folder}, còn đọc tiếp…',
|
||||
'lib.immich.hint': 'Chuột phải để đọc lại, chọn album, ngắt kết nối hoặc xoá khỏi thư viện.',
|
||||
'lib.immich.links': 'Liên kết cloud đã lưu',
|
||||
'lib.immich.dup': 'trùng địa chỉ — thư viện bị đọc hai lần',
|
||||
'lib.immich.linkForget': 'XOÁ LIÊN KẾT',
|
||||
'immich.upload': 'Lưu lên Immich',
|
||||
'immich.uploadTitle': 'Tải ảnh đã sửa lên Immich Cloud',
|
||||
'immich.uploading': 'Đang tải lên…',
|
||||
'immich.uploadSuccess': 'Đã tải ảnh lên Immich Cloud thành công!',
|
||||
'immich.uploadFailed': 'Tải lên Immich Cloud thất bại: {msg}',
|
||||
'immich.uploadToCloud': 'Tải lên Immich Cloud',
|
||||
'immich.noServer': 'Chưa kết nối máy chủ Immich Cloud',
|
||||
|
||||
// Mobile Cloud Library
|
||||
'lib.mobile.photos': 'Ảnh',
|
||||
'lib.mobile.search': 'Tìm kiếm',
|
||||
'lib.mobile.albums': 'Album',
|
||||
'lib.mobile.library': 'Thư viện',
|
||||
'lib.mobile.allAlbums': 'Tất cả album',
|
||||
'lib.mobile.allPhotos': 'Tất cả ảnh',
|
||||
'lib.mobile.addImmich': 'THÊM IMMICH',
|
||||
'lib.mobile.addImmichCloud': 'THÊM MÁY CHỦ IMMICH CLOUD',
|
||||
'lib.mobile.cloudDesc': 'Trên mobile không hỗ trợ quản lý thư mục máy tính cục bộ. Thư viện cloud giúp bạn mở và chỉnh sửa trực tiếp các kho ảnh Immich Cloud.',
|
||||
'lib.mobile.servers': 'MÁY CHỦ CLOUD',
|
||||
'lib.mobile.noServers': 'Chưa có máy chủ Immich Cloud nào được kết nối.',
|
||||
'lib.mobile.noPhotos': 'Chưa có ảnh nào trong thư mục cloud.',
|
||||
'lib.mobile.noAlbums': 'Chưa có album nào.',
|
||||
'lib.mobile.connected': 'Đã kết nối',
|
||||
'lib.mobile.scroll': 'scroll',
|
||||
'lib.mobile.openStudio': 'MỞ TRONG STUDIO',
|
||||
'lib.mobile.preview': 'Xem ảnh',
|
||||
'lib.mobile.details': 'Chi tiết',
|
||||
'lib.mobile.rescan': 'ĐỌC LẠI',
|
||||
'lib.mobile.chooseAlbums': 'CHỌN ALBUM',
|
||||
'lib.mobile.disconnect': 'NGẮT KẾT NỐI',
|
||||
'lib.mobile.searchPlaceholder': 'Tìm kiếm trên Immich (tên ảnh, tag)...',
|
||||
|
||||
'adm.title': 'Quản trị dải phim',
|
||||
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
|
||||
'adm.upload': 'THÊM ẢNH',
|
||||
@@ -272,6 +482,7 @@ export const vi = {
|
||||
'adm.userName': 'Tên',
|
||||
'adm.userEmail': 'Email',
|
||||
'adm.userRole': 'Quyền',
|
||||
'adm.userPro': 'Kích hoạt PRO',
|
||||
'adm.userPhotos': 'Số ảnh',
|
||||
'adm.userJoined': 'Ngày tạo',
|
||||
'adm.roleAdmin': 'Quản trị',
|
||||
@@ -337,6 +548,32 @@ export const vi = {
|
||||
'err.generic': 'Có lỗi xảy ra: {msg}',
|
||||
'err.offline': 'Không gọi được máy chủ.',
|
||||
|
||||
// The offline bar (src/ui/OfflineBar.tsx). Says both halves: the server is
|
||||
// gone, and what is on the screen is the copy this machine kept.
|
||||
'offline.notice': 'Mất kết nối máy chủ — đang xem bản đã lưu trên máy.',
|
||||
'offline.queued': 'máy chủ không tới được, nên thay đổi này đã lưu trên máy và sẽ gửi khi kết nối lại.',
|
||||
'offline.pending': 'Chưa gửi được: {n}',
|
||||
|
||||
// A folder the reader pointed at that is already in the library. The picker
|
||||
// gives no name back to compare by eye, so the note names the one that is there.
|
||||
'lib.dupFolder': 'Thư mục "{folder}" đã có trong thư viện.',
|
||||
// A repair the reader cut short, told apart from one that ran to its end.
|
||||
'lib.repairHalt': 'Đã dừng sửa dữ liệu: đọc lại {n} thư mục trước khi dừng.',
|
||||
|
||||
// The crash screen (src/ui/ErrorBoundary.tsx). Rare, and the only thing a
|
||||
// report of a blank page will have to carry.
|
||||
'crash.title': 'Ứng dụng gặp lỗi',
|
||||
'crash.body': 'Đã có lỗi xảy ra. Chi tiết ở dưới — gửi lại nếu còn tiếp diễn.',
|
||||
'crash.reload': 'Tải lại',
|
||||
|
||||
// The one-window guard (src/ui/SingleTab.tsx).
|
||||
'tab.title': 'RecipesCam đang mở ở tab khác',
|
||||
'tab.body': 'Ứng dụng chỉ chạy một tab một lúc. Dùng tab kia, hoặc chuyển sang tab này.',
|
||||
'tab.here': 'Dùng tab này',
|
||||
'tab.close': 'Đóng tab này',
|
||||
'tab.taken': 'Tab khác vừa mở RecipesCam và tiếp quản.',
|
||||
'tab.retake': 'Lấy lại',
|
||||
|
||||
'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)',
|
||||
} as const;
|
||||
|
||||
|
||||
+121
-17
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles/fonts.css';
|
||||
import './styles/tokens.css';
|
||||
import './styles/app.css';
|
||||
import './styles/landing.css';
|
||||
import './styles/mobile-library.css';
|
||||
import { ThemeProvider } from './theme/ThemeProvider';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { Landing } from './Landing';
|
||||
@@ -10,23 +12,106 @@ import { Workspace } from './App';
|
||||
import { Admin } from './Admin';
|
||||
import { ProfilePage } from './ProfilePage';
|
||||
import { PhotosPage } from './PhotosPage';
|
||||
import { Library } from './Library';
|
||||
import { go } from './nav';
|
||||
import { ErrorBoundary } from './ui/ErrorBoundary';
|
||||
import { OtherWindowNotice, TakenBar, useSingleWindow } from './ui/SingleTab';
|
||||
import { InstallPrompt } from './ui/InstallPrompt';
|
||||
import { OfflineBar } from './ui/OfflineBar';
|
||||
import { installTracking } from './track';
|
||||
|
||||
// Five routes, no router: the landing page, the workspace, the strip moderation
|
||||
// screen, the member's own profile and the member's photo folder. nginx serves
|
||||
// index.html for all of them (SPA fallback), so this is a pathname check.
|
||||
const path = window.location.pathname;
|
||||
const page = path.startsWith('/app') ? (
|
||||
<Workspace />
|
||||
) : path.startsWith('/admin') ? (
|
||||
<Admin />
|
||||
) : path.startsWith('/photos') ? (
|
||||
<PhotosPage />
|
||||
) : path.startsWith('/profile') ? (
|
||||
<ProfilePage />
|
||||
) : (
|
||||
<Landing />
|
||||
);
|
||||
// Six routes, no router: the landing page, the workspace, the strip moderation
|
||||
// screen, the member's own profile, the member's photo folder and the local
|
||||
// catalogue of folders on the visitor's own disk. nginx serves index.html for
|
||||
// all of them (SPA fallback), so this is a pathname check.
|
||||
//
|
||||
// The pages that are not the two heavy screens: they read no catalogue and boot
|
||||
// no renderer, so they are drawn straight from the route and nothing is kept.
|
||||
function page(route: string) {
|
||||
return route.startsWith('/admin') ? (
|
||||
<Admin />
|
||||
) : route.startsWith('/photos') ? (
|
||||
<PhotosPage />
|
||||
) : route.startsWith('/profile') ? (
|
||||
<ProfilePage />
|
||||
) : (
|
||||
<Landing />
|
||||
);
|
||||
}
|
||||
|
||||
// The studio and the library are the two screens worth a boot: ~20MB of CanvasKit
|
||||
// under one and the whole catalogue under the other. Both used to be mounted for
|
||||
// every visit, which is the 20MB and the read on a page that was shown neither,
|
||||
// and it is what a machine short of memory took for a crash. A route mounts its
|
||||
// screen the first time it is asked for and stays mounted after that, so coming
|
||||
// back from the studio is a screen opening rather than a page loading.
|
||||
const heavy = (route: string) => (route.startsWith('/app') ? 'app' : route.startsWith('/library') ? 'library' : 'other');
|
||||
|
||||
function Shell() {
|
||||
const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const target = e.target as Element | null;
|
||||
const link = target?.closest?.('a');
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
if (link.target || link.hasAttribute('download')) return;
|
||||
const url = new URL(link.href, window.location.href);
|
||||
const here = window.location.pathname + window.location.search;
|
||||
if (url.origin !== window.location.origin || url.hash) return;
|
||||
if (url.pathname + url.search === here) return;
|
||||
e.preventDefault();
|
||||
go(url.pathname + url.search);
|
||||
};
|
||||
const onPop = () => setRoute(window.location.pathname + window.location.search);
|
||||
document.addEventListener('click', onClick);
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick);
|
||||
window.removeEventListener('popstate', onPop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const kind = heavy(route);
|
||||
const [seen, setSeen] = useState(() => new Set([kind]));
|
||||
useEffect(() => {
|
||||
setSeen((prev) => (prev.has(kind) ? prev : new Set(prev).add(kind)));
|
||||
}, [kind]);
|
||||
|
||||
// One window at a time, decided before either heavy screen is mounted — see
|
||||
// `useSingleWindow`. `null` is the question still being out: nothing is drawn
|
||||
// for it, because drawing the app and then taking it away is worse than the
|
||||
// fraction of a second the answer takes.
|
||||
const { held, taken, take } = useSingleWindow(kind !== 'other');
|
||||
const blocked = kind !== 'other' && held !== false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocked ? (
|
||||
held ? (
|
||||
<OtherWindowNotice onUse={take} />
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{seen.has('app') ? (
|
||||
<div style={{ display: kind === 'app' ? 'block' : 'none', height: '100%' }}>
|
||||
<Workspace />
|
||||
</div>
|
||||
) : null}
|
||||
{seen.has('library') ? (
|
||||
<div style={{ display: kind === 'library' ? 'block' : 'none', height: '100%' }}>
|
||||
<Library />
|
||||
</div>
|
||||
) : null}
|
||||
{kind === 'other' ? page(route) : null}
|
||||
</>
|
||||
)}
|
||||
{taken ? <TakenBar onTake={take} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('#root missing');
|
||||
|
||||
@@ -35,9 +120,28 @@ if (!root) throw new Error('#root missing');
|
||||
installTracking();
|
||||
|
||||
// No StrictMode: it double-invokes effects, which would load CanvasKit twice and
|
||||
// run the render pipeline twice on every mount.
|
||||
// run the render pipeline twice on every mount. The install offer hangs off the
|
||||
// i18n provider because its copy comes from the dictionaries. The boundary is
|
||||
// the whole app's: an error on the way to the screen used to be a white page.
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider>{page}</I18nProvider>
|
||||
<I18nProvider>
|
||||
<ErrorBoundary>
|
||||
<Shell />
|
||||
</ErrorBoundary>
|
||||
<InstallPrompt />
|
||||
<OfflineBar />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Service worker, production only: in dev it would answer the browser with the
|
||||
// last build's shell instead of vite's modules. Registered after `load` so its
|
||||
// precache does not race the studio's own 20MB of wasm for the connection, and a
|
||||
// browser that refuses it (private mode, a plain-http origin) just loses the
|
||||
// offline shell — nothing else depends on it.
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Internal navigation, in one place. SPA in-place navigation preserves
|
||||
// in-memory photo and folder caches when transitioning between Web Studio
|
||||
// (/app) and Library (/library).
|
||||
//
|
||||
// `replace` is for the moves that are not steps: a screen sending a visitor back
|
||||
// where they belong (the library, to a guest) must not leave itself as the page
|
||||
// the Back button returns to, or the visitor is pushed off it on every press.
|
||||
export function go(url: string, replace = false): void {
|
||||
if (replace) history.replaceState(null, '', url);
|
||||
else history.pushState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Installability lives outside React. The browser fires `beforeinstallprompt` once,
|
||||
// whenever it likes, and the event has to be held until a click spends it — so this
|
||||
// is a module-level slot plus a subscriber list, and no store, hook or context.
|
||||
//
|
||||
// The state the visitor owns is the answer: a refusal, or an install. Remember either
|
||||
// one, and the offer never comes back. A no is an answer, not a snooze.
|
||||
|
||||
type InstallEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
};
|
||||
|
||||
const DISMISSED = 'rc.install.dismissed.v1';
|
||||
// The other answer: installed, and never asked again — including from a plain browser
|
||||
// tab, which has no `standalone` to tell it so.
|
||||
const INSTALLED = 'rc.install.installed.v1';
|
||||
|
||||
// The window event a control elsewhere in the app (the Library's own button, say)
|
||||
// fires to pull the offer forward instead of waiting out the delay.
|
||||
export const OFFER_EVENT = 'rc:install-offer';
|
||||
|
||||
let deferred: InstallEvent | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
// Registers `listener` for every change in installability and returns the
|
||||
// unsubscribe — the shape `useEffect` already has.
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function canInstall(): boolean {
|
||||
return deferred !== null && !dismissed() && !isInstalled();
|
||||
}
|
||||
|
||||
// Spends the browser's prompt and reports the answer. Null means there was nothing
|
||||
// to spend: the event never arrived, or this is a second call.
|
||||
//
|
||||
// Either answer is remembered, because either answer is the visitor's: yes is the app
|
||||
// being installed, and the browser's own sheet being waved away is a no — the event is
|
||||
// spent now, and it never fires again for this install.
|
||||
export async function promptInstall(): Promise<'accepted' | 'dismissed' | null> {
|
||||
const event = deferred;
|
||||
if (!event) return null;
|
||||
// Spent either way, so it is cleared before the await: a second prompt() on the
|
||||
// same event rejects.
|
||||
deferred = null;
|
||||
notify();
|
||||
await event.prompt();
|
||||
const { outcome } = await event.userChoice;
|
||||
if (outcome === 'accepted') markInstalled();
|
||||
else dismissInstall();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// The installed app is its own window; a Safari tab that was added to the home
|
||||
// screen keeps `standalone` on the navigator instead.
|
||||
export function isStandalone(): boolean {
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
// Every browser on iOS is WebKit, but only Safari's own UI has "Add to Home Screen",
|
||||
// and only there does the manual instruction make sense.
|
||||
export function isIosSafari(): boolean {
|
||||
const ua = navigator.userAgent;
|
||||
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
return ios && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
|
||||
}
|
||||
|
||||
export function dismissed(): boolean {
|
||||
return localStorage.getItem(DISMISSED) === '1';
|
||||
}
|
||||
|
||||
export function dismissInstall(): void {
|
||||
localStorage.setItem(DISMISSED, '1');
|
||||
notify();
|
||||
}
|
||||
|
||||
// True in the installed window itself, and also in any browser tab that watched the
|
||||
// install happen — that tab keeps running the site, and must not ask for the app it
|
||||
// just installed.
|
||||
export function isInstalled(): boolean {
|
||||
return isStandalone() || localStorage.getItem(INSTALLED) === '1';
|
||||
}
|
||||
|
||||
export function markInstalled(): void {
|
||||
localStorage.setItem(INSTALLED, '1');
|
||||
notify();
|
||||
}
|
||||
|
||||
// Asks for the offer now.
|
||||
export function offerInstall(): void {
|
||||
window.dispatchEvent(new Event(OFFER_EVENT));
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
// Without this Chromium shows its own mini-infobar and the event is spent; the
|
||||
// app's dialog is the one the visitor gets.
|
||||
event.preventDefault();
|
||||
deferred = event as InstallEvent;
|
||||
notify();
|
||||
});
|
||||
|
||||
// Fires once the app is installed, from our prompt or from the browser's own menu,
|
||||
// and is the only signal that the offer is done.
|
||||
window.addEventListener('appinstalled', () => {
|
||||
deferred = null;
|
||||
markInstalled();
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// Whether the server can be reached, and what it last said.
|
||||
//
|
||||
// Two things the offline story needs and no more: a flag the screen can watch
|
||||
// (the notice in ui/OfflineBar.tsx), and the last good answer to each read, so a
|
||||
// reload with no server comes back on the data it was already showing instead of
|
||||
// an empty page.
|
||||
//
|
||||
// The flag is set by the API layer, the one place that finds out: a fetch that
|
||||
// never got an answer is a server that is gone, and the next one that answers is
|
||||
// the server back. `navigator.onLine` is the browser's guess and is wrong exactly
|
||||
// where it matters — a server down behind a working connection — so it only sets
|
||||
// the flag one way, and never clears it.
|
||||
//
|
||||
// The last good answers live in a store of their own in IndexedDB, keyed by the
|
||||
// route that gave them: the strip's listing is a few hundred kilobytes and
|
||||
// localStorage's few megabytes hold the theme, the language and the library's
|
||||
// view besides. They are this browser's, not this account's, so they are dropped
|
||||
// whenever the account changes (see `forgetStale`, called from the auth calls in
|
||||
// api.ts).
|
||||
//
|
||||
// The writes that found no server are held here too, in order, and sent the
|
||||
// moment one answers — same storage, same account, same clearing. See `hold`.
|
||||
import { keep, recall } from '../remember';
|
||||
|
||||
// The writes waiting for a server. A write the API layer carries is always a
|
||||
// JSON string (the two that carry bytes — a photo, an avatar — never reach here,
|
||||
// see `hold`), so this is a handful of small bodies in the same small
|
||||
// localStorage the theme and the language already live in. A queue long enough
|
||||
// to matter is a queue the visitor has walked away from, and a reload keeps it
|
||||
// anyway.
|
||||
const PENDING = 'rc.pending';
|
||||
// A read is kept whole, whatever its size: the strip's listing carries thousands
|
||||
// of rows and it is exactly the read an offline visit wants an answer to. The
|
||||
// ceiling is only there so a body that is not a listing at all — a few megabytes
|
||||
// of image someone sent through a JSON route — is not written into the store.
|
||||
const CAP = 2_000_000;
|
||||
|
||||
let reachable = true;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
function set(next: boolean): void {
|
||||
if (reachable === next) return;
|
||||
reachable = next;
|
||||
notify();
|
||||
}
|
||||
|
||||
// The shape `useEffect` already has: an unsubscribe.
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function isReachable(): boolean {
|
||||
return reachable;
|
||||
}
|
||||
|
||||
// A request that never got an answer.
|
||||
export function serverGone(): void {
|
||||
set(false);
|
||||
}
|
||||
|
||||
// Any answer at all — a 401 included: a server that refuses is a server that is
|
||||
// there, and what the screen shows for it is its own business.
|
||||
export function serverAnswered(): void {
|
||||
set(true);
|
||||
flush();
|
||||
}
|
||||
|
||||
// One held write: the route, the verb, and the JSON body `call()` was handed.
|
||||
// `body: null` is a write that carries none (`DELETE`).
|
||||
export interface Held {
|
||||
method: string;
|
||||
path: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
function queue(): Held[] {
|
||||
const raw = recall(PENDING);
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as Held[];
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
// A half-written queue is no queue.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setQueue(list: Held[]): void {
|
||||
keep(PENDING, JSON.stringify(list));
|
||||
notify();
|
||||
}
|
||||
|
||||
// How many writes the server has not seen yet — the count the bar shows.
|
||||
export function heldCount(): number {
|
||||
return queue().length;
|
||||
}
|
||||
|
||||
// Hold a write the server did not answer. The same route twice is the later
|
||||
// write in the earlier one's place: two edits of one recipe offline are one
|
||||
// recipe to save, not two.
|
||||
//
|
||||
// Only what the API layer carries is held, and a file is only ever carried
|
||||
// round it (a photo upload, an avatar) — those are not held at all: there is no
|
||||
// trustworthy place for a few megabytes in this browser, and the uploader's own
|
||||
// retry is the honest answer for a file.
|
||||
export function hold(path: string, method: string, body: string | null): void {
|
||||
const list = queue();
|
||||
const at = list.findIndex((item) => item.path === path && item.method === method);
|
||||
const one: Held = { method, path, body };
|
||||
if (at === -1) list.push(one);
|
||||
else list[at] = one;
|
||||
setQueue(list);
|
||||
}
|
||||
|
||||
let sending = false;
|
||||
|
||||
// A server answered: the writes it never got go now, oldest first. A refusal
|
||||
// (4xx) is that server's own answer, so the entry goes — asking the same
|
||||
// question forever is not a queue, it is a loop. No answer at all, or a server
|
||||
// that broke (5xx, 429), stops the run and leaves the rest for the next answer.
|
||||
export function flush(): void {
|
||||
if (sending || !reachable) return;
|
||||
const list = queue();
|
||||
if (list.length === 0) return;
|
||||
sending = true;
|
||||
void (async () => {
|
||||
for (const item of list) {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${item.path}`, {
|
||||
method: item.method,
|
||||
credentials: 'same-origin',
|
||||
headers: item.body === null ? undefined : { 'content-type': 'application/json' },
|
||||
body: item.body ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// Gone again mid-run: what is left stays right where it is, in order.
|
||||
serverGone();
|
||||
break;
|
||||
}
|
||||
if (res.status >= 500 || res.status === 429) break;
|
||||
setQueue(queue().filter((it) => !(it.path === item.path && it.method === item.method)));
|
||||
}
|
||||
sending = false;
|
||||
})();
|
||||
}
|
||||
|
||||
// The reads are a database of their own, not a store added to the library's: a
|
||||
// tab left open holds the library at the version it opened it with, and a new
|
||||
// store there would have every visit wait on that tab. Their own key is the
|
||||
// route, their value the JSON string it answered with.
|
||||
const DB_NAME = 'recipescam-offline';
|
||||
const READS = 'reads';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(READS);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// One request per call, the shape the library's own `ask` has: a keyed get, put
|
||||
// or clear is one transaction and nothing more.
|
||||
function ask(mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<unknown> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = run(db.transaction(READS, mode).objectStore(READS));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// What this route answered the last time a server was there, or null. Answered
|
||||
// out of the store, so it is a promise where the old localStorage read was not;
|
||||
// the one caller already awaits.
|
||||
export async function stale<T>(path: string): Promise<T | null> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await ask('readonly', (s) => s.get(path));
|
||||
} catch {
|
||||
// No store to read, no answer to give — the caller's own empty state.
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
// A half-written value is no value.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Written in the background: the answer on screen is not waiting on the disk.
|
||||
export function keepStale(path: string, body: unknown): void {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = JSON.stringify(body);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (raw.length > CAP) return;
|
||||
void ask('readwrite', (s) => s.put(raw, path)).catch(() => {});
|
||||
}
|
||||
|
||||
// The account changed: the answers held are the last account's, and the next one
|
||||
// must not be shown them. Neither must it send its writes under the new name —
|
||||
// they were made by whoever was signed in when they landed.
|
||||
export function forgetStale(): void {
|
||||
void ask('readwrite', (s) => s.clear()).catch(() => {});
|
||||
try {
|
||||
localStorage.removeItem(PENDING);
|
||||
} catch {
|
||||
// Private mode: there was nothing to drop.
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
// The browser going offline is the flag arriving early, and no more: it is never
|
||||
// used to clear it, because a connection says nothing about the server behind it.
|
||||
window.addEventListener('offline', serverGone);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user