docs: write down the camera port and the live-view plan

CAMERA_PORT_ANDROID.md lists only what the web build has that Android does
not, one row per gap, with the web file to read next to the Android one.
The plan alongside it is the sketch for a live camera with a Skia filter
on the frame thread.
This commit is contained in:
2026-09-28 17:36:30 +07:00
parent 8dc409777e
commit 3312facd82
2 changed files with 364 additions and 0 deletions
@@ -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
+258
View File
@@ -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` | — |