import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Song } from '../types'; import { X, Play, Pause, Download, Wand2, Image as ImageIcon, Music, Video, Loader2, Palette, Layers, Zap, Type, Monitor, Aperture, Activity, Circle, Grid, Box, BarChart2, Waves, Disc, Upload, Plus, Trash2, Settings2, MousePointer2, Search, ExternalLink, Sun, Film, Minus } from 'lucide-react'; import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile, toBlobURL } from '@ffmpeg/util'; import { useResponsive } from '../context/ResponsiveContext'; interface VideoGeneratorModalProps { isOpen: boolean; onClose: () => void; song: Song | null; } type PresetType = | 'NCS Circle' | 'Linear Bars' | 'Dual Mirror' | 'Center Wave' | 'Orbital' | 'Digital Rain' | 'Hexagon' | 'Shockwave' | 'Oscilloscope' | 'Minimal'; interface VisualizerConfig { preset: PresetType; primaryColor: string; secondaryColor: string; bgDim: number; particleCount: number; } interface EffectConfig { shake: boolean; glitch: boolean; vhs: boolean; cctv: boolean; scanlines: boolean; chromatic: boolean; bloom: boolean; filmGrain: boolean; pixelate: boolean; strobe: boolean; vignette: boolean; hueShift: boolean; letterbox: boolean; } interface EffectIntensities { shake: number; glitch: number; vhs: number; cctv: number; scanlines: number; chromatic: number; bloom: number; filmGrain: number; pixelate: number; strobe: number; vignette: number; hueShift: number; letterbox: number; } interface TextLayer { id: string; text: string; x: number; // 0-100 percentage y: number; // 0-100 percentage size: number; color: string; font: string; } interface PexelsPhoto { id: number; src: { large: string; original: string }; photographer: string; } interface PexelsVideo { id: number; image: string; video_files: { link: string; quality: string; width: number }[]; user: { name: string }; } const PRESETS: { id: PresetType; label: string; icon: React.ReactNode }[] = [ { id: 'NCS Circle', label: 'Classic NCS', icon: }, { id: 'Linear Bars', label: 'Spectrum', icon: }, { id: 'Dual Mirror', label: 'Mirror', icon: }, { id: 'Center Wave', label: 'Shockwave', icon: }, { id: 'Orbital', label: 'Orbital', icon: }, { id: 'Hexagon', label: 'Hex Core', icon: }, { id: 'Oscilloscope', label: 'Analog', icon: }, { id: 'Digital Rain', label: 'Matrix', icon: }, { id: 'Shockwave', label: 'Pulse', icon: }, { id: 'Minimal', label: 'Clean', icon: }, ]; function ColumnsIcon() { return ( ); } export const VideoGeneratorModal: React.FC = ({ isOpen, onClose, song }) => { const { isMobile } = useResponsive(); const canvasRef = useRef(null); const audioRef = useRef(null); const animationRef = useRef(0); const analyserRef = useRef(null); const audioContextRef = useRef(null); const bgImageRef = useRef(null); const fileInputRef = useRef(null); const videoFileInputRef = useRef(null); // FFmpeg Refs const ffmpegRef = useRef(null); // Tabs: 'presets' | 'style' | 'text' | 'effects' const [activeTab, setActiveTab] = useState('presets'); // State const [isPlaying, setIsPlaying] = useState(false); const [backgroundType, setBackgroundType] = useState<'random' | 'custom' | 'video'>('random'); const [backgroundSeed, setBackgroundSeed] = useState(Date.now()); const [customImage, setCustomImage] = useState(null); const [videoUrl, setVideoUrl] = useState(''); const bgVideoRef = useRef(null); // Custom Album Art const [customAlbumArt, setCustomAlbumArt] = useState(null); const albumArtInputRef = useRef(null); const customAlbumArtImageRef = useRef(null); // Pexels Browser State const [showPexelsBrowser, setShowPexelsBrowser] = useState(false); const [pexelsTarget, setPexelsTarget] = useState<'background' | 'albumArt'>('background'); const [pexelsTab, setPexelsTab] = useState<'photos' | 'videos'>('photos'); const [pexelsQuery, setPexelsQuery] = useState('abstract'); const [pexelsPhotos, setPexelsPhotos] = useState([]); const [pexelsVideos, setPexelsVideos] = useState([]); const [pexelsLoading, setPexelsLoading] = useState(false); const [pexelsApiKey, setPexelsApiKey] = useState(() => localStorage.getItem('pexels_api_key') || ''); const [showPexelsApiKeyInput, setShowPexelsApiKeyInput] = useState(false); const [pexelsError, setPexelsError] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(0); const [exportStage, setExportStage] = useState<'idle' | 'capturing' | 'encoding'>('idle'); const [ffmpegLoaded, setFfmpegLoaded] = useState(false); const [ffmpegLoading, setFfmpegLoading] = useState(false); // Config State const [config, setConfig] = useState({ preset: 'NCS Circle', primaryColor: '#ec4899', // Pink-500 secondaryColor: '#3b82f6', // Blue-500 bgDim: 0.6, particleCount: 50 }); const [effects, setEffects] = useState({ shake: true, glitch: false, vhs: false, cctv: false, scanlines: false, chromatic: false, bloom: false, filmGrain: false, pixelate: false, strobe: false, vignette: false, hueShift: false, letterbox: false }); const [intensities, setIntensities] = useState({ shake: 0.05, glitch: 0.3, vhs: 0.5, cctv: 0.8, scanlines: 0.4, chromatic: 0.5, bloom: 0.5, filmGrain: 0.3, pixelate: 0.3, strobe: 0.5, vignette: 0.5, hueShift: 0.5, letterbox: 0.5 }); // Text Layers State const [textLayers, setTextLayers] = useState([]); // Init default text on load useEffect(() => { if (song) { setTextLayers([ { id: '1', text: song.title, x: 50, y: 85, size: 52, color: '#ffffff', font: 'Inter' }, { id: '2', text: song.style.toUpperCase(), x: 50, y: 92, size: 24, color: '#3b82f6', font: 'Inter' } ]); } }, [song]); // Use refs for render loop to access latest state without re-binding const configRef = useRef(config); const effectsRef = useRef(effects); const intensitiesRef = useRef(intensities); const textLayersRef = useRef(textLayers); useEffect(() => { configRef.current = config; }, [config]); useEffect(() => { effectsRef.current = effects; }, [effects]); useEffect(() => { intensitiesRef.current = intensities; }, [intensities]); useEffect(() => { textLayersRef.current = textLayers; }, [textLayers]); // Load FFmpeg const loadFFmpeg = useCallback(async () => { if (ffmpegRef.current || ffmpegLoading) return; setFfmpegLoading(true); try { const ffmpeg = new FFmpeg(); ffmpeg.on('progress', ({ progress }) => { if (exportStage === 'encoding') { setExportProgress(Math.round(progress * 100)); } }); const cdnBases = [ 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm', 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm', ]; let loaded = false; for (const baseURL of cdnBases) { try { await ffmpeg.load({ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), }); loaded = true; break; } catch { console.warn(`FFmpeg load failed from ${baseURL}, trying next CDN...`); } } if (!loaded) throw new Error('All CDN sources failed'); ffmpegRef.current = ffmpeg; setFfmpegLoaded(true); } catch (error) { console.error('Failed to load FFmpeg:', error); alert('Failed to load video encoder. Check your internet connection and try again.'); } finally { setFfmpegLoading(false); } }, [ffmpegLoading, exportStage]); // Load Background Image useEffect(() => { if (backgroundType === 'video') { bgImageRef.current = null; return; } const img = new Image(); img.crossOrigin = "Anonymous"; if (backgroundType === 'custom' && customImage) { img.src = customImage; } else { img.src = `https://picsum.photos/seed/${backgroundSeed}/1920/1080?blur=4`; } img.onload = () => { bgImageRef.current = img; }; }, [backgroundSeed, backgroundType, customImage]); // Load Background Video useEffect(() => { if (backgroundType !== 'video' || !videoUrl) { if (bgVideoRef.current) { bgVideoRef.current.pause(); bgVideoRef.current = null; } return; } const video = document.createElement('video'); video.crossOrigin = 'anonymous'; video.src = videoUrl; video.loop = true; video.muted = true; video.playsInline = true; video.autoplay = true; video.onloadeddata = () => { bgVideoRef.current = video; video.play().catch(console.error); }; video.onerror = () => { console.error('Failed to load video:', videoUrl); bgVideoRef.current = null; }; return () => { video.pause(); video.src = ''; }; }, [backgroundType, videoUrl]); // Load Custom Album Art useEffect(() => { if (!customAlbumArt) { customAlbumArtImageRef.current = null; return; } // Clear ref immediately so we don't show stale image customAlbumArtImageRef.current = null; const img = new Image(); img.crossOrigin = 'anonymous'; // Use proxy for external URLs to avoid CORS issues const isExternal = customAlbumArt.startsWith('http'); img.src = isExternal ? `/api/proxy/image?url=${encodeURIComponent(customAlbumArt)}` : customAlbumArt; img.onload = () => { customAlbumArtImageRef.current = img; }; img.onerror = () => { console.error('Failed to load custom album art:', customAlbumArt); customAlbumArtImageRef.current = null; }; }, [customAlbumArt]); // Initialize Audio & Canvas useEffect(() => { if (!isOpen || !song) return; // Reset basics setIsPlaying(false); setIsExporting(false); setExportProgress(0); setExportStage('idle'); // Audio Setup const audio = new Audio(); audio.crossOrigin = "anonymous"; audio.src = song.audioUrl || ''; audioRef.current = audio; const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; const audioCtx = new AudioContextClass(); audioContextRef.current = audioCtx; const analyser = audioCtx.createAnalyser(); analyser.fftSize = 2048; analyserRef.current = analyser; const source = audioCtx.createMediaElementSource(audio); source.connect(analyser); analyser.connect(audioCtx.destination); audio.onended = () => { setIsPlaying(false); }; // Start Loop cancelAnimationFrame(animationRef.current); renderLoop(); return () => { audio.pause(); if (audioContextRef.current?.state !== 'closed') { audioContextRef.current?.close(); } cancelAnimationFrame(animationRef.current); }; }, [isOpen, song]); const togglePlay = async () => { if (!audioRef.current || !audioContextRef.current) return; if (audioContextRef.current.state === 'suspended') { await audioContextRef.current.resume(); } if (isPlaying) audioRef.current.pause(); else audioRef.current.play(); setIsPlaying(!isPlaying); }; const startRecording = async () => { if (!canvasRef.current || !song) return; // Load FFmpeg if not loaded if (!ffmpegRef.current) { await loadFFmpeg(); if (!ffmpegRef.current) return; } setIsExporting(true); setExportStage('capturing'); setExportProgress(0); try { await renderOffline(); } catch (error) { console.error('Rendering failed:', error); alert('Video rendering failed. Please try again.'); setIsExporting(false); setExportStage('idle'); } }; const analyzeAudioOffline = async (audioBuffer: AudioBuffer, fps: number): Promise => { const duration = audioBuffer.duration; const totalFrames = Math.ceil(duration * fps); const samplesPerFrame = Math.floor(audioBuffer.sampleRate / fps); const fftSize = 2048; const frequencyBinCount = fftSize / 2; // Get raw audio data from first channel const channelData = audioBuffer.getChannelData(0); const frequencyDataFrames: Uint8Array[] = []; // Simple FFT approximation using amplitude analysis // For each frame, compute frequency-like data from audio samples for (let frame = 0; frame < totalFrames; frame++) { const startSample = frame * samplesPerFrame; const endSample = Math.min(startSample + fftSize, channelData.length); const frameData = new Uint8Array(frequencyBinCount); // Compute amplitude spectrum approximation for (let bin = 0; bin < frequencyBinCount; bin++) { let sum = 0; const binSize = Math.max(1, Math.floor((endSample - startSample) / frequencyBinCount)); const binStart = startSample + bin * binSize; const binEnd = Math.min(binStart + binSize, endSample); for (let i = binStart; i < binEnd && i < channelData.length; i++) { sum += Math.abs(channelData[i]); } const avg = binSize > 0 ? sum / binSize : 0; // Scale to 0-255 range with some amplification frameData[bin] = Math.min(255, Math.floor(avg * 512)); } frequencyDataFrames.push(frameData); } return frequencyDataFrames; }; const loadImageAsDataUrl = async (url: string): Promise => { try { // Use proxy for external URLs to avoid CORS issues const isExternal = url.startsWith('http') && !url.includes(window.location.host); const fetchUrl = isExternal ? `/api/proxy/image?url=${encodeURIComponent(url)}` : url; const response = await fetch(fetchUrl); const blob = await response.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => resolve(null); reader.readAsDataURL(blob); }); } catch { return null; } }; const renderOffline = async () => { if (!song || !ffmpegRef.current) return; // Create a separate clean canvas to avoid tainted canvas issues const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080; const ctx = canvas.getContext('2d'); if (!ctx) return; const ffmpeg = ffmpegRef.current; const fps = 30; const width = canvas.width; const height = canvas.height; const centerX = width / 2; const centerY = height / 2; setExportProgress(1); // Pre-load images via proxy to avoid CORS/tainted canvas issues let bgImage: HTMLImageElement | null = null; let bgVideo: HTMLVideoElement | null = null; let albumImage: HTMLImageElement | null = null; // Load background video or image if (backgroundType === 'video' && videoUrl) { bgVideo = document.createElement('video'); bgVideo.crossOrigin = 'anonymous'; bgVideo.src = videoUrl; bgVideo.muted = true; bgVideo.playsInline = true; await new Promise((resolve) => { bgVideo!.onloadeddata = () => resolve(); bgVideo!.onerror = () => { console.warn('Failed to load background video, falling back to image'); bgVideo = null; resolve(); }; bgVideo!.load(); }); } else if (bgImageRef.current?.src) { const bgDataUrl = await loadImageAsDataUrl(bgImageRef.current.src); if (bgDataUrl) { bgImage = new Image(); bgImage.src = bgDataUrl; await new Promise((resolve) => { bgImage!.onload = () => resolve(); bgImage!.onerror = () => resolve(); }); } } // Load album art (use custom if set, otherwise song cover) const albumArtSource = customAlbumArt || song.coverUrl; if (albumArtSource) { // Custom album art might already be a data URL const albumDataUrl = albumArtSource.startsWith('data:') ? albumArtSource : await loadImageAsDataUrl(albumArtSource); if (albumDataUrl) { albumImage = new Image(); albumImage.src = albumDataUrl; await new Promise((resolve) => { albumImage!.onload = () => resolve(); albumImage!.onerror = () => resolve(); }); } } // Fetch and decode audio setExportProgress(2); const audioUrl = song.audioUrl || ''; const audioResponse = await fetch(audioUrl); const audioArrayBuffer = await audioResponse.arrayBuffer(); // Keep a copy for FFmpeg const audioDataCopy = audioArrayBuffer.slice(0); setExportProgress(5); // Decode audio for analysis const audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); const audioBuffer = await audioCtx.decodeAudioData(audioArrayBuffer); const duration = audioBuffer.duration; const totalFrames = Math.ceil(duration * fps); setExportProgress(10); // Analyze audio to get frequency data for each frame const frequencyDataFrames = await analyzeAudioOffline(audioBuffer, fps); setExportProgress(15); // Render all frames const currentConfig = configRef.current; const currentEffects = effectsRef.current; const currentIntensities = intensitiesRef.current; const currentTexts = textLayersRef.current; for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { const time = frameIndex / fps; const dataArray = frequencyDataFrames[frameIndex] || new Uint8Array(1024); // Create time domain data (simple sine wave approximation based on bass) const timeDomain = new Uint8Array(1024); let bassSum = 0; for (let i = 0; i < 20; i++) bassSum += dataArray[i]; const bassLevel = bassSum / 20 / 255; for (let i = 0; i < timeDomain.length; i++) { timeDomain[i] = 128 + Math.sin(i * 0.1 + time * 10) * 64 * bassLevel; } // Calculate bass and pulse let bass = 0; for (let i = 0; i < 20; i++) bass += dataArray[i]; bass = bass / 20; const normBass = bass / 255; const pulse = 1 + normBass * 0.15; // Clear canvas ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, width, height); // Draw background (video or image) let bgSource: HTMLImageElement | HTMLVideoElement | null = bgImage; if (bgVideo) { // Seek video to current frame time (loop if video is shorter) const videoTime = time % (bgVideo.duration || 1); bgVideo.currentTime = videoTime; // Wait for seek to complete await new Promise((resolve) => { const onSeeked = () => { bgVideo!.removeEventListener('seeked', onSeeked); resolve(); }; bgVideo!.addEventListener('seeked', onSeeked); // Fallback timeout in case seeked never fires setTimeout(resolve, 50); }); bgSource = bgVideo; } if (bgSource) { ctx.save(); ctx.globalAlpha = 1 - currentConfig.bgDim; if (currentEffects.shake && normBass > (0.6 - (currentIntensities.shake * 0.3))) { const magnitude = currentIntensities.shake * 50; const shakeX = (Math.random() - 0.5) * magnitude * normBass; const shakeY = (Math.random() - 0.5) * magnitude * normBass; ctx.translate(shakeX, shakeY); } const zoom = 1 + (Math.sin(time * 0.5) * 0.05); ctx.translate(centerX, centerY); ctx.scale(zoom, zoom); ctx.drawImage(bgSource, -width/2, -height/2, width, height); ctx.restore(); } // Draw preset ctx.save(); if (currentEffects.shake && normBass > 0.6) { const magnitude = currentIntensities.shake * 30; const shakeX = (Math.random() - 0.5) * magnitude * normBass; const shakeY = (Math.random() - 0.5) * magnitude * normBass; ctx.translate(shakeX, shakeY); } switch(currentConfig.preset) { case 'NCS Circle': drawNCSCircle(ctx, centerX, centerY, dataArray, pulse, time, currentConfig.primaryColor, currentConfig.secondaryColor); break; case 'Linear Bars': drawLinearBars(ctx, width, height, dataArray, currentConfig.primaryColor, currentConfig.secondaryColor); break; case 'Dual Mirror': drawDualMirror(ctx, width, height, dataArray, currentConfig.primaryColor); break; case 'Center Wave': drawCenterWave(ctx, centerX, centerY, dataArray, time, currentConfig.primaryColor); break; case 'Orbital': drawOrbital(ctx, centerX, centerY, dataArray, time, currentConfig.primaryColor, currentConfig.secondaryColor); break; case 'Hexagon': drawHexagon(ctx, centerX, centerY, dataArray, pulse, time, currentConfig.primaryColor); break; case 'Oscilloscope': drawOscilloscope(ctx, width, height, timeDomain, currentConfig.primaryColor); break; case 'Digital Rain': drawDigitalRain(ctx, width, height, dataArray, time, currentConfig.primaryColor); break; case 'Shockwave': drawShockwave(ctx, centerX, centerY, bass, time, currentConfig.primaryColor); break; } drawParticles(ctx, width, height, time, bass, currentConfig.particleCount, currentConfig.primaryColor); if (['NCS Circle', 'Hexagon', 'Orbital', 'Shockwave'].includes(currentConfig.preset) && albumImage) { // Draw album art inline with pre-loaded image ctx.save(); ctx.translate(centerX, centerY); ctx.scale(pulse, pulse); ctx.shadowBlur = 40; ctx.shadowColor = currentConfig.primaryColor; ctx.beginPath(); ctx.arc(0, 0, 150, 0, Math.PI * 2); ctx.closePath(); ctx.lineWidth = 5; ctx.strokeStyle = 'white'; ctx.stroke(); ctx.clip(); ctx.drawImage(albumImage, -150, -150, 300, 300); ctx.restore(); } // Pixelate effect (applied before text so text stays sharp) if (currentEffects.pixelate) { const pixelSize = Math.max(4, Math.floor(16 * currentIntensities.pixelate)); ctx.imageSmoothingEnabled = false; const tempCanvas2 = document.createElement('canvas'); const smallW = Math.floor(width / pixelSize); const smallH = Math.floor(height / pixelSize); tempCanvas2.width = smallW; tempCanvas2.height = smallH; const tempCtx2 = tempCanvas2.getContext('2d')!; tempCtx2.drawImage(canvas, 0, 0, smallW, smallH); ctx.clearRect(0, 0, width, height); ctx.drawImage(tempCanvas2, 0, 0, smallW, smallH, 0, 0, width, height); ctx.imageSmoothingEnabled = true; } // Draw text layers ctx.shadowBlur = 10; ctx.shadowColor = 'black'; ctx.textAlign = 'center'; currentTexts.forEach(layer => { ctx.fillStyle = layer.color; const dynamicSize = layer.id === '1' && currentConfig.preset === 'Minimal' ? layer.size * pulse : layer.size; ctx.font = `bold ${dynamicSize}px ${layer.font}, sans-serif`; const xPos = (layer.x / 100) * width; const yPos = (layer.y / 100) * height; ctx.fillText(layer.text, xPos, yPos); }); ctx.restore(); // Apply post-processing effects if (currentEffects.scanlines || currentEffects.cctv) { ctx.fillStyle = `rgba(0,0,0,${currentIntensities.scanlines * 0.8})`; for (let i = 0; i < height; i += 4) { ctx.fillRect(0, i, width, 2); } } if (currentEffects.vhs || currentEffects.chromatic || (currentEffects.glitch && Math.random() > (1 - currentIntensities.glitch))) { const intensity = currentEffects.vhs ? currentIntensities.vhs : currentIntensities.chromatic; const offset = (10 * intensity) * normBass; ctx.globalCompositeOperation = 'screen'; ctx.fillStyle = `rgba(255,0,0,${0.2 * intensity})`; ctx.fillRect(-offset, 0, width, height); ctx.fillStyle = `rgba(0,0,255,${0.2 * intensity})`; ctx.fillRect(offset, 0, width, height); ctx.globalCompositeOperation = 'source-over'; } if (currentEffects.glitch && Math.random() > (1 - currentIntensities.glitch)) { ctx.fillStyle = Math.random() > 0.5 ? currentConfig.primaryColor : '#fff'; ctx.fillRect(Math.random() * width, Math.random() * height, Math.random() * 200, 4); } if (currentEffects.cctv) { const intensity = currentIntensities.cctv; ctx.globalCompositeOperation = 'overlay'; ctx.fillStyle = `rgba(0, 50, 0, ${0.4 * intensity})`; ctx.fillRect(0, 0, width, height); const grad = ctx.createRadialGradient(centerX, centerY, height * 0.4, centerX, centerY, height * 0.9); grad.addColorStop(0, 'transparent'); grad.addColorStop(1, 'black'); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = 'source-over'; } // Bloom / Glow effect if (currentEffects.bloom) { const intensity = currentIntensities.bloom; ctx.globalCompositeOperation = 'screen'; ctx.filter = `blur(${15 * intensity}px)`; ctx.globalAlpha = 0.4 * intensity; ctx.drawImage(canvas, 0, 0); ctx.filter = 'none'; ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; } // Film Grain if (currentEffects.filmGrain) { const intensity = currentIntensities.filmGrain; const imageData = ctx.getImageData(0, 0, width, height); const data = imageData.data; const grainAmount = intensity * 50; for (let i = 0; i < data.length; i += 16) { const noise = (Math.random() - 0.5) * grainAmount; data[i] += noise; data[i + 1] += noise; data[i + 2] += noise; } ctx.putImageData(imageData, 0, 0); } // Strobe effect if (currentEffects.strobe && normBass > (0.7 - currentIntensities.strobe * 0.3)) { ctx.globalCompositeOperation = 'screen'; ctx.fillStyle = `rgba(255, 255, 255, ${currentIntensities.strobe * normBass * 0.8})`; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = 'source-over'; } // Vignette effect if (currentEffects.vignette) { const intensity = currentIntensities.vignette; const grad = ctx.createRadialGradient(centerX, centerY, height * 0.3, centerX, centerY, height * 0.8); grad.addColorStop(0, 'transparent'); grad.addColorStop(1, `rgba(0, 0, 0, ${0.8 * intensity})`); ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); } // Hue Shift effect if (currentEffects.hueShift) { const hueRotation = currentIntensities.hueShift * 360 * (1 + normBass * 0.5); ctx.filter = `hue-rotate(${hueRotation}deg)`; ctx.drawImage(canvas, 0, 0); ctx.filter = 'none'; } // Letterbox effect if (currentEffects.letterbox) { const barHeight = height * 0.12 * currentIntensities.letterbox; ctx.fillStyle = 'black'; ctx.fillRect(0, 0, width, barHeight); ctx.fillRect(0, height - barHeight, width, barHeight); } // Capture frame const frameData = canvas.toDataURL('image/jpeg', 0.85); const base64Data = frameData.split(',')[1]; const binaryData = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); await ffmpeg.writeFile(`frame${String(frameIndex).padStart(6, '0')}.jpg`, binaryData); // Update progress (15-70% for frame rendering) if (frameIndex % 10 === 0) { setExportProgress(15 + Math.round((frameIndex / totalFrames) * 55)); } } setExportStage('encoding'); setExportProgress(70); // Write audio file console.log('[Video] Writing audio file...'); await ffmpeg.writeFile('audio.mp3', new Uint8Array(audioDataCopy)); setExportProgress(75); // Encode video - use ultrafast preset for browser performance console.log(`[Video] Encoding ${totalFrames} frames at ${fps}fps...`); console.log('[Video] This may take a while in the browser. Please wait...'); const encodeResult = await ffmpeg.exec([ '-framerate', String(fps), '-i', 'frame%06d.jpg', '-i', 'audio.mp3', '-c:v', 'libx264', '-preset', 'ultrafast', // Fastest encoding '-tune', 'fastdecode', // Optimize for fast decoding '-crf', '28', // Slightly lower quality but much faster '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', // Lower bitrate audio '-shortest', '-movflags', '+faststart', 'output.mp4' ]); console.log('[Video] FFmpeg encode result:', encodeResult); setExportProgress(95); // Read and download output console.log('[Video] Reading output file...'); const outputData = await ffmpeg.readFile('output.mp4'); console.log('[Video] Output file size:', outputData.length, 'bytes'); if (outputData.length === 0) { throw new Error('FFmpeg produced an empty output file'); } const blob = new Blob([outputData], { type: 'video/mp4' }); const url = URL.createObjectURL(blob); console.log('[Video] Created blob URL:', url, 'Size:', blob.size); // More reliable download method const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = `${song.title || 'suno-video'}.mp4`; document.body.appendChild(a); a.click(); // Delay cleanup to ensure download starts setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 1000); console.log('[Video] Download triggered!'); // Cleanup FFmpeg filesystem setExportProgress(98); for (let i = 0; i < totalFrames; i++) { await ffmpeg.deleteFile(`frame${String(i).padStart(6, '0')}.jpg`).catch(() => {}); } await ffmpeg.deleteFile('audio.mp3').catch(() => {}); await ffmpeg.deleteFile('output.mp4').catch(() => {}); await audioCtx.close(); setExportProgress(100); // Small delay before hiding the progress to show completion setTimeout(() => { setIsExporting(false); setExportStage('idle'); }, 500); }; const stopRecording = () => { // For offline rendering, we can't really stop mid-process // This is kept for compatibility but offline render runs to completion }; const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const reader = new FileReader(); reader.onload = (ev) => { const result = ev.target?.result as string; setCustomImage(result); setBackgroundType('custom'); }; reader.readAsDataURL(file); } }; const handleVideoFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const url = URL.createObjectURL(file); setVideoUrl(url); setBackgroundType('video'); } }; const searchPexels = async (query: string, type: 'photos' | 'videos') => { setPexelsLoading(true); setPexelsError(null); try { const endpoint = type === 'photos' ? `/api/pexels/photos?query=${encodeURIComponent(query)}` : `/api/pexels/videos?query=${encodeURIComponent(query)}`; const headers: HeadersInit = {}; if (pexelsApiKey) { headers['X-Pexels-Api-Key'] = pexelsApiKey; } const response = await fetch(endpoint, { headers }); const data = await response.json(); if (!response.ok) { if (response.status === 400 || response.status === 401) { setPexelsError(data.error || 'API key required'); setShowPexelsApiKeyInput(true); } else { setPexelsError(data.error || 'Search failed'); } return; } if (type === 'photos') { setPexelsPhotos(data.photos || []); } else { setPexelsVideos(data.videos || []); } } catch (error) { console.error('Pexels search failed:', error); setPexelsError('Search failed. Please try again.'); } finally { setPexelsLoading(false); } }; const savePexelsApiKey = (key: string) => { setPexelsApiKey(key); localStorage.setItem('pexels_api_key', key); setShowPexelsApiKeyInput(false); setPexelsError(null); // Retry search with new key if (key) { searchPexels(pexelsQuery, pexelsTab); } }; const selectPexelsPhoto = (photo: PexelsPhoto) => { if (pexelsTarget === 'albumArt') { setCustomAlbumArt(photo.src.large); } else { setCustomImage(photo.src.large); setBackgroundType('custom'); } setShowPexelsBrowser(false); }; const selectPexelsVideo = (video: PexelsVideo) => { // Get best quality video file (prefer HD) const hdFile = video.video_files.find(f => f.quality === 'hd' && f.width >= 1280); const sdFile = video.video_files.find(f => f.quality === 'sd'); const videoFile = hdFile || sdFile || video.video_files[0]; if (videoFile) { setVideoUrl(videoFile.link); setBackgroundType('video'); setShowPexelsBrowser(false); } }; const openPexelsBrowser = (target: 'background' | 'albumArt' = 'background', tab: 'photos' | 'videos' = 'photos') => { setPexelsTarget(target); setPexelsTab(target === 'albumArt' ? 'photos' : tab); // Album art is always photos setShowPexelsBrowser(true); const searchTab = target === 'albumArt' ? 'photos' : tab; if ((searchTab === 'photos' && pexelsPhotos.length === 0) || (searchTab === 'videos' && pexelsVideos.length === 0)) { searchPexels(pexelsQuery, searchTab); } }; const handleAlbumArtUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const reader = new FileReader(); reader.onload = (ev) => { setCustomAlbumArt(ev.target?.result as string); }; reader.readAsDataURL(file); } }; // --- RENDER ENGINE --- const renderLoop = () => { if (!canvasRef.current || !analyserRef.current || !song) { animationRef.current = requestAnimationFrame(renderLoop); return; } const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; // Read current state const currentConfig = configRef.current; const currentEffects = effectsRef.current; const currentIntensities = intensitiesRef.current; const currentTexts = textLayersRef.current; const width = canvas.width; const height = canvas.height; const centerX = width / 2; const centerY = height / 2; const time = Date.now() / 1000; // Data const bufferLength = analyserRef.current.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const timeDomain = new Uint8Array(bufferLength); analyserRef.current.getByteFrequencyData(dataArray); analyserRef.current.getByteTimeDomainData(timeDomain); // Bass Calc let bass = 0; for (let i = 0; i < 20; i++) bass += dataArray[i]; bass = bass / 20; const normBass = bass / 255; const pulse = 1 + normBass * 0.15; // --- 1. CLEAR & BACKGROUND --- ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, width, height); // Draw video or image background const bgSource = bgVideoRef.current && bgVideoRef.current.readyState >= 2 ? bgVideoRef.current : bgImageRef.current; if (bgSource) { ctx.save(); ctx.globalAlpha = 1 - currentConfig.bgDim; // Shake Effect (Camera) if (currentEffects.shake && normBass > (0.6 - (currentIntensities.shake * 0.3))) { const magnitude = currentIntensities.shake * 50; const shakeX = (Math.random() - 0.5) * magnitude * normBass; const shakeY = (Math.random() - 0.5) * magnitude * normBass; ctx.translate(shakeX, shakeY); } const zoom = 1 + (Math.sin(time * 0.5) * 0.05); ctx.translate(centerX, centerY); ctx.scale(zoom, zoom); ctx.drawImage(bgSource, -width/2, -height/2, width, height); ctx.restore(); } // --- 2. PRESET DRAWING --- ctx.save(); // Apply Shake to visual elements if (currentEffects.shake && normBass > 0.6) { const magnitude = currentIntensities.shake * 30; const shakeX = (Math.random() - 0.5) * magnitude * normBass; const shakeY = (Math.random() - 0.5) * magnitude * normBass; ctx.translate(shakeX, shakeY); } switch(currentConfig.preset) { case 'NCS Circle': drawNCSCircle(ctx, centerX, centerY, dataArray, pulse, time, currentConfig.primaryColor, currentConfig.secondaryColor); break; case 'Linear Bars': drawLinearBars(ctx, width, height, dataArray, currentConfig.primaryColor, currentConfig.secondaryColor); break; case 'Dual Mirror': drawDualMirror(ctx, width, height, dataArray, currentConfig.primaryColor); break; case 'Center Wave': drawCenterWave(ctx, centerX, centerY, dataArray, time, currentConfig.primaryColor); break; case 'Orbital': drawOrbital(ctx, centerX, centerY, dataArray, time, currentConfig.primaryColor, currentConfig.secondaryColor); break; case 'Hexagon': drawHexagon(ctx, centerX, centerY, dataArray, pulse, time, currentConfig.primaryColor); break; case 'Oscilloscope': drawOscilloscope(ctx, width, height, timeDomain, currentConfig.primaryColor); break; case 'Digital Rain': drawDigitalRain(ctx, width, height, dataArray, time, currentConfig.primaryColor); break; case 'Shockwave': drawShockwave(ctx, centerX, centerY, bass, time, currentConfig.primaryColor); break; } drawParticles(ctx, width, height, time, bass, currentConfig.particleCount, currentConfig.primaryColor); if (['NCS Circle', 'Hexagon', 'Orbital', 'Shockwave'].includes(currentConfig.preset)) { const rawAlbumArtUrl = customAlbumArt || song.coverUrl; // Proxy external URLs to avoid CORS issues in fallback const albumArtUrl = rawAlbumArtUrl.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawAlbumArtUrl)}` : rawAlbumArtUrl; drawAlbumArt(ctx, centerX, centerY, pulse, albumArtUrl, currentConfig.primaryColor, customAlbumArtImageRef.current); } // Pixelate effect (applied before text so text stays sharp) if (currentEffects.pixelate) { const pixelSize = Math.max(4, Math.floor(16 * currentIntensities.pixelate)); ctx.imageSmoothingEnabled = false; const tempCanvas = document.createElement('canvas'); const smallW = Math.floor(width / pixelSize); const smallH = Math.floor(height / pixelSize); tempCanvas.width = smallW; tempCanvas.height = smallH; const tempCtx = tempCanvas.getContext('2d')!; tempCtx.drawImage(canvas, 0, 0, smallW, smallH); ctx.clearRect(0, 0, width, height); ctx.drawImage(tempCanvas, 0, 0, smallW, smallH, 0, 0, width, height); ctx.imageSmoothingEnabled = true; } // --- 3. CUSTOM TEXT LAYERS --- ctx.shadowBlur = 10; ctx.shadowColor = 'black'; ctx.textAlign = 'center'; currentTexts.forEach(layer => { ctx.fillStyle = layer.color; // Adjust font size by pulse for title-like layers if needed, here we do static or slight pulse const dynamicSize = layer.id === '1' && currentConfig.preset === 'Minimal' ? layer.size * pulse : layer.size; ctx.font = `bold ${dynamicSize}px ${layer.font}, sans-serif`; const xPos = (layer.x / 100) * width; const yPos = (layer.y / 100) * height; ctx.fillText(layer.text, xPos, yPos); }); ctx.restore(); // --- 4. POST-PROCESSING EFFECTS --- // Scanlines if (currentEffects.scanlines || currentEffects.cctv) { ctx.fillStyle = `rgba(0,0,0,${currentIntensities.scanlines * 0.8})`; for (let i = 0; i < height; i+=4) { ctx.fillRect(0, i, width, 2); } } // VHS Color Shift / Chromatic Aberration if (currentEffects.vhs || currentEffects.chromatic || (currentEffects.glitch && Math.random() > (1 - currentIntensities.glitch))) { const intensity = currentEffects.vhs ? currentIntensities.vhs : currentIntensities.chromatic; const offset = (10 * intensity) * normBass; ctx.globalCompositeOperation = 'screen'; // Red Shift - draw colored rectangle offset left ctx.fillStyle = `rgba(255,0,0,${0.2 * intensity})`; ctx.fillRect(-offset, 0, width, height); // Blue Shift - draw colored rectangle offset right ctx.fillStyle = `rgba(0,0,255,${0.2 * intensity})`; ctx.fillRect(offset, 0, width, height); ctx.globalCompositeOperation = 'source-over'; } // Glitch Slices if (currentEffects.glitch && Math.random() > (1 - currentIntensities.glitch)) { const sliceHeight = Math.random() * 50; const sliceY = Math.random() * height; const offset = (Math.random() - 0.5) * 40 * currentIntensities.glitch; ctx.drawImage(canvas, 0, sliceY, width, sliceHeight, offset, sliceY, width, sliceHeight); // Random colored block ctx.fillStyle = Math.random() > 0.5 ? currentConfig.primaryColor : '#fff'; ctx.fillRect(Math.random()*width, Math.random()*height, Math.random()*200, 4); } // CCTV Vignette & Grain if (currentEffects.cctv) { const intensity = currentIntensities.cctv; // Green tint ctx.globalCompositeOperation = 'overlay'; ctx.fillStyle = `rgba(0, 50, 0, ${0.4 * intensity})`; ctx.fillRect(0, 0, width, height); // Vignette const grad = ctx.createRadialGradient(centerX, centerY, height * 0.4, centerX, centerY, height * 0.9); grad.addColorStop(0, 'transparent'); grad.addColorStop(1, 'black'); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); // Date Stamp ctx.globalCompositeOperation = 'source-over'; ctx.font = 'mono 24px monospace'; ctx.fillStyle = 'white'; ctx.shadowColor = 'black'; ctx.fillText(new Date().toLocaleString().toUpperCase(), 60, 60); ctx.fillText("REC ●", width - 120, 60); } // Bloom / Glow effect if (currentEffects.bloom) { const intensity = currentIntensities.bloom; ctx.globalCompositeOperation = 'screen'; ctx.filter = `blur(${15 * intensity}px)`; ctx.globalAlpha = 0.4 * intensity; ctx.drawImage(canvas, 0, 0); ctx.filter = 'none'; ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; } // Film Grain if (currentEffects.filmGrain) { const intensity = currentIntensities.filmGrain; const imageData = ctx.getImageData(0, 0, width, height); const data = imageData.data; const grainAmount = intensity * 50; for (let i = 0; i < data.length; i += 4) { const noise = (Math.random() - 0.5) * grainAmount; data[i] += noise; data[i + 1] += noise; data[i + 2] += noise; } ctx.putImageData(imageData, 0, 0); } // Strobe effect if (currentEffects.strobe && normBass > (0.7 - currentIntensities.strobe * 0.3)) { ctx.globalCompositeOperation = 'screen'; ctx.fillStyle = `rgba(255, 255, 255, ${currentIntensities.strobe * normBass * 0.8})`; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = 'source-over'; } // Vignette effect if (currentEffects.vignette) { const intensity = currentIntensities.vignette; const grad = ctx.createRadialGradient(centerX, centerY, height * 0.3, centerX, centerY, height * 0.8); grad.addColorStop(0, 'transparent'); grad.addColorStop(1, `rgba(0, 0, 0, ${0.8 * intensity})`); ctx.fillStyle = grad; ctx.fillRect(0, 0, width, height); } // Hue Shift effect if (currentEffects.hueShift) { const hueRotation = currentIntensities.hueShift * 360 * (1 + normBass * 0.5); ctx.filter = `hue-rotate(${hueRotation}deg)`; ctx.drawImage(canvas, 0, 0); ctx.filter = 'none'; } // Letterbox effect if (currentEffects.letterbox) { const barHeight = height * 0.12 * currentIntensities.letterbox; ctx.fillStyle = 'black'; ctx.fillRect(0, 0, width, barHeight); ctx.fillRect(0, height - barHeight, width, barHeight); } animationRef.current = requestAnimationFrame(renderLoop); }; // --- DRAWING FUNCTIONS --- // (Reusing existing drawing functions from previous step, ensuring they use updated args) const drawNCSCircle = (ctx: CanvasRenderingContext2D, cx: number, cy: number, data: Uint8Array, pulse: number, time: number, c1: string, c2: string) => { const radius = 150 + (pulse - 1) * 50; const bars = 80; const step = (Math.PI * 2) / bars; ctx.save(); ctx.translate(cx, cy); ctx.rotate(time * 0.15); for (let i = 0; i < bars; i++) { const val = data[i + 10]; const normalized = val / 255; const h = 8 + Math.pow(normalized, 1.5) * 120; ctx.save(); ctx.rotate(i * step); const grad = ctx.createLinearGradient(0, radius, 0, radius + h); grad.addColorStop(0, c1); grad.addColorStop(1, c2); ctx.fillStyle = grad; ctx.beginPath(); ctx.roundRect(-3, radius + 10, 6, h, 3); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.beginPath(); ctx.roundRect(-3, radius + 10 + h + 2, 6, 3, 2); ctx.fill(); ctx.restore(); } ctx.beginPath(); ctx.arc(0, 0, radius + 150, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); }; const drawLinearBars = (ctx: CanvasRenderingContext2D, w: number, h: number, data: Uint8Array, c1: string, c2: string) => { const bars = 64; const barW = w / bars; const gap = 2; for(let i=0; i { const bars = 40; const barH = h / bars; const cy = h/2; for(let i=0; i { for(let i=0; i<5; i++) { const r = 100 + (i * 55); const val = data[i*10]; const normalized = val / 255; const width = 4 + normalized * 6; ctx.beginPath(); ctx.strokeStyle = i % 2 === 0 ? c1 : c2; ctx.lineWidth = width; ctx.shadowBlur = 20; ctx.shadowColor = ctx.strokeStyle; const direction = i % 2 === 0 ? 1 : -1; const speed = direction * (0.5 + i * 0.1); const start = time * speed; const arcLength = Math.PI * 1.2 + normalized * Math.PI * 0.3; ctx.arc(cx, cy, r, start, start + arcLength); ctx.stroke(); } ctx.shadowBlur = 0; }; const drawHexagon = (ctx: CanvasRenderingContext2D, cx: number, cy: number, data: Uint8Array, pulse: number, time: number, color: string) => { const sides = 6; const r = 180 * pulse; ctx.save(); ctx.translate(cx, cy); ctx.rotate(time * 0.4); ctx.beginPath(); ctx.lineWidth = 12; ctx.strokeStyle = color; ctx.lineJoin = 'round'; ctx.shadowBlur = 25; ctx.shadowColor = color; for(let i=0; i<=sides; i++) { const angle = i * 2 * Math.PI / sides; const x = r * Math.cos(angle); const y = r * Math.sin(angle); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); ctx.restore(); ctx.shadowBlur = 0; }; const drawOscilloscope = (ctx: CanvasRenderingContext2D, w: number, h: number, data: Uint8Array, color: string) => { ctx.lineWidth = 3; ctx.strokeStyle = color; ctx.shadowBlur = 15; ctx.shadowColor = color; ctx.beginPath(); const sliceWidth = w / data.length; let x = 0; for(let i = 0; i < data.length; i++) { const normalized = (data[i] - 128) / 128.0; const dampened = normalized * 0.6; const yPos = (h/2) + (dampened * h/2); if(i === 0) ctx.moveTo(x, yPos); else ctx.lineTo(x, yPos); x += sliceWidth; } ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.shadowBlur = 0; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, h/2); ctx.lineTo(w, h/2); ctx.stroke(); }; const drawCenterWave = (ctx: CanvasRenderingContext2D, cx: number, cy: number, data: Uint8Array, time: number, color: string) => { ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.shadowBlur = 8; ctx.shadowColor = color; for(let i=0; i<12; i++) { ctx.beginPath(); const baseR = 60 + (i * 35); const val = data[i*4]; const normalized = val / 255; const r = baseR + Math.pow(normalized, 1.5) * 25; ctx.globalAlpha = 0.8 - (i/15); ctx.ellipse(cx, cy, r, r * 0.75, time * 0.5 + i * 0.3, 0, Math.PI * 2); ctx.stroke(); } ctx.globalAlpha = 1; ctx.shadowBlur = 0; }; const drawDigitalRain = (ctx: CanvasRenderingContext2D, w: number, h: number, data: Uint8Array, time: number, color: string) => { const cols = 50; const colW = w / cols; ctx.fillStyle = color; ctx.font = 'bold 14px monospace'; ctx.shadowBlur = 8; ctx.shadowColor = color; for(let i=0; i { const normBass = bass / 255; const maxRadius = 500; const rings = 6; ctx.shadowColor = color; for (let i = 0; i < rings; i++) { const phase = (time * 0.8 + (i * 0.4)) % 2; const progress = phase / 2; const radius = 50 + progress * maxRadius; const alpha = (1 - progress) * (0.5 + normBass * 0.5); const lineWidth = (1 - progress) * (8 + normBass * 12); if (alpha > 0.05) { ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.globalAlpha = alpha; ctx.shadowBlur = 20 + normBass * 30; ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.stroke(); } } const coreSize = 30 + normBass * 40; const coreGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreSize); coreGrad.addColorStop(0, color); coreGrad.addColorStop(0.5, color); coreGrad.addColorStop(1, 'transparent'); ctx.globalAlpha = 0.6 + normBass * 0.4; ctx.fillStyle = coreGrad; ctx.beginPath(); ctx.arc(cx, cy, coreSize, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; ctx.shadowBlur = 0; }; const drawParticles = (ctx: CanvasRenderingContext2D, w: number, h: number, time: number, bass: number, count: number, color: string) => { const normBass = bass / 255; const cx = w / 2; const cy = h / 2; // Rising particles - float upward with drift const risingCount = Math.floor(count * 0.4); for (let i = 0; i < risingCount; i++) { const seed = i * 127.1; const xBase = ((Math.sin(seed) * 10000) % w + w) % w; const drift = Math.sin(time * 2 + seed) * 30; const x = xBase + drift; const speed = 20 + (i % 7) * 15; const y = h - ((time * speed + seed * 10) % (h + 100)); const size = 2 + (i % 4) + normBass * 3; const twinkle = 0.5 + Math.sin(time * 8 + seed) * 0.3; ctx.beginPath(); ctx.fillStyle = color; ctx.shadowBlur = 15 + normBass * 10; ctx.shadowColor = color; ctx.globalAlpha = twinkle * (0.4 + normBass * 0.4); ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fill(); } // Burst particles - explode from center on bass const burstCount = Math.floor(count * 0.35); for (let i = 0; i < burstCount; i++) { const angle = (i / burstCount) * Math.PI * 2 + time * 0.3; const seed = i * 234.5; const burstPhase = (time * 1.5 + seed * 0.01) % 3; const burstProgress = burstPhase / 3; const maxDist = 300 + normBass * 200; const dist = burstProgress * maxDist; const x = cx + Math.cos(angle) * dist; const y = cy + Math.sin(angle) * dist; const size = (1 - burstProgress) * (3 + normBass * 4); const alpha = (1 - burstProgress) * (0.6 + normBass * 0.4); if (size > 0.5 && alpha > 0.1) { ctx.beginPath(); ctx.fillStyle = color; ctx.shadowBlur = 10; ctx.shadowColor = color; ctx.globalAlpha = alpha; ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fill(); } } // Orbital sparkles - circle around center const orbitalCount = Math.floor(count * 0.15); for (let i = 0; i < orbitalCount; i++) { const orbitRadius = 150 + (i % 4) * 80 + normBass * 50; const speed = (i % 2 === 0 ? 1 : -1) * (0.8 + (i % 3) * 0.3); const angle = time * speed + (i / orbitalCount) * Math.PI * 2; const x = cx + Math.cos(angle) * orbitRadius; const y = cy + Math.sin(angle) * orbitRadius; const sparkle = 0.5 + Math.sin(time * 12 + i * 5) * 0.5; const size = 2 + sparkle * 2 + normBass * 2; ctx.beginPath(); ctx.fillStyle = '#fff'; ctx.shadowBlur = 20; ctx.shadowColor = color; ctx.globalAlpha = sparkle * 0.8; ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fill(); } // Floating dust - subtle background particles const dustCount = Math.floor(count * 0.1); for (let i = 0; i < dustCount; i++) { const seed = i * 567.8; const x = ((Math.sin(seed) * 10000) % w + w) % w; const y = ((Math.cos(seed) * 10000) % h + h) % h; const drift = Math.sin(time + seed) * 2; const size = 1 + Math.sin(time * 3 + seed) * 0.5; ctx.beginPath(); ctx.fillStyle = '#fff'; ctx.shadowBlur = 5; ctx.shadowColor = '#fff'; ctx.globalAlpha = 0.2 + normBass * 0.2; ctx.arc(x + drift, y, size, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; ctx.shadowBlur = 0; }; const drawAlbumArt = (ctx: CanvasRenderingContext2D, cx: number, cy: number, pulse: number, url: string, borderColor: string, preloadedImage?: HTMLImageElement | null) => { ctx.save(); ctx.translate(cx, cy); ctx.scale(pulse, pulse); ctx.shadowBlur = 40; ctx.shadowColor = borderColor; ctx.beginPath(); ctx.arc(0, 0, 150, 0, Math.PI * 2); ctx.closePath(); ctx.lineWidth = 5; ctx.strokeStyle = 'white'; ctx.stroke(); ctx.clip(); // Use preloaded image if available, otherwise try to draw from URL if (preloadedImage && preloadedImage.complete) { ctx.drawImage(preloadedImage, -150, -150, 300, 300); } else { const img = new Image(); img.src = url; if (img.complete) { ctx.drawImage(img, -150, -150, 300, 300); } else { ctx.fillStyle = '#111'; ctx.fillRect(-150, -150, 300, 300); } } ctx.restore(); }; const addTextLayer = () => { const newLayer: TextLayer = { id: Date.now().toString(), text: 'New Text', x: 50, y: 50, size: 40, color: '#ffffff', font: 'Inter' }; setTextLayers([...textLayers, newLayer]); }; const updateTextLayer = (id: string, updates: Partial) => { setTextLayers(textLayers.map(l => l.id === id ? { ...l, ...updates } : l)); }; const removeTextLayer = (id: string) => { setTextLayers(textLayers.filter(l => l.id !== id)); }; if (!isOpen || !song) return null; return (
{/* Close Button */} {/* Mobile: Preview at top */} {isMobile && (
{/* Header */}

{/* Canvas Preview */}
{/* Playback Controls */}
)} {/* Sidebar Controls */}
{/* Header - Desktop only */} {!isMobile && (

Create professional visualizers.

)} {/* Tabs */}
{[ { id: 'presets', label: 'Presets', icon: }, { id: 'style', label: 'Style', icon: }, { id: 'text', label: 'Text', icon: }, { id: 'effects', label: 'FX', icon: } ].map(tab => ( ))}
{/* Content Area */}
{/* PRESETS TAB */} {activeTab === 'presets' && (
{PRESETS.map(preset => ( ))}
)} {/* STYLE TAB */} {activeTab === 'style' && (
{/* Background */}
{/* Type Selection */}
{/* Image Options */} {backgroundType === 'custom' && (
{customImage && (
Background
)}
)} {/* Video Options */} {backgroundType === 'video' && (
setVideoUrl(e.target.value)} className="w-full bg-zinc-800 rounded px-3 py-2 text-xs text-white border border-white/10 placeholder-zinc-500" /> {videoUrl && (

✓ Video loaded

)}
)} {/* Hidden File Inputs */}
Dimming {Math.round(config.bgDim * 100)}%
setConfig({...config, bgDim: parseFloat(e.target.value)})} className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer" />
{/* Colors */}
{[ { name: 'Neon Pink', primary: '#ec4899', secondary: '#8b5cf6' }, { name: 'Cyber Blue', primary: '#06b6d4', secondary: '#3b82f6' }, { name: 'Sunset', primary: '#f97316', secondary: '#eab308' }, { name: 'Matrix', primary: '#22c55e', secondary: '#10b981' }, { name: 'Fire', primary: '#ef4444', secondary: '#f97316' }, { name: 'Ocean', primary: '#0ea5e9', secondary: '#06b6d4' }, { name: 'Violet', primary: '#a855f7', secondary: '#ec4899' }, { name: 'Gold', primary: '#eab308', secondary: '#f59e0b' }, { name: 'Ice', primary: '#67e8f9', secondary: '#a5f3fc' }, { name: 'Mono', primary: '#ffffff', secondary: '#a1a1aa' }, ].map((preset) => ( ))}
Primary
setConfig({...config, primaryColor: e.target.value})} className="w-6 h-6 rounded cursor-pointer border-none bg-transparent" /> {config.primaryColor}
Secondary
setConfig({...config, secondaryColor: e.target.value})} className="w-6 h-6 rounded cursor-pointer border-none bg-transparent" /> {config.secondaryColor}
{/* Particles */}
Particles {config.particleCount}
setConfig({...config, particleCount: parseInt(e.target.value)})} className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer" />
{/* Center Image (Album Art) */}
{/* Preview */}
Center
{customAlbumArt && ( )}
)} {/* TEXT TAB */} {activeTab === 'text' && (
{textLayers.map((layer, index) => (
Layer {index + 1}
updateTextLayer(layer.id, { text: e.target.value })} className="w-full bg-zinc-800 rounded px-2 py-1 text-xs text-white border border-white/5" placeholder="Text content" />
updateTextLayer(layer.id, { x: parseInt(e.target.value) })} className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none" />
updateTextLayer(layer.id, { y: parseInt(e.target.value) })} className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none" />
updateTextLayer(layer.id, { size: parseInt(e.target.value) })} className="w-full bg-zinc-800 rounded px-2 py-1 text-xs text-white border border-white/5" />
updateTextLayer(layer.id, { color: e.target.value })} className="w-8 h-6 rounded cursor-pointer border-none bg-transparent" />
))}
)} {/* EFFECTS TAB */} {activeTab === 'effects' && (
{[ { id: 'shake', label: 'Bass Shake', desc: 'Camera reacts to low freq', icon: }, { id: 'glitch', label: 'Digital Glitch', desc: 'Random artifacting', icon: }, { id: 'vhs', label: 'VHS Tape', desc: 'Color bleeding & noise', icon: }, { id: 'cctv', label: 'CCTV Mode', desc: 'Night vision style', icon: }, { id: 'scanlines', label: 'Scanlines', desc: 'Old monitor effect', icon: }, { id: 'chromatic', label: 'Aberration', desc: 'RGB Split', icon: }, { id: 'bloom', label: 'Bloom', desc: 'Glow on bright areas', icon: }, { id: 'filmGrain', label: 'Film Grain', desc: 'Cinematic noise', icon: }, { id: 'pixelate', label: 'Pixelate', desc: 'Retro pixel look', icon: }, { id: 'strobe', label: 'Strobe', desc: 'Flash on bass hits', icon: }, { id: 'vignette', label: 'Vignette', desc: 'Dark edges', icon: }, { id: 'hueShift', label: 'Hue Shift', desc: 'Color rotation', icon: }, { id: 'letterbox', label: 'Letterbox', desc: 'Cinematic bars', icon: }, ].map((effect) => { const effectId = effect.id as keyof EffectConfig; const isActive = effects[effectId]; const intensity = intensities[effectId as keyof EffectIntensities]; return (
{/* Intensity Slider */} {isActive && (
Intensity {Math.round(intensity * 100)}%
setIntensities({...intensities, [effectId]: parseFloat(e.target.value)})} className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer" />
)}
); })}
)}
{/* Footer */}
{ffmpegLoading ? (
Loading video encoder...
) : isExporting ? (
{exportStage === 'capturing' ? ( <> Rendering frames {Math.round(exportProgress)}% ) : ( <> {exportProgress < 95 ? 'Encoding (be patient)...' : `Encoding MP4 ${Math.round(exportProgress)}%`} )}
) : ( )}

