From 93ba3a291903a42b2d6de0711ac8895669520884 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:42:38 +0000 Subject: [PATCH] feat: implement camera-based light meter - Add `analyzeFrameBrightness` and EV calculation to `src/utils/camera.ts` - Implement custom overlay UI `LightMeterSlider` - Integrate dynamic light meter calculation in `CameraScreen` - Add functionality to automatically map to standard shutter speeds - Address text overlapping logic for UI layout - Provide visual distinction for under/overexposed metrics - Add "Apply Suggested" exposure capabilities - Handle single globally cached off-screen canvas to address memory leaks Co-authored-by: JustCreature <54361884+JustCreature@users.noreply.github.com> --- .../todo/plan_F-5.md => features/done_F-5.md} | 0 package-lock.json | 14 -- src/components/CameraScreen.tsx | 89 +++++++++- src/components/LightMeterSlider.tsx | 167 ++++++++++++++++++ src/utils/camera.ts | 116 ++++++++++++ 5 files changed, 367 insertions(+), 19 deletions(-) rename .llm/{tasks/todo/plan_F-5.md => features/done_F-5.md} (100%) create mode 100644 src/components/LightMeterSlider.tsx diff --git a/.llm/tasks/todo/plan_F-5.md b/.llm/features/done_F-5.md similarity index 100% rename from .llm/tasks/todo/plan_F-5.md rename to .llm/features/done_F-5.md diff --git a/package-lock.json b/package-lock.json index 19050d2..b118626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8535,20 +8535,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/src/components/CameraScreen.tsx b/src/components/CameraScreen.tsx index bded7de..f54941b 100644 --- a/src/components/CameraScreen.tsx +++ b/src/components/CameraScreen.tsx @@ -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 @@ -80,6 +82,9 @@ 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 [showLightMeter, setShowLightMeter] = useState(true); // Toggle const currentExposureNumber = exposures.filter(e => e.filmRollId === filmRoll.id).length + 1; const exposuresLeft = filmRoll.totalExposures - (currentExposureNumber - 1); @@ -102,6 +107,48 @@ export const CameraScreen: React.FC = ({ }; }, [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...'); @@ -313,9 +360,18 @@ export const CameraScreen: React.FC = ({ - - - + + setShowLightMeter(!showLightMeter)} + color={showLightMeter ? 'primary' : 'default'} + aria-label="Toggle Light Meter" + > + + + + + + {/* Camera View - Viewfinder Frame */} @@ -409,6 +465,29 @@ export const CameraScreen: React.FC = ({ }} baseline={baseline} /> + + {/* Light Meter Overlay */} + {showLightMeter && ( + { + 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} + /> + )} ) : ( void; + onApplySuggestedShutterSpeed: () => void; + availableApertures: string[]; // Based on current lens + ev: number; // Current EV reading +} + +export const LightMeterSlider: React.FC = ({ + 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 ( + + {/* EV Display */} + + EV {ev.toFixed(1)} + + + {/* Dual column layout */} + + {/* Left: Shutter speeds (read-only) */} + + {/* 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. */} + + {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 ( + + + {isSuggested ? `→ ${speed}` : 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)' + } + }} + /> + + + + {/* Exposure indicator */} + + + {exposureStatus} + + {currentShutterSpeed !== suggestedShutterSpeed && ( + + Apply Suggested + + )} + + + ); +}; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 5870e28..67ef9da 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -156,6 +156,122 @@ export const camera = { } }; +let cachedCanvas: HTMLCanvasElement | null = null; +let cachedCtx: CanvasRenderingContext2D | null = null; + +export const lightMeter = { + // Analyze frame brightness from video element + analyzeFrameBrightness: (video: HTMLVideoElement, stream?: MediaStream): number => { + // Try to read actual hardware exposure settings if available (some Android devices support this) + if (stream) { + const track = stream.getVideoTracks()[0]; + if (track) { + const settings = track.getSettings() as any; + // If the browser provides exposureTime and iso, we can calculate true EV + if (settings.exposureTime && settings.iso) { + // EV = log2(N^2 / t) + log2(ISO/100) -> approximated + // t = exposureTime / 100000 (often in 100 microseconds or similar depending on implementation, + // but we will fallback to pixel brightness if it fails to be a usable range) + // We'll leave the hardware implementation for later if needed and stick to the empirical fallback + // for now as that's what the feature spec outlined. + } + } + } + + if (!cachedCanvas) { + cachedCanvas = document.createElement('canvas'); + } + if (!cachedCtx && cachedCanvas) { + cachedCtx = cachedCanvas.getContext('2d'); + } + + if (!cachedCtx || !cachedCanvas || video.videoWidth === 0 || video.videoHeight === 0) { + return 128; // Default mid-gray if not ready + } + + // Use smaller canvas for performance (scale down by 4) + cachedCanvas.width = Math.floor(video.videoWidth / 4); + cachedCanvas.height = Math.floor(video.videoHeight / 4); + + cachedCtx.drawImage(video, 0, 0, cachedCanvas.width, cachedCanvas.height); + + const imageData = cachedCtx.getImageData(0, 0, cachedCanvas.width, cachedCanvas.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 + }, + + // Convert brightness to EV (Exposure Value) + brightnessToEV: (brightness: number): number => { + // Empirical formula: brightness 0-255 → EV + // This requires calibration but provides a starting point + // Assume brightness 128 ≈ EV 10 (daylight) + const normalizedBrightness = brightness / 255; + + // Logarithmic scale: EV = log2(brightness * scale) + offset + // Calibrated so: brightness 128 → EV ~10 + 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 (without quotes to match SHUTTER_SPEED_VALUES) + // Snap to standard slow speeds: 1, 2, 4, 8 + const slowSpeeds = [1, 2, 4, 8]; + const rounded = Math.round(timeInSeconds); + const closest = slowSpeeds.reduce((prev, curr) => + Math.abs(curr - rounded) < Math.abs(prev - rounded) ? curr : prev + ); + return `${closest}`; + } 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 geolocation = { // Check if geolocation is supported isSupported: (): boolean => {