| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Sujata Studios</title> |
| |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script> |
| tailwind.config = { |
| darkMode: 'class', |
| theme: { |
| extend: { |
| colors: { studio: { 500: '#8b5cf6', 600: '#7c3aed' } } |
| } |
| } |
| } |
| </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; |
| font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
| -webkit-font-smoothing: antialiased; |
| background-color: #fafafa; |
| color: #18181b; |
| transition: background-color 0.3s ease, color 0.3s ease; |
| background-image: |
| radial-gradient(circle at 15% 50%, rgba(139, 92, 246, 0.08), transparent 25%), |
| radial-gradient(circle at 85% 30%, rgba(217, 70, 239, 0.08), transparent 25%); |
| } |
| |
| .dark body { |
| background-color: #09090b; |
| color: #e4e4e7; |
| background-image: |
| radial-gradient(circle at 15% 50%, rgba(139, 92, 246, 0.04), transparent 25%), |
| radial-gradient(circle at 85% 30%, rgba(217, 70, 239, 0.04), transparent 25%); |
| } |
| |
| .custom-scrollbar::-webkit-scrollbar { width: 5px; } |
| .custom-scrollbar::-webkit-scrollbar-track { background: transparent; } |
| .custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 10px; } |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.2); } |
| .dark .custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); } |
| .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); } |
| |
| ::-webkit-scrollbar { width: 8px; } |
| ::-webkit-scrollbar-track { background: #f4f4f5; } |
| ::-webkit-scrollbar-thumb { background: #d4d4d8; border-radius: 4px; } |
| ::-webkit-scrollbar-thumb:hover { background: #a1a1aa; } |
| .dark ::-webkit-scrollbar-track { background: #09090b; } |
| .dark ::-webkit-scrollbar-thumb { background: #27272a; } |
| .dark ::-webkit-scrollbar-thumb:hover { background: #3f3f46; } |
| |
| input[type=range] { |
| -webkit-appearance: none; |
| background: transparent; |
| width: 100%; |
| } |
| input[type=range]:focus { |
| outline: none; |
| } |
| input[type=range]::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| height: 12px; |
| width: 12px; |
| border-radius: 50%; |
| background: #8b5cf6; |
| cursor: pointer; |
| margin-top: -5px; |
| box-shadow: 0 0 10px rgba(139, 92, 246, 0.4); |
| transition: transform 0.1s; |
| } |
| .dark input[type=range]::-webkit-slider-thumb { |
| background: #a78bfa; |
| box-shadow: 0 0 10px rgba(139, 92, 246, 0.6); |
| } |
| input[type=range]::-webkit-slider-thumb:hover { |
| transform: scale(1.2); |
| background: #a78bfa; |
| } |
| .dark input[type=range]::-webkit-slider-thumb:hover { |
| background: #c4b5fd; |
| } |
| input[type=range]::-webkit-slider-runnable-track { |
| width: 100%; |
| height: 2px; |
| cursor: pointer; |
| background: rgba(0, 0, 0, 0.1); |
| border-radius: 2px; |
| } |
| .dark input[type=range]::-webkit-slider-runnable-track { |
| background: rgba(255, 255, 255, 0.1); |
| } |
| |
| select option { |
| background-color: #ffffff; |
| color: #18181b; |
| } |
| .dark select option { |
| background-color: #18181b; |
| color: #e4e4e7; |
| } |
| |
| input[type="color"] { |
| -webkit-appearance: none; |
| border: none; |
| padding: 0; |
| } |
| input[type="color"]::-webkit-color-swatch-wrapper { |
| padding: 0; |
| } |
| input[type="color"]::-webkit-color-swatch { |
| border: none; |
| border-radius: 6px; |
| } |
| </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 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>; |
| const RotateCcw = ({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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>; |
| const Lock = ({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="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>; |
| const Unlock = ({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="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 9.9-1"></path></svg>; |
| const Activity = ({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"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>; |
| const Scissors = ({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="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>; |
| const Type = ({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"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>; |
| const Zap = ({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="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>; |
| const Save = ({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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>; |
| const FlipHorizontal = ({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="M8 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/><path d="M12 20v2"/><path d="M12 14v2"/><path d="M12 8v2"/><path d="M12 2v2"/></svg>; |
| const FlipVertical = ({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 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v3"/><path d="M21 16v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/></svg>; |
| const RefreshCw = ({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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>; |
| const ChevronDown = ({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"><polyline points="6 9 12 15 18 9"/></svg>; |
| const ChevronUp = ({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"><polyline points="18 15 12 9 6 15"/></svg>; |
| const Sun = ({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="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>; |
| const Moon = ({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 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>; |
| const Trash2 = ({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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>; |
| const Info = ({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"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>; |
| const Droplet = ({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 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"/></svg>; |
| const Layers = ({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="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; |
| const X = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>; |
| const Sliders = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="2" y1="14" x2="6" y2="14"></line><line x1="10" y1="8" x2="14" y2="8"></line><line x1="18" y1="16" x2="22" y2="16"></line></svg>; |
| |
| |
| const SujataLogo = ({className}) => ( |
| <svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> |
| <clipPath id="circleClip"><circle cx="50" cy="50" r="50" /></clipPath> |
| <g clipPath="url(#circleClip)"> |
| <rect x="0" y="0" width="100" height="38" fill="#FFB000"/> |
| <rect x="0" y="38" width="100" height="12" fill="#FA8243"/> |
| <rect x="0" y="50" width="100" height="12" fill="#FF6351"/> |
| <rect x="0" y="62" width="100" height="12" fill="#FA4066"/> |
| <rect x="0" y="74" width="100" height="26" fill="#E81C6C"/> |
| </g> |
| </svg> |
| ); |
| |
| |
| const ControlHeader = ({ label, valueDisplay, onReset, extra }) => ( |
| <div className="flex justify-between items-center mb-2 group"> |
| <div className="flex items-center gap-2"> |
| <span className="text-[13px] font-semibold text-zinc-600 dark:text-zinc-400">{label}</span> |
| {extra} |
| </div> |
| <div className="flex items-center gap-2"> |
| {valueDisplay !== undefined && ( |
| <span className="text-[11px] text-violet-700 dark:text-violet-300 font-mono tracking-wider bg-violet-500/10 px-2 py-0.5 rounded-full border border-violet-500/20 shrink-0"> |
| {valueDisplay} |
| </span> |
| )} |
| {onReset && ( |
| <button onClick={onReset} className="text-zinc-400 dark:text-zinc-600 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-black/5 dark:hover:bg-white/5 p-1 rounded-md transition-all focus:outline-none opacity-80 hover:opacity-100 shrink-0" title={`Reset ${label}`}> |
| <RotateCcw className="w-3.5 h-3.5" /> |
| </button> |
| )} |
| </div> |
| </div> |
| ); |
| |
| const SectionHeader = ({ title, icon: Icon, sectionKey, minStates, toggleMin }) => ( |
| <div className={`flex justify-between items-center ${minStates[sectionKey] ? '' : 'mb-4'}`}> |
| <h2 className="text-xs font-bold tracking-widest uppercase text-zinc-600 dark:text-zinc-400 flex items-center gap-3"> |
| <div className="p-1.5 bg-violet-500/10 rounded-lg text-violet-600 dark:text-violet-400"> |
| <Icon className="w-4 h-4" /> |
| </div> |
| {title} |
| </h2> |
| <button onClick={function() { toggleMin(sectionKey); }} className="p-1 text-zinc-400 dark:text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors" title={minStates[sectionKey] ? "Expand" : "Minimize"}> |
| {minStates[sectionKey] ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />} |
| </button> |
| </div> |
| ); |
| |
| |
| const WaveformTimeline = ({ waveformData, audioTime, audioDuration, audioRef, setAudioTime }) => { |
| const containerRef = useRef(null); |
| |
| const handleClick = (e) => { |
| if (!audioRef.current || !audioDuration) return; |
| const rect = containerRef.current.getBoundingClientRect(); |
| const percent = (e.clientX - rect.left) / rect.width; |
| const newTime = percent * audioDuration; |
| audioRef.current.currentTime = newTime; |
| setAudioTime(newTime); |
| }; |
| |
| const svgPath = waveformData.map((val, i) => { |
| const x = (i / waveformData.length) * 100; |
| const h = val * 100; |
| return `M ${x} ${50 - h/2} L ${x} ${50 + h/2}`; |
| }).join(" "); |
| |
| return ( |
| <div ref={containerRef} onClick={handleClick} className="relative w-full h-10 bg-black/5 dark:bg-white/5 rounded-lg overflow-hidden cursor-pointer border border-black/5 dark:border-white/10 transition-colors hover:bg-black/10 dark:hover:bg-white/10"> |
| {waveformData.length > 0 ? ( |
| <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 w-full h-full text-violet-500/50 dark:text-violet-400/50"> |
| <path d={svgPath} stroke="currentColor" strokeWidth="0.5" strokeLinecap="round" /> |
| </svg> |
| ) : ( |
| <div className="flex h-full items-center justify-center text-[10px] tracking-widest text-zinc-500 font-mono opacity-50">NO WAVEFORM DATA</div> |
| )} |
| <div className="absolute top-0 left-0 h-full bg-violet-500/20 pointer-events-none transition-all duration-75" style={{width: `${audioDuration ? (audioTime/audioDuration)*100 : 0}%`}}></div> |
| <div className="absolute top-0 w-0.5 h-full bg-violet-600 dark:bg-white shadow-[0_0_8px_rgba(139,92,246,0.8)] dark:shadow-[0_0_8px_rgba(255,255,255,1)] pointer-events-none transition-all duration-75" style={{left: `${audioDuration ? (audioTime/audioDuration)*100 : 0}%`}}></div> |
| </div> |
| ); |
| }; |
| |
| function App() { |
| const [isDark, setIsDark] = useState(true); |
| |
| useEffect(() => { |
| if (isDark) { |
| document.documentElement.classList.add('dark'); |
| } else { |
| document.documentElement.classList.remove('dark'); |
| } |
| }, [isDark]); |
| |
| 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 watermarkImgRef = useRef(null); |
| |
| const dataArrayRef = useRef(new Uint8Array(2048)); |
| const bassArrayRef = useRef(new Uint8Array(10)); |
| const peakArrayRef = useRef(new Float32Array(2048)); |
| const particlesRef = useRef([]); |
| 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 [isDraggingFile, setIsDraggingFile] = useState(false); |
| const [waveformData, setWaveformData] = useState([]); |
| |
| |
| const [vizType, setVizType] = useState('bars'); |
| const [barShape, setBarShape] = useState('pill'); |
| const [thickness, setThickness] = useState(12); |
| const [spacing, setSpacing] = useState(8); |
| const [sensitivity, setSensitivity] = useState(1.5); |
| const [smoothing, setSmoothing] = useState(0.85); |
| |
| |
| const [showPeaks, setShowPeaks] = useState(false); |
| const [peakDecay, setPeakDecay] = useState(2.0); |
| |
| const [colorMode, setColorMode] = useState('gradient'); |
| const [gradStops, setGradStops] = useState(2); |
| const [color, setColor] = useState('#8b5cf6'); |
| const [color2, setColor2] = useState('#d946ef'); |
| const [color3, setColor3] = useState('#3b82f6'); |
| const [color4, setColor4] = useState('#ec4899'); |
| |
| const [glow, setGlow] = useState(false); |
| const [taperEdges, setTaperEdges] = useState(false); |
| const [glitchEffect, setGlitchEffect] = useState(false); |
| const [glitchThreshold, setGlitchThreshold] = useState(240); |
| |
| |
| const [bgType, setBgType] = useState('transparent'); |
| const [bgColor, setBgColor] = useState('#09090b'); |
| const [bgImageSrc, setBgImageSrc] = useState(null); |
| const [bgImageFit, setBgImageFit] = useState('contain'); |
| const [bgBeatReactive, setBgBeatReactive] = useState(false); |
| const [showParticles, setShowParticles] = useState(false); |
| const [particleCount, setParticleCount] = useState(150); |
| |
| |
| const [offsetX, setOffsetX] = useState(0); |
| const [offsetY, setOffsetY] = useState(0); |
| const [scale, setScale] = useState(1.0); |
| const [scaleX, setScaleX] = useState(1.0); |
| const [scaleY, setScaleY] = useState(1.0); |
| const [rotation, setRotation] = useState(0); |
| const offsetRef = useRef({ x: 0, y: 0 }); |
| const [isDragging, setIsDragging] = useState(false); |
| const dragRef = useRef({ startX: 0, startY: 0, initX: 0, initY: 0 }); |
| const [scaleLock, setScaleLock] = useState(false); |
| const scaleRatioRef = useRef(1.0); |
| const [mirrorX, setMirrorX] = useState(false); |
| const [mirrorY, setMirrorY] = useState(false); |
| const [isFilled, setIsFilled] = useState(true); |
| |
| |
| const [overlayText1, setOverlayText1] = useState(''); |
| const [overlayText2, setOverlayText2] = useState(''); |
| const [overlayTextSize, setOverlayTextSize] = useState(60); |
| const [overlayTextColor, setOverlayTextColor] = useState('#ffffff'); |
| const [overlayTextPos, setOverlayTextPos] = useState('bottom'); |
| |
| const [watermarkSrc, setWatermarkSrc] = useState(null); |
| const [watermarkSize, setWatermarkSize] = useState(15); |
| const [watermarkOpacity, setWatermarkOpacity] = useState(0.8); |
| const [watermarkPosX, setWatermarkPosX] = useState(0); |
| const [watermarkPosY, setWatermarkPosY] = useState(35); |
| |
| |
| const [freqBand, setFreqBand] = useState('all'); |
| const [flashOnBeat, setFlashOnBeat] = useState(false); |
| const [flashColor, setFlashColor] = useState('#ffffff'); |
| const [flashThreshold, setFlashThreshold] = useState(230); |
| const [resolution, setResolution] = useState('4k_16_9'); |
| const [exportFormat, setExportFormat] = useState('mp4'); |
| const [exportFps, setExportFps] = useState(30); |
| const [exportStart, setExportStart] = useState(0); |
| const [exportEnd, setExportEnd] = useState(0); |
| const [safeZone, setSafeZone] = useState('none'); |
| |
| |
| const [showExportInfo, setShowExportInfo] = useState(false); |
| const [minStates, setMinStates] = useState({ |
| audio: false, visual: false, transform: false, text: false, output: false, export: false |
| }); |
| const toggleMin = function(key) { |
| setMinStates(function(prev) { return {...prev, [key]: !prev[key]}; }); |
| }; |
| |
| const playButtonRef = useRef(null); |
| |
| 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 } |
| }; |
| |
| const savePreset = function() { |
| const preset = { |
| vizType, barShape, color, color2, color3, color4, gradStops, thickness, spacing, sensitivity, smoothing, colorMode, |
| glow, taperEdges, showPeaks, peakDecay, glitchEffect, glitchThreshold, resolution, bgType, bgColor, bgImageFit, |
| scale, scaleX, scaleY, rotation, offsetX, offsetY, mirrorX, mirrorY, isFilled, bgBeatReactive, showParticles, particleCount, |
| freqBand, flashOnBeat, flashColor, flashThreshold, overlayText1, overlayText2, overlayTextSize, overlayTextColor, overlayTextPos, |
| watermarkSize, watermarkOpacity, watermarkPosX, watermarkPosY |
| }; |
| localStorage.setItem('viz_preset_studio', JSON.stringify(preset)); |
| alert('Studio Settings saved as Preset!'); |
| }; |
| |
| const loadPreset = function() { |
| const str = localStorage.getItem('viz_preset_studio'); |
| if (str) { |
| try { |
| const p = JSON.parse(str); |
| if (p.vizType !== undefined) setVizType(p.vizType); |
| if (p.barShape !== undefined) setBarShape(p.barShape); |
| if (p.color !== undefined) setColor(p.color); |
| if (p.color2 !== undefined) setColor2(p.color2); |
| if (p.color3 !== undefined) setColor3(p.color3); |
| if (p.color4 !== undefined) setColor4(p.color4); |
| if (p.gradStops !== undefined) setGradStops(p.gradStops); |
| if (p.thickness !== undefined) setThickness(p.thickness); |
| if (p.spacing !== undefined) setSpacing(p.spacing); |
| if (p.sensitivity !== undefined) setSensitivity(p.sensitivity); |
| if (p.smoothing !== undefined) setSmoothing(p.smoothing); |
| if (p.colorMode !== undefined) setColorMode(p.colorMode); |
| if (p.glow !== undefined) setGlow(p.glow); |
| if (p.taperEdges !== undefined) setTaperEdges(p.taperEdges); |
| if (p.showPeaks !== undefined) setShowPeaks(p.showPeaks); |
| if (p.peakDecay !== undefined) setPeakDecay(p.peakDecay); |
| if (p.glitchEffect !== undefined) setGlitchEffect(p.glitchEffect); |
| if (p.glitchThreshold !== undefined) setGlitchThreshold(p.glitchThreshold); |
| if (p.resolution !== undefined) setResolution(p.resolution); |
| if (p.bgType !== undefined) setBgType(p.bgType); |
| if (p.bgColor !== undefined) setBgColor(p.bgColor); |
| if (p.bgImageFit !== undefined) setBgImageFit(p.bgImageFit); |
| if (p.scale !== undefined) setScale(p.scale); |
| if (p.scaleX !== undefined) setScaleX(p.scaleX); |
| if (p.scaleY !== undefined) setScaleY(p.scaleY); |
| if (p.rotation !== undefined) setRotation(p.rotation); |
| if (p.offsetX !== undefined) { setOffsetX(p.offsetX); offsetRef.current.x = p.offsetX; } |
| if (p.offsetY !== undefined) { setOffsetY(p.offsetY); offsetRef.current.y = p.offsetY; } |
| if (p.mirrorX !== undefined) setMirrorX(p.mirrorX); |
| if (p.mirrorY !== undefined) setMirrorY(p.mirrorY); |
| if (p.isFilled !== undefined) setIsFilled(p.isFilled); |
| if (p.bgBeatReactive !== undefined) setBgBeatReactive(p.bgBeatReactive); |
| if (p.showParticles !== undefined) setShowParticles(p.showParticles); |
| if (p.particleCount !== undefined) setParticleCount(p.particleCount); |
| if (p.freqBand !== undefined) setFreqBand(p.freqBand); |
| if (p.flashOnBeat !== undefined) setFlashOnBeat(p.flashOnBeat); |
| if (p.flashColor !== undefined) setFlashColor(p.flashColor); |
| if (p.flashThreshold !== undefined) setFlashThreshold(p.flashThreshold); |
| if (p.overlayText1 !== undefined) setOverlayText1(p.overlayText1); |
| if (p.overlayText2 !== undefined) setOverlayText2(p.overlayText2); |
| if (p.overlayTextSize !== undefined) setOverlayTextSize(p.overlayTextSize); |
| if (p.overlayTextColor !== undefined) setOverlayTextColor(p.overlayTextColor); |
| if (p.overlayTextPos !== undefined) setOverlayTextPos(p.overlayTextPos); |
| if (p.watermarkSize !== undefined) setWatermarkSize(p.watermarkSize); |
| if (p.watermarkOpacity !== undefined) setWatermarkOpacity(p.watermarkOpacity); |
| if (p.watermarkPosX !== undefined) setWatermarkPosX(p.watermarkPosX); |
| if (p.watermarkPosY !== undefined) setWatermarkPosY(p.watermarkPosY); |
| } catch (e) { console.error("Failed to load preset"); } |
| } else { alert('No preset found!'); } |
| }; |
| |
| const handleResetAll = function() { |
| if (confirm("Are you sure you want to reset all settings to Studio defaults?")) { |
| setVizType('bars'); setBarShape('pill'); setColor('#8b5cf6'); setColor2('#d946ef'); setColor3('#3b82f6'); setColor4('#ec4899'); setGradStops(2); |
| setThickness(12); setSpacing(8); setSensitivity(1.5); setSmoothing(0.85); setColorMode('gradient'); |
| setGlow(false); setTaperEdges(false); setShowPeaks(false); setPeakDecay(2.0); setGlitchEffect(false); setGlitchThreshold(240); |
| setResolution('4k_16_9'); setBgType('transparent'); setBgColor('#09090b'); setBgImageSrc(null); setBgImageFit('contain'); |
| setShowParticles(false); setParticleCount(150); |
| setScale(1.0); setScaleX(1.0); setScaleY(1.0); setRotation(0); setOffsetX(0); setOffsetY(0); offsetRef.current = {x: 0, y: 0}; |
| setMirrorX(false); setMirrorY(false); setIsFilled(true); setBgBeatReactive(false); |
| setOverlayText1(''); setOverlayText2(''); setOverlayTextSize(60); setOverlayTextColor('#ffffff'); setOverlayTextPos('bottom'); |
| if(watermarkSrc) URL.revokeObjectURL(watermarkSrc); setWatermarkSrc(null); setWatermarkSize(15); setWatermarkOpacity(0.8); setWatermarkPosX(0); setWatermarkPosY(35); |
| setFreqBand('all'); setFlashOnBeat(false); setFlashColor('#ffffff'); setFlashThreshold(230); setExportStart(0); setExportEnd(0); |
| } |
| }; |
| |
| useEffect(() => { |
| if (bgImageSrc) { |
| const img = new Image(); |
| img.onload = () => { bgImgRef.current = img; }; |
| img.src = bgImageSrc; |
| } else { bgImgRef.current = null; } |
| }, [bgImageSrc]); |
| |
| useEffect(() => { |
| if (watermarkSrc) { |
| const img = new Image(); |
| img.onload = () => { watermarkImgRef.current = img; }; |
| img.src = watermarkSrc; |
| } else { watermarkImgRef.current = null; } |
| }, [watermarkSrc]); |
| |
| const handleBgUpload = function(e) { |
| const file = e.target.files[0]; |
| if (file) { |
| if (bgImageSrc) URL.revokeObjectURL(bgImageSrc); |
| setBgImageSrc(URL.createObjectURL(file)); |
| setBgType('image'); |
| } |
| }; |
| |
| const handleWatermarkUpload = function(e) { |
| const file = e.target.files[0]; |
| if (file) { |
| if (watermarkSrc) URL.revokeObjectURL(watermarkSrc); |
| setWatermarkSrc(URL.createObjectURL(file)); |
| } |
| }; |
| |
| const generateWaveform = async (file) => { |
| try { |
| const arrayBuffer = await file.arrayBuffer(); |
| const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); |
| const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); |
| const channelData = audioBuffer.getChannelData(0); |
| const points = 200; |
| const step = Math.ceil(channelData.length / points); |
| const peaks = []; |
| let maxPeak = 0; |
| for (let i = 0; i < points; i++) { |
| let min = 1.0; |
| let max = -1.0; |
| for (let j = 0; j < step; j++) { |
| const val = channelData[i * step + j]; |
| if (val < min) min = val; |
| if (val > max) max = val; |
| } |
| const amplitude = Math.max(Math.abs(min), Math.abs(max)); |
| if (amplitude > maxPeak) maxPeak = amplitude; |
| peaks.push(amplitude); |
| } |
| const normalized = peaks.map(p => (maxPeak ? p / maxPeak : 0)); |
| setWaveformData(normalized); |
| } catch (err) { |
| console.error("Waveform generation failed", err); |
| setWaveformData([]); |
| } |
| }; |
| |
| 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 processAudioFile = function(file) { |
| 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; |
| } |
| generateWaveform(file); |
| } |
| }; |
| |
| const handleFileUpload = function(e) { |
| processAudioFile(e.target.files[0]); |
| }; |
| |
| const handleRemoveAudio = useCallback(() => { |
| if (audioSrc) URL.revokeObjectURL(audioSrc); |
| setAudioSrc(null); |
| setFileName(''); |
| setIsPlaying(false); |
| setAudioTime(0); |
| setWaveformData([]); |
| if (audioRef.current) { |
| audioRef.current.pause(); |
| audioRef.current.currentTime = 0; |
| } |
| }, [audioSrc]); |
| |
| const handleDragOver = function(e) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| if (!isDraggingFile && !isExportingVideo) setIsDraggingFile(true); |
| }; |
| |
| const handleDragLeave = function(e) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| setIsDraggingFile(false); |
| }; |
| |
| const handleDrop = function(e) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| setIsDraggingFile(false); |
| if (isExportingVideo) return; |
| |
| const file = e.dataTransfer.files[0]; |
| if (file && file.type.startsWith('audio/')) { |
| processAudioFile(file); |
| } else if (file) { |
| alert("Please drop a valid audio file (MP3, WAV, etc)."); |
| } |
| }; |
| |
| const togglePlay = function() { |
| if (!audioSrc) return; |
| initAudio(); |
| if (isPlaying) { |
| audioRef.current.pause(); |
| setIsPlaying(false); |
| } else { |
| audioRef.current.play().then(() => setIsPlaying(true)).catch(e => console.error("Playback prevented.", e)); |
| } |
| }; |
| |
| const handleTimeUpdate = function() { if (audioRef.current) setAudioTime(audioRef.current.currentTime); }; |
| const handleLoadedMetadata = function() { if (audioRef.current) setAudioDuration(audioRef.current.duration); }; |
| const handleSeek = function(e) { |
| const time = Number(e.target.value); |
| if (audioRef.current) audioRef.current.currentTime = time; |
| setAudioTime(time); |
| }; |
| |
| const formatTime = function(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 = function(e) { |
| setIsDragging(true); |
| dragRef.current = { startX: e.clientX, startY: e.clientY, initX: offsetRef.current.x, initY: offsetRef.current.y }; |
| }; |
| const handleMouseMove = function(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; |
| const newX = Math.max(-50, Math.min(50, dragRef.current.initX + percentX)); |
| const newY = Math.max(-50, Math.min(50, dragRef.current.initY + percentY)); |
| offsetRef.current.x = newX; offsetRef.current.y = newY; |
| setOffsetX(newX); setOffsetY(newY); |
| }; |
| const handleMouseUpOrLeave = function() { setIsDragging(false); }; |
| |
| const handleScaleXChange = function(val) { |
| setScaleX(val); |
| if (scaleLock) setScaleY(Math.max(0.1, Math.min(5.0, val / scaleRatioRef.current))); |
| }; |
| const handleScaleYChange = function(val) { |
| setScaleY(val); |
| if (scaleLock) setScaleX(Math.max(0.1, Math.min(5.0, val * scaleRatioRef.current))); |
| }; |
| const toggleScaleLock = function() { |
| if (!scaleLock) scaleRatioRef.current = scaleX / scaleY; |
| setScaleLock(!scaleLock); |
| }; |
| |
| useEffect(() => { |
| const handleKeyDown = (e) => { |
| if (e.code === 'Space' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { |
| e.preventDefault(); |
| playButtonRef.current?.click(); |
| } |
| }; |
| window.addEventListener('keydown', handleKeyDown); |
| return () => window.removeEventListener('keydown', handleKeyDown); |
| }, []); |
| |
| const getSafeZoneStyle = function(zone, canvasW, canvasH) { |
| const canvasRatio = canvasW / canvasH; |
| let targetRatio = canvasRatio; |
| |
| if (zone === '1:1') targetRatio = 1; |
| else if (zone === '4:5') targetRatio = 4/5; |
| else if (zone === '9:16') targetRatio = 9/16; |
| else if (zone === '16:9') targetRatio = 16/9; |
| else if (zone === 'title_safe') return { width: '90%', height: '90%' }; |
| |
| if (targetRatio > canvasRatio) return { width: '100%', aspectRatio: `${targetRatio}` }; |
| else return { height: '100%', aspectRatio: `${targetRatio}` }; |
| }; |
| |
| |
| const draw = useCallback(() => { |
| if (!canvasRef.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', 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); |
| |
| let bassAvg = 0; |
| if (analyserRef.current) { |
| analyserRef.current.smoothingTimeConstant = smoothing; |
| analyserRef.current.fftSize = 2048; |
| |
| const dataArray = dataArrayRef.current; |
| const bassArray = bassArrayRef.current; |
| |
| analyserRef.current.getByteFrequencyData(bassArray); |
| for(let i=0; i<10; i++) bassAvg += bassArray[i]; |
| bassAvg /= 10; |
| |
| if (vizType === 'bars' || vizType === 'circle' || vizType === 'symmetric_wave') { |
| analyserRef.current.getByteFrequencyData(dataArray); |
| } else if (vizType === 'wave') { |
| analyserRef.current.getByteTimeDomainData(dataArray); |
| } |
| } |
| |
| |
| 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 (bgImageFit === 'stretch') { drawW = width; drawH = height; drawX = 0; drawY = 0; } |
| else if (bgImageFit === 'cover') { |
| 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; } |
| } else if (bgImageFit === 'fit-width') { |
| drawW = width; drawH = width / imgRatio; drawX = 0; drawY = (height - drawH) / 2; |
| } else { |
| if (imgRatio > canvasRatio) { drawW = width; drawH = width / imgRatio; drawX = 0; drawY = (height - drawH) / 2; } |
| else { drawH = height; drawW = height * imgRatio; drawX = (width - drawW) / 2; drawY = 0; } |
| } |
| |
| if (bgBeatReactive) { |
| const bgScale = 1.0 + (bassAvg / 255) * 0.08; |
| const scaledW = drawW * bgScale; |
| const scaledH = drawH * bgScale; |
| const scaledX = drawX - (scaledW - drawW) / 2; |
| const scaledY = drawY - (scaledH - drawH) / 2; |
| ctx.drawImage(img, scaledX, scaledY, scaledW, scaledH); |
| } else { |
| ctx.drawImage(img, drawX, drawY, drawW, drawH); |
| } |
| } |
| |
| if (showParticles) { |
| if (particlesRef.current.length !== particleCount) { |
| particlesRef.current = Array.from({length: particleCount}, () => ({ |
| x: Math.random() * width, y: Math.random() * height, |
| vx: (Math.random() - 0.5) * 2, vy: (Math.random() - 0.5) * 2, |
| size: Math.random() * 2 + 1, hue: Math.random() * 360 |
| })); |
| } |
| |
| ctx.save(); |
| ctx.globalCompositeOperation = 'screen'; |
| const boost = 1 + (bassAvg / 100); |
| |
| particlesRef.current.forEach(p => { |
| p.x += p.vx * boost; p.y += p.vy * boost; |
| if (p.x < 0) p.x = width; if (p.x > width) p.x = 0; |
| if (p.y < 0) p.y = height; if (p.y > height) p.y = 0; |
| |
| ctx.fillStyle = `hsla(${colorMode==='rainbow'? p.hue : 260}, 80%, 70%, ${0.2 + (bassAvg/512)})`; |
| ctx.beginPath(); |
| ctx.arc(p.x, p.y, p.size * (1 + bassAvg/255), 0, Math.PI*2); |
| ctx.fill(); |
| }); |
| ctx.restore(); |
| } |
| |
| if (analyserRef.current) { |
| const bufferLength = analyserRef.current.frequencyBinCount; |
| const dataArray = dataArrayRef.current; |
| |
| ctx.save(); |
| const centerX = width / 2 + (width * (offsetRef.current.x / 100)); |
| const centerY = height / 2 + (height * (offsetRef.current.y / 100)); |
| ctx.translate(centerX, centerY); |
| ctx.scale(scale * scaleX, scale * scaleY); |
| 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); |
| if (gradStops >= 3) grad.addColorStop(0.5, color3); |
| if (gradStops >= 4) grad.addColorStop(0.75, color4); |
| 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; |
| } |
| |
| if (flashOnBeat && bassAvg > flashThreshold) { activeColor = flashColor; } |
| |
| ctx.strokeStyle = activeColor; |
| ctx.fillStyle = activeColor; |
| ctx.lineCap = 'round'; |
| ctx.lineJoin = 'round'; |
| |
| let startIndex = 0; |
| let bandLength = Math.floor(bufferLength * 0.75); |
| if (freqBand === 'bass') { startIndex = 0; bandLength = Math.floor(bufferLength * 0.08); } |
| else if (freqBand === 'mid') { startIndex = Math.floor(bufferLength * 0.08); bandLength = Math.floor(bufferLength * 0.3); } |
| else if (freqBand === 'treble') { startIndex = Math.floor(bufferLength * 0.38); bandLength = Math.floor(bufferLength * 0.37); } |
| |
| const drawVisualizerPath = () => { |
| const buildShapes = () => { |
| ctx.beginPath(); |
| ctx.lineCap = barShape === 'flat' ? 'butt' : 'round'; |
| |
| if (vizType === 'bars') { |
| const step = thickness + spacing; |
| const maxBars = Math.floor((width / 2) / step); |
| const numBars = Math.min(maxBars, bandLength); |
| |
| for (let i = 0; i < numBars; i++) { |
| const dataIndex = startIndex + Math.floor((i / numBars) * bandLength); |
| const boost = Math.pow(1 + (i / numBars), 1.5); |
| |
| let edgeMultiplier = 1; |
| if (taperEdges) edgeMultiplier = Math.pow(1 - (i / numBars), 2); |
| |
| const value = dataArray[dataIndex] * boost * sensitivity * edgeMultiplier; |
| const barHeight = Math.max(thickness / 2, (value / 255) * height * 0.8); |
| const xOffset = i * step + (step / 2); |
| |
| |
| if (showPeaks) { |
| if (barHeight > peakArrayRef.current[dataIndex]) peakArrayRef.current[dataIndex] = barHeight; |
| else peakArrayRef.current[dataIndex] = Math.max(0, peakArrayRef.current[dataIndex] - peakDecay); |
| } |
| |
| if (barShape === 'dots') { |
| ctx.moveTo(xOffset + thickness/2, height/2 - barHeight); ctx.arc(xOffset, height/2 - barHeight, thickness/2, 0, Math.PI*2); |
| ctx.moveTo(-xOffset + thickness/2, height/2 - barHeight); ctx.arc(-xOffset, height/2 - barHeight, thickness/2, 0, Math.PI*2); |
| } else if (barShape === 'diamonds') { |
| const s = thickness; |
| ctx.moveTo(xOffset, height/2 - barHeight - s); ctx.lineTo(xOffset + s, height/2 - barHeight); ctx.lineTo(xOffset, height/2 - barHeight + s); ctx.lineTo(xOffset - s, height/2 - barHeight); ctx.closePath(); |
| ctx.moveTo(-xOffset, height/2 - barHeight - s); ctx.lineTo(-xOffset + s, height/2 - barHeight); ctx.lineTo(-xOffset, height/2 - barHeight + s); ctx.lineTo(-xOffset - s, height/2 - barHeight); ctx.closePath(); |
| } else if (isFilled && barShape === 'pill' && ctx.roundRect) { |
| ctx.roundRect(xOffset - thickness/2, height/2 - barHeight, thickness, barHeight, thickness/2); |
| ctx.roundRect(-xOffset - thickness/2, height/2 - barHeight, thickness, barHeight, thickness/2); |
| } else if (isFilled) { |
| ctx.rect(xOffset - thickness/2, height/2 - barHeight, thickness, barHeight); |
| ctx.rect(-xOffset - thickness/2, height/2 - barHeight, thickness, barHeight); |
| } else { |
| 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 === 'symmetric_wave') { |
| const step = thickness + spacing; |
| const maxBars = Math.floor((width / 2) / step); |
| const usefulLength = Math.floor(bandLength * 0.75); |
| const numBars = Math.min(maxBars, usefulLength); |
| |
| for (let i = 0; i < numBars; i++) { |
| const dataIndex = startIndex + Math.floor((i / numBars) * usefulLength); |
| const boost = Math.pow(1 + (i / numBars), 1.5); |
| |
| let edgeMultiplier = 1; |
| if (taperEdges) edgeMultiplier = Math.pow(1 - (i / numBars), 2); |
| |
| const value = dataArray[dataIndex] * boost * sensitivity * edgeMultiplier; |
| const barHeight = Math.max(thickness / 2, (value / 255) * height * 0.4); |
| const xOffset = i * step + (step / 2); |
| |
| if (showPeaks) { |
| if (barHeight > peakArrayRef.current[dataIndex]) peakArrayRef.current[dataIndex] = barHeight; |
| else peakArrayRef.current[dataIndex] = Math.max(0, peakArrayRef.current[dataIndex] - peakDecay); |
| } |
| |
| if (barShape === 'dots') { |
| ctx.moveTo(xOffset + thickness/2, -barHeight); ctx.arc(xOffset, -barHeight, thickness/2, 0, Math.PI*2); |
| ctx.moveTo(xOffset + thickness/2, barHeight); ctx.arc(xOffset, barHeight, thickness/2, 0, Math.PI*2); |
| ctx.moveTo(-xOffset + thickness/2, -barHeight); ctx.arc(-xOffset, -barHeight, thickness/2, 0, Math.PI*2); |
| ctx.moveTo(-xOffset + thickness/2, barHeight); ctx.arc(-xOffset, barHeight, thickness/2, 0, Math.PI*2); |
| } else if (isFilled && barShape === 'pill' && ctx.roundRect) { |
| ctx.roundRect(xOffset - thickness/2, -barHeight, thickness, barHeight * 2, thickness/2); |
| ctx.roundRect(-xOffset - thickness/2, -barHeight, thickness, barHeight * 2, thickness/2); |
| } else if (isFilled) { |
| ctx.rect(xOffset - thickness/2, -barHeight, thickness, barHeight * 2); |
| ctx.rect(-xOffset - thickness/2, -barHeight, thickness, barHeight * 2); |
| } else { |
| ctx.moveTo(xOffset, -barHeight); ctx.lineTo(xOffset, barHeight); |
| ctx.moveTo(-xOffset, -barHeight); ctx.lineTo(-xOffset, barHeight); |
| } |
| } |
| } else if (vizType === 'wave') { |
| const sliceWidth = width / bandLength; |
| let x = -width / 2; |
| if (isFilled) ctx.moveTo(-width/2, height/2); |
| |
| for (let i = 0; i < bandLength; i++) { |
| const dataIndex = startIndex + i; |
| const normalized = (dataArray[dataIndex] / 128.0) - 1; |
| |
| let edgeMultiplier = 1; |
| if (taperEdges) edgeMultiplier = Math.pow(1 - (Math.abs(i - bandLength/2) / (bandLength/2)), 2); |
| |
| const y = normalized * sensitivity * (height / 2) * edgeMultiplier; |
| |
| if (i === 0 && !isFilled) ctx.moveTo(x, y); |
| else ctx.lineTo(x, y); |
| x += sliceWidth; |
| } |
| if (isFilled) { ctx.lineTo(width/2, height/2); ctx.closePath(); } |
| } else if (vizType === 'circle') { |
| const radius = height / 4; |
| const circumference = 2 * Math.PI * radius; |
| const stepSize = Math.max(1, thickness + spacing); |
| const bars = Math.floor(circumference / stepSize); |
| if (bars > 0) { |
| const step = (Math.PI * 2) / bars; |
| const halfBars = Math.floor(bars / 2); |
| |
| for (let i = 0; i < bars; i++) { |
| const iSymmetric = i <= halfBars ? i : bars - i; |
| const dataProgression = halfBars === 0 ? 0 : (iSymmetric / halfBars); |
| const dataIndex = startIndex + Math.floor(dataProgression * bandLength * 0.75); |
| const boost = Math.pow(1 + dataProgression, 1.5); |
| let edgeMultiplier = taperEdges ? Math.pow(1 - dataProgression, 2) : 1; |
| |
| const value = dataArray[dataIndex] * boost * sensitivity * edgeMultiplier; |
| const barHeight = Math.max(thickness / 2, (value / 255) * (height * 0.35)); |
| |
| const angle = i * step + (Math.PI / 2); |
| |
| if (showPeaks) { |
| if (barHeight > peakArrayRef.current[dataIndex]) peakArrayRef.current[dataIndex] = barHeight; |
| else peakArrayRef.current[dataIndex] = Math.max(0, peakArrayRef.current[dataIndex] - peakDecay); |
| } |
| |
| 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); |
| |
| if (barShape === 'dots') { |
| ctx.moveTo(x2 + thickness/2, y2); ctx.arc(x2, y2, thickness/2, 0, Math.PI*2); |
| } else if (barShape === 'diamonds') { |
| const s = thickness; |
| const dirX = Math.cos(angle); const dirY = Math.sin(angle); |
| const perpX = Math.cos(angle + Math.PI/2); const perpY = Math.sin(angle + Math.PI/2); |
| ctx.moveTo(x2 + dirX*s, y2 + dirY*s); ctx.lineTo(x2 + perpX*s, y2 + perpY*s); |
| ctx.lineTo(x2 - dirX*s, y2 - dirY*s); ctx.lineTo(x2 - perpX*s, y2 - perpY*s); ctx.closePath(); |
| } else { |
| ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); |
| } |
| } |
| } |
| } |
| }; |
| |
| const executeDraw = () => { |
| buildShapes(); |
| if (mirrorX) { ctx.save(); ctx.scale(-1, 1); buildShapes(); ctx.restore(); } |
| if (mirrorY) { ctx.save(); ctx.scale(1, -1); buildShapes(); ctx.restore(); } |
| if (mirrorX && mirrorY) { ctx.save(); ctx.scale(-1, -1); buildShapes(); ctx.restore(); } |
| |
| if (isFilled && (vizType === 'bars' || vizType === 'symmetric_wave' || vizType === 'wave')) ctx.fill(); else ctx.stroke(); |
| |
| if (vizType === 'circle' && !isFilled && barShape !== 'dots' && barShape !== 'diamonds') { |
| const radius = height / 4; |
| ctx.beginPath(); ctx.arc(0, 0, radius - thickness, 0, Math.PI * 2); |
| ctx.lineWidth = Math.max(1, thickness / 3); ctx.stroke(); |
| } |
| |
| |
| if (showPeaks && (vizType === 'bars' || vizType === 'symmetric_wave' || vizType === 'circle')) { |
| ctx.beginPath(); |
| ctx.lineCap = 'butt'; |
| |
| if (vizType === 'bars' || vizType === 'symmetric_wave') { |
| const step = thickness + spacing; |
| const maxBars = Math.floor((width / 2) / step); |
| const usefulLength = Math.floor(bandLength * 0.75); |
| const numBars = Math.min(maxBars, usefulLength); |
| |
| for (let i = 0; i < numBars; i++) { |
| const dataIndex = startIndex + Math.floor((i / numBars) * usefulLength); |
| const pHeight = peakArrayRef.current[dataIndex]; |
| if (pHeight <= 0) continue; |
| const xOffset = i * step + (step / 2); |
| |
| if (vizType === 'bars') { |
| ctx.moveTo(xOffset - thickness/2, height/2 - pHeight - 8); ctx.lineTo(xOffset + thickness/2, height/2 - pHeight - 8); |
| ctx.moveTo(-xOffset - thickness/2, height/2 - pHeight - 8); ctx.lineTo(-xOffset + thickness/2, height/2 - pHeight - 8); |
| } else { |
| ctx.moveTo(xOffset - thickness/2, -pHeight - 8); ctx.lineTo(xOffset + thickness/2, -pHeight - 8); |
| ctx.moveTo(xOffset - thickness/2, pHeight + 8); ctx.lineTo(xOffset + thickness/2, pHeight + 8); |
| ctx.moveTo(-xOffset - thickness/2, -pHeight - 8); ctx.lineTo(-xOffset + thickness/2, -pHeight - 8); |
| ctx.moveTo(-xOffset - thickness/2, pHeight + 8); ctx.lineTo(-xOffset + thickness/2, pHeight + 8); |
| } |
| } |
| } else if (vizType === 'circle') { |
| const radius = height / 4; |
| const circumference = 2 * Math.PI * radius; |
| const stepSize = Math.max(1, thickness + spacing); |
| const bars = Math.floor(circumference / stepSize); |
| if (bars > 0) { |
| const step = (Math.PI * 2) / bars; |
| const halfBars = Math.floor(bars / 2); |
| |
| for (let i = 0; i < bars; i++) { |
| const iSymmetric = i <= halfBars ? i : bars - i; |
| const prog = halfBars === 0 ? 0 : (iSymmetric / halfBars); |
| const dataIndex = startIndex + Math.floor(prog * bandLength * 0.75); |
| |
| const pHeight = peakArrayRef.current[dataIndex]; |
| if (pHeight <= 0) continue; |
| |
| const angle = i * step + (Math.PI / 2); |
| const px1 = Math.cos(angle) * (radius + pHeight); |
| const py1 = Math.sin(angle) * (radius + pHeight); |
| const offset = Math.max(2, thickness*0.2); |
| const px2 = Math.cos(angle) * (radius + pHeight + offset); |
| const py2 = Math.sin(angle) * (radius + pHeight + offset); |
| |
| ctx.moveTo(px1, py1); ctx.lineTo(px2, py2); |
| } |
| } |
| } |
| ctx.stroke(); |
| } |
| }; |
| |
| if (glitchEffect && bassAvg > glitchThreshold) { |
| const offset = (bassAvg - glitchThreshold) * 0.3; |
| ctx.globalCompositeOperation = 'screen'; |
| ctx.lineWidth = thickness; |
| |
| ctx.save(); ctx.translate(-offset, 0); ctx.strokeStyle='#ff4444'; ctx.fillStyle='#ff4444'; executeDraw(); ctx.restore(); |
| ctx.save(); ctx.translate(offset, offset/2); ctx.strokeStyle='#44ff44'; ctx.fillStyle='#44ff44'; executeDraw(); ctx.restore(); |
| ctx.save(); ctx.translate(0, -offset); ctx.strokeStyle='#4444ff'; ctx.fillStyle='#4444ff'; executeDraw(); ctx.restore(); |
| |
| ctx.globalCompositeOperation = 'source-over'; |
| } else if (glow) { |
| ctx.globalCompositeOperation = 'lighter'; |
| ctx.lineWidth = thickness * 3; ctx.globalAlpha = 0.15; executeDraw(); |
| ctx.lineWidth = thickness * 1.5; ctx.globalAlpha = 0.4; executeDraw(); |
| ctx.lineWidth = thickness; ctx.globalAlpha = 1.0; executeDraw(); |
| ctx.globalCompositeOperation = 'source-over'; |
| } else { |
| ctx.lineWidth = thickness; |
| executeDraw(); |
| } |
| }; |
| |
| drawVisualizerPath(); |
| ctx.restore(); |
| } |
| |
| if (watermarkImgRef.current) { |
| ctx.save(); |
| ctx.globalAlpha = watermarkOpacity; |
| const ww = (watermarkSize / 100) * width; |
| const wh = ww * (watermarkImgRef.current.height / watermarkImgRef.current.width); |
| const wx = (watermarkPosX / 100) * width + (width/2 - ww/2); |
| const wy = (watermarkPosY / 100) * height + (height/2 - wh/2); |
| ctx.drawImage(watermarkImgRef.current, wx, wy, ww, wh); |
| ctx.restore(); |
| } |
| |
| if (overlayText1 || overlayText2) { |
| ctx.save(); ctx.fillStyle = overlayTextColor; ctx.textAlign = 'center'; |
| const scaleFactor = res.w / 3840; |
| const fSize1 = overlayTextSize * scaleFactor * 3; |
| const fSize2 = fSize1 * 0.6; |
| |
| let startY = res.h / 2; |
| if (overlayTextPos === 'top') startY = res.h * 0.15; |
| if (overlayTextPos === 'bottom') startY = res.h * 0.85; |
| |
| ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 15 * scaleFactor; |
| ctx.shadowOffsetX = 3 * scaleFactor; ctx.shadowOffsetY = 3 * scaleFactor; |
| |
| if (overlayText1) { ctx.font = `bold ${fSize1}px sans-serif`; ctx.fillText(overlayText1, res.w / 2, startY); } |
| if (overlayText2) { ctx.font = `600 ${fSize2}px sans-serif`; ctx.fillText(overlayText2, res.w / 2, startY + fSize1 * 1.2); } |
| ctx.restore(); |
| } |
| |
| reqIdRef.current = requestAnimationFrame(draw); |
| }, [vizType, barShape, color, color2, color3, color4, gradStops, thickness, spacing, sensitivity, smoothing, colorMode, glow, taperEdges, showPeaks, peakDecay, glitchEffect, glitchThreshold, resolution, bgType, bgColor, bgImageFit, scale, scaleX, scaleY, rotation, offsetX, offsetY, isExportingVideo, exportFps, mirrorX, mirrorY, isFilled, bgBeatReactive, showParticles, particleCount, freqBand, flashOnBeat, flashColor, flashThreshold, overlayText1, overlayText2, overlayTextSize, overlayTextColor, overlayTextPos, watermarkSize, watermarkOpacity, watermarkPosX, watermarkPosY]); |
| |
| useEffect(() => { |
| reqIdRef.current = requestAnimationFrame(draw); |
| return () => cancelAnimationFrame(reqIdRef.current); |
| }, [draw]); |
| |
| const handleAudioEnded = function() { |
| setIsPlaying(false); |
| if (isExportingVideo) stopVideoExport(); |
| }; |
| |
| const exportImage = function() { |
| 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 function() { |
| 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 = exportStart > 0 ? exportStart : 0; |
| |
| const canvasStream = canvasRef.current.captureStream(exportFps); |
| const audioStream = destRef.current.stream; |
| const combinedStream = new MediaStream([...canvasStream.getTracks(), ...audioStream.getAudioTracks()]); |
| |
| let options = {}; let ext = 'webm'; |
| const targetBitrate = resolution.startsWith('4k') ? 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("Browser doesn't support MP4. 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) { alert("Recorder error."); setIsExportingVideo(false); return; } |
| mediaRecorderRef.current.ondataavailable = function(e) { if (e.data && e.data.size > 0) chunksRef.current.push(e.data); }; |
| mediaRecorderRef.current.onstop = function() { |
| const blob = new Blob(chunksRef.current, { type: mediaRecorderRef.current.mimeType || 'video/mp4' }); |
| const url = URL.createObjectURL(blob); |
| const link = document.createElement('a'); link.download = `viz_${Date.now()}.${ext}`; link.href = url; link.click(); URL.revokeObjectURL(url); |
| setIsExportingVideo(false); setExportProgress(0); |
| }; |
| |
| const duration = audioRef.current.duration; |
| const progressInterval = setInterval(function() { |
| if (audioRef.current && !audioRef.current.paused) { |
| const current = audioRef.current.currentTime; |
| const end = (exportEnd > 0 && exportEnd < duration) ? exportEnd : duration; |
| const start = exportStart > 0 ? exportStart : 0; |
| setExportProgress(Math.min(100, Math.max(0, ((current - start) / (end - start)) * 100))); |
| if (current >= end) stopVideoExport(); |
| } else { clearInterval(progressInterval); } |
| }, 250); |
| |
| mediaRecorderRef.current.start(1000); |
| audioRef.current.play().then(()=>setIsPlaying(true)).catch(e => console.error(e)); |
| }; |
| |
| const stopVideoExport = function() { |
| if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') mediaRecorderRef.current.stop(); |
| audioRef.current.pause(); setIsPlaying(false); |
| }; |
| |
| return ( |
| <div className="min-h-screen font-sans selection:bg-violet-500/30 pb-10"> |
| <header className="border-b border-black/5 dark:border-white/5 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-xl p-5 sticky top-0 z-50 flex flex-col sm:flex-row items-center justify-between gap-4 transition-colors"> |
| <div className="flex items-center gap-3"> |
| <SujataLogo className="w-9 h-9 shadow-sm rounded-full drop-shadow-md" /> |
| <h1 className="text-2xl sm:text-3xl font-black tracking-tighter text-zinc-900 dark:text-zinc-50 drop-shadow-sm">Sujata Studios</h1> |
| </div> |
| <div className="flex items-center gap-3"> |
| <button onClick={handleResetAll} className="flex items-center gap-2 bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 border border-black/5 dark:border-white/5 px-4 py-2 rounded-xl text-sm text-zinc-700 dark:text-zinc-300 font-medium transition-all active:scale-95" title="Reset all settings to default"> |
| <RefreshCw className="w-4 h-4" /> Reset All |
| </button> |
| <button onClick={function(){setIsDark(!isDark);}} className="flex items-center justify-center w-10 h-10 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 border border-black/5 dark:border-white/5 rounded-xl text-zinc-600 dark:text-zinc-300 transition-all active:scale-95" title="Toggle Theme"> |
| {isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />} |
| </button> |
| </div> |
| </header> |
| |
| <main className="w-full max-w-[1800px] mx-auto p-4 sm:p-6 flex flex-col lg:grid lg:grid-cols-12 gap-6 lg:gap-8 relative"> |
| |
| <div className="order-3 lg:order-1 lg:col-span-3 space-y-6 overflow-y-auto pr-2 custom-scrollbar lg:sticky lg:top-28 lg:h-[calc(100vh-8rem)]"> |
| |
| <section className="bg-white/60 dark:bg-zinc-900/40 backdrop-blur-2xl p-5 sm:p-6 rounded-3xl border border-black/5 dark:border-white/5 shadow-xl shadow-black/5 dark:shadow-2xl transition-all"> |
| <SectionHeader title="Visual Settings" icon={Settings2} sectionKey="visual" minStates={minStates} toggleMin={toggleMin} /> |
| {!minStates.visual && ( |
| <div className="space-y-6 animate-in fade-in slide-in-from-top-2 duration-200"> |
| |
| <div> |
| <ControlHeader label="Style & Fill" |
| extra={ |
| <label className="flex items-center gap-2 cursor-pointer group ml-4"> |
| <span className="text-[11px] font-medium uppercase tracking-widest text-zinc-500 group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors">Fill Shapes</span> |
| <div className={`w-8 h-4 rounded-full transition-colors relative ${isFilled ? 'bg-violet-500' : 'bg-zinc-300 dark:bg-zinc-800'}`}> |
| <div className={`w-3 h-3 bg-white rounded-full absolute top-0.5 transition-transform ${isFilled ? 'left-4.5 translate-x-[14px]' : 'left-0.5'}`}></div> |
| </div> |
| <input type="checkbox" className="hidden" checked={isFilled} onChange={function(e) { setIsFilled(e.target.checked); }} /> |
| </label> |
| } |
| /> |
| <div className="grid grid-cols-2 gap-2 mb-3"> |
| {[ {id: 'bars', label: 'Bars (Freq)'}, {id: 'symmetric_wave', label: 'Wave (Bars)'}, {id: 'wave', label: 'Wave (Line)'}, {id: 'circle', label: 'Circle'} ].map(function(type) { |
| return <button key={type.id} onClick={function() { setVizType(type.id); }} className={`py-2 px-3 rounded-xl text-sm font-medium transition-all ${vizType === type.id ? 'bg-violet-600 text-white shadow-lg shadow-violet-500/25 border border-violet-500/50' : 'bg-black/5 dark:bg-black/40 text-zinc-600 dark:text-zinc-400 border border-black/5 dark:border-white/5 hover:border-black/10 dark:hover:border-white/10 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-black/10 dark:hover:bg-black/60'}`}>{type.label}</button>; |
| })} |
| </div> |
| |
| {(vizType === 'bars' || vizType === 'symmetric_wave' || vizType === 'circle') && ( |
| <div className="flex items-center justify-between border-t border-black/5 dark:border-white/5 pt-3 mt-1 group"> |
| <span className="text-[11px] font-bold tracking-widest uppercase text-zinc-500">Bar Shape</span> |
| <select value={barShape} onChange={function(e) { setBarShape(e.target.value); }} className="bg-white dark:bg-black/40 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-xs rounded-lg px-2 py-1 outline-none focus:border-violet-500/50 cursor-pointer"> |
| <option value="pill">Pill (Rounded)</option> |
| <option value="flat">Flat (Box)</option> |
| <option value="diamonds">Diamonds</option> |
| <option value="dots">Dots Only</option> |
| </select> |
| </div> |
| )} |
| </div> |
| |
| <div> |
| <ControlHeader label="Frequency Band Isolation" /> |
| <select value={freqBand} onChange={function(e) { setFreqBand(e.target.value); }} className="w-full bg-white dark:bg-black/40 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-sm rounded-xl px-4 py-2.5 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all cursor-pointer"> |
| <option value="all">All Frequencies (Default)</option> |
| <option value="bass">Bass Only (Kick/Sub)</option> |
| <option value="mid">Mids Only (Vocals/Melody)</option> |
| <option value="treble">Treble Only (Hi-Hats)</option> |
| </select> |
| </div> |
| |
| <div className="p-4 bg-zinc-100/50 dark:bg-black/20 rounded-2xl border border-black/5 dark:border-white/5 space-y-4 shadow-inner"> |
| <ControlHeader label="Color Style" /> |
| <div className="flex flex-wrap items-center gap-2 mb-2 w-full"> |
| <select value={colorMode} onChange={function(e) { setColorMode(e.target.value); }} className="w-auto flex-1 min-w-[80px] bg-white dark:bg-black/60 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-xs rounded-xl px-2 py-2 outline-none focus:border-violet-500/50 cursor-pointer"> |
| <option value="solid">Solid</option><option value="gradient">Gradient</option><option value="rainbow">Rainbow</option> |
| </select> |
| |
| {colorMode === 'gradient' && ( |
| <select value={gradStops} onChange={function(e) { setGradStops(Number(e.target.value)); }} className="w-auto min-w-[60px] bg-white dark:bg-black/60 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-xs rounded-xl px-1 py-2 outline-none focus:border-violet-500/50 cursor-pointer"> |
| <option value={2}>2x</option><option value={3}>3x</option><option value={4}>4x</option> |
| </select> |
| )} |
| |
| {colorMode !== 'rainbow' && <input type="color" value={color} onChange={function(e) { setColor(e.target.value); }} className="h-9 w-10 shrink-0 rounded-lg cursor-pointer bg-white dark:bg-black border border-black/10 dark:border-white/10" />} |
| {colorMode === 'gradient' && <input type="color" value={color2} onChange={function(e) { setColor2(e.target.value); }} className="h-9 w-10 shrink-0 rounded-lg cursor-pointer bg-white dark:bg-black border border-black/10 dark:border-white/10" />} |
| {colorMode === 'gradient' && gradStops >= 3 && <input type="color" value={color3} onChange={function(e) { setColor3(e.target.value); }} className="h-9 w-10 shrink-0 rounded-lg cursor-pointer bg-white dark:bg-black border border-black/10 dark:border-white/10 animate-in zoom-in-95" />} |
| {colorMode === 'gradient' && gradStops >= 4 && <input type="color" value={color4} onChange={function(e) { setColor4(e.target.value); }} className="h-9 w-10 shrink-0 rounded-lg cursor-pointer bg-white dark:bg-black border border-black/10 dark:border-white/10 animate-in zoom-in-95" />} |
| |
| {colorMode === 'solid' && <input type="text" value={color} onChange={function(e) { setColor(e.target.value); }} className="flex-1 min-w-[80px] bg-white dark:bg-black/60 border border-black/10 dark:border-white/10 rounded-xl px-2 py-1.5 text-sm focus:ring-1 focus:ring-violet-500/50 outline-none uppercase font-mono text-zinc-800 dark:text-zinc-200" />} |
| </div> |
| |
| {/* Chromatic Glitch */} |
| <div className="pt-4 border-t border-black/5 dark:border-white/5"> |
| <div className="flex justify-between items-center group mb-2"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Chromatic Glitch</span> |
| <Layers className="w-4 h-4 text-cyan-500 dark:text-cyan-400" /> |
| </div> |
| <label className="relative inline-flex items-center cursor-pointer shrink-0"> |
| <input type="checkbox" checked={glitchEffect} onChange={function(e) { setGlitchEffect(e.target.checked); }} className="sr-only peer" /> |
| <div className="w-10 h-5 bg-zinc-300 dark:bg-zinc-800 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-white/10 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-cyan-500"></div> |
| </label> |
| </div> |
| <span className="text-xs text-zinc-500 leading-snug">RGB split on heavy bass hits. (Overrides Glow)</span> |
| {glitchEffect && ( |
| <div className="flex items-center gap-4 animate-in fade-in slide-in-from-top-2 mt-3 bg-white/80 dark:bg-black/40 p-3 rounded-xl border border-black/5 dark:border-white/5"> |
| <div className="flex-1"> |
| <div className="flex justify-between items-center text-xs text-zinc-600 dark:text-zinc-400 mb-2"> |
| <span>Glitch Threshold</span> |
| <button onClick={function() { setGlitchThreshold(240); }} className="hover:text-violet-600 dark:hover:text-violet-400 transition-colors focus:outline-none"><RotateCcw className="w-3 h-3" /></button> |
| </div> |
| <input type="range" min="150" max="250" value={250 - (glitchThreshold - 150)} onChange={function(e) { setGlitchThreshold(250 - (Number(e.target.value) - 150)); }} className="w-full accent-cyan-500" /> |
| </div> |
| </div> |
| )} |
| </div> |
| |
| {/* Beat Flashes */} |
| <div className="pt-4 border-t border-black/5 dark:border-white/5"> |
| <div className="flex justify-between items-center group mb-2"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Beat Flash</span> |
| <Zap className="w-4 h-4 text-fuchsia-500 dark:text-fuchsia-400" /> |
| </div> |
| <label className="relative inline-flex items-center cursor-pointer shrink-0"> |
| <input type="checkbox" checked={flashOnBeat} onChange={function(e) { setFlashOnBeat(e.target.checked); }} className="sr-only peer" /> |
| <div className="w-10 h-5 bg-zinc-300 dark:bg-zinc-800 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-white/10 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-fuchsia-500"></div> |
| </label> |
| </div> |
| <span className="text-xs text-zinc-500 leading-snug">Enable rhythmic flashing on heavy bass.</span> |
| {flashOnBeat && ( |
| <div className="flex items-center gap-4 animate-in fade-in slide-in-from-top-2 mt-3 bg-white/80 dark:bg-black/40 p-3 rounded-xl border border-black/5 dark:border-white/5"> |
| <input type="color" value={flashColor} onChange={function(e) { setFlashColor(e.target.value); }} className="h-10 w-12 rounded-lg cursor-pointer bg-white dark:bg-black border border-black/10 dark:border-white/10 shrink-0" title="Flash Color" /> |
| <div className="flex-1"> |
| <div className="flex justify-between items-center text-xs text-zinc-600 dark:text-zinc-400 mb-2"> |
| <span>Sensitivity</span> |
| <button onClick={function() { setFlashThreshold(230); }} className="hover:text-violet-600 dark:hover:text-violet-400 transition-colors focus:outline-none" title="Reset Sensitivity"><RotateCcw className="w-3 h-3" /></button> |
| </div> |
| <input type="range" min="150" max="250" value={250 - (flashThreshold - 150)} onChange={function(e) { setFlashThreshold(250 - (Number(e.target.value) - 150)); }} className="w-full" /> |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| <div className="space-y-4"> |
| <div className="flex justify-between items-center py-2 border-b border-black/5 dark:border-white/5"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Neon Glow Effect</span> |
| <Sparkles className="w-4 h-4 text-amber-500 dark:text-amber-400 ml-1" /> |
| </div> |
| <label className="relative inline-flex items-center cursor-pointer shrink-0"> |
| <input type="checkbox" checked={glow} onChange={function(e) { setGlow(e.target.checked); }} className="sr-only peer" /> |
| <div className="w-11 h-6 bg-zinc-300 dark:bg-zinc-800 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-white/10 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-500"></div> |
| </label> |
| </div> |
| |
| <div className="flex justify-between items-center py-2 border-b border-black/5 dark:border-white/5"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Taper Edges</span> |
| <Activity className="w-4 h-4 text-emerald-500 dark:text-emerald-400 ml-1" /> |
| </div> |
| <label className="relative inline-flex items-center cursor-pointer shrink-0"> |
| <input type="checkbox" checked={taperEdges} onChange={function(e) { setTaperEdges(e.target.checked); }} className="sr-only peer" /> |
| <div className="w-11 h-6 bg-zinc-300 dark:bg-zinc-800 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-white/10 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-500"></div> |
| </label> |
| </div> |
| |
| {(vizType === 'bars' || vizType === 'symmetric_wave' || vizType === 'circle') && ( |
| <div className="py-2 space-y-4"> |
| <div className="flex justify-between items-center group"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Peak Hold (Ghost Bars)</span> |
| </div> |
| <label className="relative inline-flex items-center cursor-pointer shrink-0"> |
| <input type="checkbox" checked={showPeaks} onChange={function(e) { setShowPeaks(e.target.checked); }} className="sr-only peer" /> |
| <div className="w-11 h-6 bg-zinc-300 dark:bg-zinc-800 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-white/10 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-500"></div> |
| </label> |
| </div> |
| {showPeaks && ( |
| <div className="pl-4 border-l-2 border-violet-500/20 animate-in fade-in slide-in-from-top-2"> |
| <ControlHeader label="Peak Decay Gravity" valueDisplay={`${peakDecay.toFixed(1)}`} onReset={function() { setPeakDecay(2.0); }} /> |
| <input type="range" min="0.1" max="10.0" step="0.1" value={peakDecay} onChange={function(e) { setPeakDecay(Number(e.target.value)); }} className="w-full" /> |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| |
| <div className="space-y-5 pt-2 border-t border-black/5 dark:border-white/5"> |
| <div> |
| <ControlHeader label="Line Thickness" valueDisplay={`${thickness}px`} onReset={function() { setThickness(12); }} /> |
| <input type="range" min="2" max="64" value={thickness} onChange={function(e) { setThickness(Number(e.target.value)); }} className="w-full" /> |
| </div> |
| <div className={vizType === 'wave' ? 'opacity-30 pointer-events-none' : ''}> |
| <ControlHeader label="Space Between Lines" valueDisplay={`${spacing}px`} onReset={function() { setSpacing(8); }} /> |
| <input type="range" min="0" max="64" value={spacing} onChange={function(e) { setSpacing(Number(e.target.value)); }} className="w-full" disabled={vizType === 'wave'} /> |
| </div> |
| <div> |
| <ControlHeader label="Amplitude (Height)" valueDisplay={`${sensitivity.toFixed(1)}x`} onReset={function() { setSensitivity(1.5); }} /> |
| <input type="range" min="0.5" max="3.0" step="0.1" value={sensitivity} onChange={function(e) { setSensitivity(Number(e.target.value)); }} className="w-full" /> |
| </div> |
| <div> |
| <ControlHeader label="Motion Smoothing" valueDisplay={`${Math.round(smoothing * 100)}%`} onReset={function() { setSmoothing(0.85); }} /> |
| <input type="range" min="0.1" max="0.99" step="0.01" value={smoothing} onChange={function(e) { setSmoothing(Number(e.target.value)); }} className="w-full" /> |
| </div> |
| </div> |
| </div> |
| )} |
| </section> |
| </div> |
| |
| {/* --- CENTER CANVAS PREVIEW SECTION --- */} |
| <div className="order-2 lg:order-2 lg:col-span-6 flex flex-col gap-5 lg:sticky lg:top-28 lg:h-[calc(100vh-8rem)]"> |
| <div className="bg-white/60 dark:bg-zinc-900/40 backdrop-blur-2xl rounded-3xl border border-black/5 dark:border-white/5 shadow-xl shadow-black/5 dark:shadow-2xl overflow-hidden flex-1 relative flex flex-col min-h-0 group"> |
| |
| <div className="p-4 border-b border-black/5 dark:border-white/5 bg-zinc-100/80 dark:bg-black/20 flex flex-wrap justify-between items-center z-10 shrink-0 gap-3 backdrop-blur-md"> |
| <span className="text-sm font-bold tracking-wide uppercase text-zinc-800 dark:text-zinc-300 flex items-center gap-2"> |
| Live Preview |
| <span className="bg-white dark:bg-black/50 text-[10px] px-2.5 py-1 rounded-full text-zinc-600 dark:text-zinc-400 border border-black/10 dark:border-white/5 tracking-wider"> |
| {RESOLUTIONS[resolution]?.w}x{RESOLUTIONS[resolution]?.h} |
| </span> |
| </span> |
| <div className="flex items-center gap-3"> |
| <span className="text-[11px] uppercase tracking-widest text-zinc-500 font-bold">Safe Zone</span> |
| <select |
| value={safeZone} |
| onChange={function(e) { setSafeZone(e.target.value); }} |
| className="bg-white dark:bg-black/40 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-300 text-xs rounded-lg px-3 py-1.5 outline-none cursor-pointer focus:border-violet-500/50" |
| > |
| <option value="none">Off</option> |
| <option value="1:1">1:1 (Square)</option> |
| <option value="4:5">4:5 (Portrait)</option> |
| <option value="9:16">9:16 (Vertical)</option> |
| <option value="16:9">16:9 (Landscape)</option> |
| <option value="title_safe">Title Safe (10%)</option> |
| </select> |
| </div> |
| </div> |
| |
| <div className="flex-1 w-full relative flex items-center justify-center p-4 sm:p-8 overflow-hidden bg-zinc-100 dark:bg-black/60 min-h-0" |
| style={ bgType === 'transparent' ? { |
| backgroundImage: isDark |
| ? 'repeating-linear-gradient(45deg, #09090b 25%, transparent 25%, transparent 75%, #09090b 75%, #09090b), repeating-linear-gradient(45deg, #09090b 25%, #18181b 25%, #18181b 75%, #09090b 75%, #09090b)' |
| : 'repeating-linear-gradient(45deg, #f4f4f5 25%, transparent 25%, transparent 75%, #f4f4f5 75%, #f4f4f5), repeating-linear-gradient(45deg, #f4f4f5 25%, #e4e4e7 25%, #e4e4e7 75%, #f4f4f5 75%, #f4f4f5)', |
| backgroundPosition: '0 0, 10px 10px', |
| backgroundSize: '20px 20px' |
| } : {}} |
| onDragOver={handleDragOver} |
| onDragLeave={handleDragLeave} |
| onDrop={handleDrop} |
| > |
| {/* Drag & Drop Feedback Overlay */} |
| {isDraggingFile && ( |
| <div className="absolute inset-4 z-50 bg-violet-500/20 backdrop-blur-sm border-4 border-dashed border-violet-500/50 rounded-2xl flex flex-col items-center justify-center pointer-events-none transition-all animate-in fade-in zoom-in-95"> |
| <Upload className="w-16 h-16 text-violet-500 mb-4 animate-bounce" /> |
| <h2 className="text-2xl font-bold text-violet-700 dark:text-violet-400 drop-shadow-lg">Drop Audio File Here</h2> |
| </div> |
| )} |
| |
| <div className="relative flex items-center justify-center shadow-2xl shadow-black/10 dark:shadow-black/50 ring-1 ring-black/5 dark:ring-white/10 transition-transform duration-500 ease-out" |
| style={{ |
| aspectRatio: `${RESOLUTIONS[resolution]?.w} / ${RESOLUTIONS[resolution]?.h}`, |
| maxHeight: '100%', |
| maxWidth: '100%' |
| }}> |
| |
| {/* Floating Audio Removal Button */} |
| {audioSrc && ( |
| <button |
| onClick={handleRemoveAudio} |
| className="absolute top-4 right-4 z-40 bg-white/80 dark:bg-black/60 hover:bg-red-500 dark:hover:bg-red-500 text-zinc-600 dark:text-zinc-300 hover:text-white p-2.5 rounded-xl backdrop-blur-md border border-black/10 dark:border-white/10 transition-all active:scale-95 group shadow-lg opacity-0 group-hover:opacity-100" |
| title="Remove Audio" |
| > |
| <Trash2 className="w-4 h-4" /> |
| </button> |
| )} |
| |
| <canvas |
| key={bgType === 'transparent' ? 'alpha' : 'solid'} |
| ref={canvasRef} |
| width={RESOLUTIONS[resolution]?.w || 3840} |
| height={RESOLUTIONS[resolution]?.h || 2160} |
| onMouseDown={handleMouseDown} |
| onMouseMove={handleMouseMove} |
| onMouseUp={handleMouseUpOrLeave} |
| onMouseLeave={handleMouseUpOrLeave} |
| className={`w-full h-full bg-transparent ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`} |
| /> |
| |
| {/* Pixel Perfect Bound Safe Zones */} |
| {safeZone !== 'none' && ( |
| <div |
| className="absolute pointer-events-none border-[3px] border-dashed border-red-500/80 bg-red-500/5 flex items-center justify-center z-30" |
| style={getSafeZoneStyle(safeZone, RESOLUTIONS[resolution]?.w || 3840, RESOLUTIONS[resolution]?.h || 2160)} |
| > |
| <span className="absolute top-3 left-3 text-[10px] font-bold tracking-wider text-red-600 dark:text-red-300 bg-white/80 dark:bg-black/80 px-2 py-1 rounded-md border border-red-500/30 shadow-lg backdrop-blur-md uppercase"> |
| {safeZone === 'title_safe' ? 'Title' : safeZone} Safe Area |
| </span> |
| <div className="w-4 h-[1px] bg-red-500/50 absolute"></div> |
| <div className="h-4 w-[1px] bg-red-500/50 absolute"></div> |
| </div> |
| )} |
| </div> |
| |
| {!audioSrc && !isDraggingFile && ( |
| <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none bg-white/80 dark:bg-zinc-950/80 backdrop-blur-sm z-20 transition-opacity"> |
| <Loader2 className="w-12 h-12 text-zinc-400 dark:text-zinc-600 animate-spin mb-4" /> |
| <p className="text-zinc-500 dark:text-zinc-400 font-medium tracking-wide uppercase text-sm">Awaiting Audio Input</p> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* Playback Controls & Waveform */} |
| <div className="bg-white/60 dark:bg-zinc-900/40 backdrop-blur-2xl p-5 sm:p-6 rounded-3xl border border-black/5 dark:border-white/5 shadow-xl shadow-black/5 dark:shadow-2xl shrink-0 flex flex-col gap-4 transition-all"> |
| |
| {/* Interactive Waveform Timeline Component */} |
| <WaveformTimeline |
| waveformData={waveformData} |
| audioTime={audioTime} |
| audioDuration={audioDuration} |
| audioRef={audioRef} |
| setAudioTime={setAudioTime} |
| /> |
| |
| <div className="flex items-center gap-5"> |
| <button ref={playButtonRef} onClick={togglePlay} disabled={!audioSrc || isExportingVideo} className="w-14 h-14 shrink-0 bg-violet-600 hover:bg-violet-500 dark:bg-zinc-100 dark:hover:bg-white text-white dark:text-zinc-950 rounded-full flex items-center justify-center transition-transform hover:scale-105 active:scale-95 disabled:opacity-50 disabled:hover:scale-100 disabled:hover:bg-violet-600 shadow-xl shadow-violet-500/30 dark:shadow-white/10"> |
| {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 justify-between items-center text-xs font-mono text-zinc-600 dark:text-zinc-400"> |
| <span className="bg-black/5 dark:bg-black/40 px-3 py-1.5 rounded-lg border border-black/5 dark:border-white/5">{formatTime(audioTime)}</span> |
| <span className="text-zinc-800 dark:text-zinc-300 font-sans truncate px-4 font-medium max-w-[200px] sm:max-w-xs text-center">{fileName || 'No audio selected'}</span> |
| <span className="bg-black/5 dark:bg-black/40 px-3 py-1.5 rounded-lg border border-black/5 dark:border-white/5">{formatTime(audioDuration)}</span> |
| </div> |
| </div> |
| </div> |
| |
| </div> |
| |
| {/* --- RIGHT SIDEBAR (Input & Setup) --- */} |
| <div className="order-1 lg:order-3 lg:col-span-3 space-y-6 overflow-y-auto pr-2 custom-scrollbar lg:sticky lg:top-28 lg:h-[calc(100vh-8rem)]"> |
| |
| <section className="bg-white/60 dark:bg-zinc-900/40 backdrop-blur-2xl p-5 sm:p-6 rounded-3xl border border-black/5 dark:border-white/5 shadow-xl shadow-black/5 dark:shadow-2xl transition-all"> |
| <SectionHeader title="Audio Input" icon={Upload} sectionKey="audio" minStates={minStates} toggleMin={toggleMin} /> |
| {!minStates.audio && ( |
| <div className="animate-in fade-in slide-in-from-top-2 duration-200"> |
| <ControlHeader label="Upload File" /> |
| <label className="block w-full cursor-pointer bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors border-2 border-dashed border-black/10 dark:border-white/10 hover:border-violet-500/50 rounded-2xl p-6 sm:p-8 text-center group relative overflow-hidden"> |
| <div className="absolute inset-0 bg-violet-500/5 opacity-0 group-hover:opacity-100 transition-opacity"></div> |
| <input type="file" accept="audio/*" onChange={handleFileUpload} className="hidden" disabled={isExportingVideo} /> |
| <div className="mx-auto w-12 h-12 bg-white dark:bg-white/5 border border-black/5 dark:border-white/10 rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300 ease-out shadow-sm dark:shadow-lg"> |
| <Upload className="w-5 h-5 text-violet-600 dark:text-violet-400" /> |
| </div> |
| <p className="font-semibold text-sm text-zinc-800 dark:text-zinc-300">{fileName ? fileName : 'Click to browse audio file'}</p> |
| <p className="text-xs text-zinc-500 mt-2 tracking-wide">MP3, WAV, FLAC</p> |
| </label> |
| </div> |
| )} |
| {/* AUDIO ELEMENT: Moved OUTSIDE the collapsible block so playback never breaks when minimized */} |
| <audio |
| ref={audioRef} |
| src={audioSrc} |
| crossOrigin="anonymous" |
| onEnded={handleAudioEnded} |
| onPlay={function() { setIsPlaying(true); }} |
| onPause={function() { setIsPlaying(false); }} |
| onTimeUpdate={handleTimeUpdate} |
| onLoadedMetadata={handleLoadedMetadata} |
| className="hidden" |
| /> |
| </section> |
| |
| <section className="bg-white/60 dark:bg-zinc-900/40 backdrop-blur-2xl p-5 sm:p-6 rounded-3xl border border-black/5 dark:border-white/5 shadow-xl shadow-black/5 dark:shadow-2xl transition-all"> |
| <SectionHeader title="Transform & Symmetry" icon={RotateCcw} sectionKey="transform" minStates={minStates} toggleMin={toggleMin} /> |
| {!minStates.transform && ( |
| <div className="space-y-6 animate-in fade-in slide-in-from-top-2 duration-200"> |
| <div> |
| <ControlHeader label="Symmetry / Mirror" /> |
| <div className="grid grid-cols-2 gap-3 mt-2"> |
| <label className={`flex items-center justify-center gap-2 py-2.5 px-3 rounded-xl border cursor-pointer transition-all ${mirrorX ? 'bg-violet-500/10 border-violet-500/30 text-violet-700 dark:text-violet-300 shadow-inner' : 'bg-white dark:bg-black/40 border-black/5 dark:border-white/5 text-zinc-600 dark:text-zinc-400 hover:border-black/10 dark:hover:border-white/10 hover:text-zinc-900 dark:hover:text-zinc-200'}`}> |
| <input type="checkbox" className="hidden" checked={mirrorX} onChange={function(e){setMirrorX(e.target.checked);}} /> |
| <FlipHorizontal className="w-4 h-4" /> <span className="text-sm font-medium">Mirror X</span> |
| </label> |
| <label className={`flex items-center justify-center gap-2 py-2.5 px-3 rounded-xl border cursor-pointer transition-all ${mirrorY ? 'bg-violet-500/10 border-violet-500/30 text-violet-700 dark:text-violet-300 shadow-inner' : 'bg-white dark:bg-black/40 border-black/5 dark:border-white/5 text-zinc-600 dark:text-zinc-400 hover:border-black/10 dark:hover:border-white/10 hover:text-zinc-900 dark:hover:text-zinc-200'}`}> |
| <input type="checkbox" className="hidden" checked={mirrorY} onChange={function(e){setMirrorY(e.target.checked);}} /> |
| <FlipVertical className="w-4 h-4" /> <span className="text-sm font-medium">Mirror Y</span> |
| </label> |
| </div> |
| </div> |
| |
| <div className="space-y-5 pt-2"> |
| <div> |
| <ControlHeader label="Size (Global Scale)" valueDisplay={`${scale.toFixed(2)}x`} onReset={function() { setScale(1.0); }} /> |
| <input type="range" min="0.1" max="3.0" step="0.1" value={scale} onChange={function(e) { setScale(Number(e.target.value)); }} className="w-full" /> |
| </div> |
| <div> |
| <ControlHeader label="Horizontal Size (Width)" valueDisplay={`${scaleX.toFixed(2)}x`} onReset={function() { handleScaleXChange(1.0); if(scaleLock) handleScaleYChange(1.0); }} |
| extra={ |
| <button onClick={toggleScaleLock} title={scaleLock ? "Unlock Aspect Ratio" : "Lock Aspect Ratio"} className={`p-1.5 ml-2 rounded-md transition-colors ${scaleLock ? 'text-violet-600 dark:text-violet-400 bg-violet-500/10' : 'text-zinc-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-black/5 dark:hover:bg-white/5'}`}> |
| {scaleLock ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />} |
| </button> |
| } |
| /> |
| <input type="range" min="0.1" max="5.0" step="0.1" value={scaleX} onChange={function(e) { handleScaleXChange(Number(e.target.value)); }} className="w-full" /> |
| </div> |
| <div> |
| <ControlHeader label="Vertical Size (Height)" valueDisplay={`${scaleY.toFixed(2)}x`} onReset={function() { handleScaleYChange(1.0); if(scaleLock) handleScaleXChange(1.0); }} |
| extra={ |
| <button onClick={toggleScaleLock} title={scaleLock ? "Unlock Aspect Ratio" : "Lock Aspect Ratio"} className={`p-1.5 ml-2 rounded-md transition-colors ${scaleLock ? 'text-violet-600 dark:text-violet-400 bg-violet-500/10' : 'text-zinc-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-black/5 dark:hover:bg-white/5'}`}> |
| {scaleLock ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />} |
| </button> |
| } |
| /> |
| <input type="range" min="0.1" max="5.0" step="0.1" value={scaleY} onChange={function(e) { handleScaleYChange(Number(e.target.value)); }} className="w-full" /> |
| </div> |
| <div> |
| <ControlHeader label="Rotation" valueDisplay={`${rotation}°`} onReset={function() { setRotation(0); }} /> |
| <input type="range" min="0" max="360" step="1" value={rotation} onChange={function(e) { setRotation(Number(e.target.value)); }} className="w-full" /> |
| </div> |
| <div> |
| <ControlHeader label="Horizontal Position" valueDisplay={`${Math.round(offsetX)}%`} onReset={function() { setOffsetX(0); offsetRef.current.x = 0; }} /> |
| <input type="range" min="-50" max="50" step="1" value={offsetX} onChange={function(e) { const val = Number(e.target.value); setOffsetX(val); offsetRef.current.x = val; }} className="w-full" /> |
| </div> |
| <div> |
| <ControlHeader label="Vertical Position" valueDisplay={`${Math.round(offsetY)}%`} onReset={function() { setOffsetY(0); offsetRef.current.y = 0; }} /> |
| <input type="range" min="-50" max="50" step="1" value={offsetY} onChange={function(e) { const val = Number(e.target.value); setOffsetY(val); offsetRef.current.y = val; }} className="w-full" /> |
| </div> |
| </div> |
| </div> |
| )} |
| </section> |
| |
| <section className="bg-white/60 dark:bg-zinc-900/40 backdrop-blur-2xl p-5 sm:p-6 rounded-3xl border border-black/5 dark:border-white/5 shadow-xl shadow-black/5 dark:shadow-2xl transition-all"> |
| <SectionHeader title="Output Setup" icon={Monitor} sectionKey="output" minStates={minStates} toggleMin={toggleMin} /> |
| {!minStates.output && ( |
| <div className="space-y-6 animate-in fade-in slide-in-from-top-2 duration-200"> |
| <div> |
| <ControlHeader label="Resolution & Ratio" /> |
| <select value={resolution} onChange={function(e) { setResolution(e.target.value); }} disabled={isExportingVideo} className="w-full bg-white dark:bg-black/40 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-sm rounded-xl px-4 py-3 outline-none focus:border-violet-500/50 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"> |
| <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> |
| <ControlHeader label="Background Environment" /> |
| <div className="flex gap-2 mb-3"> |
| {['transparent', 'color', 'image'].map(function(type) { |
| return <button key={type} onClick={function() { setBgType(type); }} className={`flex-1 py-2 px-2 rounded-xl text-xs font-semibold uppercase tracking-wider transition-all ${bgType === type ? 'bg-violet-600 text-white shadow-lg shadow-violet-500/25 border border-violet-500/50' : 'bg-black/5 dark:bg-black/40 text-zinc-600 dark:text-zinc-400 border border-black/5 dark:border-white/5 hover:border-black/10 dark:hover:border-white/10 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>{type}</button>; |
| })} |
| </div> |
| {bgType === 'color' && ( |
| <div className="flex items-center gap-3 mt-3 bg-white dark:bg-black/30 p-2 rounded-xl border border-black/5 dark:border-white/5"> |
| <input type="color" value={bgColor} onChange={function(e) { setBgColor(e.target.value); }} className="h-10 w-14 rounded-lg cursor-pointer bg-white dark:bg-black border border-black/10 dark:border-white/10" /> |
| <span className="text-sm font-mono text-zinc-700 dark:text-zinc-300 uppercase tracking-widest truncate">{bgColor}</span> |
| </div> |
| )} |
| {bgType === 'image' && ( |
| <div className="mt-3 space-y-3"> |
| <label className="flex items-center justify-center gap-2 w-full cursor-pointer bg-white/50 dark:bg-black/30 hover:bg-white dark:hover:bg-black/50 transition-colors border border-dashed border-black/10 dark:border-white/10 hover:border-violet-500/50 rounded-xl p-4 text-center text-sm font-medium text-zinc-700 dark:text-zinc-300 group"> |
| <ImagePlus className="w-5 h-5 text-zinc-400 dark:text-zinc-500 group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors" /> {bgImageSrc ? 'Change Image' : 'Upload Background Image'} |
| <input type="file" accept="image/*" onChange={handleBgUpload} className="hidden" /> |
| </label> |
| {bgImageSrc && ( |
| <div className="space-y-3 bg-zinc-100/50 dark:bg-black/20 p-3 rounded-xl border border-black/5 dark:border-white/5"> |
| <div className="flex justify-between items-center"> |
| <span className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Image Fit</span> |
| <select value={bgImageFit} onChange={function(e) { setBgImageFit(e.target.value); }} className="bg-white dark:bg-black/60 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-xs rounded-lg px-3 py-1.5 outline-none focus:border-violet-500/50 cursor-pointer"> |
| <option value="contain">Contain (No Crop)</option> |
| <option value="cover">Cover (Fill Canvas)</option> |
| <option value="fit-width">Fit to Width</option> |
| <option value="stretch">Stretch (Exact Fit)</option> |
| </select> |
| </div> |
| <label className="flex justify-between items-center cursor-pointer border-t border-black/5 dark:border-white/5 pt-3"> |
| <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300 flex items-center gap-2"><Zap className="w-3.5 h-3.5 text-fuchsia-500 dark:text-fuchsia-400" /> Beat Reactive Pulse</span> |
| <div className="relative inline-flex items-center"> |
| <input type="checkbox" checked={bgBeatReactive} onChange={function(e) { setBgBeatReactive(e.target.checked); }} className="sr-only peer" /> |
| <div className="w-9 h-5 bg-zinc-300 dark:bg-zinc-800 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-white/10 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-fuchsia-500"></div> |
| </div> |
| </label> |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| |
| <div className="pt-4 border-t border-black/5 dark:border-white/5 space-y-4"> |
| <div className="flex justify-between items-center py-2 border-b border-black/5 dark:border-white/5"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Particle Environment</span> |
| <Droplet className="w-4 h-4 text-cyan-500 dark:text-cyan-400 ml-1" /> |
| </div> |
| <label className="relative inline-flex items-center cursor-pointer shrink-0"> |
| <input type="checkbox" checked={showParticles} onChange={function(e) { setShowParticles(e.target.checked); }} className="sr-only peer" /> |
| <div className="w-11 h-6 bg-zinc-300 dark:bg-zinc-800 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-white/10 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div> |
| </label> |
| </div> |
| |
| {showParticles && ( |
| <div className="pl-4 border-l-2 border-cyan-500/20 animate-in fade-in slide-in-from-top-2 pt-2"> |
| <ControlHeader label="Particle Density" valueDisplay={`${particleCount}`} onReset={function() { setParticleCount(150); }} /> |
| <input type="range" min="10" max="500" step="10" value={particleCount} onChange={function(e) { setParticleCount(Number(e.target.value)); }} className="w-full accent-cyan-500" /> |
| </div> |
| )} |
| </div> |
| |
| </div> |
| )} |
| </section> |
| |
| <section className="bg-white/60 dark:bg-zinc-900/40 backdrop-blur-2xl p-5 sm:p-6 rounded-3xl border border-black/5 dark:border-white/5 shadow-xl shadow-black/5 dark:shadow-2xl transition-all"> |
| <SectionHeader title="Save & Export" icon={Save} sectionKey="export" minStates={minStates} toggleMin={toggleMin} /> |
| |
| {!minStates.export && ( |
| <div className="animate-in fade-in slide-in-from-top-2 duration-200"> |
| <div className="flex flex-col gap-3 mb-6"> |
| <ControlHeader label="Local Configuration" /> |
| <div className="flex gap-3"> |
| <button onClick={savePreset} className="flex-1 bg-white dark:bg-black/40 hover:bg-zinc-50 dark:hover:bg-white/5 text-zinc-700 dark:text-zinc-300 py-2.5 rounded-xl text-sm font-medium border border-black/10 dark:border-white/5 hover:border-black/20 dark:hover:border-white/10 transition-all active:scale-95 shadow-sm">Save Preset</button> |
| <button onClick={loadPreset} className="flex-1 bg-white dark:bg-black/40 hover:bg-zinc-50 dark:hover:bg-white/5 text-zinc-700 dark:text-zinc-300 py-2.5 rounded-xl text-sm font-medium border border-black/10 dark:border-white/5 hover:border-black/20 dark:hover:border-white/10 transition-all active:scale-95 shadow-sm">Load Preset</button> |
| </div> |
| </div> |
| |
| <div className="flex flex-col gap-4"> |
| <div className="bg-zinc-100/50 dark:bg-black/20 p-4 rounded-2xl border border-black/5 dark:border-white/5 shadow-inner"> |
| |
| <ControlHeader label="Video Format & Framerate" extra={ |
| <button onClick={function(){ setShowExportInfo(!showExportInfo); }} className="text-zinc-400 hover:text-violet-500 transition-colors focus:outline-none ml-2" title="Export Information"> |
| <Info className="w-4 h-4" /> |
| </button> |
| } /> |
| |
| <div className="flex flex-col 2xl:flex-row gap-3"> |
| <select value={exportFormat} onChange={function(e) { setExportFormat(e.target.value); }} className="w-full bg-white dark:bg-black/60 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-sm rounded-xl px-3 py-3 outline-none focus:border-violet-500/50 cursor-pointer"> |
| <option value="webm">WebM (Transparent)</option> |
| <option value="mp4">MP4 (Solid BG / DaVinci)</option> |
| </select> |
| <select value={exportFps} onChange={function(e) { setExportFps(Number(e.target.value)); }} className="w-full 2xl:w-32 bg-white dark:bg-black/60 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-sm rounded-xl px-3 py-3 outline-none focus:border-violet-500/50 cursor-pointer"> |
| <option value={30}>30 FPS</option> |
| <option value={60}>60 FPS</option> |
| </select> |
| </div> |
| |
| {showExportInfo && ( |
| <div className="mt-4 p-3.5 bg-violet-500/10 border border-violet-500/20 rounded-xl animate-in fade-in slide-in-from-top-2"> |
| <p className="text-xs text-violet-700 dark:text-violet-300 leading-relaxed font-medium"> |
| <strong>Video Freezing/Crashing?</strong> Real-time 4K encoding is heavy. To fix this:<br/> |
| 1. Set Framerate to <strong>30 FPS</strong>.<br/> |
| 2. Turn off <strong>Neon Glow Effect</strong>.<br/> |
| 3. Lower Resolution to <strong>1080p</strong>. |
| </p> |
| </div> |
| )} |
| |
| <div className="mt-5 pt-4 border-t border-black/5 dark:border-white/5"> |
| <ControlHeader label="Time Range (Clipping)" extra={<Scissors className="w-4 h-4 ml-2 text-zinc-400 dark:text-zinc-500" />} /> |
| <div className="flex gap-3 items-center"> |
| <div className="flex-1"> |
| <span className="text-[11px] font-semibold tracking-wider uppercase text-zinc-500 block mb-1.5">Start (Sec)</span> |
| <input type="number" min="0" value={exportStart} onChange={function(e) { setExportStart(Number(e.target.value)); }} className="w-full bg-white dark:bg-black/60 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-sm rounded-xl px-3 py-2 outline-none focus:border-violet-500/50" /> |
| </div> |
| <span className="text-zinc-400 dark:text-zinc-600 mt-5 font-medium">to</span> |
| <div className="flex-1"> |
| <span className="text-[11px] font-semibold tracking-wider uppercase text-zinc-500 block mb-1.5">End (0=Full)</span> |
| <input type="number" min="0" value={exportEnd} onChange={function(e) { setExportEnd(Number(e.target.value)); }} className="w-full bg-white dark:bg-black/60 border border-black/10 dark:border-white/10 text-zinc-800 dark:text-zinc-200 text-sm rounded-xl px-3 py-2 outline-none focus:border-violet-500/50" /> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <button onClick={exportImage} disabled={isExportingVideo} className="w-full bg-white dark:bg-white/5 hover:bg-zinc-50 dark:hover:bg-white/10 text-zinc-800 dark:text-zinc-200 border border-black/10 dark:border-white/10 font-semibold py-3.5 px-4 rounded-2xl flex items-center justify-center gap-2 transition-all active:scale-95 disabled:opacity-50 disabled:hover:scale-100 shadow-sm dark:shadow-none"> |
| <ImageIcon className="w-4 h-4" /> Save Snapshot (PNG) |
| </button> |
| |
| {isExportingVideo ? ( |
| <div className="w-full space-y-3 mt-2 bg-white dark:bg-black/40 p-4 rounded-2xl border border-black/5 dark:border-white/5 shadow-sm"> |
| <button onClick={stopVideoExport} className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3.5 px-4 rounded-xl flex items-center justify-center gap-2 transition-all active:scale-95 shadow-lg shadow-red-500/25 border border-red-400"> |
| <StopCircle className="w-5 h-5" /> Stop & Save Early |
| </button> |
| <div className="w-full bg-zinc-200 dark:bg-black/60 rounded-full h-3 border border-black/5 dark:border-white/10 overflow-hidden shadow-inner"> |
| <div className="bg-gradient-to-r from-violet-500 to-fuchsia-500 h-3 rounded-full transition-all duration-300" style={{ width: `${exportProgress}%` }}></div> |
| </div> |
| <p className="text-xs font-bold tracking-widest uppercase text-center text-zinc-500 dark:text-zinc-400">Recording... {Math.round(exportProgress)}%</p> |
| </div> |
| ) : ( |
| <button onClick={startVideoExport} disabled={!audioSrc} className="w-full bg-gradient-to-tr from-violet-600 to-fuchsia-500 hover:from-violet-500 hover:to-fuchsia-400 text-white font-bold py-4 px-4 rounded-2xl flex items-center justify-center gap-2 transition-all active:scale-95 shadow-xl shadow-violet-500/25 border border-violet-400/50 disabled:opacity-50 disabled:active:scale-100 mt-2"> |
| <Video className="w-5 h-5" /> Export Video ({exportFormat.toUpperCase()}) |
| </button> |
| )} |
| </div> |
| </div> |
| )} |
| </section> |
| </div> |
| |
| </main> |
| </div> |
| ); |
| } |
| |
| const root = ReactDOM.createRoot(document.getElementById('root')); |
| root.render(<App />); |
| </script> |
| </body> |
| </html> |