Add_local_development_files_and_media_cache_utilities
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ workspace/*
|
|||||||
|
|
||||||
.opencode
|
.opencode
|
||||||
|
|
||||||
|
data/
|
||||||
|
|
||||||
.trae/
|
.trae/
|
||||||
/backend/database/migrations/*
|
/backend/database/migrations/*
|
||||||
|
|||||||
74
fix_database.py
Normal file
74
fix_database.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Quick fix for missing wavespeed columns in usage_summaries table
|
||||||
|
Run this script to fix the database schema issue
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
def fix_database():
|
||||||
|
# Find database file
|
||||||
|
db_path = None
|
||||||
|
for path in ["backend/database.db", "database.db"]:
|
||||||
|
if os.path.exists(path):
|
||||||
|
db_path = path
|
||||||
|
break
|
||||||
|
|
||||||
|
if not db_path:
|
||||||
|
print("❌ Database not found!")
|
||||||
|
print("Please make sure you're running this from the project root directory")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📁 Using database: {db_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='usage_summaries'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("❌ Table 'usage_summaries' not found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get current columns
|
||||||
|
cursor.execute("PRAGMA table_info(usage_summaries)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# Columns that need to be added
|
||||||
|
missing_columns = []
|
||||||
|
required_columns = [
|
||||||
|
'wavespeed_calls', 'tavily_calls', 'serper_calls', 'metaphor_calls',
|
||||||
|
'firecrawl_calls', 'stability_calls', 'exa_calls', 'video_calls',
|
||||||
|
'image_edit_calls', 'audio_calls', 'wavespeed_tokens', 'wavespeed_cost',
|
||||||
|
'tavily_cost', 'serper_cost', 'metaphor_cost', 'firecrawl_cost',
|
||||||
|
'stability_cost', 'exa_cost', 'video_cost', 'image_edit_cost', 'audio_cost'
|
||||||
|
]
|
||||||
|
|
||||||
|
for col in required_columns:
|
||||||
|
if col not in columns:
|
||||||
|
missing_columns.append(col)
|
||||||
|
|
||||||
|
if missing_columns:
|
||||||
|
print(f"➕ Adding {len(missing_columns)} missing columns...")
|
||||||
|
for col in missing_columns:
|
||||||
|
if col.endswith('_calls') or col.endswith('_tokens'):
|
||||||
|
cursor.execute(f"ALTER TABLE usage_summaries ADD COLUMN {col} INTEGER DEFAULT 0")
|
||||||
|
else: # cost columns
|
||||||
|
cursor.execute(f"ALTER TABLE usage_summaries ADD COLUMN {col} FLOAT DEFAULT 0.0")
|
||||||
|
print(f" ✅ Added {col}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("🎉 Database schema updated successfully!")
|
||||||
|
else:
|
||||||
|
print("✅ All columns already exist!")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("🔧 Fixing database schema for usage_summaries...")
|
||||||
|
fix_database()
|
||||||
|
print("✅ Done!")
|
||||||
406
frontend/src/components/PodcastMaker/RobustCamera.tsx
Normal file
406
frontend/src/components/PodcastMaker/RobustCamera.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
357
frontend/src/utils/mediaCache.ts
Normal file
357
frontend/src/utils/mediaCache.ts
Normal 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();
|
||||||
|
};
|
||||||
131
migrate_usage_summaries.py
Normal file
131
migrate_usage_summaries.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to add missing wavespeed columns to usage_summaries table
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def get_db_path():
|
||||||
|
"""Find the database file"""
|
||||||
|
# Look for common database locations
|
||||||
|
possible_paths = [
|
||||||
|
"backend/database.db",
|
||||||
|
"backend/data/database.db",
|
||||||
|
"database.db",
|
||||||
|
"data/database.db"
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
# If not found, check if there's a .db file in backend directory
|
||||||
|
backend_dir = Path("backend")
|
||||||
|
if backend_dir.exists():
|
||||||
|
for db_file in backend_dir.glob("*.db"):
|
||||||
|
return str(db_file)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def migrate_usage_summaries():
|
||||||
|
"""Add missing wavespeed columns to usage_summaries table"""
|
||||||
|
|
||||||
|
db_path = get_db_path()
|
||||||
|
if not db_path:
|
||||||
|
print("❌ Database file not found!")
|
||||||
|
print("Looked in:")
|
||||||
|
for path in ["backend/database.db", "backend/data/database.db", "database.db", "data/database.db"]:
|
||||||
|
print(f" - {path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"📁 Using database: {db_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if table exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='usage_summaries'
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("❌ Table 'usage_summaries' does not exist!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get current columns
|
||||||
|
cursor.execute("PRAGMA table_info(usage_summaries)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
print(f"📋 Current columns: {columns}")
|
||||||
|
|
||||||
|
# Columns to add
|
||||||
|
columns_to_add = [
|
||||||
|
("wavespeed_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("tavily_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("serper_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("metaphor_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("firecrawl_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("stability_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("exa_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("video_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("image_edit_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("audio_calls", "INTEGER DEFAULT 0"),
|
||||||
|
("wavespeed_tokens", "INTEGER DEFAULT 0"),
|
||||||
|
("wavespeed_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("tavily_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("serper_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("metaphor_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("firecrawl_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("stability_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("exa_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("video_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("image_edit_cost", "FLOAT DEFAULT 0.0"),
|
||||||
|
("audio_cost", "FLOAT DEFAULT 0.0")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add missing columns
|
||||||
|
added_columns = []
|
||||||
|
for column_name, column_def in columns_to_add:
|
||||||
|
if column_name not in columns:
|
||||||
|
print(f"➕ Adding column: {column_name}")
|
||||||
|
cursor.execute(f"ALTER TABLE usage_summaries ADD COLUMN {column_name} {column_def}")
|
||||||
|
added_columns.append(column_name)
|
||||||
|
else:
|
||||||
|
print(f"✅ Column already exists: {column_name}")
|
||||||
|
|
||||||
|
if added_columns:
|
||||||
|
conn.commit()
|
||||||
|
print(f"🎉 Successfully added {len(added_columns)} columns:")
|
||||||
|
for col in added_columns:
|
||||||
|
print(f" - {col}")
|
||||||
|
else:
|
||||||
|
print("✅ All columns already exist!")
|
||||||
|
|
||||||
|
# Verify the changes
|
||||||
|
cursor.execute("PRAGMA table_info(usage_summaries)")
|
||||||
|
new_columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
print(f"📋 Updated columns: {new_columns}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"❌ Database error: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Unexpected error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("🚀 Starting usage_summaries migration...")
|
||||||
|
|
||||||
|
if migrate_usage_summaries():
|
||||||
|
print("✅ Migration completed successfully!")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("❌ Migration failed!")
|
||||||
|
sys.exit(1)
|
||||||
50
simple_migrate.py
Normal file
50
simple_migrate.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Find database
|
||||||
|
db_paths = ["backend/database.db", "backend/data/database.db", "database.db", "data/database.db"]
|
||||||
|
db_path = None
|
||||||
|
|
||||||
|
for path in db_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
db_path = path
|
||||||
|
break
|
||||||
|
|
||||||
|
if not db_path:
|
||||||
|
print("Database not found!")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print(f"Using database: {db_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check table
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='usage_summaries'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("Table usage_summaries not found!")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Get columns
|
||||||
|
cursor.execute("PRAGMA table_info(usage_summaries)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
print(f"Columns: {columns}")
|
||||||
|
|
||||||
|
# Check for wavespeed_calls
|
||||||
|
if "wavespeed_calls" in columns:
|
||||||
|
print("✅ wavespeed_calls column exists")
|
||||||
|
else:
|
||||||
|
print("❌ wavespeed_calls column missing")
|
||||||
|
|
||||||
|
# Add the column
|
||||||
|
print("Adding wavespeed_calls column...")
|
||||||
|
cursor.execute("ALTER TABLE usage_summaries ADD COLUMN wavespeed_calls INTEGER DEFAULT 0")
|
||||||
|
conn.commit()
|
||||||
|
print("✅ wavespeed_calls column added")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Migration completed!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
Reference in New Issue
Block a user