6814b055cb
The live view is the one place where the look is chosen before the picture exists, so the columns that hold it have to stay in reach while the camera is open. The viewfinder was a fixed black sheet over the whole app: the rail and every slider were behind it, and the only way to change the look was to close the camera, grade the file, and open it again. It now covers the stage and nothing else — the stage and the view share a slot (`.stage-slot`, position relative) and the view is absolutely placed inside it, z-index 5, black in either theme. Measured at 1600px: the rail and the columns are on screen, the feed takes 1347px of the 1600 and the stage's own 253px rail the rest. A slider moved with the camera open reaches the very next frame (the render loop reads the recipe per frame, so nothing had to be wired), a monochrome sim takes the live feed from sat 42.1 to 0, and the shutter hands the studio that same frame with the sim already on it. The preview render is skipped while the viewfinder is up (`|| shooting` in the guard, and in the effect's deps): it would run the same recipe through the same renderer as the frames the user is actually watching, and the live one wins. Opening it is PRO, like the HSL chips and the geotag — the frames are the paid ones. The button stays on the page for everyone and carries the badge, and the click runs the app's own `promptPro`: the account dialog for a guest, the verification panel for a signed-in account, `/api/auth/me` being what says which. A verified account gets the camera; a guest gets `.modal-backdrop` and no `camera-view` at all. Probes: cam-pro-gate (guest sees the badge, the click opens the dialog, no viewfinder; verified opens it), cam-live-edit (rail and columns survive the live view, feed covers the stage, the recipe reaches the live frames and the shot), cam-smoke, cam-renegotiate, cam-close-flip.
251 lines
9.5 KiB
TypeScript
251 lines
9.5 KiB
TypeScript
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-view" 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>
|
|
);
|
|
}
|