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}
|
||||
|
||||
Reference in New Issue
Block a user