Files
motovaultpro/frontend/src/shared/components/CameraCapture/CameraCapture.tsx
Eric Gullickson 1ff1931864
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m20s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
fix: re-request camera stream on retake when tracks are inactive (refs #123)
The retake button failed because the stream tracks could become inactive
during the crop phase, but handleRetake never re-acquired the camera.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:26:37 -06:00

391 lines
10 KiB
TypeScript

/**
* @ai-summary Main camera capture component with viewfinder, crop tool, and file fallback
* @ai-context Orchestrates camera access, photo capture, cropping, and file selection
*/
import React, { useState, useCallback, useEffect } from 'react';
import { Box, Alert, Button, Typography, CircularProgress } from '@mui/material';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import type { CameraCaptureProps, CaptureState } from './types';
import {
DEFAULT_ACCEPTED_FORMATS,
DEFAULT_MAX_FILE_SIZE,
GUIDANCE_CONFIGS,
getInitialCropForGuidance,
} from './types';
import { useCameraPermission } from './useCameraPermission';
import { CameraViewfinder } from './CameraViewfinder';
import { CropTool } from './CropTool';
import { FileInputFallback } from './FileInputFallback';
export const CameraCapture: React.FC<CameraCaptureProps> = ({
onCapture,
onCancel,
guidanceType = 'none',
allowCrop = true,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
acceptedFormats = DEFAULT_ACCEPTED_FORMATS,
}) => {
const [captureState, setCaptureState] = useState<CaptureState>('idle');
const [capturedImageSrc, setCapturedImageSrc] = useState<string | null>(null);
const [capturedFile, setCapturedFile] = useState<File | null>(null);
const [useFallback, setUseFallback] = useState(false);
const {
permissionState,
stream,
error: cameraError,
facingMode,
hasMultipleCameras,
requestPermission,
switchCamera,
stopStream,
} = useCameraPermission();
// Request camera permission on mount
useEffect(() => {
if (captureState === 'idle' && !useFallback) {
setCaptureState('viewfinder');
requestPermission();
}
}, [captureState, useFallback, requestPermission]);
// Clean up on unmount
useEffect(() => {
return () => {
stopStream();
if (capturedImageSrc) {
URL.revokeObjectURL(capturedImageSrc);
}
};
}, [stopStream, capturedImageSrc]);
const handleCapture = useCallback(() => {
if (!stream) return;
// Get video element from stream
const videoTracks = stream.getVideoTracks();
if (videoTracks.length === 0) return;
const videoTrack = videoTracks[0];
const settings = videoTrack.getSettings();
// Create canvas for capture
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Find the video element to capture from
const videoElements = document.querySelectorAll('video');
let sourceVideo: HTMLVideoElement | null = null;
for (const video of videoElements) {
if (video.srcObject === stream) {
sourceVideo = video;
break;
}
}
if (!sourceVideo) return;
// Set canvas size to video dimensions
canvas.width = settings.width || sourceVideo.videoWidth || 1920;
canvas.height = settings.height || sourceVideo.videoHeight || 1080;
// Draw video frame to canvas
ctx.drawImage(sourceVideo, 0, 0, canvas.width, canvas.height);
// Convert to blob
canvas.toBlob(
(blob) => {
if (!blob) return;
// Create file from blob
const file = new File([blob], `capture-${Date.now()}.jpg`, {
type: 'image/jpeg',
});
setCapturedFile(file);
setCapturedImageSrc(URL.createObjectURL(blob));
if (allowCrop) {
setCaptureState('cropping');
} else {
// Skip cropping, directly complete
onCapture(file);
stopStream();
}
},
'image/jpeg',
0.92
);
}, [stream, allowCrop, onCapture, stopStream]);
const handleCropConfirm = useCallback(
(croppedBlob: Blob) => {
if (!capturedFile) return;
const croppedFile = new File(
[croppedBlob],
`cropped-${capturedFile.name}`,
{ type: croppedBlob.type }
);
onCapture(capturedFile, croppedFile);
stopStream();
},
[capturedFile, onCapture, stopStream]
);
const handleCropSkip = useCallback(() => {
if (!capturedFile) return;
onCapture(capturedFile);
stopStream();
}, [capturedFile, onCapture, stopStream]);
const handleRetake = useCallback(() => {
if (capturedImageSrc) {
URL.revokeObjectURL(capturedImageSrc);
}
setCapturedFile(null);
setCapturedImageSrc(null);
setCaptureState('viewfinder');
// Re-request camera if stream tracks are no longer active
const isStreamActive = stream?.getVideoTracks().some(
(track) => track.readyState === 'live'
);
if (!isStreamActive) {
requestPermission();
}
}, [capturedImageSrc, stream, requestPermission]);
const handleCropReset = useCallback(() => {
// Just reset crop area, keep the captured image
}, []);
const handleCancel = useCallback(() => {
stopStream();
if (capturedImageSrc) {
URL.revokeObjectURL(capturedImageSrc);
}
onCancel();
}, [stopStream, capturedImageSrc, onCancel]);
const handleFileSelect = useCallback(
(file: File) => {
setCapturedFile(file);
setCapturedImageSrc(URL.createObjectURL(file));
if (allowCrop) {
setCaptureState('cropping');
} else {
onCapture(file);
}
},
[allowCrop, onCapture]
);
const handleSwitchToFallback = useCallback(() => {
stopStream();
setUseFallback(true);
}, [stopStream]);
const handleSwitchToCamera = useCallback(() => {
setUseFallback(false);
setCaptureState('idle');
}, []);
// Get aspect ratio for crop tool if guidance type is set
const cropAspectRatio =
guidanceType !== 'none' ? GUIDANCE_CONFIGS[guidanceType].aspectRatio : undefined;
// Calculate crop coordinates centered on the guidance overlay area
const cropInitialArea = getInitialCropForGuidance(guidanceType);
// Render permission error state
if (permissionState === 'denied' && !useFallback) {
return (
<PermissionErrorView
error={cameraError || 'Camera permission denied'}
onUseFileInput={handleSwitchToFallback}
onCancel={handleCancel}
/>
);
}
// Render unavailable state
if (permissionState === 'unavailable' && !useFallback) {
return (
<PermissionErrorView
error={cameraError || 'Camera not available'}
onUseFileInput={handleSwitchToFallback}
onCancel={handleCancel}
/>
);
}
// Render file input fallback
if (useFallback) {
return (
<Box sx={{ width: '100%', height: '100%' }}>
<FileInputFallback
onFileSelect={handleFileSelect}
onCancel={handleCancel}
acceptedFormats={acceptedFormats}
maxFileSize={maxFileSize}
/>
{/* Option to switch back to camera if available */}
{permissionState !== 'unavailable' && permissionState !== 'denied' && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Button
onClick={handleSwitchToCamera}
startIcon={<CameraAltIcon />}
variant="text"
>
Use Camera Instead
</Button>
</Box>
)}
</Box>
);
}
// Render crop tool
if (captureState === 'cropping' && capturedImageSrc) {
return (
<CropTool
imageSrc={capturedImageSrc}
initialCrop={cropInitialArea}
lockAspectRatio={guidanceType !== 'none' && guidanceType !== 'vin'}
aspectRatio={cropAspectRatio}
onConfirm={handleCropConfirm}
onReset={handleCropReset}
onRetake={handleRetake}
onSkip={handleCropSkip}
/>
);
}
// Render loading state
if (!stream && permissionState === 'prompt') {
return (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'black',
color: 'white',
gap: 2,
}}
>
<CircularProgress color="inherit" />
<Typography>Requesting camera access...</Typography>
<Button
onClick={handleSwitchToFallback}
startIcon={<UploadFileIcon />}
sx={{ color: 'white', mt: 2 }}
>
Upload File Instead
</Button>
</Box>
);
}
// Render viewfinder
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
<CameraViewfinder
stream={stream}
guidanceType={guidanceType}
onCapture={handleCapture}
onCancel={handleCancel}
onSwitchCamera={switchCamera}
canSwitchCamera={hasMultipleCameras}
facingMode={facingMode}
/>
{/* Fallback option button */}
<Box
sx={{
position: 'absolute',
bottom: 100,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 10,
}}
>
<Button
onClick={handleSwitchToFallback}
startIcon={<UploadFileIcon />}
size="small"
sx={{
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
}}
>
Upload File
</Button>
</Box>
</Box>
);
};
interface PermissionErrorViewProps {
error: string;
onUseFileInput: () => void;
onCancel: () => void;
}
const PermissionErrorView: React.FC<PermissionErrorViewProps> = ({
error,
onUseFileInput,
onCancel,
}) => (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
gap: 3,
backgroundColor: 'background.paper',
}}
>
<Alert severity="warning" sx={{ maxWidth: 400 }}>
{error}
</Alert>
<Typography variant="body2" color="text.secondary" textAlign="center">
You can still upload images from your device
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', justifyContent: 'center' }}>
<Button
variant="contained"
onClick={onUseFileInput}
startIcon={<UploadFileIcon />}
>
Upload File
</Button>
<Button variant="outlined" onClick={onCancel}>
Cancel
</Button>
</Box>
</Box>
);
export default CameraCapture;