Files
RecipesCam/CAMERA_PORT_ANDROID.md
T
3dtours 3312facd82 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.
2026-09-28 17:36:30 +07:00

20 KiB
Raw Blame History

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:

  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 —