| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>4K Transparent Audio Visualizer</title> |
| |
| <script src="https://cdn.tailwindcss.com"></script> |
| |
| <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script> |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script> |
| |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> |
|
|
| <style> |
| body { margin: 0; padding: 0; background-color: #020617; color: #e2e8f0; } |
| |
| ::-webkit-scrollbar { width: 8px; } |
| ::-webkit-scrollbar-track { background: #0f172a; } |
| ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; } |
| ::-webkit-scrollbar-thumb:hover { background: #475569; } |
| </style> |
| </head> |
| <body> |
| <div id="root"></div> |
|
|
| <script type="text/babel"> |
| const { useState, useRef, useEffect, useCallback } = React; |
| |
| |
| const Upload = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; |
| const Play = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>; |
| const Pause = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>; |
| const ImageIcon = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; |
| const Video = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>; |
| const Settings2 = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; |
| const Loader2 = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>; |
| const StopCircle = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6"/></svg>; |
| const Sparkles = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg>; |
| const Monitor = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>; |
| const ImagePlus = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/><circle cx="9" cy="9" r="2"/><path d="M19 3v6"/><path d="M16 6h6"/></svg>; |
| |
| function App() { |
| const canvasRef = useRef(null); |
| const audioRef = useRef(null); |
| const audioCtxRef = useRef(null); |
| const analyserRef = useRef(null); |
| const sourceRef = useRef(null); |
| const destRef = useRef(null); |
| const reqIdRef = useRef(null); |
| const mediaRecorderRef = useRef(null); |
| const chunksRef = useRef([]); |
| const bgImgRef = useRef(null); |
| |
| |
| const dataArrayRef = useRef(new Uint8Array(2048)); |
| const lastDrawTimeRef = useRef(0); |
| |
| |
| const [audioSrc, setAudioSrc] = useState(null); |
| const [fileName, setFileName] = useState(''); |
| const [isPlaying, setIsPlaying] = useState(false); |
| const [isExportingVideo, setIsExportingVideo] = useState(false); |
| const [exportProgress, setExportProgress] = useState(0); |
| |
| |
| const [audioTime, setAudioTime] = useState(0); |
| const [audioDuration, setAudioDuration] = useState(0); |
| |
| |
| const [vizType, setVizType] = useState('bars'); |
| const [color, setColor] = useState('#00ffcc'); |
| const [thickness, setThickness] = useState(12); |
| const [spacing, setSpacing] = useState(8); |
| const [sensitivity, setSensitivity] = useState(1.5); |
| const [smoothing, setSmoothing] = useState(0.85); |
| |
| |
| const [offsetX, setOffsetX] = useState(0); |
| const [offsetY, setOffsetY] = useState(0); |
| const [scale, setScale] = useState(1.0); |
| const [rotation, setRotation] = useState(0); |
| |
| |
| const [isDragging, setIsDragging] = useState(false); |
| const dragRef = useRef({ startX: 0, startY: 0, initX: 0, initY: 0 }); |
| |
| |
| const [colorMode, setColorMode] = useState('solid'); |
| const [color2, setColor2] = useState('#b829ff'); |
| const [glow, setGlow] = useState(false); |
| const [resolution, setResolution] = useState('4k_16_9'); |
| const [bgType, setBgType] = useState('transparent'); |
| const [bgColor, setBgColor] = useState('#000000'); |
| const [bgImageSrc, setBgImageSrc] = useState(null); |
| const [exportFormat, setExportFormat] = useState('mp4'); |
| const [exportFps, setExportFps] = useState(30); |
| |
| const RESOLUTIONS = { |
| '4k_16_9': { w: 3840, h: 2160, label: '4K (16:9)', isVertical: false }, |
| '1080p_16_9': { w: 1920, h: 1080, label: '1080p (16:9)', isVertical: false }, |
| '4k_9_16': { w: 2160, h: 3840, label: '4K Vertical (9:16)', isVertical: true }, |
| '1080p_9_16': { w: 1080, h: 1920, label: '1080p Vertical (9:16)', isVertical: true } |
| }; |
| |
| useEffect(() => { |
| if (bgImageSrc) { |
| const img = new Image(); |
| img.onload = () => { bgImgRef.current = img; }; |
| img.src = bgImageSrc; |
| } else { |
| bgImgRef.current = null; |
| } |
| }, [bgImageSrc]); |
| |
| const handleBgUpload = (e) => { |
| const file = e.target.files[0]; |
| if (file) { |
| if (bgImageSrc) URL.revokeObjectURL(bgImageSrc); |
| setBgImageSrc(URL.createObjectURL(file)); |
| setBgType('image'); |
| } |
| }; |
| |
| const initAudio = useCallback(() => { |
| if (!audioCtxRef.current) { |
| const AudioContext = window.AudioContext || window.webkitAudioContext; |
| audioCtxRef.current = new AudioContext(); |
| analyserRef.current = audioCtxRef.current.createAnalyser(); |
| destRef.current = audioCtxRef.current.createMediaStreamDestination(); |
| |
| if (!sourceRef.current && audioRef.current) { |
| sourceRef.current = audioCtxRef.current.createMediaElementSource(audioRef.current); |
| sourceRef.current.connect(analyserRef.current); |
| analyserRef.current.connect(audioCtxRef.current.destination); |
| analyserRef.current.connect(destRef.current); |
| } |
| } |
| |
| if (audioCtxRef.current.state === 'suspended') { |
| audioCtxRef.current.resume(); |
| } |
| }, []); |
| |
| const handleFileUpload = (e) => { |
| const file = e.target.files[0]; |
| if (file) { |
| if (audioSrc) URL.revokeObjectURL(audioSrc); |
| const url = URL.createObjectURL(file); |
| setAudioSrc(url); |
| setFileName(file.name); |
| setIsPlaying(false); |
| setAudioTime(0); |
| if (audioRef.current) { |
| audioRef.current.pause(); |
| audioRef.current.currentTime = 0; |
| } |
| } |
| }; |
| |
| const togglePlay = () => { |
| if (!audioSrc) return; |
| initAudio(); |
| |
| if (isPlaying) { |
| audioRef.current.pause(); |
| } else { |
| audioRef.current.play(); |
| } |
| setIsPlaying(!isPlaying); |
| }; |
| |
| const handleTimeUpdate = () => { |
| if (audioRef.current) setAudioTime(audioRef.current.currentTime); |
| }; |
| |
| const handleLoadedMetadata = () => { |
| if (audioRef.current) setAudioDuration(audioRef.current.duration); |
| }; |
| |
| const handleSeek = (e) => { |
| const time = Number(e.target.value); |
| if (audioRef.current) audioRef.current.currentTime = time; |
| setAudioTime(time); |
| }; |
| |
| const formatTime = (time) => { |
| if (isNaN(time)) return "0:00"; |
| const m = Math.floor(time / 60); |
| const s = Math.floor(time % 60).toString().padStart(2, '0'); |
| return `${m}:${s}`; |
| }; |
| |
| const handleMouseDown = (e) => { |
| setIsDragging(true); |
| dragRef.current = { |
| startX: e.clientX, |
| startY: e.clientY, |
| initX: offsetX, |
| initY: offsetY |
| }; |
| }; |
| |
| const handleMouseMove = (e) => { |
| if (!isDragging || !canvasRef.current) return; |
| const rect = canvasRef.current.getBoundingClientRect(); |
| |
| const deltaX = e.clientX - dragRef.current.startX; |
| const deltaY = e.clientY - dragRef.current.startY; |
| |
| const percentX = (deltaX / rect.width) * 100; |
| const percentY = (deltaY / rect.height) * 100; |
| |
| setOffsetX(Math.max(-50, Math.min(50, dragRef.current.initX + percentX))); |
| setOffsetY(Math.max(-50, Math.min(50, dragRef.current.initY + percentY))); |
| }; |
| |
| const handleMouseUpOrLeave = () => { |
| setIsDragging(false); |
| }; |
| |
| const draw = useCallback(() => { |
| if (!canvasRef.current || !analyserRef.current) { |
| reqIdRef.current = requestAnimationFrame(draw); |
| return; |
| } |
| |
| |
| const now = performance.now(); |
| if (isExportingVideo && exportFps < 60) { |
| const msPerFrame = 1000 / exportFps; |
| if (now - lastDrawTimeRef.current < msPerFrame) { |
| reqIdRef.current = requestAnimationFrame(draw); |
| return; |
| } |
| } |
| lastDrawTimeRef.current = now; |
| |
| const canvas = canvasRef.current; |
| |
| |
| const ctx = canvas.getContext('2d', { |
| alpha: bgType === 'transparent', |
| desynchronized: true, |
| willReadFrequently: false |
| }); |
| |
| const res = RESOLUTIONS[resolution] || RESOLUTIONS['4k_16_9']; |
| |
| if (canvas.width !== res.w || canvas.height !== res.h) { |
| canvas.width = res.w; |
| canvas.height = res.h; |
| } |
| |
| const width = canvas.width; |
| const height = canvas.height; |
| |
| ctx.clearRect(0, 0, width, height); |
| |
| |
| if (bgType === 'color') { |
| ctx.fillStyle = bgColor; |
| ctx.fillRect(0, 0, width, height); |
| } else if (bgType === 'image' && bgImgRef.current) { |
| const img = bgImgRef.current; |
| const imgRatio = img.width / img.height; |
| const canvasRatio = width / height; |
| let drawW, drawH, drawX, drawY; |
| if (imgRatio > canvasRatio) { |
| drawH = height; |
| drawW = height * imgRatio; |
| drawX = (width - drawW) / 2; |
| drawY = 0; |
| } else { |
| drawW = width; |
| drawH = width / imgRatio; |
| drawX = 0; |
| drawY = (height - drawH) / 2; |
| } |
| ctx.drawImage(img, drawX, drawY, drawW, drawH); |
| } |
| |
| |
| analyserRef.current.smoothingTimeConstant = smoothing; |
| analyserRef.current.fftSize = 2048; |
| |
| const bufferLength = analyserRef.current.frequencyBinCount; |
| const dataArray = dataArrayRef.current; |
| |
| if (vizType === 'bars' || vizType === 'circle') { |
| analyserRef.current.getByteFrequencyData(dataArray); |
| } else if (vizType === 'wave') { |
| analyserRef.current.getByteTimeDomainData(dataArray); |
| } |
| |
| ctx.save(); |
| |
| |
| const centerX = width / 2 + (width * (offsetX / 100)); |
| const centerY = height / 2 + (height * (offsetY / 100)); |
| ctx.translate(centerX, centerY); |
| ctx.scale(scale, scale); |
| ctx.rotate((rotation * Math.PI) / 180); |
| |
| |
| let activeColor = color; |
| if (colorMode === 'gradient') { |
| const grad = ctx.createLinearGradient(-width/2, -height/2, width/2, height/2); |
| grad.addColorStop(0, color); |
| grad.addColorStop(1, color2); |
| activeColor = grad; |
| } else if (colorMode === 'rainbow') { |
| const grad = ctx.createLinearGradient(-width/2, 0, width/2, 0); |
| grad.addColorStop(0, '#ff0000'); |
| grad.addColorStop(0.16, '#ffff00'); |
| grad.addColorStop(0.33, '#00ff00'); |
| grad.addColorStop(0.5, '#00ffff'); |
| grad.addColorStop(0.66, '#0000ff'); |
| grad.addColorStop(0.83, '#ff00ff'); |
| grad.addColorStop(1, '#ff0000'); |
| activeColor = grad; |
| } |
| |
| ctx.strokeStyle = activeColor; |
| ctx.fillStyle = activeColor; |
| ctx.lineCap = 'round'; |
| ctx.lineJoin = 'round'; |
| |
| |
| const drawVisualizerPath = () => { |
| ctx.beginPath(); |
| |
| if (vizType === 'bars') { |
| const step = thickness + spacing; |
| const maxBars = Math.floor((width / 2) / step); |
| const usefulLength = Math.floor(bufferLength * 0.75); |
| const numBars = Math.min(maxBars, usefulLength); |
| |
| for (let i = 0; i < numBars; i++) { |
| const dataIndex = Math.floor((i / numBars) * usefulLength); |
| const boost = Math.pow(1 + (i / numBars), 1.5); |
| const value = dataArray[dataIndex] * boost * sensitivity; |
| |
| const barHeight = Math.max(thickness / 2, (value / 255) * height * 0.8); |
| const xOffset = i * step + (step / 2); |
| |
| ctx.moveTo(xOffset, height / 2 - (thickness / 2)); |
| ctx.lineTo(xOffset, height / 2 - barHeight); |
| |
| ctx.moveTo(-xOffset, height / 2 - (thickness / 2)); |
| ctx.lineTo(-xOffset, height / 2 - barHeight); |
| } |
| } else if (vizType === 'wave') { |
| const sliceWidth = width / bufferLength; |
| let x = -width / 2; |
| |
| for (let i = 0; i < bufferLength; i++) { |
| const normalized = (dataArray[i] / 128.0) - 1; |
| const y = normalized * sensitivity * (height / 2); |
| |
| if (i === 0) { |
| ctx.moveTo(x, y); |
| } else { |
| ctx.lineTo(x, y); |
| } |
| x += sliceWidth; |
| } |
| } else if (vizType === 'circle') { |
| const radius = height / 4; |
| const circumference = 2 * Math.PI * radius; |
| const stepSize = thickness + spacing; |
| const bars = Math.min(180, Math.floor(circumference / stepSize)); |
| const step = (Math.PI * 2) / bars; |
| |
| for (let i = 0; i < bars; i++) { |
| const dataIndex = Math.floor((i / bars) * (bufferLength / 2)); |
| const value = (dataArray[dataIndex] / 255) * sensitivity; |
| const barHeight = Math.max(thickness / 2, value * (height / 3)); |
| const angle = i * step; |
| |
| const x1 = Math.cos(angle) * radius; |
| const y1 = Math.sin(angle) * radius; |
| const x2 = Math.cos(angle) * (radius + barHeight); |
| const y2 = Math.sin(angle) * (radius + barHeight); |
| |
| ctx.moveTo(x1, y1); |
| ctx.lineTo(x2, y2); |
| } |
| } |
| |
| ctx.stroke(); |
| |
| if (vizType === 'circle') { |
| const radius = height / 4; |
| ctx.beginPath(); |
| ctx.arc(0, 0, radius - thickness, 0, Math.PI * 2); |
| const originalWidth = ctx.lineWidth; |
| ctx.lineWidth = originalWidth / 2; |
| ctx.stroke(); |
| ctx.lineWidth = originalWidth; |
| } |
| }; |
| |
| |
| if (glow) { |
| ctx.globalCompositeOperation = 'lighter'; |
| |
| ctx.lineWidth = thickness * 3; |
| ctx.globalAlpha = 0.15; |
| drawVisualizerPath(); |
| |
| ctx.lineWidth = thickness * 1.5; |
| ctx.globalAlpha = 0.4; |
| drawVisualizerPath(); |
| |
| ctx.lineWidth = thickness; |
| ctx.globalAlpha = 1.0; |
| drawVisualizerPath(); |
| |
| ctx.globalCompositeOperation = 'source-over'; |
| } else { |
| ctx.lineWidth = thickness; |
| ctx.globalAlpha = 1.0; |
| drawVisualizerPath(); |
| } |
| |
| ctx.restore(); |
| reqIdRef.current = requestAnimationFrame(draw); |
| }, [vizType, color, thickness, spacing, sensitivity, smoothing, colorMode, color2, glow, resolution, bgType, bgColor, offsetX, offsetY, scale, rotation, isExportingVideo, exportFps]); |
| |
| useEffect(() => { |
| reqIdRef.current = requestAnimationFrame(draw); |
| return () => cancelAnimationFrame(reqIdRef.current); |
| }, [draw]); |
| |
| const handleAudioEnded = () => { |
| setIsPlaying(false); |
| if (isExportingVideo) stopVideoExport(); |
| }; |
| |
| const exportImage = () => { |
| if (!canvasRef.current) return; |
| const link = document.createElement('a'); |
| link.download = `visualizer_${Date.now()}.png`; |
| link.href = canvasRef.current.toDataURL('image/png'); |
| link.click(); |
| }; |
| |
| const startVideoExport = async () => { |
| if (!audioSrc || !canvasRef.current || !audioCtxRef.current) { |
| alert("Please upload an audio file and press play at least once to initialize."); |
| return; |
| } |
| |
| setIsExportingVideo(true); |
| setExportProgress(0); |
| chunksRef.current = []; |
| |
| audioRef.current.pause(); |
| audioRef.current.currentTime = 0; |
| |
| |
| const canvasStream = canvasRef.current.captureStream(exportFps); |
| const audioStream = destRef.current.stream; |
| |
| const combinedTracks = [...canvasStream.getTracks(), ...audioStream.getAudioTracks()]; |
| const combinedStream = new MediaStream(combinedTracks); |
| |
| let options = {}; |
| let ext = 'webm'; |
| |
| |
| const is4k = resolution.startsWith('4k'); |
| const targetBitrate = is4k ? 15000000 : 8000000; |
| |
| if (exportFormat === 'mp4') { |
| if (MediaRecorder.isTypeSupported('video/mp4; codecs=h264')) { |
| options = { mimeType: 'video/mp4; codecs=h264', videoBitsPerSecond: targetBitrate }; |
| ext = 'mp4'; |
| } else if (MediaRecorder.isTypeSupported('video/mp4')) { |
| options = { mimeType: 'video/mp4', videoBitsPerSecond: targetBitrate }; |
| ext = 'mp4'; |
| } else { |
| alert("Your browser doesn't natively support MP4 export. Falling back to WebM."); |
| options = { mimeType: 'video/webm; codecs=vp9', videoBitsPerSecond: targetBitrate }; |
| } |
| } else { |
| options = { mimeType: 'video/webm; codecs=vp9', videoBitsPerSecond: targetBitrate }; |
| if (!MediaRecorder.isTypeSupported(options.mimeType)) { |
| options = { mimeType: 'video/webm; codecs=vp8', videoBitsPerSecond: targetBitrate }; |
| } |
| if (!MediaRecorder.isTypeSupported(options.mimeType)) { |
| options = { videoBitsPerSecond: targetBitrate }; |
| } |
| } |
| |
| try { |
| mediaRecorderRef.current = new MediaRecorder(combinedStream, options); |
| } catch (e) { |
| console.error(e); |
| alert("Error starting video recorder. See console."); |
| setIsExportingVideo(false); |
| return; |
| } |
| |
| mediaRecorderRef.current.ondataavailable = (e) => { |
| if (e.data && e.data.size > 0) chunksRef.current.push(e.data); |
| }; |
| |
| mediaRecorderRef.current.onstop = () => { |
| const blob = new Blob(chunksRef.current, { type: mediaRecorderRef.current.mimeType || 'video/mp4' }); |
| const url = URL.createObjectURL(blob); |
| const link = document.createElement('a'); |
| link.download = `visualizer_${resolution}_${exportFps}fps_${Date.now()}.${ext}`; |
| link.href = url; |
| link.click(); |
| URL.revokeObjectURL(url); |
| setIsExportingVideo(false); |
| setExportProgress(0); |
| }; |
| |
| const duration = audioRef.current.duration; |
| const progressInterval = setInterval(() => { |
| if (audioRef.current && !audioRef.current.paused) { |
| setExportProgress((audioRef.current.currentTime / duration) * 100); |
| } else { |
| clearInterval(progressInterval); |
| } |
| }, 500); |
| |
| |
| mediaRecorderRef.current.start(1000); |
| await audioRef.current.play(); |
| setIsPlaying(true); |
| }; |
| |
| const stopVideoExport = () => { |
| if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { |
| mediaRecorderRef.current.stop(); |
| } |
| audioRef.current.pause(); |
| setIsPlaying(false); |
| }; |
| |
| return ( |
| <div className="min-h-screen bg-slate-950 text-slate-200 font-sans selection:bg-cyan-500/30 font-sans"> |
| <header className="border-b border-slate-800 bg-slate-900/50 p-6 flex items-center justify-between"> |
| <div className="flex items-center gap-3"> |
| <div className="bg-cyan-500/20 p-2 rounded-lg"> |
| <Video className="w-6 h-6 text-cyan-400" /> |
| </div> |
| <h1 className="text-xl font-bold tracking-tight text-white">4K Transparent Visualizer</h1> |
| </div> |
| <div className="text-sm text-slate-400 hidden sm:block"> |
| All processing is strictly local. |
| </div> |
| </header> |
| |
| <main className="container mx-auto p-6 grid lg:grid-cols-12 gap-8"> |
| |
| <div className="lg:col-span-4 space-y-6"> |
| |
| <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl"> |
| <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2"> |
| <Upload className="w-4 h-4" /> Audio Input |
| </h2> |
| <label className="block w-full cursor-pointer bg-slate-800 hover:bg-slate-700 transition-colors border-2 border-dashed border-slate-600 rounded-xl p-8 text-center group"> |
| <input |
| type="file" |
| accept="audio/*" |
| onChange={handleFileUpload} |
| className="hidden" |
| disabled={isExportingVideo} |
| /> |
| <div className="mx-auto w-12 h-12 bg-slate-900 rounded-full flex items-center justify-center mb-3 group-hover:scale-110 transition-transform"> |
| <Upload className="w-6 h-6 text-cyan-400" /> |
| </div> |
| <p className="font-medium text-slate-300"> |
| {fileName ? fileName : 'Click to browse audio file'} |
| </p> |
| <p className="text-xs text-slate-500 mt-2">MP3, WAV, FLAC</p> |
| </label> |
| |
| <audio |
| ref={audioRef} |
| src={audioSrc} |
| onEnded={handleAudioEnded} |
| onPlay={() => setIsPlaying(true)} |
| onPause={() => setIsPlaying(false)} |
| onTimeUpdate={handleTimeUpdate} |
| onLoadedMetadata={handleLoadedMetadata} |
| /> |
| </section> |
| |
| <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl"> |
| <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2"> |
| <Settings2 className="w-4 h-4" /> Visual Settings |
| </h2> |
| |
| <div className="space-y-5"> |
| <div> |
| <label className="block text-sm font-medium text-slate-400 mb-2">Style</label> |
| <div className="grid grid-cols-3 gap-2"> |
| {['bars', 'wave', 'circle'].map(type => ( |
| <button |
| key={type} |
| onClick={() => setVizType(type)} |
| className={`py-2 px-3 rounded-lg text-sm font-medium capitalize transition-all ${ |
| vizType === type |
| ? 'bg-slate-700 text-white shadow-inner border border-slate-600' |
| : 'bg-slate-950 text-slate-400 border border-slate-800 hover:border-slate-600' |
| }`} |
| > |
| {type} |
| </button> |
| ))} |
| </div> |
| </div> |
| |
| <div> |
| <div className="flex justify-between items-center mb-2"> |
| <label className="text-sm font-medium text-slate-400">Color Style</label> |
| <select |
| value={colorMode} |
| onChange={(e) => setColorMode(e.target.value)} |
| className="bg-slate-950 border border-slate-700 text-slate-300 text-xs rounded px-2 py-1 outline-none" |
| > |
| <option value="solid">Solid</option> |
| <option value="gradient">Gradient</option> |
| <option value="rainbow">Rainbow</option> |
| </select> |
| </div> |
| |
| <div className="flex items-center gap-3"> |
| {colorMode !== 'rainbow' && ( |
| <input |
| type="color" |
| value={color} |
| onChange={(e) => setColor(e.target.value)} |
| className="h-10 w-14 rounded cursor-pointer bg-slate-950 border border-slate-700 shrink-0" |
| /> |
| )} |
| {colorMode === 'gradient' && ( |
| <> |
| <span className="text-slate-500 text-xs font-medium">to</span> |
| <input |
| type="color" |
| value={color2} |
| onChange={(e) => setColor2(e.target.value)} |
| className="h-10 w-14 rounded cursor-pointer bg-slate-950 border border-slate-700 shrink-0" |
| /> |
| </> |
| )} |
| {colorMode === 'solid' && ( |
| <input |
| type="text" |
| value={color} |
| onChange={(e) => setColor(e.target.value)} |
| className="flex-1 bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-cyan-500 outline-none uppercase font-mono" |
| /> |
| )} |
| </div> |
| </div> |
| |
| <div className="flex items-center justify-between bg-slate-950 p-3 rounded-xl border border-slate-800"> |
| <div className="flex items-center gap-2"> |
| <Sparkles className="w-4 h-4 text-amber-400" /> |
| <span className="text-sm font-medium text-slate-300">Neon Glow Effect</span> |
| </div> |
| <label className="relative inline-flex items-center cursor-pointer"> |
| <input type="checkbox" checked={glow} onChange={(e) => setGlow(e.target.checked)} className="sr-only peer" /> |
| <div className="w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div> |
| </label> |
| </div> |
| |
| <div> |
| <label className="flex justify-between text-sm font-medium text-slate-400 mb-2"> |
| <span>Line Thickness</span> |
| <span className="text-slate-500">{thickness}px</span> |
| </label> |
| <input |
| type="range" |
| min="2" max="64" |
| value={thickness} |
| onChange={(e) => setThickness(Number(e.target.value))} |
| className="w-full accent-cyan-500 cursor-pointer" |
| /> |
| </div> |
| |
| <div> |
| <label className="flex justify-between text-sm font-medium text-slate-400 mb-2"> |
| <span>Space Between Lines</span> |
| <span className="text-slate-500">{spacing}px</span> |
| </label> |
| <input |
| type="range" |
| min="0" max="64" |
| value={spacing} |
| onChange={(e) => setSpacing(Number(e.target.value))} |
| className="w-full accent-cyan-500 cursor-pointer" |
| /> |
| </div> |
| |
| <div> |
| <label className="flex justify-between text-sm font-medium text-slate-400 mb-2"> |
| <span>Amplitude (Height)</span> |
| <span className="text-slate-500">{sensitivity.toFixed(1)}x</span> |
| </label> |
| <input |
| type="range" |
| min="0.5" max="3.0" step="0.1" |
| value={sensitivity} |
| onChange={(e) => setSensitivity(Number(e.target.value))} |
| className="w-full accent-cyan-500 cursor-pointer" |
| /> |
| </div> |
| |
| <div> |
| <label className="flex justify-between text-sm font-medium text-slate-400 mb-2"> |
| <span>Motion Smoothing</span> |
| <span className="text-slate-500">{Math.round(smoothing * 100)}%</span> |
| </label> |
| <input |
| type="range" |
| min="0.1" max="0.99" step="0.01" |
| value={smoothing} |
| onChange={(e) => setSmoothing(Number(e.target.value))} |
| className="w-full accent-cyan-500 cursor-pointer" |
| /> |
| </div> |
| |
| <div className="pt-4 mt-4 border-t border-slate-800 space-y-5"> |
| <h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">Transform & Position</h3> |
| |
| <div> |
| <label className="flex justify-between text-sm font-medium text-slate-400 mb-2"> |
| <span>Size (Scale)</span> |
| <span className="text-slate-500">{scale.toFixed(2)}x</span> |
| </label> |
| <input |
| type="range" |
| min="0.1" max="3.0" step="0.1" |
| value={scale} |
| onChange={(e) => setScale(Number(e.target.value))} |
| className="w-full accent-cyan-500 cursor-pointer" |
| /> |
| </div> |
| |
| <div> |
| <label className="flex justify-between text-sm font-medium text-slate-400 mb-2"> |
| <span>Rotation</span> |
| <span className="text-slate-500">{rotation}°</span> |
| </label> |
| <input |
| type="range" |
| min="0" max="360" step="1" |
| value={rotation} |
| onChange={(e) => setRotation(Number(e.target.value))} |
| className="w-full accent-cyan-500 cursor-pointer" |
| /> |
| </div> |
| |
| <div> |
| <label className="flex justify-between text-sm font-medium text-slate-400 mb-2"> |
| <span>Horizontal Position</span> |
| <span className="text-slate-500">{Math.round(offsetX)}%</span> |
| </label> |
| <input |
| type="range" |
| min="-50" max="50" step="1" |
| value={offsetX} |
| onChange={(e) => setOffsetX(Number(e.target.value))} |
| className="w-full accent-cyan-500 cursor-pointer" |
| /> |
| </div> |
| |
| <div> |
| <label className="flex justify-between text-sm font-medium text-slate-400 mb-2"> |
| <span>Vertical Position</span> |
| <span className="text-slate-500">{Math.round(offsetY)}%</span> |
| </label> |
| <input |
| type="range" |
| min="-50" max="50" step="1" |
| value={offsetY} |
| onChange={(e) => setOffsetY(Number(e.target.value))} |
| className="w-full accent-cyan-500 cursor-pointer" |
| /> |
| </div> |
| </div> |
| |
| </div> |
| </section> |
| |
| <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl"> |
| <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2"> |
| <Monitor className="w-4 h-4" /> Output Setup |
| </h2> |
| |
| <div className="space-y-5"> |
| <div> |
| <label className="block text-sm font-medium text-slate-400 mb-2">Aspect Ratio & Resolution</label> |
| <select |
| value={resolution} |
| onChange={(e) => setResolution(e.target.value)} |
| className="w-full bg-slate-950 border border-slate-700 text-slate-300 text-sm rounded-lg px-3 py-2.5 outline-none focus:ring-1 focus:ring-cyan-500" |
| > |
| <option value="4k_16_9">4K Landscape (3840x2160)</option> |
| <option value="1080p_16_9">1080p Landscape (1920x1080)</option> |
| <option value="4k_9_16">4K Vertical / Reels (2160x3840)</option> |
| <option value="1080p_9_16">1080p Vertical / Reels (1080x1920)</option> |
| </select> |
| </div> |
| |
| <div> |
| <label className="block text-sm font-medium text-slate-400 mb-2">Background Type</label> |
| <div className="flex gap-2 mb-3"> |
| {['transparent', 'color', 'image'].map(type => ( |
| <button |
| key={type} |
| onClick={() => setBgType(type)} |
| className={`flex-1 py-2 px-2 rounded-lg text-xs font-medium capitalize transition-all ${ |
| bgType === type |
| ? 'bg-slate-700 text-white shadow-inner border border-slate-600' |
| : 'bg-slate-950 text-slate-400 border border-slate-800 hover:border-slate-600' |
| }`} |
| > |
| {type} |
| </button> |
| ))} |
| </div> |
| |
| {bgType === 'color' && ( |
| <div className="flex items-center gap-3 mt-2 bg-slate-950 p-2 rounded-lg border border-slate-800"> |
| <input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="h-8 w-12 rounded cursor-pointer bg-slate-950 border border-slate-700" /> |
| <span className="text-sm font-mono text-slate-400 uppercase">{bgColor}</span> |
| </div> |
| )} |
| |
| {bgType === 'image' && ( |
| <div className="mt-2"> |
| <label className="flex items-center justify-center gap-2 w-full cursor-pointer bg-slate-950 hover:bg-slate-800 transition-colors border border-dashed border-slate-600 rounded-lg p-3 text-center text-sm text-slate-300"> |
| <ImagePlus className="w-4 h-4" /> |
| {bgImageSrc ? 'Change Image' : 'Upload Background Image'} |
| <input type="file" accept="image/*" onChange={handleBgUpload} className="hidden" /> |
| </label> |
| </div> |
| )} |
| </div> |
| </div> |
| </section> |
| |
| <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl"> |
| <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">Export Options</h2> |
| |
| <div className="flex flex-col gap-3"> |
| <div className="bg-slate-950 p-4 rounded-xl border border-slate-800 mb-2"> |
| <label className="block text-sm font-medium text-slate-400 mb-2">Video Format & Framerate</label> |
| <div className="flex flex-col 2xl:flex-row gap-3"> |
| <select |
| value={exportFormat} |
| onChange={(e) => setExportFormat(e.target.value)} |
| className="w-full bg-slate-900 border border-slate-700 text-slate-300 text-sm rounded-lg px-3 py-2.5 outline-none focus:ring-1 focus:ring-cyan-500" |
| > |
| <option value="webm">WebM (Transparent)</option> |
| <option value="mp4">MP4 (Solid BG / DaVinci)</option> |
| </select> |
| |
| <select |
| value={exportFps} |
| onChange={(e) => setExportFps(Number(e.target.value))} |
| className="w-full 2xl:w-32 bg-slate-900 border border-slate-700 text-slate-300 text-sm rounded-lg px-3 py-2.5 outline-none focus:ring-1 focus:ring-cyan-500" |
| > |
| <option value={30}>30 FPS</option> |
| <option value={60}>60 FPS</option> |
| </select> |
| </div> |
| |
| <div className="mt-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg"> |
| <p className="text-xs text-red-400 leading-relaxed"> |
| <strong>Video Freezing/Crashing?</strong> Real-time 4K encoding is extremely heavy. To fix this:<br/> |
| 1. Set Framerate to <strong>30 FPS</strong>.<br/> |
| 2. Turn off <strong>Neon Glow Effect</strong> (massive performance boost).<br/> |
| 3. Lower Resolution to <strong>1080p</strong>. |
| </p> |
| </div> |
| |
| {exportFormat === 'mp4' && ( |
| <div className="mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg"> |
| <p className="text-xs text-amber-500/90 leading-relaxed"> |
| <strong>DaVinci Resolve Tip:</strong> MP4s cannot be transparent. Set your Background Type to <strong>Color (Black)</strong>, export the MP4, and in DaVinci, set your clip's Composite Mode to <strong>Screen</strong> or <strong>Add</strong>. |
| </p> |
| </div> |
| )} |
| </div> |
| |
| <button |
| onClick={exportImage} |
| disabled={isExportingVideo} |
| className="w-full bg-slate-800 hover:bg-slate-700 text-white font-medium py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50" |
| > |
| <ImageIcon className="w-4 h-4" /> |
| Save Snapshot (PNG) |
| </button> |
| |
| {isExportingVideo ? ( |
| <div className="w-full space-y-3"> |
| <button |
| onClick={stopVideoExport} |
| className="w-full bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 font-bold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-colors" |
| > |
| <StopCircle className="w-5 h-5" /> |
| Stop & Save |
| </button> |
| <div className="w-full bg-slate-950 rounded-full h-2.5 border border-slate-800 overflow-hidden"> |
| <div className="bg-cyan-500 h-2.5 rounded-full transition-all duration-300" style={{ width: `${exportProgress}%` }}></div> |
| </div> |
| <p className="text-xs text-center text-slate-400">Recording video... {Math.round(exportProgress)}%</p> |
| </div> |
| ) : ( |
| <button |
| onClick={startVideoExport} |
| disabled={!audioSrc} |
| className="w-full bg-gradient-to-r from-indigo-500 to-cyan-500 hover:from-indigo-400 hover:to-cyan-400 text-white font-bold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-cyan-500/20 disabled:opacity-50 disabled:shadow-none" |
| > |
| <Video className="w-5 h-5" /> |
| Export Video ({exportFormat.toUpperCase()}) |
| </button> |
| )} |
| </div> |
| </section> |
| |
| </div> |
| |
| <div className="lg:col-span-8 flex flex-col gap-4 lg:sticky lg:top-6 lg:h-[calc(100vh-3rem)]"> |
| <div className="bg-slate-900 rounded-2xl border border-slate-800 shadow-xl overflow-hidden flex-1 relative flex flex-col min-h-0"> |
| |
| <div className="p-4 border-b border-slate-800 bg-slate-900/80 flex justify-between items-center z-10 shrink-0"> |
| <span className="text-sm font-semibold text-slate-300 flex items-center gap-2"> |
| Live Preview |
| <span className="bg-slate-800 text-xs px-2 py-0.5 rounded text-slate-400 border border-slate-700"> |
| {RESOLUTIONS[resolution]?.w}x{RESOLUTIONS[resolution]?.h} |
| </span> |
| </span> |
| <span className="text-xs text-slate-500"> |
| {bgType === 'transparent' ? 'Checkerboard denotes transparency' : 'Background included in export'} |
| </span> |
| </div> |
| |
| <div |
| className="flex-1 w-full relative flex items-center justify-center p-4 sm:p-6 overflow-hidden bg-black/50 min-h-0" |
| style={ bgType === 'transparent' ? { |
| backgroundImage: 'repeating-linear-gradient(45deg, #0f172a 25%, transparent 25%, transparent 75%, #0f172a 75%, #0f172a), repeating-linear-gradient(45deg, #0f172a 25%, #1e293b 25%, #1e293b 75%, #0f172a 75%, #0f172a)', |
| backgroundPosition: '0 0, 10px 10px', |
| backgroundSize: '20px 20px' |
| } : {}} |
| > |
| <canvas |
| ref={canvasRef} |
| width={RESOLUTIONS[resolution]?.w || 3840} |
| height={RESOLUTIONS[resolution]?.h || 2160} |
| onMouseDown={handleMouseDown} |
| onMouseMove={handleMouseMove} |
| onMouseUp={handleMouseUpOrLeave} |
| onMouseLeave={handleMouseUpOrLeave} |
| className={`max-w-full max-h-full object-contain drop-shadow-2xl rounded-lg ring-1 ring-white/10 bg-transparent ${RESOLUTIONS[resolution]?.isVertical ? 'aspect-[9/16]' : 'aspect-video'} ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`} |
| /> |
| |
| {!audioSrc && ( |
| <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none bg-slate-900/80 backdrop-blur-sm z-20"> |
| <Loader2 className="w-12 h-12 text-slate-500 animate-spin mb-4 opacity-50" /> |
| <p className="text-slate-400 font-medium">Awaiting Audio Input</p> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| <div className="bg-slate-900 p-4 sm:p-5 rounded-2xl border border-slate-800 shadow-xl shrink-0 flex flex-col gap-3"> |
| <div className="flex items-center gap-4"> |
| <button |
| onClick={togglePlay} |
| disabled={!audioSrc || isExportingVideo} |
| className="w-12 h-12 shrink-0 bg-cyan-500 hover:bg-cyan-400 text-slate-950 rounded-full flex items-center justify-center transition-colors disabled:opacity-50 disabled:hover:bg-cyan-500" |
| > |
| {isPlaying ? <Pause className="w-6 h-6 fill-current" /> : <Play className="w-6 h-6 fill-current ml-1" />} |
| </button> |
| |
| <div className="flex-1 flex flex-col gap-2"> |
| <div className="flex justify-between text-xs font-mono text-slate-400"> |
| <span>{formatTime(audioTime)}</span> |
| <span className="text-slate-300 font-sans truncate px-4">{fileName || 'No audio selected'}</span> |
| <span>{formatTime(audioDuration)}</span> |
| </div> |
| <input |
| type="range" |
| min="0" |
| max={audioDuration || 100} |
| value={audioTime} |
| onChange={handleSeek} |
| disabled={!audioSrc || isExportingVideo} |
| className="w-full accent-cyan-500 cursor-pointer h-2 bg-slate-800 rounded-lg appearance-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-cyan-400 [&::-webkit-slider-thumb]:rounded-full" |
| /> |
| </div> |
| </div> |
| </div> |
| |
| </div> |
| |
| </main> |
| </div> |
| ); |
| } |
| |
| const root = ReactDOM.createRoot(document.getElementById('root')); |
| root.render(<App />); |
| </script> |
| </body> |
| </html> |