Files
RecipesCam/docker/frontend/src/engine/camera.ts
T
3dtours cb1839e36b web: shoot through the live camera
The one path where the look is chosen before the picture exists: OPEN CAMERA
grades the camera's own feed with the recipe in force, many times a second, and
the shutter hands the studio the sensor's still under that same recipe. Preview
and file differ in resolution only — the still is `takePhoto`'s own frame, not a
copy of the small preview video, with `grabFrame` and a 2d copy of the element
behind it for the browsers that ship no ImageCapture.

The renderer gains two inputs for it: `sourceImage`, a picture the caller
already decoded (re-encoding the camera's frame to JPEG only to decode it again
would cost more than the whole render), and `drawTo`, which paints the finished
picture instead of encoding it. One render is in flight at a time; a frame that
arrives during one is dropped, so a slow device shows a lower frame rate rather
than a queue of moments that have passed.

The view flashed black on a phone. Setting width/height on a canvas resets its
bitmap: measured on the preview, a resize leaves mean 0 until the next render
lands, which on this box is 0.5s and on a phone more. The buffer was sized from
every incoming frame, and a capture that renegotiates its resolution — which
Chromium does when the page is too slow to consume its frames, and this pipeline
runs ~2 fps at 720p under software GL — strobed black/picture at every switch.
The buffer is now sized on the first frame and after that only when the frame's
aspect changes: a same-aspect frame is scaled into it. Swapping a 1280x720
stream for a 640x360 one mid-view now leaves the buffer at 1280x720 with no
black frame, and 640x360 renders at 6-13 fps instead of 2.

The frames are read from a <video>, which is now IN the document (1px, behind
the black backdrop) rather than detached: Safari draws blank frames from a
detached video, which is the same black-between-pictures. It leaves the document
with the view, and the tracks are stopped, so the camera light goes out.

Probes: cam-smoke (feed painted, resolution, frame rate, a monochrome sim
reaching the live frames, shutter into the studio, close, console clean),
cam-renegotiate (no resize, no blank frame, status line on the frames),
cam-close-flip (flip returns a picture; video gone on close).
2026-09-26 08:26:28 +07:00

121 lines
4.9 KiB
TypeScript

// 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<CameraSession> {
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<void>((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<ImageBitmap> }).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<Blob> {
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`;
}