Files
ace-step-ui/components/VideoGeneratorModal.tsx
T
fspecii e1625a717d Fix multiple issues: format API, Gradio availability, storage, UI responsiveness
- Format endpoint now calls ACE-Step /format_input REST API directly instead of
  spawning Python, fixing ENOENT errors on Windows (#44, #27, #34)
- isGradioAvailable() tries /gradio_api/info, /info, / in sequence to handle
  Gradio 4.x/5.x/6.x version differences, fixing generation fallback (#53, #20)
- Storage getUrl/getPublicUrl normalize /audio/ prefix to prevent double-prefix
  URLs when reference tracks are used for cover generation (#10)
- Gradio args: fix normalization_db default from 0.0 to -1.0 (Gradio default)
- Volume popover: add 400ms delay before hiding to prevent accidental dismissal (#51)
- Polling: skip setSongs state update when nothing changed to reduce re-renders (#51)
- FFmpeg: add jsdelivr CDN fallback when unpkg fails (#30)
- Model switching: add switchModelIfNeeded() via /v1/init REST API (#45)
2026-03-02 17:43:53 +02:00

2415 lines
102 KiB
TypeScript

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: <Circle size={16} /> },
{ id: 'Linear Bars', label: 'Spectrum', icon: <BarChart2 size={16} /> },
{ id: 'Dual Mirror', label: 'Mirror', icon: <ColumnsIcon /> },
{ id: 'Center Wave', label: 'Shockwave', icon: <Waves size={16} /> },
{ id: 'Orbital', label: 'Orbital', icon: <Disc size={16} /> },
{ id: 'Hexagon', label: 'Hex Core', icon: <Box size={16} /> },
{ id: 'Oscilloscope', label: 'Analog', icon: <Activity size={16} /> },
{ id: 'Digital Rain', label: 'Matrix', icon: <Grid size={16} /> },
{ id: 'Shockwave', label: 'Pulse', icon: <Aperture size={16} /> },
{ id: 'Minimal', label: 'Clean', icon: <Type size={16} /> },
];
function ColumnsIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18"/>
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>
);
}
export const VideoGeneratorModal: React.FC<VideoGeneratorModalProps> = ({ isOpen, onClose, song }) => {
const { isMobile } = useResponsive();
const canvasRef = useRef<HTMLCanvasElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const animationRef = useRef<number>(0);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const bgImageRef = useRef<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const videoFileInputRef = useRef<HTMLInputElement>(null);
// FFmpeg Refs
const ffmpegRef = useRef<FFmpeg | null>(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<string | null>(null);
const [videoUrl, setVideoUrl] = useState<string>('');
const bgVideoRef = useRef<HTMLVideoElement | null>(null);
// Custom Album Art
const [customAlbumArt, setCustomAlbumArt] = useState<string | null>(null);
const albumArtInputRef = useRef<HTMLInputElement>(null);
const customAlbumArtImageRef = useRef<HTMLImageElement | null>(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<PexelsPhoto[]>([]);
const [pexelsVideos, setPexelsVideos] = useState<PexelsVideo[]>([]);
const [pexelsLoading, setPexelsLoading] = useState(false);
const [pexelsApiKey, setPexelsApiKey] = useState<string>(() => localStorage.getItem('pexels_api_key') || '');
const [showPexelsApiKeyInput, setShowPexelsApiKeyInput] = useState(false);
const [pexelsError, setPexelsError] = useState<string | null>(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<VisualizerConfig>({
preset: 'NCS Circle',
primaryColor: '#ec4899', // Pink-500
secondaryColor: '#3b82f6', // Blue-500
bgDim: 0.6,
particleCount: 50
});
const [effects, setEffects] = useState<EffectConfig>({
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<EffectIntensities>({
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<TextLayer[]>([]);
// 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<Uint8Array[]> => {
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<string | null> => {
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<void>((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<void>((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<void>((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<void>((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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<bars; i++) {
const val = data[i * 2];
const normalized = val / 255;
const barH = 10 + Math.pow(normalized, 1.3) * (h * 0.35);
const grad = ctx.createLinearGradient(0, h/2, 0, h/2 - barH);
grad.addColorStop(0, c1);
grad.addColorStop(1, c2);
ctx.fillStyle = grad;
ctx.fillRect(i * barW + gap/2, h/2 - barH, barW - gap, barH);
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.fillRect(i * barW + gap/2, h/2, barW - gap, barH * 0.3);
}
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(0, h/2, w, 1);
};
const drawDualMirror = (ctx: CanvasRenderingContext2D, w: number, h: number, data: Uint8Array, color: string) => {
const bars = 40;
const barH = h / bars;
const cy = h/2;
for(let i=0; i<bars; i++) {
const val = data[i*3];
const normalized = val / 255;
const len = 20 + Math.pow(normalized, 1.4) * (w * 0.3);
const alpha = 0.4 + normalized * 0.6;
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
ctx.fillRect(0, cy - (i*barH), len, barH-2);
ctx.fillRect(0, cy + (i*barH), len, barH-2);
ctx.fillRect(w - len, cy - (i*barH), len, barH-2);
ctx.fillRect(w - len, cy + (i*barH), len, barH-2);
}
ctx.globalAlpha = 1;
};
const drawOrbital = (ctx: CanvasRenderingContext2D, cx: number, cy: number, data: Uint8Array, time: number, c1: string, c2: string) => {
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<cols; i++) {
const val = data[i*2];
const normalized = val / 255;
const len = 8 + Math.floor(Math.pow(normalized, 1.3) * 15);
const baseSpeed = 40 + (i % 5) * 10;
const speedOffset = (time * baseSpeed) % h;
for(let j=0; j<len; j++) {
const char = String.fromCharCode(0x30A0 + Math.random() * 96);
const y = (speedOffset + (j * 18)) % h;
ctx.globalAlpha = (1 - (j/len)) * 0.8;
ctx.fillText(char, i * colW, y);
}
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
};
const drawShockwave = (ctx: CanvasRenderingContext2D, cx: number, cy: number, bass: number, time: number, color: string) => {
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<TextLayer>) => {
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 (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/90 backdrop-blur-md p-0 md:p-4 animate-in fade-in duration-200">
<div className={`bg-suno-card w-full h-full md:max-w-7xl md:h-[90vh] md:rounded-2xl border-0 md:border border-white/10 overflow-hidden shadow-2xl relative ${isMobile ? 'flex flex-col' : 'flex'}`}>
{/* Close Button */}
<button onClick={onClose} className="absolute top-3 right-3 md:top-4 md:right-4 z-50 p-2 bg-black/50 hover:bg-white/20 rounded-full text-white transition-colors">
<X size={isMobile ? 20 : 24} />
</button>
{/* Mobile: Preview at top */}
{isMobile && (
<div className="relative bg-black flex-shrink-0">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 p-3 bg-gradient-to-b from-black/80 to-transparent">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Video className="text-pink-500" size={18} />
Video Studio
</h2>
</div>
{/* Canvas Preview */}
<div className="aspect-video w-full">
<canvas
ref={canvasRef}
width={1920}
height={1080}
className="w-full h-full object-contain bg-[#0a0a0a]"
/>
</div>
{/* Playback Controls */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent flex items-center justify-center">
<button
onClick={togglePlay}
disabled={isExporting}
className="w-14 h-14 rounded-full bg-white text-black flex items-center justify-center shadow-xl tap-highlight-none disabled:opacity-50"
>
{isPlaying ? <Pause fill="black" size={22} /> : <Play fill="black" className="ml-1" size={22} />}
</button>
</div>
</div>
)}
{/* Sidebar Controls */}
<div className={`${isMobile ? 'flex-1 overflow-hidden' : 'w-96'} bg-suno-panel ${isMobile ? '' : 'border-r border-white/5'} flex flex-col z-20`}>
{/* Header - Desktop only */}
{!isMobile && (
<div className="p-6 border-b border-white/5">
<h2 className="text-xl font-bold text-white mb-1 flex items-center gap-2">
<Video className="text-pink-500" size={20} />
Video Studio
</h2>
<p className="text-zinc-500 text-xs">Create professional visualizers.</p>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-white/5">
{[
{ id: 'presets', label: 'Presets', icon: <Grid size={14} /> },
{ id: 'style', label: 'Style', icon: <Palette size={14} /> },
{ id: 'text', label: 'Text', icon: <Type size={14} /> },
{ id: 'effects', label: 'FX', icon: <Zap size={14} /> }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-xs font-bold uppercase tracking-wider transition-colors ${activeTab === tab.id ? 'text-white border-b-2 border-pink-500 bg-white/5' : 'text-zinc-500 hover:text-zinc-300'}`}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 md:p-6 space-y-4 md:space-y-6">
{/* PRESETS TAB */}
{activeTab === 'presets' && (
<div className="grid grid-cols-2 gap-3">
{PRESETS.map(preset => (
<button
key={preset.id}
onClick={() => setConfig({ ...config, preset: preset.id })}
className={`flex flex-col items-center gap-2 p-4 rounded-xl border transition-all ${config.preset === preset.id ? 'bg-pink-600/20 border-pink-500 text-white' : 'bg-black/20 border-white/5 text-zinc-400 hover:bg-white/5 hover:border-white/10'}`}
>
<div className={`p-2 rounded-full ${config.preset === preset.id ? 'bg-pink-500 text-white' : 'bg-black/40 text-zinc-500'}`}>
{preset.icon}
</div>
<span className="text-xs font-medium">{preset.label}</span>
</button>
))}
</div>
)}
{/* STYLE TAB */}
{activeTab === 'style' && (
<div className="space-y-6">
{/* Background */}
<div className="space-y-3">
<label className="text-xs font-bold text-zinc-500 uppercase flex justify-between">
Background
</label>
<div className="bg-black/20 p-3 rounded-lg border border-white/5 space-y-3">
{/* Type Selection */}
<div className="grid grid-cols-3 gap-2">
<button
onClick={() => { setBackgroundType('random'); setBackgroundSeed(Date.now()); }}
className={`py-2 rounded text-xs font-bold flex items-center justify-center gap-1 ${backgroundType === 'random' ? 'bg-pink-600 text-white' : 'bg-zinc-800 text-zinc-400'}`}
>
<Wand2 size={12}/> Random
</button>
<button
onClick={() => setBackgroundType('custom')}
className={`py-2 rounded text-xs font-bold flex items-center justify-center gap-1 ${backgroundType === 'custom' ? 'bg-pink-600 text-white' : 'bg-zinc-800 text-zinc-400'}`}
>
<ImageIcon size={12}/> Image
</button>
<button
onClick={() => setBackgroundType('video')}
className={`py-2 rounded text-xs font-bold flex items-center justify-center gap-1 ${backgroundType === 'video' ? 'bg-pink-600 text-white' : 'bg-zinc-800 text-zinc-400'}`}
>
<Video size={12}/> Video
</button>
</div>
{/* Image Options */}
{backgroundType === 'custom' && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => fileInputRef.current?.click()}
className="py-2 px-3 bg-zinc-700 hover:bg-zinc-600 rounded text-xs text-white flex items-center justify-center gap-1"
>
<Upload size={12}/> Upload
</button>
<button
onClick={() => openPexelsBrowser('background', 'photos')}
className="py-2 px-3 bg-emerald-600 hover:bg-emerald-700 rounded text-xs text-white flex items-center justify-center gap-1"
>
<Search size={12}/> Pexels
</button>
</div>
{customImage && (
<div className="relative rounded overflow-hidden h-20">
<img src={customImage} alt="Background" className="w-full h-full object-cover" />
</div>
)}
</div>
)}
{/* Video Options */}
{backgroundType === 'video' && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => videoFileInputRef.current?.click()}
className="py-2 px-3 bg-zinc-700 hover:bg-zinc-600 rounded text-xs text-white flex items-center justify-center gap-1"
>
<Upload size={12}/> Upload
</button>
<button
onClick={() => openPexelsBrowser('background', 'videos')}
className="py-2 px-3 bg-emerald-600 hover:bg-emerald-700 rounded text-xs text-white flex items-center justify-center gap-1"
>
<Search size={12}/> Pexels
</button>
</div>
<input
type="text"
placeholder="Or paste video URL..."
value={videoUrl}
onChange={(e) => 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 && (
<p className="text-[10px] text-emerald-400 truncate"> Video loaded</p>
)}
</div>
)}
{/* Hidden File Inputs */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept="image/*"
/>
<input
type="file"
ref={videoFileInputRef}
onChange={handleVideoFileUpload}
className="hidden"
accept="video/*"
/>
<div>
<div className="flex justify-between text-sm text-zinc-300 mb-2">
<span>Dimming</span>
<span>{Math.round(config.bgDim * 100)}%</span>
</div>
<input
type="range" min="0" max="1" step="0.1"
value={config.bgDim}
onChange={(e) => setConfig({...config, bgDim: parseFloat(e.target.value)})}
className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
</div>
{/* Colors */}
<div className="space-y-3">
<label className="text-xs font-bold text-zinc-500 uppercase">Color Presets</label>
<div className="grid grid-cols-5 gap-2">
{[
{ 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) => (
<button
key={preset.name}
onClick={() => setConfig({...config, primaryColor: preset.primary, secondaryColor: preset.secondary})}
className={`group relative h-8 rounded-lg overflow-hidden border-2 transition-all ${
config.primaryColor === preset.primary && config.secondaryColor === preset.secondary
? 'border-white scale-110 shadow-lg'
: 'border-transparent hover:border-white/30 hover:scale-105'
}`}
title={preset.name}
>
<div className="absolute inset-0 flex">
<div className="flex-1" style={{ backgroundColor: preset.primary }} />
<div className="flex-1" style={{ backgroundColor: preset.secondary }} />
</div>
</button>
))}
</div>
</div>
<div className="space-y-3">
<label className="text-xs font-bold text-zinc-500 uppercase">Custom Colors</label>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-[10px] text-zinc-400 mb-1 block">Primary</span>
<div className="flex items-center gap-2 bg-black/20 p-2 rounded border border-white/5">
<input type="color" value={config.primaryColor} onChange={(e) => setConfig({...config, primaryColor: e.target.value})} className="w-6 h-6 rounded cursor-pointer border-none bg-transparent" />
<span className="text-xs text-zinc-300 font-mono">{config.primaryColor}</span>
</div>
</div>
<div>
<span className="text-[10px] text-zinc-400 mb-1 block">Secondary</span>
<div className="flex items-center gap-2 bg-black/20 p-2 rounded border border-white/5">
<input type="color" value={config.secondaryColor} onChange={(e) => setConfig({...config, secondaryColor: e.target.value})} className="w-6 h-6 rounded cursor-pointer border-none bg-transparent" />
<span className="text-xs text-zinc-300 font-mono">{config.secondaryColor}</span>
</div>
</div>
</div>
</div>
{/* Particles */}
<div className="space-y-3">
<div className="flex justify-between text-xs font-bold text-zinc-500 uppercase">
<span>Particles</span>
<span>{config.particleCount}</span>
</div>
<input
type="range" min="0" max="200" step="10"
value={config.particleCount}
onChange={(e) => setConfig({...config, particleCount: parseInt(e.target.value)})}
className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer"
/>
</div>
{/* Center Image (Album Art) */}
<div className="space-y-3">
<label className="text-xs font-bold text-zinc-500 uppercase">Center Image</label>
<div className="bg-black/20 p-3 rounded-lg border border-white/5 space-y-3">
<div className="flex items-center gap-3">
{/* Preview */}
<div className="w-16 h-16 rounded-lg overflow-hidden bg-zinc-800 flex-shrink-0">
<img
src={customAlbumArt || song?.coverUrl || ''}
alt="Center"
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => albumArtInputRef.current?.click()}
className="py-1.5 px-2 bg-zinc-700 hover:bg-zinc-600 rounded text-[10px] text-white flex items-center justify-center gap-1"
>
<Upload size={10}/> Upload
</button>
<button
onClick={() => openPexelsBrowser('albumArt')}
className="py-1.5 px-2 bg-emerald-600 hover:bg-emerald-700 rounded text-[10px] text-white flex items-center justify-center gap-1"
>
<Search size={10}/> Pexels
</button>
</div>
{customAlbumArt && (
<button
onClick={() => setCustomAlbumArt(null)}
className="w-full py-1 text-[10px] text-zinc-500 hover:text-red-400"
>
Reset to default
</button>
)}
</div>
</div>
<input
type="file"
ref={albumArtInputRef}
onChange={handleAlbumArtUpload}
className="hidden"
accept="image/*"
/>
</div>
</div>
</div>
)}
{/* TEXT TAB */}
{activeTab === 'text' && (
<div className="space-y-4">
<button
onClick={addTextLayer}
className="w-full py-2 bg-pink-600 text-white rounded-lg flex items-center justify-center gap-2 text-xs font-bold hover:bg-pink-700"
>
<Plus size={14} /> Add Text Layer
</button>
<div className="space-y-3">
{textLayers.map((layer, index) => (
<div key={layer.id} className="bg-black/20 p-3 rounded-lg border border-white/5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-zinc-500">Layer {index + 1}</span>
<button onClick={() => removeTextLayer(layer.id)} className="text-zinc-500 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
<input
type="text"
value={layer.text}
onChange={(e) => 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"
/>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] text-zinc-500 block mb-1">X Position</label>
<input type="range" min="0" max="100" value={layer.x} onChange={(e) => updateTextLayer(layer.id, { x: parseInt(e.target.value) })} className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none" />
</div>
<div>
<label className="text-[10px] text-zinc-500 block mb-1">Y Position</label>
<input type="range" min="0" max="100" value={layer.y} onChange={(e) => updateTextLayer(layer.id, { y: parseInt(e.target.value) })} className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none" />
</div>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-[10px] text-zinc-500 block mb-1">Size</label>
<input type="number" value={layer.size} onChange={(e) => 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" />
</div>
<div>
<label className="text-[10px] text-zinc-500 block mb-1">Color</label>
<input type="color" value={layer.color} onChange={(e) => updateTextLayer(layer.id, { color: e.target.value })} className="w-8 h-6 rounded cursor-pointer border-none bg-transparent" />
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* EFFECTS TAB */}
{activeTab === 'effects' && (
<div className="space-y-2">
{[
{ id: 'shake', label: 'Bass Shake', desc: 'Camera reacts to low freq', icon: <Activity size={16}/> },
{ id: 'glitch', label: 'Digital Glitch', desc: 'Random artifacting', icon: <Zap size={16}/> },
{ id: 'vhs', label: 'VHS Tape', desc: 'Color bleeding & noise', icon: <Disc size={16}/> },
{ id: 'cctv', label: 'CCTV Mode', desc: 'Night vision style', icon: <Monitor size={16}/> },
{ id: 'scanlines', label: 'Scanlines', desc: 'Old monitor effect', icon: <Grid size={16}/> },
{ id: 'chromatic', label: 'Aberration', desc: 'RGB Split', icon: <Layers size={16}/> },
{ id: 'bloom', label: 'Bloom', desc: 'Glow on bright areas', icon: <Sun size={16}/> },
{ id: 'filmGrain', label: 'Film Grain', desc: 'Cinematic noise', icon: <Film size={16}/> },
{ id: 'pixelate', label: 'Pixelate', desc: 'Retro pixel look', icon: <Grid size={16}/> },
{ id: 'strobe', label: 'Strobe', desc: 'Flash on bass hits', icon: <Zap size={16}/> },
{ id: 'vignette', label: 'Vignette', desc: 'Dark edges', icon: <Circle size={16}/> },
{ id: 'hueShift', label: 'Hue Shift', desc: 'Color rotation', icon: <Palette size={16}/> },
{ id: 'letterbox', label: 'Letterbox', desc: 'Cinematic bars', icon: <Minus size={16}/> },
].map((effect) => {
const effectId = effect.id as keyof EffectConfig;
const isActive = effects[effectId];
const intensity = intensities[effectId as keyof EffectIntensities];
return (
<div key={effect.id} className={`rounded-lg border transition-all ${isActive ? 'bg-pink-600/10 border-pink-500/30' : 'bg-black/20 border-white/5'}`}>
<button
onClick={() => setEffects(prev => ({ ...prev, [effectId]: !prev[effectId] }))}
className="w-full flex items-center justify-between p-3"
>
<div className="flex items-center gap-3">
<div className={`p-1.5 rounded-md ${isActive ? 'bg-pink-500 text-white' : 'bg-zinc-800 text-zinc-500'}`}>
{effect.icon}
</div>
<div className="text-left">
<div className={`text-sm font-bold ${isActive ? 'text-white' : 'text-zinc-400'}`}>{effect.label}</div>
<div className="text-[10px] text-zinc-500">{effect.desc}</div>
</div>
</div>
<div className={`w-3 h-3 rounded-full ${isActive ? 'bg-pink-500 shadow-[0_0_8px_rgba(236,72,153,0.8)]' : 'bg-zinc-700'}`}></div>
</button>
{/* Intensity Slider */}
{isActive && (
<div className="px-3 pb-3 pt-0 animate-in fade-in slide-in-from-top-2">
<div className="flex justify-between text-[10px] text-zinc-400 mb-1">
<span>Intensity</span>
<span>{Math.round(intensity * 100)}%</span>
</div>
<input
type="range" min="0" max="1" step="0.05"
value={intensity}
onChange={(e) => setIntensities({...intensities, [effectId]: parseFloat(e.target.value)})}
className="w-full accent-pink-500 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer"
/>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 md:p-6 border-t border-white/5 bg-black/20 space-y-3 safe-area-inset-bottom">
{ffmpegLoading ? (
<div className="w-full bg-zinc-800 rounded-xl h-12 flex items-center justify-center px-4">
<div className="flex items-center gap-2 text-white font-bold text-sm">
<Loader2 className="animate-spin" size={18} />
Loading video encoder...
</div>
</div>
) : isExporting ? (
<div className="w-full bg-zinc-800 rounded-xl h-12 flex items-center justify-center px-4 relative overflow-hidden">
<div
className={`absolute left-0 top-0 bottom-0 transition-all duration-100 ${exportStage === 'capturing' ? 'bg-pink-600/20' : 'bg-blue-600/20'}`}
style={{ width: `${exportProgress}%` }}
/>
<div className="flex items-center gap-2 z-10 text-white font-bold text-sm">
{exportStage === 'capturing' ? (
<>
<Loader2 className="animate-spin text-pink-400" size={16} />
Rendering frames {Math.round(exportProgress)}%
</>
) : (
<>
<Loader2 className="animate-spin text-blue-400" size={16} />
{exportProgress < 95 ? 'Encoding (be patient)...' : `Encoding MP4 ${Math.round(exportProgress)}%`}
</>
)}
</div>
</div>
) : (
<button
onClick={startRecording}
disabled={ffmpegLoading}
className="w-full h-12 bg-white text-black font-bold rounded-xl flex items-center justify-center gap-2 hover:scale-105 transition-transform disabled:opacity-50"
>
<Download size={18} />
Render Video (MP4)
</button>
)}
<p className="text-[10px] text-zinc-600 text-center">
{ffmpegLoaded ? 'Encoder ready • ' : ''}Offline rendering - faster than real-time.
</p>
</div>
</div>
{/* Preview Area - Desktop only */}
{!isMobile && (
<div className="flex-1 bg-black relative flex flex-col">
<canvas
ref={canvasRef}
width={1920}
height={1080}
className="w-full h-full object-contain bg-[#0a0a0a]"
/>
{/* Playback Controls Overlay */}
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-black/90 via-black/40 to-transparent flex items-center justify-center gap-6">
<button
onClick={togglePlay}
disabled={isExporting}
className="w-16 h-16 rounded-full bg-white text-black flex items-center justify-center hover:scale-105 transition-transform shadow-xl hover:shadow-2xl disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPlaying ? <Pause fill="black" size={24} /> : <Play fill="black" className="ml-1" size={24} />}
</button>
</div>
</div>
)}
</div>
{/* Pexels Browser Modal */}
{showPexelsBrowser && (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-zinc-900 w-full max-w-4xl max-h-[80vh] rounded-2xl border border-white/10 flex flex-col overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-600 rounded-lg">
<ExternalLink size={18} className="text-white" />
</div>
<div>
<h3 className="text-white font-bold">
{pexelsTarget === 'albumArt' ? 'Select Center Image' : 'Select Background'}
</h3>
<p className="text-zinc-500 text-xs">
{pexelsTarget === 'albumArt' ? 'Choose an image for the center circle' : 'Free stock photos & videos'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowPexelsApiKeyInput(!showPexelsApiKeyInput)}
className={`p-2 hover:bg-white/10 rounded-lg ${pexelsApiKey ? 'text-emerald-400' : 'text-amber-400'}`}
title={pexelsApiKey ? 'API key configured' : 'Set API key'}
>
<Settings2 size={20} />
</button>
<button onClick={() => setShowPexelsBrowser(false)} className="p-2 hover:bg-white/10 rounded-lg text-zinc-400">
<X size={20} />
</button>
</div>
</div>
{/* API Key Input */}
{showPexelsApiKeyInput && (
<div className="p-4 bg-zinc-800/50 border-b border-white/10 space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-zinc-300">Pexels API Key</label>
<a
href="https://www.pexels.com/api/new/"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-emerald-400 hover:underline flex items-center gap-1"
>
Get free API key <ExternalLink size={10} />
</a>
</div>
<div className="flex gap-2">
<input
type="password"
value={pexelsApiKey}
onChange={(e) => 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"
/>
<button
onClick={() => savePexelsApiKey(pexelsApiKey)}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg text-white font-bold text-sm"
>
Save
</button>
</div>
<p className="text-xs text-zinc-500">Your API key is stored locally in your browser.</p>
</div>
)}
{/* Error Message */}
{pexelsError && (
<div className="px-4 py-2 bg-red-500/10 border-b border-red-500/20 text-red-400 text-sm flex items-center gap-2">
<span>{pexelsError}</span>
{!pexelsApiKey && (
<button
onClick={() => setShowPexelsApiKeyInput(true)}
className="text-red-300 underline hover:text-red-200"
>
Set API key
</button>
)}
</div>
)}
{/* Tabs & Search */}
<div className="p-4 border-b border-white/10 space-y-3">
{pexelsTarget !== 'albumArt' && (
<div className="flex gap-2">
<button
onClick={() => { setPexelsTab('photos'); searchPexels(pexelsQuery, 'photos'); }}
className={`px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2 ${pexelsTab === 'photos' ? 'bg-emerald-600 text-white' : 'bg-zinc-800 text-zinc-400'}`}
>
<ImageIcon size={14} /> Photos
</button>
<button
onClick={() => { setPexelsTab('videos'); searchPexels(pexelsQuery, 'videos'); }}
className={`px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2 ${pexelsTab === 'videos' ? 'bg-emerald-600 text-white' : 'bg-zinc-800 text-zinc-400'}`}
>
<Video size={14} /> Videos
</button>
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={pexelsQuery}
onChange={(e) => 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"
/>
<button
onClick={() => searchPexels(pexelsQuery, pexelsTab)}
disabled={pexelsLoading}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg text-white font-bold text-sm flex items-center gap-2 disabled:opacity-50"
>
{pexelsLoading ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
Search
</button>
</div>
{/* Quick Tags */}
<div className="flex flex-wrap gap-2">
{['abstract', 'nature', 'city', 'space', 'neon', 'particles', 'smoke', 'fire', 'water', 'technology'].map(tag => (
<button
key={tag}
onClick={() => { setPexelsQuery(tag); searchPexels(tag, pexelsTab); }}
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded-full text-xs text-zinc-400 hover:text-white capitalize"
>
{tag}
</button>
))}
</div>
</div>
{/* Results Grid */}
<div className="flex-1 overflow-y-auto p-4">
{pexelsLoading ? (
<div className="flex items-center justify-center h-48">
<Loader2 size={32} className="animate-spin text-emerald-500" />
</div>
) : pexelsTab === 'photos' ? (
<div className="grid grid-cols-3 gap-3">
{pexelsPhotos.map(photo => (
<button
key={photo.id}
onClick={() => selectPexelsPhoto(photo)}
className="relative group rounded-lg overflow-hidden aspect-video bg-zinc-800"
>
<img src={photo.src.large} alt="" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="text-white text-xs font-bold bg-emerald-600 px-3 py-1 rounded-full">Select</span>
</div>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
<p className="text-[10px] text-zinc-300 truncate">by {photo.photographer}</p>
</div>
</button>
))}
</div>
) : (
<div className="grid grid-cols-3 gap-3">
{pexelsVideos.map(video => (
<button
key={video.id}
onClick={() => selectPexelsVideo(video)}
className="relative group rounded-lg overflow-hidden aspect-video bg-zinc-800"
>
<img src={video.image} alt="" className="w-full h-full object-cover" />
<div className="absolute top-2 right-2 bg-black/60 px-2 py-0.5 rounded text-[10px] text-white font-bold">
<Video size={10} className="inline mr-1" />VIDEO
</div>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="text-white text-xs font-bold bg-emerald-600 px-3 py-1 rounded-full">Select</span>
</div>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
<p className="text-[10px] text-zinc-300 truncate">by {video.user.name}</p>
</div>
</button>
))}
</div>
)}
{!pexelsLoading && pexelsPhotos.length === 0 && pexelsTab === 'photos' && (
<p className="text-center text-zinc-500 py-8">No photos found. Try a different search term.</p>
)}
{!pexelsLoading && pexelsVideos.length === 0 && pexelsTab === 'videos' && (
<p className="text-center text-zinc-500 py-8">No videos found. Try a different search term.</p>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-white/10 bg-zinc-800/50">
<p className="text-[10px] text-zinc-500 text-center">
Photos and videos provided by <a href="https://www.pexels.com" target="_blank" rel="noopener noreferrer" className="text-emerald-400 hover:underline">Pexels</a>. Free for commercial use.
</p>
</div>
</div>
</div>
)}
</div>
);
};