From a13f9db39f0eb1600c98db74070d74f0aa7a7313 Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Fri, 20 Feb 2026 17:20:49 +0100 Subject: [PATCH] feat: lightmeter (not working) --- src/components/CameraScreen.tsx | 91 ++++++++++++++++++- src/components/LightMeterSlider.tsx | 127 ++++++++++++++++++++++++++ src/utils/camera.ts | 132 ++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/components/LightMeterSlider.tsx diff --git a/src/components/CameraScreen.tsx b/src/components/CameraScreen.tsx index bded7de..b25f5ff 100644 --- a/src/components/CameraScreen.tsx +++ b/src/components/CameraScreen.tsx @@ -26,11 +26,12 @@ import { Close, ArrowBack } from '@mui/icons-material'; -import { camera, geolocation } from '../utils/camera'; +import { camera, geolocation, lightMeter } from '../utils/camera'; import type { FilmRoll, Exposure, ExposureSettings, Lens } from '../types'; import { APERTURE, APERTURE_VALUES, SHUTTER_SPEED, SHUTTER_SPEED_VALUES, EI_VALUES } from '../types'; import { FocalLengthSlider } from './FocalLengthSlider'; import { FocalLengthRulerOverlay } from './FocalLengthRulerOverlay'; +import { LightMeterSlider } from './LightMeterSlider'; import { colors } from '../theme'; // Add CSS for enhanced shutter effect animation @@ -80,6 +81,10 @@ export const CameraScreen: React.FC = ({ const [showSettingsDialog, setShowSettingsDialog] = useState(false); const [showShutterEffect, setShowShutterEffect] = useState(false); const [showLensChangeDialog, setShowLensChangeDialog] = useState(false); + const [currentEV, setCurrentEV] = useState(10); // Default EV + const [suggestedShutterSpeed, setSuggestedShutterSpeed] = useState('1/125'); + const [meterMode, setMeterMode] = useState<'hardware' | 'fallback'>('fallback'); + const showLightMeter = true; // Always show light meter (toggle can be added later) const currentExposureNumber = exposures.filter(e => e.filmRollId === filmRoll.id).length + 1; const exposuresLeft = filmRoll.totalExposures - (currentExposureNumber - 1); @@ -102,6 +107,57 @@ export const CameraScreen: React.FC = ({ }; }, [isCameraActive]); + // Continuous brightness monitoring for light meter using camera hardware data + useEffect(() => { + if (!isCameraActive || !streamRef.current) return; + + // Analyze brightness every 1000ms + const intervalId = setInterval(() => { + if (streamRef.current) { + // Try to get camera's actual exposure data (more accurate) + const cameraData = lightMeter.getCameraExposureData(streamRef.current); + + let ev: number; + + if (cameraData) { + // Use hardware data to calculate EV + ev = lightMeter.calculateEVFromCamera( + cameraData.iso, + cameraData.exposureTime, + currentSettings.aperture + ); + setMeterMode('hardware'); + console.log('✓ Using camera hardware data:', { cameraData, ev }); + } else { + // Fallback to canvas-based brightness analysis + if (videoRef.current && videoRef.current.videoWidth > 0) { + const brightness = lightMeter.analyzeFrameBrightness(videoRef.current); + ev = lightMeter.brightnessToEV(brightness); + setMeterMode('fallback'); + console.log('⚠ Using fallback brightness analysis (less accurate):', { brightness, ev }); + } else { + return; // Video not ready + } + } + + setCurrentEV(ev); + + // Calculate suggested shutter speed + const iso = filmRoll.ei || filmRoll.iso; + const shutter = lightMeter.calculateShutterSpeed( + currentSettings.aperture, + ev, + iso + ); + setSuggestedShutterSpeed(shutter); + + console.log('Light meter result:', { ev, shutter, filmIso: iso, mode: cameraData ? 'hardware' : 'fallback' }); + } + }, 1000); // Update every 1 second + + return () => clearInterval(intervalId); + }, [isCameraActive, currentSettings.aperture, filmRoll.iso, filmRoll.ei]); + const startCamera = async () => { try { console.log('Starting camera initialization...'); @@ -273,6 +329,22 @@ export const CameraScreen: React.FC = ({ setShowLensChangeDialog(false); }; + // Get available apertures based on current lens + const getAvailableApertures = (): string[] => { + const currentLens = lenses.find(l => l.id === currentSettings.lensId); + + if (!currentLens) { + return APERTURE_VALUES; // All apertures if no lens + } + + // Filter apertures based on lens max aperture + const maxAperture = parseFloat(currentLens.maxAperture.replace('f/', '')); + return APERTURE_VALUES.filter(ap => { + const fNumber = parseFloat(ap.replace('f/', '')); + return fNumber >= maxAperture; + }); + }; + return ( {/* Header */} @@ -409,6 +481,23 @@ export const CameraScreen: React.FC = ({ }} baseline={baseline} /> + + {/* Light Meter Overlay */} + {showLightMeter && ( + { + setCurrentSettings(prev => ({ + ...prev, + aperture: newAperture as typeof APERTURE[keyof typeof APERTURE] + })); + }} + availableApertures={getAvailableApertures()} + ev={currentEV} + mode={meterMode} + /> + )} ) : ( void; + availableApertures: string[]; + ev: number; + mode?: 'hardware' | 'fallback'; +} + +export const LightMeterSlider: React.FC = ({ + aperture, + suggestedShutterSpeed, + onApertureChange, + availableApertures, + ev, + mode = 'fallback' +}) => { + // Find index of current aperture + const currentIndex = availableApertures.indexOf(aperture); + + // Filter shutter speeds to show (exclude BULB and slow speeds for cleaner display) + const displayShutterSpeeds = SHUTTER_SPEED_VALUES.filter(speed => + speed !== 'BULB' && !speed.includes('"') + ).slice(0, 10); // Show top 10 speeds + + return ( + + {/* EV Display */} + + EV {ev.toFixed(1)} + + {/* Mode indicator */} + + {mode === 'hardware' ? '● Hardware' : '○ Estimated'} + + + {/* Dual column layout */} + + {/* Left: Shutter speeds (read-only) */} + + {displayShutterSpeeds.map((speed, idx) => ( + + {speed === suggestedShutterSpeed && '→'}{speed} + + ))} + + + {/* Vertical separator */} + + + {/* Right: Aperture slider */} + + { + onApertureChange(availableApertures[newValue as number]); + }} + min={0} + max={availableApertures.length - 1} + step={1} + marks={availableApertures.map((ap, idx) => ({ + value: idx, + label: ap.replace('f/', '') + }))} + sx={{ + height: '100%', + color: 'white', + '& .MuiSlider-thumb': { + width: 20, + height: 20, + border: '2px solid white' + }, + '& .MuiSlider-markLabel': { + color: 'white', + fontSize: '10px', + transform: 'translateX(8px)' + }, + '& .MuiSlider-mark': { + backgroundColor: 'rgba(255,255,255,0.5)', + width: 2 + } + }} + /> + + + + {/* Exposure indicator */} + + + ● Correct + + + + ); +}; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 5870e28..47f05cf 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -191,6 +191,138 @@ export const geolocation = { } }; +export const lightMeter = { + // Get camera's actual exposure settings (ISO, exposure time) + getCameraExposureData: (stream: MediaStream): { iso: number; exposureTime: number } | null => { + try { + const videoTrack = stream.getVideoTracks()[0]; + if (!videoTrack) return null; + + const settings = videoTrack.getSettings() as MediaTrackSettings & { + iso?: number; + exposureTime?: number; + }; + + // Get actual ISO and exposure time from camera hardware + const iso = settings.iso; + const exposureTime = settings.exposureTime; + + if (iso && exposureTime) { + console.log('Camera settings:', { iso, exposureTime, settings }); + return { iso, exposureTime }; + } + + return null; + } catch (error) { + console.warn('Could not get camera exposure data:', error); + return null; + } + }, + + // Calculate EV from camera's actual ISO and exposure time + calculateEVFromCamera: (iso: number, exposureTime: number, aperture: string): number => { + // EV formula: EV = log2(N²/t) + log2(ISO/100) + // Where: + // - N = f-number (aperture) + // - t = exposure time in seconds + // - ISO = sensitivity + + const fNumber = parseFloat(aperture.replace('f/', '')); + + // Convert exposure time from microseconds to seconds + const exposureSeconds = exposureTime / 1000000; + + // Calculate EV + const ev = Math.log2((fNumber * fNumber) / exposureSeconds) + Math.log2(iso / 100); + + console.log('EV calculation:', { fNumber, exposureSeconds, iso, ev }); + + return Math.round(ev * 10) / 10; // Round to 1 decimal + }, + + // Fallback: Analyze frame brightness from video element (less accurate due to auto-exposure) + analyzeFrameBrightness: (video: HTMLVideoElement): number => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx || video.videoWidth === 0 || video.videoHeight === 0) { + return 128; // Default mid-gray if not ready + } + + // Use smaller canvas for performance (scale down by 4) + canvas.width = Math.floor(video.videoWidth / 4); + canvas.height = Math.floor(video.videoHeight / 4); + + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + + let totalLuminance = 0; + let pixelCount = 0; + + // Sample every 4th pixel for better performance + for (let i = 0; i < data.length; i += 16) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // ITU-R BT.709 luminance formula + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + totalLuminance += luminance; + pixelCount++; + } + + return totalLuminance / pixelCount; // Returns 0-255 + }, + + // Fallback: Convert brightness to EV (less accurate) + brightnessToEV: (brightness: number): number => { + // Empirical formula: brightness 0-255 → EV + const normalizedBrightness = brightness / 255; + + // Logarithmic scale + const ev = Math.log2(normalizedBrightness * 100 + 0.01) + 3.5; + + return Math.round(ev * 10) / 10; // Round to 1 decimal + }, + + // Calculate shutter speed for given aperture and EV + calculateShutterSpeed: (aperture: string, ev: number, iso: number): string => { + // EV formula: EV = log2(N²/t) + log2(ISO/100) + // Solve for t (shutter speed time) + // t = N² / (2^EV * ISO/100) + + // Parse aperture (e.g., "f/2.8" → 2.8) + const fNumber = parseFloat(aperture.replace('f/', '')); + + // Calculate shutter time in seconds + const shutterTime = (fNumber * fNumber) / (Math.pow(2, ev) * (iso / 100)); + + // Convert to standard shutter speed notation + return lightMeter.formatShutterSpeed(shutterTime); + }, + + // Format shutter time to standard notation (e.g., 1/125, 1/500) + formatShutterSpeed: (timeInSeconds: number): string => { + if (timeInSeconds >= 1) { + // Slow shutter: 1", 2", 4" + return `${Math.round(timeInSeconds)}"`; + } else { + // Fast shutter: 1/125, 1/500, etc. + const denominator = Math.round(1 / timeInSeconds); + + // Snap to standard values + const standardSpeeds = [4000, 2000, 1000, 500, 250, 125, 60, 30, 15, 8, 4, 2]; + const closest = standardSpeeds.reduce((prev, curr) => + Math.abs(curr - denominator) < Math.abs(prev - denominator) ? curr : prev + ); + + return `1/${closest}`; + } + } +}; + export const fileUtils = { // Scale and compress image file (for gallery uploads) scaleImageFile: (file: File): Promise => {