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
+111
View File
@@ -0,0 +1,111 @@
/**
* Downloads a card image from a base64 data URL, data URI, or Firebase Storage URL
* @param imageUrl - The base64 data URL, data URI, or Firebase Storage URL of the image
* @param cardName - The name of the card to use as filename
*/
export async function downloadCardImage(imageUrl: string, cardName: string): Promise<void> {
try {
if (!imageUrl) {
throw new Error('No image available to download');
}
// Create a temporary anchor element
const link = document.createElement('a');
// Set the download attribute with a filename
const sanitizedName = cardName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
link.download = `${sanitizedName}_pokemon_card.png`;
// Handle different types of image URLs
if (imageUrl.startsWith('data:')) {
// Data URL - use directly
link.href = imageUrl;
} else if (imageUrl.startsWith('http')) {
// Firebase Storage URL or other HTTP URL - fetch and convert to blob URL
const response = await fetch(imageUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
link.href = blobUrl;
// Clean up the blob URL after download
link.addEventListener('click', () => {
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
});
} else {
// Assume it's a base64 string without data URL prefix
const dataUrl = `data:image/png;base64,${imageUrl}`;
link.href = dataUrl;
}
// Append to body, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('Error downloading card image:', error);
throw new Error('Failed to download card image');
}
}
/**
* Downloads a card image from a base64 string
* @param base64String - The base64 string of the image
* @param cardName - The name of the card to use as filename
*/
export function downloadCardFromBase64(base64String: string, cardName: string): void {
if (!base64String) {
throw new Error('No image data available to download');
}
const dataUrl = `data:image/jpeg;base64,${base64String}`;
downloadCardImage(dataUrl, cardName);
}
/**
* Downloads a card video from a Firebase Storage URL or base64 data
* @param videoUrl - The Firebase Storage URL or base64 data of the video
* @param cardName - The name of the card to use as filename
*/
export async function downloadCardVideo(videoUrl: string, cardName: string): Promise<void> {
try {
if (!videoUrl) {
throw new Error('No video available to download');
}
// Create a temporary anchor element
const link = document.createElement('a');
// Set the download attribute with a filename
const sanitizedName = cardName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
link.download = `${sanitizedName}_pokemon_card_video.mp4`;
// Handle different types of video URLs
if (videoUrl.startsWith('data:video/')) {
// Data URL - use directly
link.href = videoUrl;
} else if (videoUrl.startsWith('http')) {
// Firebase Storage URL or other HTTP URL - fetch and convert to blob URL
const response = await fetch(videoUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
link.href = blobUrl;
// Clean up the blob URL after download
link.addEventListener('click', () => {
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
});
} else {
// Assume it's a base64 string without data URL prefix
const dataUrl = `data:video/mp4;base64,${videoUrl}`;
link.href = dataUrl;
}
// Append to body, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('Error downloading card video:', error);
throw new Error('Failed to download card video');
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { CardGenerationParams, PhotoCardGenerationParams } from '@/types';
export function serializeGenerationParams(params: CardGenerationParams): URLSearchParams {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
return searchParams;
}
export function serializePhotoGenerationParams(params: PhotoCardGenerationParams): URLSearchParams {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
return searchParams;
}
export function parseGenerationParams(searchParams: URLSearchParams): Partial<CardGenerationParams> {
const params: Partial<CardGenerationParams> = {};
if (searchParams.get('pokemonName')) params.pokemonName = searchParams.get('pokemonName')!;
if (searchParams.get('pokemonType')) params.pokemonType = searchParams.get('pokemonType')!;
if (searchParams.get('isIllustrationRare')) params.isIllustrationRare = searchParams.get('isIllustrationRare') === 'true';
if (searchParams.get('isHolo')) params.isHolo = searchParams.get('isHolo') === 'true';
if (searchParams.get('backgroundDescription')) params.backgroundDescription = searchParams.get('backgroundDescription')!;
if (searchParams.get('pokemonDescription')) params.pokemonDescription = searchParams.get('pokemonDescription')!;
if (searchParams.get('language')) params.language = searchParams.get('language') as any;
if (searchParams.get('hp')) params.hp = parseInt(searchParams.get('hp')!);
if (searchParams.get('attackName1')) params.attackName1 = searchParams.get('attackName1')!;
if (searchParams.get('attackDamage1')) params.attackDamage1 = parseInt(searchParams.get('attackDamage1')!);
if (searchParams.get('attackName2')) params.attackName2 = searchParams.get('attackName2')!;
if (searchParams.get('attackDamage2')) params.attackDamage2 = parseInt(searchParams.get('attackDamage2')!);
if (searchParams.get('weakness')) params.weakness = searchParams.get('weakness')!;
if (searchParams.get('resistance')) params.resistance = searchParams.get('resistance')!;
if (searchParams.get('retreatCost')) params.retreatCost = parseInt(searchParams.get('retreatCost')!);
return params;
}
export function parsePhotoGenerationParams(searchParams: URLSearchParams): Partial<PhotoCardGenerationParams> {
const params: Partial<PhotoCardGenerationParams> = {};
if (searchParams.get('photoDataUri')) params.photoDataUri = searchParams.get('photoDataUri')!;
if (searchParams.get('pokemonName')) params.pokemonName = searchParams.get('pokemonName')!;
if (searchParams.get('pokemonType')) params.pokemonType = searchParams.get('pokemonType')!;
if (searchParams.get('styleDescription')) params.styleDescription = searchParams.get('styleDescription')!;
if (searchParams.get('language')) params.language = searchParams.get('language') as any;
if (searchParams.get('hp')) params.hp = parseInt(searchParams.get('hp')!);
if (searchParams.get('attackName1')) params.attackName1 = searchParams.get('attackName1')!;
if (searchParams.get('attackDamage1')) params.attackDamage1 = parseInt(searchParams.get('attackDamage1')!);
if (searchParams.get('attackName2')) params.attackName2 = searchParams.get('attackName2')!;
if (searchParams.get('attackDamage2')) params.attackDamage2 = parseInt(searchParams.get('attackDamage2')!);
if (searchParams.get('weakness')) params.weakness = searchParams.get('weakness')!;
if (searchParams.get('resistance')) params.resistance = searchParams.get('resistance')!;
if (searchParams.get('retreatCost')) params.retreatCost = parseInt(searchParams.get('retreatCost')!);
return params;
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Utility functions for image processing and compression
*/
/**
* Compress a base64 image to reduce file size
* @param base64Image - Base64 encoded image string
* @param maxWidth - Maximum width in pixels (default: 512)
* @param maxHeight - Maximum height in pixels (default: 712) - Pokemon card aspect ratio
* @param quality - JPEG quality 0-1 (default: 0.8)
* @returns Promise<string> - Compressed base64 image
*/
export function compressBase64Image(
base64Image: string,
maxWidth: number = 512,
maxHeight: number = 712,
quality: number = 0.8
): Promise<string> {
return new Promise((resolve, reject) => {
// Create an image element
const img = new Image();
img.onload = () => {
// Create a canvas element
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get canvas context'));
return;
}
// Calculate new dimensions while maintaining aspect ratio
let { width, height } = img;
if (width > height) {
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width = (width * maxHeight) / height;
height = maxHeight;
}
}
// Set canvas dimensions
canvas.width = width;
canvas.height = height;
// Draw and compress the image
ctx.drawImage(img, 0, 0, width, height);
// Get compressed base64 string
const compressedBase64 = canvas.toDataURL('image/jpeg', quality);
resolve(compressedBase64);
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
// Handle both data URLs and plain base64
const imageDataUrl = base64Image.startsWith('data:')
? base64Image
: `data:image/jpeg;base64,${base64Image}`;
img.src = imageDataUrl;
});
}
/**
* Get the size of a base64 string in bytes
* @param base64String - Base64 encoded string
* @returns number - Size in bytes
*/
export function getBase64Size(base64String: string): number {
// Remove data URL prefix if present
const base64Data = base64String.includes(',')
? base64String.split(',')[1]
: base64String;
// Calculate size: (base64 length * 3/4) - padding
const padding = base64Data.endsWith('==') ? 2 : base64Data.endsWith('=') ? 1 : 0;
return Math.floor((base64Data.length * 3) / 4) - padding;
}
/**
* Format bytes to human readable format
* @param bytes - Number of bytes
* @returns string - Formatted string (e.g., "1.2 MB")
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
+236
View File
@@ -0,0 +1,236 @@
export interface ShareOptions {
cardName: string;
cardImageUrl?: string;
publicImageUrl?: string; // For social media previews
onImageCopied?: (platform: string) => void;
onImageUploaded?: (url: string) => void;
}
import { uploadImageToStorage } from './storageUtils';
// Cache for uploaded image URLs to avoid re-uploading the same image
const uploadedImageCache = new Map<string, string>();
// Helper function to get a cache key for an image
const getImageCacheKey = (cardName: string, imageDataUrl: string): string => {
// Use first 100 characters of base64 data as a simple hash
const imageHash = imageDataUrl.slice(0, 100);
return `${cardName}_${imageHash}`;
};
// Helper function to upload image and get public URL with caching
const getOrUploadImageUrl = async (
cardName: string,
cardImageUrl: string,
onImageUploaded?: (url: string) => void
): Promise<string | null> => {
try {
// If the image URL is already a Firebase Storage URL or HTTP URL, return it directly
if (cardImageUrl.startsWith('http')) {
console.log('Using existing Firebase Storage URL:', cardImageUrl);
return cardImageUrl;
}
// Check cache first for base64 images
const cacheKey = getImageCacheKey(cardName, cardImageUrl);
const cachedUrl = uploadedImageCache.get(cacheKey);
if (cachedUrl) {
console.log('Using cached image URL:', cachedUrl);
return cachedUrl;
}
// Upload image to storage (for base64 data URLs)
const imageUrl = await uploadImageToStorage(cardImageUrl, cardName);
// Cache the URL
uploadedImageCache.set(cacheKey, imageUrl);
// Notify callback
onImageUploaded?.(imageUrl);
return imageUrl;
} catch (error) {
console.error('Failed to upload image:', error);
return null;
}
};
const downloadImageAsBlob = async (imageDataUrl: string): Promise<Blob> => {
const response = await fetch(imageDataUrl);
return response.blob();
};
// Helper function to copy image and text to clipboard
export const copyImageAndText = async ({ cardName, cardImageUrl }: ShareOptions) => {
try {
const text = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
if (cardImageUrl && cardImageUrl.startsWith('data:') && navigator.clipboard?.write) {
// Copy both image and text to clipboard (modern browsers)
const imageBlob = await downloadImageAsBlob(cardImageUrl);
const clipboardItems = [
new ClipboardItem({
'image/png': imageBlob,
'text/plain': new Blob([text], { type: 'text/plain' })
})
];
await navigator.clipboard.write(clipboardItems);
return true;
} else {
// Fallback to text only
await navigator.clipboard.writeText(text);
return false; // Indicates image wasn't copied
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
throw error;
}
};
export const shareOnTwitter = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
// If we have an image, try to get or upload it to get a public URL
let imageUrl = publicImageUrl;
if (!imageUrl && cardImageUrl) {
if (cardImageUrl.startsWith('http')) {
// Already a Firebase Storage URL, use directly
imageUrl = cardImageUrl;
} else if (cardImageUrl.startsWith('data:')) {
// Base64 data URL, need to upload
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
if (uploadedUrl) {
imageUrl = uploadedUrl;
} else {
// If upload failed, fallback to copying to clipboard
try {
await copyImageAndText({ cardName, cardImageUrl });
onImageCopied?.('Twitter/X');
} catch (clipboardError) {
console.warn('Failed to copy image to clipboard:', clipboardError);
}
}
}
}
// If we have a public image URL, include it in the tweet
if (imageUrl) {
shareText += ` ${imageUrl}`;
}
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`;
window.open(url, '_blank', 'width=550,height=420');
};
export const shareOnFacebook = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
let shareUrl = 'https://cardex.xavidop.me';
// If we have an image, try to get or upload it to get a public URL
let imageUrl = publicImageUrl;
if (!imageUrl && cardImageUrl) {
if (cardImageUrl.startsWith('http')) {
// Already a Firebase Storage URL, use directly
imageUrl = cardImageUrl;
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
} else if (cardImageUrl.startsWith('data:')) {
// Base64 data URL, need to upload
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
if (uploadedUrl) {
imageUrl = uploadedUrl;
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
} else {
// If upload failed, fallback to copying to clipboard
try {
await copyImageAndText({ cardName, cardImageUrl });
onImageCopied?.('Facebook');
} catch (clipboardError) {
console.warn('Failed to copy image to clipboard:', clipboardError);
}
}
}
} else if (imageUrl) {
shareUrl = imageUrl;
}
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}&quote=${encodeURIComponent(shareText)}`;
window.open(url, '_blank', 'width=580,height=296');
};
export const shareOnLinkedIn = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
let shareUrl = 'https://cardex.xavidop.me';
// If we have an image, try to get or upload it to get a public URL
let imageUrl = publicImageUrl;
if (!imageUrl && cardImageUrl) {
if (cardImageUrl.startsWith('http')) {
// Already a Firebase Storage URL, use directly
imageUrl = cardImageUrl;
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
} else if (cardImageUrl.startsWith('data:')) {
// Base64 data URL, need to upload
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
if (uploadedUrl) {
imageUrl = uploadedUrl;
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
} else {
// If upload failed, fallback to copying to clipboard
try {
await copyImageAndText({ cardName, cardImageUrl });
onImageCopied?.('LinkedIn');
} catch (clipboardError) {
console.warn('Failed to copy image to clipboard:', clipboardError);
}
}
}
} else if (imageUrl) {
shareUrl = imageUrl;
}
const url = `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(`AI-Generated Pokémon Card: ${cardName}`)}&summary=${encodeURIComponent(shareText)}&source=Cardex`;
window.open(url, '_blank', 'width=520,height=570');
};
export const copyShareLink = ({ cardName }: ShareOptions) => {
const text = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
navigator.clipboard.writeText(text);
};
// Native share API for mobile devices
export const nativeShare = async ({ cardName, cardImageUrl }: ShareOptions) => {
if (navigator.share) {
try {
const shareData: ShareData = {
title: `AI-Generated Pokémon Card`,
text: `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`,
url: 'https://cardex.xavidop.me',
};
// If we have an image and the browser supports file sharing
if (cardImageUrl && navigator.canShare) {
try {
// Convert data URL or Firebase Storage URL to blob
const response = await fetch(cardImageUrl);
const blob = await response.blob();
const file = new File([blob], `${cardName}-card.png`, { type: 'image/png' });
// Check if we can share files
if (navigator.canShare({ files: [file] })) {
shareData.files = [file];
}
} catch (error) {
console.warn('Failed to add image to share:', error);
}
}
await navigator.share(shareData);
return true;
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Error sharing:', error);
}
return false;
}
}
return false;
};
+121
View File
@@ -0,0 +1,121 @@
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { storage } from '@/lib/firebase';
// Convert base64 data URL to blob
const dataURLToBlob = (dataURL: string): Blob => {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
};
// Upload image to Firebase Storage and return public URL
export const uploadImageToStorage = async (
imageDataUrl: string,
cardName: string,
userId?: string
): Promise<string> => {
try {
// Convert data URL to blob
const blob = dataURLToBlob(imageDataUrl);
// Create a unique filename with timestamp
const timestamp = Date.now();
const sanitizedCardName = cardName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
// Determine the storage path based on whether userId is provided
const filename = userId
? `users/${userId}/cards/${sanitizedCardName}_${timestamp}.png`
: `cards/${sanitizedCardName}_${timestamp}.png`;
// Create storage reference
const storageRef = ref(storage, filename);
// Upload the blob
const snapshot = await uploadBytes(storageRef, blob, {
contentType: 'image/png',
cacheControl: 'public, max-age=31536000', // 1 year cache
});
// Get the download URL
const downloadURL = await getDownloadURL(snapshot.ref);
console.log('Image uploaded successfully:', downloadURL);
return downloadURL;
} catch (error) {
console.error('Failed to upload image to storage:', error);
throw new Error('Failed to upload image to storage');
}
};
// Upload card image to Firebase Storage in user's collection folder
export const uploadCardImageToStorage = async (
imageDataUrl: string,
userId: string,
cardName: string
): Promise<string> => {
return uploadImageToStorage(imageDataUrl, cardName, userId);
};
// Convert base64 video data to blob
const base64ToVideoBlob = (base64Data: string): Blob => {
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: 'video/mp4' });
};
// Upload video to Firebase Storage and return public URL
// This function handles base64 video data only
export const uploadVideoToStorage = async (
videoBase64: string,
userId: string,
cardName: string
): Promise<string> => {
try {
// Check if this is actually a URL instead of base64 data (this shouldn't happen)
if (videoBase64.startsWith('http')) {
throw new Error('uploadVideoToStorage received a URL instead of base64 data. Video should be downloaded first.');
}
// Convert base64 to blob
const blob = base64ToVideoBlob(videoBase64);
// Create a unique filename with timestamp
const timestamp = Date.now();
const sanitizedCardName = cardName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
const filename = `users/${userId}/videos/${sanitizedCardName}_${timestamp}.mp4`;
// Create storage reference
const storageRef = ref(storage, filename);
// Upload the blob
const snapshot = await uploadBytes(storageRef, blob, {
contentType: 'video/mp4',
cacheControl: 'public, max-age=31536000', // 1 year cache
});
// Get the download URL
const downloadURL = await getDownloadURL(snapshot.ref);
console.log('Video uploaded successfully to Firebase Storage:', downloadURL);
return downloadURL;
} catch (error) {
console.error('Failed to upload video to storage:', error);
throw new Error('Failed to upload video to storage');
}
};
// Clean up old shared images (optional, can be called periodically)
export const cleanupOldSharedImages = async (olderThanDays: number = 7) => {
// This would require Firebase Admin SDK or Cloud Functions
// For now, we'll rely on Firebase Storage lifecycle rules
console.log(`Cleanup of images older than ${olderThanDays} days should be configured in Firebase Storage lifecycle rules`);
};