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).
This commit is contained in:
2026-09-26 08:26:28 +07:00
parent fd2d9935c1
commit cb1839e36b
9 changed files with 576 additions and 13 deletions
+65 -11
View File
@@ -10,11 +10,13 @@ import { NameModal } from './ui/NameModal';
import { DeletePhotoModal } from './ui/DeletePhotoModal';
import { ClearPhotoModal } from './ui/ClearPhotoModal';
import { ExportModal } from './ui/ExportModal';
import { CameraView } from './ui/CameraView';
import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel';
import { useI18n } from './i18n/I18nProvider';
import { loadEngine, MARK_FACES } from './engine/assets';
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
import { cameraSupported } from './engine/camera';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
import { api, type Photo, type SavedRecipe, type User } from './api';
@@ -565,6 +567,10 @@ export function Workspace() {
// CLEAR's confirmation is up: the photo is still on the stage until one of its
// answers is taken.
const [clearing, setClearing] = useState(false);
// The live viewfinder is open. It grades the camera's feed with the recipe in
// force, so shooting is the one path where the look is chosen before the
// picture exists.
const [shooting, setShooting] = useState(false);
// null = closed. /app?auth=1 opens the studio's dialog straight on the sign-in
// tab (sign-in is the default; ?auth=signup asks for the other one); the
// landing page runs the same dialog in place instead of linking here.
@@ -649,6 +655,25 @@ export function Workspace() {
setComparing(false);
}, [source]);
// The geometry and the stamps a render carries, apart from its recipe: the
// preview, the compare copy and the live camera all render with THIS one
// object, so the look the user frames against is the look the file gets.
const stageOptions = useMemo(
() => ({
wallframeLandscape: frameId === 'wallframe-landscape',
sharpen: true,
photoRotation: rotation,
photoStraighten: straighten,
photoFlipH: flipH,
photoFlipV: flipV,
cropRect: cropLive ? cropRect : null,
watermark: markOn && markText ? { text: markText, ...markStyle } : null,
gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[frameId, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle]
);
useEffect(() => {
if (!engine || !previewBytes) return;
const token = ++renderToken.current;
@@ -658,17 +683,7 @@ export function Workspace() {
// The geometry is the frame's own; only the recipe differs between the
// render and the compare copy, so the two can never drift apart.
const frame = cropEditing ? 'none' : frameId;
const options = {
wallframeLandscape: frameId === 'wallframe-landscape',
sharpen: true,
photoRotation: rotation,
photoStraighten: straighten,
photoFlipH: flipH,
photoFlipV: flipV,
cropRect: cropLive ? cropRect : null,
watermark: markOn && markText ? { text: markText, ...markStyle } : null,
gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
};
const options = stageOptions;
const out = await renderPhoto({
source: previewBytes,
recipe,
@@ -877,6 +892,29 @@ export function Workspace() {
[defaultLook, pro, locateMe]
);
// A shot, from the live viewfinder into the studio. The recipe is already in
// force, so the picture arrives wearing the look the user framed: nothing is
// re-applied here, and the file is written later by the same export the
// dropped photos use — on the still, not on a frame of that small video.
const takeShot = useCallback(
async (bytes: Uint8Array, name: string, blob: Blob) => {
setShooting(false);
setError(null);
setBusy(t('auth.busy'));
setOpenPhotoId(null);
try {
await adoptPhoto(bytes, name, blob);
// A shot survives a reload like any other photo of the session's own.
void savePhoto(name, bytes);
} catch {
setError(t('err.decode'));
} finally {
setBusy(null);
}
},
[t, adoptPhoto]
);
const loadFile = useCallback(
async (file: File) => {
setError(null);
@@ -3189,6 +3227,7 @@ export function Workspace() {
peek={peek}
onPeek={setPeek}
onFile={(file) => void loadFile(file)}
onCamera={cameraSupported() ? () => setShooting(true) : undefined}
cropEditing={cropEditing}
cropRect={cropRect}
cropWH={cropWH}
@@ -3330,6 +3369,21 @@ export function Workspace() {
/>
) : null}
{shooting && engine ? (
<CameraView
recipe={recipe}
// A crop being framed is not committed, so the live view composes on
// the bare photo — exactly the geometry the preview behind it uses.
frameId={cropEditing ? 'none' : frameId}
options={stageOptions}
useGeotag={useGeotag}
gpsInfo={gpsInfo}
assets={engine.assets}
onShot={takeShot}
onClose={() => setShooting(false)}
/>
) : null}
{choosingExport ? (
<ExportModal
longest={editedLongest}
+120
View File
@@ -0,0 +1,120 @@
// 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`;
}
+18 -2
View File
@@ -96,13 +96,22 @@ export interface RenderOptions {
}
export interface RenderInput {
source: Uint8Array;
source?: Uint8Array;
recipe: Recipe;
frameId: FrameId;
useGeotag?: boolean;
gpsInfo?: GPSInfo | null;
options?: RenderOptions;
assets: RenderAssets;
// A picture the caller already decoded, instead of `source`'s bytes. The live
// camera has a new one every frame: re-encoding it to JPEG only for this to
// decode it again would cost more than the whole render.
sourceImage?: any;
// Paint the finished picture instead of encoding it, and hand it over here.
// The live view blits it and lets it go; the bytes of a JPEG would be thrown
// away thirty times a second. Called synchronously, with an image that is
// already dead once this returns.
drawTo?: (image: any) => void;
}
export interface RenderResult {
@@ -370,7 +379,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
return obj as T;
};
try {
const skImage0 = Skia.Image.MakeImageFromEncoded(input.source);
const skImage0 = input.sourceImage ?? (input.source ? Skia.Image.MakeImageFromEncoded(input.source) : null);
if (!skImage0) {
console.error('Failed to parse source image');
return null;
@@ -1056,6 +1065,13 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
}
}
// 10. The live view stops here: it wants the picture, not a file, and it
// says so with `drawTo` — no encode, no DPI patch, no bytes.
if (input.drawTo) {
input.drawTo(resultImage);
return { bytes: new Uint8Array(), width: resultImage.width(), height: resultImage.height() };
}
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
if (!bytes || bytes.length === 0) {
console.error('Failed to encode image to JPEG');
+5
View File
@@ -159,6 +159,11 @@ export async function initSkia(options: InitSkiaOptions = {}): Promise<any> {
canvasKit.MakeImageFromEncoded(
data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer)
),
// An image straight off a canvas source — what the camera's own <video>
// is. CanvasKit draws it to a canvas and reads it back; the live view
// needs no JPEG round trip to hand a frame to the renderer.
MakeImageFromCanvasImageSource: (src: CanvasImageSource) =>
canvasKit.MakeImageFromCanvasImageSource(src),
// An image from bytes rather than from a file — the tone curve's 256x1
// lookup table (see toneCurve.ts). RN Skia takes the factory here as
// `Image.MakeImage(info, bytes, bytesPerRow)`; CanvasKit keeps that one
+11
View File
@@ -32,6 +32,17 @@ export const en: Dict = {
'drop.keepHint': 'Save the untouched photo to the device photo library. A browser that cannot share files downloads it instead.',
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
// The live viewfinder (see vi.ts for what the status line is for).
'cam.open': 'OPEN CAMERA',
'cam.shot': 'SHOOT',
'cam.flip': 'Switch front/back camera',
'cam.starting': 'Opening the camera…',
'cam.status': '{w}×{h} · {fps} fps',
'cam.denied': 'The browser was not allowed the camera. Grant it, then open again.',
'cam.unsupported': 'This address cannot use a camera — it needs HTTPS or localhost.',
'cam.fail': 'The camera did not open.',
'cam.shotFail': 'The shot failed — try again.',
'hist.title': 'HISTOGRAM',
'hist.drag': 'Drag to move',
'hist.hide': 'Hide the histogram',
+13
View File
@@ -39,6 +39,19 @@ export const vi = {
'drop.keepHint': 'Lưu nguyên ảnh gốc chưa chỉnh vào thư viện ảnh của máy. Trình duyệt không chia sẻ được tệp thì ảnh sẽ được tải về.',
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
// The live viewfinder. The status line is the one fact the user cannot see
// for themselves: the preview runs at its own resolution, and how fast the
// device grades it is what tells them the phone is keeping up.
'cam.open': 'MỞ CAMERA',
'cam.shot': 'CHỤP',
'cam.flip': 'Đổi camera trước/sau',
'cam.starting': 'Đang mở camera…',
'cam.status': '{w}×{h} · {fps} fps',
'cam.denied': 'Trình duyệt chưa được cho phép dùng camera. Hãy bật quyền rồi mở lại.',
'cam.unsupported': 'Trình duyệt không cho dùng camera ở địa chỉ này — cần HTTPS hoặc localhost.',
'cam.fail': 'Không mở được camera.',
'cam.shotFail': 'Chụp không thành công — thử lại.',
// The histogram overlay. 'HISTOGRAM' is the word Vietnamese photographers use.
'hist.title': 'HISTOGRAM',
'hist.drag': 'Kéo để di chuyển',
+71
View File
@@ -278,6 +278,10 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
}
.dropzone.over { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
.dropzone strong { display: block; color: var(--text); margin-bottom: 6px; font-size: 16px; }
/* The camera sits beside the picker's own hint, so it needs a line of its own
rather than riding on the sentence above it. The click on this button must
not also open the file dialog the dropzone itself listens for. */
.dropzone .btn { margin-top: 14px; }
.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; overflow: hidden; }
.canvas-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow); }
/* Crop editor: the layer is the photo's own box, the rect is the framing and
@@ -713,6 +717,73 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.stage .toolbar .dims { font-family: var(--mono); white-space: nowrap; }
.err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; }
/* --- live viewfinder ---------------------------------------------------- */
/* Full screen and black whatever the theme: a viewfinder is something the user
looks through, not a panel in the page. */
.camera-backdrop {
position: fixed;
inset: 0;
z-index: 70;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px;
}
/* The graded frame itself. `object-fit: contain` keeps the whole picture on
screen at any window shape, which is the composition the export writes. */
.camera-feed {
flex: 1 1 auto;
min-height: 0;
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: #000;
}
.camera-bar,
.camera-deck {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 900px;
}
.camera-bar .hint { color: #a9a9a9; font-family: var(--mono); }
.camera-bar .spacer { flex: 1 1 auto; }
.camera-deck .camera-spacer { width: 44px; }
.camera-err { max-width: 900px; }
/* The <video> the live frames are read from. It has to be IN the document —
Safari paints blank frames from a detached video — but never seen: 1px, faint
and out of the way, behind the black backdrop. Not `display: none`: that stops
the frames as surely as detaching it. */
.camera-source-host {
position: fixed;
left: 0;
bottom: 0;
width: 1px;
height: 1px;
opacity: 0.01;
overflow: hidden;
pointer-events: none;
}
.camera-source-host video { width: 1px; height: 1px; }
/* The shutter: the app's one round control, because it is the one control whose
shape says what it does. */
.shutter {
width: 62px;
height: 62px;
margin: 0 auto;
padding: 0;
border: 3px solid #fff;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.65);
cursor: pointer;
}
.shutter:disabled { opacity: 0.4; cursor: default; }
/* --- auth dialog -------------------------------------------------------- */
.modal-backdrop {
position: fixed;
+250
View File
@@ -0,0 +1,250 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { CK, Skia } from '../engine/skiaShim';
import { renderPhoto, type RenderAssets, type RenderOptions } from '../engine/exportEngine';
import { cameraSupported, captureStill, closeCamera, openCamera, type CameraSession, type Facing } from '../engine/camera';
import type { FrameId, GPSInfo, Recipe } from '../../shared/types';
// The live view: the camera's own feed, graded by the very pipeline that writes
// the file, so whatever the user frames is what they get. Preview and file
// differ in resolution only (see camera.ts) — never in look.
//
// One render is in flight at a time. A frame that arrives while the last is
// still being graded is dropped rather than queued: the queue would grow by
// itself on a slow device, and the honest answer there is a lower frame rate,
// not twenty renders of a moment that has already passed.
// How often the frame-rate readout is refreshed. The number is a fact about the
// device, not a control, so it only has to be current enough to read.
const FPS_WINDOW_MS = 500;
export function CameraView({
recipe,
frameId,
options,
useGeotag,
gpsInfo,
assets,
onShot,
onClose,
}: {
recipe: Recipe;
frameId: FrameId;
options: RenderOptions;
useGeotag: boolean;
gpsInfo: GPSInfo | null;
assets: RenderAssets;
// One shot, full resolution, straight into the studio — where the same recipe
// is already applied, on the still rather than on the video.
onShot: (bytes: Uint8Array, name: string, blob: Blob) => Promise<void> | void;
onClose: () => void;
}) {
const { t } = useI18n();
const [facing, setFacing] = useState<Facing>('back');
const [ready, setReady] = useState(false);
const [fps, setFps] = useState(0);
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [shooting, setShooting] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const sessionRef = useRef<CameraSession | null>(null);
const busyRef = useRef(false);
// The buffer is sized once, on the first frame. See `blit`.
const sizedRef = useRef(false);
// One offscreen canvas, reused: where a frame that is not the buffer's size is
// staged so it can be scaled into the fixed buffer.
const scratchRef = useRef<HTMLCanvasElement | null>(null);
// The loop runs off refs, so a recipe change (or a watermark added while the
// camera is open) reaches the very next frame without restarting the camera.
const liveRef = useRef({ recipe, frameId, options, useGeotag, gpsInfo });
liveRef.current = { recipe, frameId, options, useGeotag, gpsInfo };
// The composed picture, onto the visible canvas. A raw readback, not a JPEG:
// this is the one place the bytes are wanted as pixels.
//
// The canvas buffer is sized on the first frame and, after that, only when the
// frame's ASPECT changes. Setting width/height resets a canvas' bitmap, so a
// resize is a black view until the next render lands — and a camera that
// answers at another resolution mid-session (Chromium's capture renegotiates
// when the page is too slow to consume its frames) would strobe between black
// and picture at every switch. Same aspect, so the frame is scaled into the
// buffer instead: the view goes soft rather than blank. A new aspect (the
// other camera, usually) is worth the one clear — and the camera is starting
// over there anyway.
const blit = useCallback((image: any) => {
const canvas = canvasRef.current;
if (!canvas) return;
const w = image.width();
const h = image.height();
const pixels = image.readPixels(0, 0, {
width: w,
height: h,
colorType: CK.ColorType.RGBA_8888,
alphaType: CK.AlphaType.Unpremul,
colorSpace: CK.ColorSpace.SRGB,
});
if (!pixels) return;
const clamped = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.length);
const aspect = w / h;
const resized =
!sizedRef.current ||
Math.abs(canvas.width / Math.max(1, canvas.height) - aspect) > 0.01;
if (resized) {
sizedRef.current = true;
canvas.width = w;
canvas.height = h;
}
// The status line reports the frames as they arrive, not the buffer: after a
// renegotiation those are two different numbers, and the incoming one is the
// resolution the preview is really running at.
setSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h }));
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (w === canvas.width && h === canvas.height) {
ctx.putImageData(new ImageData(clamped, w, h), 0, 0);
return;
}
const scratch = (scratchRef.current ??= document.createElement('canvas'));
if (scratch.width !== w || scratch.height !== h) {
scratch.width = w;
scratch.height = h;
}
const sctx = scratch.getContext('2d');
if (!sctx) return;
sctx.putImageData(new ImageData(clamped, w, h), 0, 0);
ctx.drawImage(scratch, 0, 0, w, h, 0, 0, canvas.width, canvas.height);
}, []);
useEffect(() => {
if (!cameraSupported()) {
setError(t('cam.unsupported'));
return;
}
let raf = 0;
let alive = true;
let frames = 0;
let since = performance.now();
openCamera(facing)
.then((session) => {
if (!alive) {
closeCamera(session);
return;
}
sessionRef.current = session;
// The frames are read from a video element, and a DETACHED one is not
// reliably painted: Safari draws blank frames from a video that is not in
// the document, which is the same black-between-pictures a phone showed.
// It lives behind the view, one pixel wide and never seen.
hostRef.current?.appendChild(session.video);
setReady(true);
const tick = () => {
raf = requestAnimationFrame(tick);
const now = performance.now();
frames++;
if (now - since >= FPS_WINDOW_MS) {
setFps(Math.round((frames * 1000) / (now - since)));
frames = 0;
since = now;
}
if (busyRef.current || !Skia) return;
const video = session.video;
if (video.readyState < 2) return;
const source = Skia.Image.MakeImageFromCanvasImageSource(video);
if (!source) return;
busyRef.current = true;
const live = liveRef.current;
renderPhoto({
sourceImage: source,
recipe: live.recipe,
frameId: live.frameId,
useGeotag: live.useGeotag,
gpsInfo: live.gpsInfo,
assets,
options: live.options,
drawTo: blit,
})
.catch(() => undefined)
.finally(() => {
busyRef.current = false;
});
};
raf = requestAnimationFrame(tick);
})
.catch((err: unknown) => {
if (!alive) return;
// A refused permission and an absent camera are different problems with
// different answers, and the browser's own name is the honest one.
setError(String((err as Error)?.name ?? err) === 'NotAllowedError' ? t('cam.denied') : t('cam.fail'));
});
return () => {
alive = false;
cancelAnimationFrame(raf);
closeCamera(sessionRef.current);
sessionRef.current = null;
};
}, [facing, assets, blit, t]);
const shoot = useCallback(async () => {
const session = sessionRef.current;
if (!session || shooting) return;
setShooting(true);
setError(null);
try {
const shot = await captureStill(session);
await onShot(shot.bytes, shot.name, shot.blob);
} catch {
setError(t('cam.shotFail'));
setShooting(false);
}
}, [onShot, shooting, t]);
return (
<div className="camera-backdrop" data-key="camera-view">
{/* The stage and the file are one look; the live frame is shown whole, so
the user composes against the geometry the export will use. */}
<canvas ref={canvasRef} className="camera-feed" data-key="camera-feed" />
{/* Where the <video> the frames are read from lives: in the document, out
of sight (see the effect above). */}
<div ref={hostRef} className="camera-source-host" aria-hidden />
<div className="camera-bar">
<span className="hint" data-key="camera-status">
{/* The one number worth printing: the resolution the preview runs at,
and how fast the device is actually grading it. */}
{ready && size ? t('cam.status', { fps, w: size.w, h: size.h }) : t('cam.starting')}
</span>
<span className="spacer" />
<button type="button" className="btn" data-key="camera-close" onClick={onClose}>
{t('act.cancel')}
</button>
</div>
{error ? <p className="err camera-err">{error}</p> : null}
<div className="camera-deck">
<button
type="button"
className="btn"
data-key="camera-flip"
title={t('cam.flip')}
onClick={() => {
setReady(false);
setFacing((f) => (f === 'back' ? 'front' : 'back'));
}}
>
⇄
</button>
<button
type="button"
className="shutter"
data-key="camera-shutter"
aria-label={t('cam.shot')}
disabled={!ready || shooting}
onClick={shoot}
/>
<span className="camera-spacer" />
</div>
</div>
);
}
+23
View File
@@ -242,6 +242,7 @@ export function ImageStage({
peek,
onPeek,
onFile,
onCamera,
cropEditing = false,
cropRect,
cropWH = null,
@@ -289,6 +290,10 @@ export function ImageStage({
peek: boolean;
onPeek: (on: boolean) => void;
onFile: (file: File) => void;
// The other way a picture arrives: shot here, on the device's own camera, and
// graded by the same pipeline the export uses (see CameraView). Absent on a
// build with no camera to open.
onCamera?: () => void;
// Crop editor: shown over the un-cropped preview until APPLY commits it.
cropEditing?: boolean;
cropRect?: CropRect;
@@ -1424,6 +1429,19 @@ export function ImageStage({
>
<strong>{t('drop.title')}</strong>
<span>{t('drop.hint')}</span>
{onCamera ? (
<button
type="button"
className="btn primary"
data-key="open-camera-empty"
onClick={(e) => {
e.stopPropagation();
onCamera();
}}
>
{t('cam.open')}
</button>
) : null}
<p className="hint">{t('drop.demoLimit', { px: 2048 })}</p>
</div>
) : (
@@ -1979,6 +1997,11 @@ export function ImageStage({
<button type="button" className="btn" data-key="open-photo" onClick={() => inputRef.current?.click()}>
{t('drop.replace')}
</button>
{onCamera ? (
<button type="button" className="btn" data-key="open-camera" onClick={onCamera}>
{t('cam.open')}
</button>
) : null}
{onSaveOriginal ? (
<button
type="button"