mirror of
https://github.com/xavidop/cardex.git
synced 2026-09-04 17:23:30 +00:00
feat: video generation, image to image generation and a bunch of new features
This commit is contained in:
+5
-1
@@ -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
@@ -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.');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user