Add_local_development_files_and_media_cache_utilities

This commit is contained in:
ajaysi
2026-03-12 15:25:49 +05:30
parent e90a29c27e
commit 446b59e31d
6 changed files with 1019 additions and 0 deletions

View File

@@ -0,0 +1,406 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
CircularProgress,
Alert,
IconButton,
Tooltip
} from '@mui/material';
import {
CameraAlt,
FlipCameraAndroid,
Close,
Camera
} from '@mui/icons-material';
interface RobustCameraProps {
onCapture: (imageDataUrl: string) => void;
onClose: () => void;
open: boolean;
}
export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose, open }) => {
const [stream, setStream] = useState<MediaStream | null>(null);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cameraReady, setCameraReady] = useState(false);
// Use multiple refs for different purposes
const videoElementRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const cameraInitRef = useRef<boolean>(false);
const retryCountRef = useRef<number>(0);
// Clean up stream
const cleanupStream = useCallback(() => {
console.log('[RobustCamera] Cleaning up stream');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (videoElementRef.current && videoElementRef.current.srcObject) {
videoElementRef.current.srcObject = null;
}
setStream(null);
setCameraReady(false);
cameraInitRef.current = false;
retryCountRef.current = 0;
}, []);
// Initialize camera
const initializeCamera = useCallback(async () => {
if (cameraInitRef.current || loading) {
console.log('[RobustCamera] Camera already initializing or loading');
return;
}
console.log('[RobustCamera] Starting camera initialization');
cameraInitRef.current = true;
setLoading(true);
setError(null);
cleanupStream();
try {
const constraints = {
video: {
facingMode: facingMode,
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
};
console.log('[RobustCamera] Requesting camera with constraints:', constraints);
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
console.log('[RobustCamera] Camera stream obtained successfully');
streamRef.current = mediaStream;
setStream(mediaStream);
// Attach to video element
if (videoElementRef.current) {
console.log('[RobustCamera] Video element found, attaching stream');
videoElementRef.current.srcObject = mediaStream;
videoElementRef.current.onloadedmetadata = () => {
console.log('[RobustCamera] Video metadata loaded');
setCameraReady(true);
setLoading(false);
videoElementRef.current?.play().catch(err => {
console.error('[RobustCamera] Video play error:', err);
setError('Camera stream obtained but video display failed. Please try again.');
});
};
videoElementRef.current.onerror = (err) => {
console.error('[RobustCamera] Video error:', err);
setError('Failed to display camera feed.');
setLoading(false);
};
} else {
console.log('[RobustCamera] Video element not found, will attach when ready');
setCameraReady(false);
setLoading(false);
// Stream will be attached when video element mounts
}
} catch (err) {
console.error('[RobustCamera] Camera access error:', err);
cleanupStream();
setLoading(false);
if (err instanceof Error) {
if (err.name === 'NotAllowedError') {
setError('Camera access denied. Please allow camera permissions to take a selfie.');
} else if (err.name === 'NotFoundError') {
setError('No camera found on this device.');
} else if (err.name === 'NotReadableError') {
setError('Camera is already in use by another application.');
} else {
setError('Failed to access camera. Please try again.');
}
}
}
}, [facingMode, loading, cleanupStream]);
// Attach stream when video element is available
const attachStreamToVideo = useCallback(() => {
if (videoElementRef.current && streamRef.current && !cameraReady) {
console.log('[RobustCamera] Attaching stream to video element');
const video = videoElementRef.current;
const stream = streamRef.current;
// Clear any existing stream
if (video.srcObject) {
const oldStream = video.srcObject as MediaStream;
oldStream.getTracks().forEach(track => track.stop());
}
// Attach new stream
video.srcObject = stream;
video.onloadedmetadata = () => {
console.log('[RobustCamera] Video metadata loaded after attachment');
setCameraReady(true);
setLoading(false);
video.play().catch(err => {
console.error('[RobustCamera] Video play error after attachment:', err);
setError('Camera stream obtained but video display failed. Please try again.');
});
};
video.onerror = (err) => {
console.error('[RobustCamera] Video error after attachment:', err);
setError('Failed to display camera feed.');
setLoading(false);
};
} else {
console.log('[RobustCamera] Cannot attach stream:', {
videoExists: !!videoElementRef.current,
streamExists: !!streamRef.current,
cameraReady
});
}
}, [cameraReady]);
// Capture photo
const capturePhoto = useCallback(() => {
if (!videoElementRef.current || !canvasRef.current || !cameraReady) {
console.log('[RobustCamera] Cannot capture: video or canvas not ready');
return;
}
const video = videoElementRef.current;
const canvas = canvasRef.current;
// Set canvas dimensions to match video
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw video frame to canvas
const context = canvas.getContext('2d');
if (context) {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Convert to data URL
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
console.log('[RobustCamera] Photo captured successfully');
onCapture(imageDataUrl);
onClose();
}
}, [cameraReady, onCapture, onClose]);
// Flip camera
const flipCamera = useCallback(() => {
console.log('[RobustCamera] Flipping camera');
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
}, []);
// Initialize camera when dialog opens
useEffect(() => {
if (open) {
console.log('[RobustCamera] Dialog opened, initializing camera');
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
initializeCamera();
}, 300);
return () => {
clearTimeout(timer);
cleanupStream();
};
}
}, [open]); // Remove initializeCamera and cleanupStream from dependencies
// Re-initialize when facing mode changes
useEffect(() => {
if (open && cameraReady) {
console.log('[RobustCamera] Facing mode changed, re-initializing camera');
cleanupStream();
const timer = setTimeout(() => {
initializeCamera();
}, 500);
return () => clearTimeout(timer);
}
}, [facingMode]); // Remove other dependencies to prevent loops
// Attach stream when video element is available
useEffect(() => {
if (videoElementRef.current && streamRef.current && !cameraReady) {
console.log('[RobustCamera] Video element available, attaching stream');
attachStreamToVideo();
}
}, [stream, cameraReady]); // Trigger when stream changes
// Attach stream when component mounts or stream changes
useEffect(() => {
if (open && stream && !cameraReady && videoElementRef.current) {
console.log('[RobustCamera] Stream available, attaching to video element');
attachStreamToVideo();
}
}, [open, stream]); // Remove cameraReady and attachStreamToVideo to prevent loops
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
overflow: 'hidden'
}
}}
>
<DialogTitle sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: 'primary.main',
color: 'white'
}}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CameraAlt />
Take a Selfie
</Typography>
<IconButton onClick={onClose} sx={{ color: 'white' }}>
<Close />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 3, minHeight: 400 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loading && (
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 300,
gap: 2
}}>
<CircularProgress size={60} />
<Typography variant="body1" color="text.secondary">
Initializing camera...
</Typography>
</Box>
)}
{!loading && !error && (
<Box sx={{
position: 'relative',
width: '100%',
maxWidth: 600,
mx: 'auto',
bgcolor: 'black',
borderRadius: 2,
overflow: 'hidden'
}}>
<video
ref={videoElementRef}
autoPlay
playsInline
muted
style={{
width: '100%',
height: 'auto',
display: cameraReady ? 'block' : 'none',
transform: facingMode === 'user' ? 'scaleX(-1)' : 'none'
}}
/>
{!cameraReady && stream && (
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.8)',
color: 'white'
}}>
<Typography variant="body1">
Camera stream ready, attaching to display...
</Typography>
</Box>
)}
{!cameraReady && !stream && !loading && (
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.8)',
color: 'white',
gap: 2
}}>
<Camera sx={{ fontSize: 60 }} />
<Typography variant="body1" textAlign="center">
Camera not ready
</Typography>
</Box>
)}
</Box>
)}
<canvas
ref={canvasRef}
style={{ display: 'none' }}
/>
</DialogContent>
<DialogActions sx={{ p: 3, gap: 2 }}>
{cameraReady && (
<>
<Tooltip title="Flip Camera">
<IconButton onClick={flipCamera} color="primary">
<FlipCameraAndroid />
</IconButton>
</Tooltip>
<Button
onClick={capturePhoto}
variant="contained"
size="large"
startIcon={<CameraAlt />}
sx={{
borderRadius: 2,
px: 3,
py: 1.5
}}
>
Capture Photo
</Button>
</>
)}
<Button onClick={onClose} variant="outlined">
Cancel
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,357 @@
/**
* Media Cache Utility
*
* Provides intelligent caching for images, videos, and audio to prevent
* unnecessary network requests when navigating between phases.
*
* Enhanced with scene-specific cache keys to prevent cross-contamination
* between different podcasts and scenes.
*/
interface CacheEntry {
blobUrl: string;
timestamp: number;
originalUrl: string;
mediaType: 'image' | 'video' | 'audio';
size?: number;
sceneId?: string;
projectId?: string;
}
class MediaCache {
private cache = new Map<string, CacheEntry>();
private readonly maxAge = 10 * 60 * 1000; // 10 minutes
private readonly maxSize = 50; // Maximum number of cached items
private blobCleanupMap = new Map<string, string>(); // Maps blobUrl to cache key
/**
* Generate a unique cache key that includes scene and project context
*/
private generateCacheKey(url: string, sceneId?: string, projectId?: string): string {
// Create a composite key that prevents cross-contamination
const parts = [url];
if (sceneId) parts.push(`scene:${sceneId}`);
if (projectId) parts.push(`project:${projectId}`);
return parts.join('|');
}
/**
* Extract original URL from cache key
*/
private extractOriginalUrl(cacheKey: string): string {
return cacheKey.split('|')[0];
}
/**
* Get cached blob URL for a media resource with optional scene context
*/
get(url: string, sceneId?: string, projectId?: string): string | null {
const cacheKey = this.generateCacheKey(url, sceneId, projectId);
const entry = this.cache.get(cacheKey);
if (!entry) {
// Try without scene context for backwards compatibility
const fallbackKey = this.generateCacheKey(url);
const fallbackEntry = this.cache.get(fallbackKey);
if (fallbackEntry) {
console.log(`[MediaCache] Cache hit (fallback) for ${fallbackEntry.mediaType}:`, url);
return fallbackEntry.blobUrl;
}
return null;
}
// Check if cache entry is still valid
if (Date.now() - entry.timestamp > this.maxAge) {
this.invalidate(cacheKey);
return null;
}
console.log(`[MediaCache] Cache hit for ${entry.mediaType}:`, url,
sceneId ? `(scene: ${sceneId})` : '',
projectId ? `(project: ${projectId})` : '');
return entry.blobUrl;
}
/**
* Set cached blob URL for a media resource with scene context
*/
set(url: string, blobUrl: string, mediaType: 'image' | 'video' | 'audio', size?: number, sceneId?: string, projectId?: string): void {
const cacheKey = this.generateCacheKey(url, sceneId, projectId);
// Clean up existing blob URL if it exists
const existingEntry = this.cache.get(cacheKey);
if (existingEntry && existingEntry.blobUrl !== blobUrl) {
this.revokeBlob(existingEntry.blobUrl);
}
const entry: CacheEntry = {
blobUrl,
timestamp: Date.now(),
originalUrl: url,
mediaType,
size,
sceneId,
projectId
};
this.cache.set(cacheKey, entry);
this.blobCleanupMap.set(blobUrl, cacheKey);
// Enforce cache size limit
if (this.cache.size > this.maxSize) {
this.evictOldest();
}
console.log(`[MediaCache] Cached ${mediaType}:`, url,
sceneId ? `(scene: ${sceneId})` : '',
projectId ? `(project: ${projectId})` : '');
}
/**
* Check if URL is cached (with optional scene context)
*/
has(url: string, sceneId?: string, projectId?: string): boolean {
const cacheKey = this.generateCacheKey(url, sceneId, projectId);
const entry = this.cache.get(cacheKey);
if (!entry) return false;
// Check if still valid
if (Date.now() - entry.timestamp > this.maxAge) {
this.invalidate(cacheKey);
return false;
}
return true;
}
/**
* Invalidate cache entry for a specific URL (with optional scene context)
*/
invalidate(url: string, sceneId?: string, projectId?: string): void {
const cacheKey = this.generateCacheKey(url, sceneId, projectId);
const entry = this.cache.get(cacheKey);
if (entry) {
this.revokeBlob(entry.blobUrl);
this.cache.delete(cacheKey);
this.blobCleanupMap.delete(entry.blobUrl);
}
}
/**
* Clear all cache
*/
clear(): void {
// Revoke all blob URLs
for (const entry of this.cache.values()) {
this.revokeBlob(entry.blobUrl);
}
this.cache.clear();
this.blobCleanupMap.clear();
console.log('[MediaCache] Cache cleared');
}
/**
* Clear cache for specific scene
*/
clearScene(sceneId: string): void {
const toDelete: string[] = [];
for (const [cacheKey, entry] of this.cache.entries()) {
if (entry.sceneId === sceneId) {
toDelete.push(cacheKey);
}
}
toDelete.forEach(cacheKey => {
const entry = this.cache.get(cacheKey);
if (entry) {
this.revokeBlob(entry.blobUrl);
this.cache.delete(cacheKey);
this.blobCleanupMap.delete(entry.blobUrl);
}
});
if (toDelete.length > 0) {
console.log(`[MediaCache] Cleared ${toDelete.length} cache entries for scene: ${sceneId}`);
}
}
/**
* Clear cache for specific project
*/
clearProject(projectId: string): void {
const toDelete: string[] = [];
for (const [cacheKey, entry] of this.cache.entries()) {
if (entry.projectId === projectId) {
toDelete.push(cacheKey);
}
}
toDelete.forEach(cacheKey => {
const entry = this.cache.get(cacheKey);
if (entry) {
this.revokeBlob(entry.blobUrl);
this.cache.delete(cacheKey);
this.blobCleanupMap.delete(entry.blobUrl);
}
});
if (toDelete.length > 0) {
console.log(`[MediaCache] Cleared ${toDelete.length} cache entries for project: ${projectId}`);
}
}
/**
* Get cache statistics
*/
getStats() {
const stats = {
totalEntries: this.cache.size,
entriesByType: {} as Record<string, number>,
entriesByScene: {} as Record<string, number>,
entriesByProject: {} as Record<string, number>,
totalSize: 0,
oldestEntry: 0,
newestEntry: 0
};
let oldest = Date.now();
let newest = 0;
for (const entry of this.cache.values()) {
// Count by type
stats.entriesByType[entry.mediaType] = (stats.entriesByType[entry.mediaType] || 0) + 1;
// Count by scene
if (entry.sceneId) {
stats.entriesByScene[entry.sceneId] = (stats.entriesByScene[entry.sceneId] || 0) + 1;
}
// Count by project
if (entry.projectId) {
stats.entriesByProject[entry.projectId] = (stats.entriesByProject[entry.projectId] || 0) + 1;
}
// Sum sizes
if (entry.size) {
stats.totalSize += entry.size;
}
// Track ages
oldest = Math.min(oldest, entry.timestamp);
newest = Math.max(newest, entry.timestamp);
}
stats.oldestEntry = oldest > 0 ? Date.now() - oldest : 0;
stats.newestEntry = newest > 0 ? Date.now() - newest : 0;
return stats;
}
/**
* Clean up expired entries
*/
cleanup(): void {
const now = Date.now();
const toDelete: string[] = [];
for (const [cacheKey, entry] of this.cache.entries()) {
if (now - entry.timestamp > this.maxAge) {
toDelete.push(cacheKey);
}
}
toDelete.forEach(cacheKey => this.invalidate(this.extractOriginalUrl(cacheKey)));
if (toDelete.length > 0) {
console.log(`[MediaCache] Cleaned up ${toDelete.length} expired entries`);
}
}
/**
* Revoke blob URL safely
*/
private revokeBlob(blobUrl: string): void {
try {
if (blobUrl.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
}
} catch (error) {
console.warn('[MediaCache] Failed to revoke blob URL:', error);
}
}
/**
* Evict oldest cache entry
*/
private evictOldest(): void {
let oldestKey = '';
let oldestTime = Date.now();
for (const [cacheKey, entry] of this.cache.entries()) {
if (entry.timestamp < oldestTime) {
oldestTime = entry.timestamp;
oldestKey = cacheKey;
}
}
if (oldestKey) {
const entry = this.cache.get(oldestKey);
if (entry) {
console.log('[MediaCache] Evicted oldest entry:', this.extractOriginalUrl(oldestKey),
entry.sceneId ? `(scene: ${entry.sceneId})` : '');
}
this.invalidate(this.extractOriginalUrl(oldestKey));
}
}
}
// Export singleton instance
export const mediaCache = new MediaCache();
// Set up periodic cleanup
setInterval(() => {
mediaCache.cleanup();
}, 5 * 60 * 1000); // Clean up every 5 minutes
// Export utility functions with enhanced scene-aware signatures
export const getCachedMedia = (url: string, sceneId?: string, projectId?: string): string | null => {
return mediaCache.get(url, sceneId, projectId);
};
export const setCachedMedia = (
url: string,
blobUrl: string,
mediaType: 'image' | 'video' | 'audio',
size?: number,
sceneId?: string,
projectId?: string
): void => {
mediaCache.set(url, blobUrl, mediaType, size, sceneId, projectId);
};
export const hasCachedMedia = (url: string, sceneId?: string, projectId?: string): boolean => {
return mediaCache.has(url, sceneId, projectId);
};
export const invalidateMediaCache = (url: string, sceneId?: string, projectId?: string): void => {
mediaCache.invalidate(url, sceneId, projectId);
};
export const clearMediaCache = (): void => {
mediaCache.clear();
};
export const clearSceneMediaCache = (sceneId: string): void => {
mediaCache.clearScene(sceneId);
};
export const clearProjectMediaCache = (projectId: string): void => {
mediaCache.clearProject(projectId);
};
export const getMediaCacheStats = () => {
return mediaCache.getStats();
};