/** * @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 = ({ onCapture, onCancel, guidanceType = 'none', allowCrop = true, maxFileSize = DEFAULT_MAX_FILE_SIZE, acceptedFormats = DEFAULT_ACCEPTED_FORMATS, }) => { const [captureState, setCaptureState] = useState('idle'); const [capturedImageSrc, setCapturedImageSrc] = useState(null); const [capturedFile, setCapturedFile] = useState(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 ( ); } // Render unavailable state if (permissionState === 'unavailable' && !useFallback) { return ( ); } // Render file input fallback if (useFallback) { return ( {/* Option to switch back to camera if available */} {permissionState !== 'unavailable' && permissionState !== 'denied' && ( )} ); } // Render crop tool if (captureState === 'cropping' && capturedImageSrc) { return ( ); } // Render loading state if (!stream && permissionState === 'prompt') { return ( Requesting camera access... ); } // Render viewfinder return ( {/* Fallback option button */} ); }; interface PermissionErrorViewProps { error: string; onUseFileInput: () => void; onCancel: () => void; } const PermissionErrorView: React.FC = ({ error, onUseFileInput, onCancel, }) => ( {error} You can still upload images from your device ); export default CameraCapture;