Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion src/components/CameraScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +81,10 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
const [showShutterEffect, setShowShutterEffect] = useState(false);
const [showLensChangeDialog, setShowLensChangeDialog] = useState(false);
const [currentEV, setCurrentEV] = useState<number>(10); // Default EV
const [suggestedShutterSpeed, setSuggestedShutterSpeed] = useState<string>('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);
Expand All @@ -102,6 +107,57 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
};
}, [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...');
Expand Down Expand Up @@ -273,6 +329,22 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
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 (
<Container maxWidth="sm" sx={{ height: '100vh', py: 2, display: 'flex', flexDirection: 'column' }}>
{/* Header */}
Expand Down Expand Up @@ -409,6 +481,23 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
}}
baseline={baseline}
/>

{/* Light Meter Overlay */}
{showLightMeter && (
<LightMeterSlider
aperture={currentSettings.aperture}
suggestedShutterSpeed={suggestedShutterSpeed}
onApertureChange={(newAperture) => {
setCurrentSettings(prev => ({
...prev,
aperture: newAperture as typeof APERTURE[keyof typeof APERTURE]
}));
}}
availableApertures={getAvailableApertures()}
ev={currentEV}
mode={meterMode}
/>
)}
</>
) : (
<Box
Expand Down
127 changes: 127 additions & 0 deletions src/components/LightMeterSlider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react';
import { Box, Typography, Slider } from '@mui/material';
import { SHUTTER_SPEED_VALUES } from '../types';

interface LightMeterSliderProps {
aperture: string;
suggestedShutterSpeed: string;
onApertureChange: (aperture: string) => void;
availableApertures: string[];
ev: number;
mode?: 'hardware' | 'fallback';
}

export const LightMeterSlider: React.FC<LightMeterSliderProps> = ({
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 (
<Box sx={{
position: 'absolute',
right: 10,
top: 80,
bottom: 80,
width: 120,
zIndex: 15,
backgroundColor: mode === 'hardware' ? 'rgba(0, 150, 0, 0.5)' : 'rgba(0, 0, 0, 0.5)',
borderRadius: 2,
padding: 2,
backdropFilter: 'blur(4px)',
display: 'flex',
flexDirection: 'column',
transition: 'background-color 0.3s ease'
}}>
{/* EV Display */}
<Typography variant="caption" color="white" align="center" sx={{ mb: 0.5, fontWeight: 600 }}>
EV {ev.toFixed(1)}
</Typography>
{/* Mode indicator */}
<Typography variant="caption" color="white" align="center" sx={{ mb: 1, fontSize: '8px', opacity: 0.8 }}>
{mode === 'hardware' ? '● Hardware' : '○ Estimated'}
</Typography>

{/* Dual column layout */}
<Box sx={{ flex: 1, display: 'flex', position: 'relative' }}>
{/* Left: Shutter speeds (read-only) */}
<Box sx={{ flex: 1, position: 'relative' }}>
{displayShutterSpeeds.map((speed, idx) => (
<Typography
key={speed}
variant="caption"
color="white"
sx={{
position: 'absolute',
top: `${(idx / (displayShutterSpeeds.length - 1)) * 100}%`,
left: 4,
fontSize: '10px',
fontWeight: speed === suggestedShutterSpeed ? 'bold' : 'normal',
color: speed === suggestedShutterSpeed ? '#4CAF50' : 'white',
transform: 'translateY(-50%)'
}}
>
{speed === suggestedShutterSpeed && '→'}{speed}
</Typography>
))}
</Box>

{/* Vertical separator */}
<Box sx={{ width: 1, backgroundColor: 'rgba(255,255,255,0.3)', mx: 0.5 }} />

{/* Right: Aperture slider */}
<Box sx={{ flex: 1, position: 'relative', pl: 1 }}>
<Slider
orientation="vertical"
value={currentIndex}
onChange={(_, newValue) => {
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
}
}}
/>
</Box>
</Box>

{/* Exposure indicator */}
<Box sx={{ mt: 1, textAlign: 'center' }}>
<Typography variant="caption" color="#4CAF50" sx={{ fontSize: '10px' }}>
● Correct
</Typography>
</Box>
</Box>
);
};
132 changes: 132 additions & 0 deletions src/utils/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
Expand Down