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.
20 KiB
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).
// 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ế:
// 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:
// 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:
- 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.
- 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). - 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 |
— |