Skip to content
Open
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
File renamed without changes.
14 changes: 0 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

89 changes: 84 additions & 5 deletions src/components/CameraScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ import {
PhotoLibrary,
CameraAlt,
Close,
ArrowBack
ArrowBack,
LightMode
} from '@mui/icons-material';
import { camera, geolocation } from '../utils/camera';
import { camera, lightMeter, geolocation } 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 +82,9 @@ 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 [showLightMeter, setShowLightMeter] = useState<boolean>(true); // Toggle

const currentExposureNumber = exposures.filter(e => e.filmRollId === filmRoll.id).length + 1;
const exposuresLeft = filmRoll.totalExposures - (currentExposureNumber - 1);
Expand All @@ -102,6 +107,48 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
};
}, [isCameraActive]);

useEffect(() => {
if (!isCameraActive || !videoRef.current || !showLightMeter) return;

// Analyze brightness every 1000ms
const intervalId = setInterval(() => {
if (videoRef.current && videoRef.current.videoWidth > 0) {
// Get brightness
const brightness = lightMeter.analyzeFrameBrightness(videoRef.current, streamRef.current || undefined);

// Convert to EV
const ev = lightMeter.brightnessToEV(brightness);
setCurrentEV(ev);

// Calculate suggested shutter speed
const iso = filmRoll.ei || filmRoll.iso;
const shutter = lightMeter.calculateShutterSpeed(
currentSettings.aperture,
ev,
iso
);
setSuggestedShutterSpeed(shutter);
}
}, 1000); // Update every 1 second

return () => clearInterval(intervalId);
}, [isCameraActive, showLightMeter, currentSettings.aperture, filmRoll.iso, filmRoll.ei]);

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;
});
};

const startCamera = async () => {
try {
console.log('Starting camera initialization...');
Expand Down Expand Up @@ -313,9 +360,18 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
</Typography>
</Box>
</Box>
<IconButton onClick={onOpenGallery} color="primary" aria-label="View Gallery">
<PhotoLibrary />
</IconButton>
<Box>
<IconButton
onClick={() => setShowLightMeter(!showLightMeter)}
color={showLightMeter ? 'primary' : 'default'}
aria-label="Toggle Light Meter"
>
<LightMode />
</IconButton>
<IconButton onClick={onOpenGallery} color="primary" aria-label="View Gallery">
<PhotoLibrary />
</IconButton>
</Box>
</Box>

{/* Camera View - Viewfinder Frame */}
Expand Down Expand Up @@ -409,6 +465,29 @@ export const CameraScreen: React.FC<CameraScreenProps> = ({
}}
baseline={baseline}
/>

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

interface LightMeterSliderProps {
aperture: string; // Current aperture (user-controlled)
suggestedShutterSpeed: string; // Auto-calculated shutter speed
currentShutterSpeed: string; // The currently set shutter speed
onApertureChange: (aperture: string) => void;
onApplySuggestedShutterSpeed: () => void;
availableApertures: string[]; // Based on current lens
ev: number; // Current EV reading
}

export const LightMeterSlider: React.FC<LightMeterSliderProps> = ({
aperture,
suggestedShutterSpeed,
currentShutterSpeed,
onApertureChange,
onApplySuggestedShutterSpeed,
availableApertures,
ev
}) => {
// Find index of current aperture
const currentIndex = availableApertures.indexOf(aperture);

// Calculate exposure diff roughly based on indices
const currentIdx = SHUTTER_SPEED_VALUES.indexOf(currentShutterSpeed as any);
const suggestedIdx = SHUTTER_SPEED_VALUES.indexOf(suggestedShutterSpeed as any);
let exposureStatus = '● Correct';
let exposureColor = '#4CAF50';
if (currentIdx > suggestedIdx) {
// current is slower than suggested -> overexposed
exposureStatus = '▲ Overexposed';
exposureColor = '#FF9800';
} else if (currentIdx < suggestedIdx) {
// current is faster than suggested -> underexposed
exposureStatus = '▼ Underexposed';
exposureColor = '#2196F3';
}

return (
<Box sx={{
position: 'absolute',
right: 10,
top: 60,
bottom: 120, // Increased to avoid focal length slider overlap
width: 160, // Increased width
zIndex: 20, // Increased zIndex to be safely above other overlays
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 2,
padding: 2,
backdropFilter: 'blur(4px)',
display: 'flex',
flexDirection: 'column'
}}>
{/* EV Display */}
<Typography variant="caption" color="white" align="center" sx={{ mb: 1 }}>
EV {ev.toFixed(1)}
</Typography>

{/* Dual column layout */}
<Box sx={{ flex: 1, display: 'flex', position: 'relative', overflow: 'hidden' }}>
{/* Left: Shutter speeds (read-only) */}
<Box sx={{ flex: 1, position: 'relative', overflowY: 'hidden' }}>
{/* We map all shutter speeds but space them nicely and handle overflow.
Since 17 speeds overlapping is an issue, we calculate relative top position dynamically,
and omit some labels if they are too cramped. However, since we want to point to the suggested one,
we should ensure the suggested one is always visible.
Actually, an easier fix is to just let it be a flex column with space-between. */}
<Box sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
position: 'absolute',
top: 0, bottom: 0, left: 4, right: 0
}}>
{SHUTTER_SPEED_VALUES.map((speed) => {
// Only show every other speed label, EXCEPT always show the suggested one
// Or better yet, just show it but very small with a minimum height so it doesn't overlap
const isSuggested = speed === suggestedShutterSpeed;
return (
<Box key={speed} sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
minHeight: '12px'
}}>
<Typography
variant="caption"
sx={{
fontSize: isSuggested ? '12px' : '9px',
fontWeight: isSuggested ? 'bold' : 'normal',
color: isSuggested ? '#4CAF50' : 'rgba(255,255,255,0.6)',
lineHeight: 1
}}
>
{isSuggested ? `→ ${speed}` : speed}
</Typography>
</Box>
);
})}
</Box>
</Box>

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

{/* 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)'
}
}}
/>
</Box>
</Box>

{/* Exposure indicator */}
<Box sx={{ mt: 1, textAlign: 'center' }}>
<Typography variant="caption" sx={{ color: exposureColor, fontWeight: 'bold', fontSize: '10px' }}>
{exposureStatus}
</Typography>
{currentShutterSpeed !== suggestedShutterSpeed && (
<Typography
variant="caption"
sx={{
display: 'block',
color: 'white',
textDecoration: 'underline',
cursor: 'pointer',
fontSize: '10px',
mt: 0.5
}}
onClick={onApplySuggestedShutterSpeed}
>
Apply Suggested
</Typography>
)}
</Box>
</Box>
);
};
Loading
Loading