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
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>
391 lines
10 KiB
TypeScript
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;
|