feat: video generation, image to image generation and a bunch of new features

This commit is contained in:
xavidop
2025-07-23 16:12:17 +02:00
parent 5a5b279c4a
commit d3bf5498ad
46 changed files with 6606 additions and 1329 deletions
+5 -1
View File
@@ -2,6 +2,7 @@
import { initializeApp, getApps, getApp, type FirebaseApp } from 'firebase/app';
import { getAuth, type Auth, connectAuthEmulator } from 'firebase/auth';
import { getFirestore, type Firestore, connectFirestoreEmulator } from 'firebase/firestore';
import { getStorage, type FirebaseStorage, connectStorageEmulator } from 'firebase/storage';
import firebaseConfig from '@/config/firebase';
// Define which keys from firebaseConfig are considered essential for initialization.
@@ -47,6 +48,7 @@ if (missingEnvVars.length > 0) {
let app: FirebaseApp;
let auth: Auth;
let db: Firestore;
let storage: FirebaseStorage;
// Track if emulators have been connected to avoid multiple connections
let emulatorsConnected = false;
@@ -59,12 +61,14 @@ try {
}
auth = getAuth(app);
db = getFirestore(app);
storage = getStorage(app);
// Connect to emulators in development - do this immediately after getting auth/db instances
if (process.env.NODE_ENV === 'development' && !emulatorsConnected && typeof window !== 'undefined') {
try {
connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true });
connectFirestoreEmulator(db, 'localhost', 8080);
connectStorageEmulator(storage, 'localhost', 9199);
emulatorsConnected = true;
console.log('Firebase emulators connected successfully');
} catch (error) {
@@ -81,4 +85,4 @@ try {
throw error; // Re-throw the original Firebase error
}
export { app, auth, db };
export { app, auth, db, storage };
+277 -23
View File
@@ -12,9 +12,13 @@ import {
Timestamp,
getDoc,
orderBy,
setDoc,
} from 'firebase/firestore';
import { db, auth } from './firebase'; // Main Firebase config
import type { PokemonCard, ScannedCardData } from '@/types';
import { db, auth, storage } from './firebase'; // Main Firebase config
import type { PokemonCard, ScannedCardData, UserProfile, UserApiKeys } from '@/types';
import { uploadCardImageToStorage, uploadVideoToStorage } from '@/utils/storageUtils';
import { ref, deleteObject } from 'firebase/storage';
import { generateCardVideo } from '@/ai/flows/generate-card-video';
const CARDS_COLLECTION = 'users'; // Top-level collection for users
@@ -23,9 +27,95 @@ const getPokemonCardsCollectionRef = (userId: string) => {
return collection(db, CARDS_COLLECTION, userId, 'pokemon_cards');
};
// Background function to generate and upload video for a card
const generateVideoForCard = async (userId: string, cardId: string, card: PokemonCard) => {
try {
console.log(`Starting background video generation for card: ${card.name}`);
// Update status to generating
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'generating'
});
// Determine Pokemon type from generation params or default
const pokemonType = card.generationParams?.pokemonType ||
card.photoGenerationParams?.pokemonType ||
'Normal';
// Generate video using AI
const videoResult = await generateCardVideo({
cardImageUrl: card.imageUrl,
pokemonName: card.name,
pokemonType: pokemonType,
userId: userId,
});
console.log('Video generation result:', {
hasError: !!videoResult.error,
hasVideoBase64: !!videoResult.videoBase64,
videoBase64Type: typeof videoResult.videoBase64,
videoBase64Preview: videoResult.videoBase64?.substring(0, 50) + '...',
});
if (videoResult.error || !videoResult.videoBase64) {
console.error('Video generation failed:', videoResult.error);
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
return;
}
// Ensure we have base64 data, not a URL
if (videoResult.videoBase64.startsWith('http')) {
console.error('Video generation returned a URL instead of base64 data:', videoResult.videoBase64);
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
return;
}
// Upload video to storage
console.log('Uploading video to Firebase Storage...');
const videoUrl = await uploadVideoToStorage(videoResult.videoBase64, userId, card.name);
console.log('Video uploaded to Firebase Storage:', videoUrl);
// Validate that videoUrl is a proper Firebase Storage URL (production or emulator), not raw data
if (!videoUrl.startsWith('http')) {
console.error('Invalid video URL returned from storage upload:', videoUrl.substring(0, 100));
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
return;
}
// Additional safety check - ensure we're not storing large data in Firestore
if (videoUrl.length > 2000) { // URLs should be much shorter than this
console.error('Video URL is suspiciously long, might be raw data:', videoUrl.length);
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
return;
}
// Update card with video info (only storing the URL, not the video data)
await updateCardInCollection(userId, cardId, {
videoUrl, // This should only be a Firebase Storage URL
videoGenerationStatus: 'completed',
videoPrompt: videoResult.prompt,
});
console.log(`Video generation completed successfully for card: ${card.name}`);
} catch (error) {
console.error('Error in background video generation:', error);
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
}
};
export const addCardToCollection = async (
userId: string,
cardData: Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
cardData: Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt' | 'imageUrl'> & { imageDataUrl: string }
): Promise<string> => {
if (!userId) {
console.error("addCardToCollection: User ID is missing.");
@@ -37,47 +127,46 @@ export const addCardToCollection = async (
console.log("DEBUG: Auth user matches:", auth.currentUser?.uid === userId);
try {
// First, upload the image to Firebase Storage
console.log("Uploading card image to Firebase Storage...");
const imageUrl = await uploadCardImageToStorage(cardData.imageDataUrl, userId, cardData.name);
const collectionRef = getPokemonCardsCollectionRef(userId);
const { imageDataUrl, ...cardDataWithoutImage } = cardData;
const docPayload = {
...cardData,
...cardDataWithoutImage,
imageUrl, // Store the Firebase Storage URL instead of base64 data
userId,
// Don't initialize video generation automatically - make it opt-in
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
};
// Log information for debugging, excluding potentially very large imageDataUrl from general log
const payloadKeys = Object.keys(docPayload);
const imageDataUrlLength = docPayload.imageDataUrl ? docPayload.imageDataUrl.length : 0;
console.log(
"Attempting to add card to Firestore. Path:",
collectionRef.path,
"Payload keys:",
payloadKeys.join(', '),
"imageDataUrl length:",
imageDataUrlLength
Object.keys(docPayload).join(', '),
"Image URL:",
imageUrl
);
if (imageDataUrlLength > 500 * 1024) { // Warn if image data is > 500KB
console.warn(
`imageDataUrl is very large (length: ${imageDataUrlLength} bytes). This might approach or exceed Firestore's 1MiB document size limit and could cause write failures. Consider storing images in Firebase Storage instead.`
);
}
const docRef = await addDoc(collectionRef, docPayload);
console.log("Card successfully added to Firestore with ID:", docRef.id);
// Video generation is now opt-in - users can trigger it manually from the UI
return docRef.id;
} catch (error) {
console.error('Error adding card to Firestore (full error object):', error); // Log the full error object
console.error('Error adding card to Firestore (full error object):', error);
let detailedMessage = 'Failed to add card to collection.';
if (error instanceof Error) {
detailedMessage = error.message; // Start with the basic error message
// Attempt to access Firebase-specific error code
// Firebase errors often have a 'code' property, but to be safe with typing, we check its existence.
detailedMessage = error.message;
const firebaseError = error as any;
if (firebaseError.code) {
detailedMessage = `Error: ${firebaseError.code} - ${error.message}`;
}
}
throw new Error(detailedMessage); // Throw a new error with the potentially more detailed message
throw new Error(detailedMessage);
}
};
@@ -114,13 +203,36 @@ export const getCardById = async (userId: string, cardId: string): Promise<Pokem
export const updateCardInCollection = async (
userId: string,
cardId: string,
cardData: Partial<Omit<PokemonCard, 'id' | 'userId' | 'createdAt'>>
cardData: Partial<Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'imageUrl'>> & { imageDataUrl?: string }
): Promise<void> => {
if (!userId || !cardId) throw new Error('User ID and Card ID are required to update a card.');
try {
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
let updateData: any = { ...cardData };
// Safety check: ensure we're not trying to store large video data in Firestore
if (updateData.videoUrl && updateData.videoUrl.length > 2000) {
console.error('Attempted to store large video data in Firestore. This is not allowed.');
throw new Error('Invalid video data: videos must be stored in Firebase Storage, not Firestore');
}
// Validate video URL format if present - allow both production and emulator URLs
if (updateData.videoUrl && !updateData.videoUrl.startsWith('http')) {
console.error('Invalid video URL format:', updateData.videoUrl.substring(0, 100));
throw new Error('Video URL must be a valid HTTP URL from Firebase Storage');
}
// If a new image is provided, upload it to storage first
if (cardData.imageDataUrl) {
console.log("Uploading updated card image to Firebase Storage...");
const imageUrl = await uploadCardImageToStorage(cardData.imageDataUrl, userId, cardData.name || 'updated_card');
updateData.imageUrl = imageUrl;
delete updateData.imageDataUrl; // Remove the base64 data from the update
}
await updateDoc(cardDocRef, {
...cardData,
...updateData,
updatedAt: serverTimestamp(),
});
} catch (error) {
@@ -133,9 +245,151 @@ export const deleteCardFromCollection = async (userId: string, cardId: string):
if (!userId || !cardId) throw new Error('User ID and Card ID are required to delete a card.');
try {
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
// Get the card data first to delete the associated image from storage
const cardDoc = await getDoc(cardDocRef);
if (cardDoc.exists()) {
const cardData = cardDoc.data() as PokemonCard;
// Delete image from storage (production or emulator)
if (cardData.imageUrl && (cardData.imageUrl.includes('firebase') || cardData.imageUrl.includes('localhost:9199'))) {
try {
// Extract the storage path from the URL and delete the image
const url = new URL(cardData.imageUrl);
const pathMatch = url.pathname.match(/\/o\/(.+)\?/);
if (pathMatch) {
const storagePath = decodeURIComponent(pathMatch[1]);
const imageRef = ref(storage, storagePath);
await deleteObject(imageRef);
console.log('Card image deleted from storage:', storagePath);
}
} catch (storageError) {
console.warn('Failed to delete card image from storage:', storageError);
// Continue with deletion even if storage deletion fails
}
}
// Delete video from storage if it exists (production or emulator)
if (cardData.videoUrl && (cardData.videoUrl.includes('firebase') || cardData.videoUrl.includes('localhost:9199'))) {
try {
const url = new URL(cardData.videoUrl);
const pathMatch = url.pathname.match(/\/o\/(.+)\?/);
if (pathMatch) {
const storagePath = decodeURIComponent(pathMatch[1]);
const videoRef = ref(storage, storagePath);
await deleteObject(videoRef);
console.log('Card video deleted from storage:', storagePath);
}
} catch (storageError) {
console.warn('Failed to delete card video from storage:', storageError);
}
}
}
await deleteDoc(cardDocRef);
} catch (error) {
console.error('Error deleting card: ', error);
throw new Error('Failed to delete card.');
}
};
// Manual function to generate video for an existing card
export const generateVideoForExistingCard = async (userId: string, cardId: string): Promise<void> => {
if (!userId || !cardId) throw new Error('User ID and Card ID are required to generate video.');
try {
// Get the card data
const card = await getCardById(userId, cardId);
if (!card) {
throw new Error('Card not found');
}
// Trigger video generation
await generateVideoForCard(userId, cardId, card);
} catch (error) {
console.error('Error generating video for existing card:', error);
throw new Error('Failed to generate video for card.');
}
};
// User Profile Management Functions
/**
* Creates or updates a user profile in Firestore
*/
export const createOrUpdateUserProfile = async (userId: string, profileData: Partial<UserProfile>): Promise<void> => {
if (!userId) throw new Error('User ID is required to create/update profile.');
try {
const userRef = doc(db, 'users', userId);
const updateData: any = {
...profileData,
id: userId,
updatedAt: serverTimestamp(),
};
// If creating for the first time, add createdAt
const existingDoc = await getDoc(userRef);
if (!existingDoc.exists()) {
updateData.createdAt = serverTimestamp();
}
await setDoc(userRef, updateData, { merge: true });
} catch (error) {
console.error('Error creating/updating user profile:', error);
throw new Error('Failed to create/update user profile.');
}
};
/**
* Gets a user profile from Firestore
*/
export const getUserProfile = async (userId: string): Promise<UserProfile | null> => {
if (!userId) throw new Error('User ID is required to get profile.');
try {
const userRef = doc(db, 'users', userId);
const userSnap = await getDoc(userRef);
if (userSnap.exists()) {
return userSnap.data() as UserProfile;
}
return null;
} catch (error) {
console.error('Error getting user profile:', error);
throw new Error('Failed to get user profile.');
}
};
/**
* Updates user API keys
*/
export const updateUserApiKeys = async (userId: string, apiKeys: UserApiKeys): Promise<void> => {
if (!userId) throw new Error('User ID is required to update API keys.');
try {
const userRef = doc(db, 'users', userId);
await updateDoc(userRef, {
apiKeys: apiKeys,
updatedAt: serverTimestamp(),
});
} catch (error) {
console.error('Error updating user API keys:', error);
throw new Error('Failed to update API keys.');
}
};
/**
* Gets user API keys
*/
export const getUserApiKeys = async (userId: string): Promise<UserApiKeys | null> => {
if (!userId) throw new Error('User ID is required to get API keys.');
try {
const profile = await getUserProfile(userId);
return profile?.apiKeys || null;
} catch (error) {
console.error('Error getting user API keys:', error);
throw new Error('Failed to get API keys.');
}
};