// The live viewfinder, on the camera the browser hands over. // // Two resolutions, on purpose. The PREVIEW goes through the same render // pipeline as an export, many times a second, so it asks for 720p and no more: // the frame is only ever shown a few hundred pixels wide, and a phone holding a // graded shader loop at full rate is a phone that heats up until the browser // throttles it (camera_native_app_web.md, the thermal note). The STILL is the // opposite — `ImageCapture.takePhoto()` asks the sensor for the photo itself, so // what lands in the studio is not a frame of that same small video, which is the // one thing the web got to keep (md §4.2). // // What the browser will NOT give: the ultrawide or the tele lens, and the // phone's own HDR/Night processing. `facingMode` is the whole lens menu here // (md §3). export type Facing = 'back' | 'front'; // `ideal`, never `exact`: a device with no 720p mode hands back its nearest, // where a refusal would cost the entire camera. const PREVIEW = { width: 1280, height: 720 }; export interface CameraSession { stream: MediaStream; video: HTMLVideoElement; } // Camera is a secure-origin API: https, or localhost. A plain-http LAN address // (the NAS on `http://192.168.x.x:8090`) has `mediaDevices` undefined, and the // caller has to say so rather than fail silently. export function cameraSupported(): boolean { return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; } export async function openCamera(facing: Facing): Promise { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facing === 'front' ? 'user' : 'environment' }, width: { ideal: PREVIEW.width }, height: { ideal: PREVIEW.height }, }, audio: false, }); const video = document.createElement('video'); video.muted = true; video.playsInline = true; video.setAttribute('playsinline', ''); video.srcObject = stream; await video.play(); // play() resolves on the element's move to a playing state, which can still // be a beat before there is a frame to draw. if (video.readyState < 2) { await new Promise((resolve) => { video.onloadeddata = () => resolve(); }); } return { stream, video }; } // The light must go out with the view: a track left running keeps the camera // indicator on and the battery draining. The element goes with it — it is in the // document while the view is open (see CameraView) and has no use outside it. export function closeCamera(session: CameraSession | null): void { if (!session) return; for (const track of session.stream.getTracks()) track.stop(); session.video.srcObject = null; session.video.remove(); } // The shutter. Three answers, best first: `takePhoto` is the sensor's own still // at full size, `grabFrame` a decoded frame of the same video, and a 2D copy of // the element whatever is on screen. Safari ships no ImageCapture at all, so a // viewfinder that could only shoot through it would show a picture and take // none. export async function captureStill( session: CameraSession ): Promise<{ bytes: Uint8Array; blob: Blob; name: string }> { const track = session.stream.getVideoTracks()[0]; const pack = async (blob: Blob) => ({ bytes: new Uint8Array(await blob.arrayBuffer()), blob, name: stillName(), }); for (const shoot of ['photo', 'frame'] as const) { try { const capture = new ImageCapture(track); if (shoot === 'photo') return await pack(await capture.takePhoto()); // `grabFrame` is in the spec but not in TypeScript's DOM lib. const frame = await (capture as unknown as { grabFrame(): Promise }).grabFrame(); if (frame) return await pack(await drawJpeg(frame, frame.width, frame.height)); } catch { /* not this browser, or not this lens: try the next answer down */ } } const el = session.video; return await pack(await drawJpeg(el, el.videoWidth, el.videoHeight)); } function drawJpeg(source: CanvasImageSource, w: number, h: number): Promise { const canvas = document.createElement('canvas'); canvas.width = Math.max(1, w); canvas.height = Math.max(1, h); const ctx = canvas.getContext('2d'); if (!ctx) return Promise.reject(new Error('no 2d context')); ctx.drawImage(source, 0, 0, canvas.width, canvas.height); return new Promise((resolve, reject) => { canvas.toBlob( (blob) => (blob ? resolve(blob) : reject(new Error('encode failed'))), 'image/jpeg', 0.95 ); }); } // A shot is named the moment it is taken, because that is the only fact about it // the browser knows: it carries no EXIF and no original filename. function stillName(): string { const d = new Date(); const p = (n: number) => String(n).padStart(2, '0'); return `camera-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`; }