{ffmpegLoaded ? 'Encoder ready • ' : ''}Offline rendering - faster than real-time.

{/* Preview Area - Desktop only */} {!isMobile && (
{/* Playback Controls Overlay */}
)}
{/* Pexels Browser Modal */} {showPexelsBrowser && (
{/* Header */}

{pexelsTarget === 'albumArt' ? 'Select Center Image' : 'Select Background'}

{pexelsTarget === 'albumArt' ? 'Choose an image for the center circle' : 'Free stock photos & videos'}

{/* API Key Input */} {showPexelsApiKeyInput && (
Get free API key
setPexelsApiKey(e.target.value)} placeholder="Enter your Pexels API key..." className="flex-1 bg-zinc-900 rounded-lg px-4 py-2 text-sm text-white border border-white/10 placeholder-zinc-500" />

Your API key is stored locally in your browser.

)} {/* Error Message */} {pexelsError && (
{pexelsError} {!pexelsApiKey && ( )}
)} {/* Tabs & Search */}
{pexelsTarget !== 'albumArt' && (
)}
setPexelsQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && searchPexels(pexelsQuery, pexelsTab)} placeholder="Search for backgrounds..." className="flex-1 bg-zinc-800 rounded-lg px-4 py-2 text-sm text-white border border-white/10 placeholder-zinc-500" />
{/* Quick Tags */}
{['abstract', 'nature', 'city', 'space', 'neon', 'particles', 'smoke', 'fire', 'water', 'technology'].map(tag => ( ))}
{/* Results Grid */}
{pexelsLoading ? (
) : pexelsTab === 'photos' ? (
{pexelsPhotos.map(photo => ( ))}
) : (
{pexelsVideos.map(video => ( ))}
)} {!pexelsLoading && pexelsPhotos.length === 0 && pexelsTab === 'photos' && (

No photos found. Try a different search term.

)} {!pexelsLoading && pexelsVideos.length === 0 && pexelsTab === 'videos' && (

No videos found. Try a different search term.

)}
{/* Footer */}

Photos and videos provided by Pexels. Free for commercial use.

)}
); };