diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 68ab336..49d9b7f 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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 ? ( + setShooting(false)} + /> + ) : null} + {choosingExport ? ( { + 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`; +} diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 00e213d..f2286c5 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -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 { canvasKit.MakeImageFromEncoded( data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer) ), + // An image straight off a canvas source — what the camera's own