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:
+65
-11
@@ -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}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user