Create m3pro-greatcopy
Browse filesGemini said
I've added the new Horizontal (Scale X) and Vertical (Scale Y) sliders while keeping the original master "Size (Scale)" slider perfectly intact. The master size slider scales both dimensions uniformly, while the new sliders let you stretch or squash the visualizer in specific directions.
Here is the code patch to apply these additions cleanly without altering any other features:
- m3pro-greatcopy +1159 -0
m3pro-greatcopy
ADDED
|
@@ -0,0 +1,1159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>4K Transparent Audio Visualizer</title>
|
| 7 |
+
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
|
| 10 |
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
| 11 |
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
| 12 |
+
|
| 13 |
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
| 14 |
+
|
| 15 |
+
<style>
|
| 16 |
+
body { margin: 0; padding: 0; background-color: #020617; color: #e2e8f0; }
|
| 17 |
+
/* Custom scrollbar for webkit */
|
| 18 |
+
::-webkit-scrollbar { width: 8px; }
|
| 19 |
+
::-webkit-scrollbar-track { background: #0f172a; }
|
| 20 |
+
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
| 21 |
+
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
| 22 |
+
</style>
|
| 23 |
+
</head>
|
| 24 |
+
<body>
|
| 25 |
+
<div id="root"></div>
|
| 26 |
+
|
| 27 |
+
<script type="text/babel">
|
| 28 |
+
const { useState, useRef, useEffect, useCallback } = React;
|
| 29 |
+
|
| 30 |
+
// --- Native SVG Icons ---
|
| 31 |
+
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>;
|
| 32 |
+
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>;
|
| 33 |
+
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>;
|
| 34 |
+
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>;
|
| 35 |
+
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>;
|
| 36 |
+
const Settings2 = ({className}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>;
|
| 37 |
+
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>;
|
| 38 |
+
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>;
|
| 39 |
+
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>;
|
| 40 |
+
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>;
|
| 41 |
+
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>;
|
| 42 |
+
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>;
|
| 43 |
+
|
| 44 |
+
function App() {
|
| 45 |
+
const canvasRef = useRef(null);
|
| 46 |
+
const audioRef = useRef(null);
|
| 47 |
+
const audioCtxRef = useRef(null);
|
| 48 |
+
const analyserRef = useRef(null);
|
| 49 |
+
const sourceRef = useRef(null);
|
| 50 |
+
const destRef = useRef(null);
|
| 51 |
+
const reqIdRef = useRef(null);
|
| 52 |
+
const mediaRecorderRef = useRef(null);
|
| 53 |
+
const chunksRef = useRef([]);
|
| 54 |
+
const bgImgRef = useRef(null);
|
| 55 |
+
|
| 56 |
+
const dataArrayRef = useRef(new Uint8Array(2048));
|
| 57 |
+
const lastDrawTimeRef = useRef(0);
|
| 58 |
+
|
| 59 |
+
// State
|
| 60 |
+
const [audioSrc, setAudioSrc] = useState(null);
|
| 61 |
+
const [fileName, setFileName] = useState('');
|
| 62 |
+
const [isPlaying, setIsPlaying] = useState(false);
|
| 63 |
+
const [isExportingVideo, setIsExportingVideo] = useState(false);
|
| 64 |
+
const [exportProgress, setExportProgress] = useState(0);
|
| 65 |
+
|
| 66 |
+
// Audio Player State
|
| 67 |
+
const [audioTime, setAudioTime] = useState(0);
|
| 68 |
+
const [audioDuration, setAudioDuration] = useState(0);
|
| 69 |
+
|
| 70 |
+
// Settings
|
| 71 |
+
const [vizType, setVizType] = useState('bars'); // 'bars', 'wave', 'circle'
|
| 72 |
+
const [color, setColor] = useState('#00ffcc');
|
| 73 |
+
const [thickness, setThickness] = useState(12);
|
| 74 |
+
const [spacing, setSpacing] = useState(8);
|
| 75 |
+
const [sensitivity, setSensitivity] = useState(1.5);
|
| 76 |
+
const [smoothing, setSmoothing] = useState(0.85);
|
| 77 |
+
|
| 78 |
+
// Transform Settings
|
| 79 |
+
const [offsetX, setOffsetX] = useState(0);
|
| 80 |
+
const [offsetY, setOffsetY] = useState(0);
|
| 81 |
+
const [scale, setScale] = useState(1.0);
|
| 82 |
+
const [scaleX, setScaleX] = useState(1.0); // NEW: Horizontal Scale
|
| 83 |
+
const [scaleY, setScaleY] = useState(1.0); // NEW: Vertical Scale
|
| 84 |
+
const [rotation, setRotation] = useState(0);
|
| 85 |
+
|
| 86 |
+
// FIX: Added offset ref to prevent React State thrashing on drag
|
| 87 |
+
const offsetRef = useRef({ x: 0, y: 0 });
|
| 88 |
+
|
| 89 |
+
// Drag State
|
| 90 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 91 |
+
const dragRef = useRef({ startX: 0, startY: 0, initX: 0, initY: 0 });
|
| 92 |
+
|
| 93 |
+
// Advanced Settings
|
| 94 |
+
const [colorMode, setColorMode] = useState('solid');
|
| 95 |
+
const [color2, setColor2] = useState('#b829ff');
|
| 96 |
+
const [glow, setGlow] = useState(false);
|
| 97 |
+
const [resolution, setResolution] = useState('4k_16_9');
|
| 98 |
+
const [bgType, setBgType] = useState('transparent');
|
| 99 |
+
const [bgColor, setBgColor] = useState('#000000');
|
| 100 |
+
const [bgImageSrc, setBgImageSrc] = useState(null);
|
| 101 |
+
const [bgImageFit, setBgImageFit] = useState('contain');
|
| 102 |
+
const [exportFormat, setExportFormat] = useState('mp4');
|
| 103 |
+
const [exportFps, setExportFps] = useState(30);
|
| 104 |
+
|
| 105 |
+
const playButtonRef = useRef(null);
|
| 106 |
+
|
| 107 |
+
const RESOLUTIONS = {
|
| 108 |
+
'4k_16_9': { w: 3840, h: 2160, label: '4K (16:9)', isVertical: false },
|
| 109 |
+
'1080p_16_9': { w: 1920, h: 1080, label: '1080p (16:9)', isVertical: false },
|
| 110 |
+
'4k_9_16': { w: 2160, h: 3840, label: '4K Vertical (9:16)', isVertical: true },
|
| 111 |
+
'1080p_9_16': { w: 1080, h: 1920, label: '1080p Vertical (9:16)', isVertical: true }
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
useEffect(() => {
|
| 115 |
+
if (bgImageSrc) {
|
| 116 |
+
const img = new Image();
|
| 117 |
+
img.onload = () => { bgImgRef.current = img; };
|
| 118 |
+
img.src = bgImageSrc;
|
| 119 |
+
} else {
|
| 120 |
+
bgImgRef.current = null;
|
| 121 |
+
}
|
| 122 |
+
}, [bgImageSrc]);
|
| 123 |
+
|
| 124 |
+
const handleBgUpload = (e) => {
|
| 125 |
+
const file = e.target.files[0];
|
| 126 |
+
if (file) {
|
| 127 |
+
if (bgImageSrc) URL.revokeObjectURL(bgImageSrc);
|
| 128 |
+
setBgImageSrc(URL.createObjectURL(file));
|
| 129 |
+
setBgType('image');
|
| 130 |
+
}
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const initAudio = useCallback(() => {
|
| 134 |
+
if (!audioCtxRef.current) {
|
| 135 |
+
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
| 136 |
+
audioCtxRef.current = new AudioContext();
|
| 137 |
+
analyserRef.current = audioCtxRef.current.createAnalyser();
|
| 138 |
+
destRef.current = audioCtxRef.current.createMediaStreamDestination();
|
| 139 |
+
|
| 140 |
+
if (!sourceRef.current && audioRef.current) {
|
| 141 |
+
sourceRef.current = audioCtxRef.current.createMediaElementSource(audioRef.current);
|
| 142 |
+
sourceRef.current.connect(analyserRef.current);
|
| 143 |
+
analyserRef.current.connect(audioCtxRef.current.destination);
|
| 144 |
+
analyserRef.current.connect(destRef.current);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
if (audioCtxRef.current.state === 'suspended') {
|
| 149 |
+
audioCtxRef.current.resume();
|
| 150 |
+
}
|
| 151 |
+
}, []);
|
| 152 |
+
|
| 153 |
+
const handleFileUpload = (e) => {
|
| 154 |
+
const file = e.target.files[0];
|
| 155 |
+
if (file) {
|
| 156 |
+
if (audioSrc) URL.revokeObjectURL(audioSrc);
|
| 157 |
+
const url = URL.createObjectURL(file);
|
| 158 |
+
setAudioSrc(url);
|
| 159 |
+
setFileName(file.name);
|
| 160 |
+
setIsPlaying(false);
|
| 161 |
+
setAudioTime(0);
|
| 162 |
+
if (audioRef.current) {
|
| 163 |
+
audioRef.current.pause();
|
| 164 |
+
audioRef.current.currentTime = 0;
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
const togglePlay = () => {
|
| 170 |
+
if (!audioSrc) return;
|
| 171 |
+
initAudio();
|
| 172 |
+
|
| 173 |
+
if (isPlaying) {
|
| 174 |
+
audioRef.current.pause();
|
| 175 |
+
} else {
|
| 176 |
+
audioRef.current.play();
|
| 177 |
+
}
|
| 178 |
+
setIsPlaying(!isPlaying);
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
const handleTimeUpdate = () => {
|
| 182 |
+
if (audioRef.current) setAudioTime(audioRef.current.currentTime);
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
const handleLoadedMetadata = () => {
|
| 186 |
+
if (audioRef.current) setAudioDuration(audioRef.current.duration);
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const handleSeek = (e) => {
|
| 190 |
+
const time = Number(e.target.value);
|
| 191 |
+
if (audioRef.current) audioRef.current.currentTime = time;
|
| 192 |
+
setAudioTime(time);
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
const formatTime = (time) => {
|
| 196 |
+
if (isNaN(time)) return "0:00";
|
| 197 |
+
const m = Math.floor(time / 60);
|
| 198 |
+
const s = Math.floor(time % 60).toString().padStart(2, '0');
|
| 199 |
+
return `${m}:${s}`;
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
const handleMouseDown = (e) => {
|
| 203 |
+
setIsDragging(true);
|
| 204 |
+
dragRef.current = {
|
| 205 |
+
startX: e.clientX,
|
| 206 |
+
startY: e.clientY,
|
| 207 |
+
initX: offsetRef.current.x,
|
| 208 |
+
initY: offsetRef.current.y
|
| 209 |
+
};
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
+
const handleMouseMove = (e) => {
|
| 213 |
+
if (!isDragging || !canvasRef.current) return;
|
| 214 |
+
const rect = canvasRef.current.getBoundingClientRect();
|
| 215 |
+
|
| 216 |
+
const deltaX = e.clientX - dragRef.current.startX;
|
| 217 |
+
const deltaY = e.clientY - dragRef.current.startY;
|
| 218 |
+
|
| 219 |
+
const percentX = (deltaX / rect.width) * 100;
|
| 220 |
+
const percentY = (deltaY / rect.height) * 100;
|
| 221 |
+
|
| 222 |
+
const newX = Math.max(-50, Math.min(50, dragRef.current.initX + percentX));
|
| 223 |
+
const newY = Math.max(-50, Math.min(50, dragRef.current.initY + percentY));
|
| 224 |
+
|
| 225 |
+
// FIX: Set the ref so the canvas draws smoothly without tearing down the draw loop
|
| 226 |
+
offsetRef.current.x = newX;
|
| 227 |
+
offsetRef.current.y = newY;
|
| 228 |
+
|
| 229 |
+
// Sync UI inputs
|
| 230 |
+
setOffsetX(newX);
|
| 231 |
+
setOffsetY(newY);
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
const handleMouseUpOrLeave = () => {
|
| 235 |
+
setIsDragging(false);
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
useEffect(() => {
|
| 239 |
+
const handleKeyDown = (e) => {
|
| 240 |
+
if (e.code === 'Space' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
|
| 241 |
+
e.preventDefault();
|
| 242 |
+
playButtonRef.current?.click();
|
| 243 |
+
}
|
| 244 |
+
};
|
| 245 |
+
window.addEventListener('keydown', handleKeyDown);
|
| 246 |
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
| 247 |
+
}, []);
|
| 248 |
+
|
| 249 |
+
const draw = useCallback(() => {
|
| 250 |
+
if (!canvasRef.current) {
|
| 251 |
+
reqIdRef.current = requestAnimationFrame(draw);
|
| 252 |
+
return;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
const now = performance.now();
|
| 256 |
+
if (isExportingVideo && exportFps < 60) {
|
| 257 |
+
const msPerFrame = 1000 / exportFps;
|
| 258 |
+
if (now - lastDrawTimeRef.current < msPerFrame) {
|
| 259 |
+
reqIdRef.current = requestAnimationFrame(draw);
|
| 260 |
+
return;
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
lastDrawTimeRef.current = now;
|
| 264 |
+
|
| 265 |
+
const canvas = canvasRef.current;
|
| 266 |
+
|
| 267 |
+
// FIX: Removed `desynchronized: true` as it historically breaks canvas.captureStream()
|
| 268 |
+
const ctx = canvas.getContext('2d', {
|
| 269 |
+
alpha: bgType === 'transparent',
|
| 270 |
+
willReadFrequently: false
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
const res = RESOLUTIONS[resolution] || RESOLUTIONS['4k_16_9'];
|
| 274 |
+
|
| 275 |
+
if (canvas.width !== res.w || canvas.height !== res.h) {
|
| 276 |
+
canvas.width = res.w;
|
| 277 |
+
canvas.height = res.h;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
const width = canvas.width;
|
| 281 |
+
const height = canvas.height;
|
| 282 |
+
|
| 283 |
+
ctx.clearRect(0, 0, width, height);
|
| 284 |
+
|
| 285 |
+
// Draw Background
|
| 286 |
+
if (bgType === 'color') {
|
| 287 |
+
ctx.fillStyle = bgColor;
|
| 288 |
+
ctx.fillRect(0, 0, width, height);
|
| 289 |
+
} else if (bgType === 'image' && bgImgRef.current) {
|
| 290 |
+
const img = bgImgRef.current;
|
| 291 |
+
const imgRatio = img.width / img.height;
|
| 292 |
+
const canvasRatio = width / height;
|
| 293 |
+
let drawW, drawH, drawX, drawY;
|
| 294 |
+
|
| 295 |
+
if (bgImageFit === 'stretch') {
|
| 296 |
+
drawW = width;
|
| 297 |
+
drawH = height;
|
| 298 |
+
drawX = 0;
|
| 299 |
+
drawY = 0;
|
| 300 |
+
} else if (bgImageFit === 'cover') {
|
| 301 |
+
if (imgRatio > canvasRatio) {
|
| 302 |
+
drawH = height;
|
| 303 |
+
drawW = height * imgRatio;
|
| 304 |
+
drawX = (width - drawW) / 2;
|
| 305 |
+
drawY = 0;
|
| 306 |
+
} else {
|
| 307 |
+
drawW = width;
|
| 308 |
+
drawH = width / imgRatio;
|
| 309 |
+
drawX = 0;
|
| 310 |
+
drawY = (height - drawH) / 2;
|
| 311 |
+
}
|
| 312 |
+
} else if (bgImageFit === 'fit-width') {
|
| 313 |
+
drawW = width;
|
| 314 |
+
drawH = width / imgRatio;
|
| 315 |
+
drawX = 0;
|
| 316 |
+
drawY = (height - drawH) / 2;
|
| 317 |
+
} else {
|
| 318 |
+
// contain logic
|
| 319 |
+
if (imgRatio > canvasRatio) {
|
| 320 |
+
drawW = width;
|
| 321 |
+
drawH = width / imgRatio;
|
| 322 |
+
drawX = 0;
|
| 323 |
+
drawY = (height - drawH) / 2;
|
| 324 |
+
} else {
|
| 325 |
+
drawH = height;
|
| 326 |
+
drawW = height * imgRatio;
|
| 327 |
+
drawX = (width - drawW) / 2;
|
| 328 |
+
drawY = 0;
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
ctx.drawImage(img, drawX, drawY, drawW, drawH);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
if (analyserRef.current) {
|
| 335 |
+
// Get Audio Data
|
| 336 |
+
analyserRef.current.smoothingTimeConstant = smoothing;
|
| 337 |
+
analyserRef.current.fftSize = 2048;
|
| 338 |
+
|
| 339 |
+
const bufferLength = analyserRef.current.frequencyBinCount;
|
| 340 |
+
const dataArray = dataArrayRef.current;
|
| 341 |
+
|
| 342 |
+
if (vizType === 'bars' || vizType === 'circle') {
|
| 343 |
+
analyserRef.current.getByteFrequencyData(dataArray);
|
| 344 |
+
} else if (vizType === 'wave') {
|
| 345 |
+
analyserRef.current.getByteTimeDomainData(dataArray);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
ctx.save();
|
| 349 |
+
|
| 350 |
+
// FIX: Use ref values to position the visualizer, allowing high-performance drag and drop
|
| 351 |
+
const centerX = width / 2 + (width * (offsetRef.current.x / 100));
|
| 352 |
+
const centerY = height / 2 + (height * (offsetRef.current.y / 100));
|
| 353 |
+
ctx.translate(centerX, centerY);
|
| 354 |
+
|
| 355 |
+
// FIX: Combine master scale with independent X and Y scales
|
| 356 |
+
ctx.scale(scale * scaleX, scale * scaleY);
|
| 357 |
+
|
| 358 |
+
ctx.rotate((rotation * Math.PI) / 180);
|
| 359 |
+
|
| 360 |
+
// Set Colors
|
| 361 |
+
let activeColor = color;
|
| 362 |
+
if (colorMode === 'gradient') {
|
| 363 |
+
const grad = ctx.createLinearGradient(-width/2, -height/2, width/2, height/2);
|
| 364 |
+
grad.addColorStop(0, color);
|
| 365 |
+
grad.addColorStop(1, color2);
|
| 366 |
+
activeColor = grad;
|
| 367 |
+
} else if (colorMode === 'rainbow') {
|
| 368 |
+
const grad = ctx.createLinearGradient(-width/2, 0, width/2, 0);
|
| 369 |
+
grad.addColorStop(0, '#ff0000');
|
| 370 |
+
grad.addColorStop(0.16, '#ffff00');
|
| 371 |
+
grad.addColorStop(0.33, '#00ff00');
|
| 372 |
+
grad.addColorStop(0.5, '#00ffff');
|
| 373 |
+
grad.addColorStop(0.66, '#0000ff');
|
| 374 |
+
grad.addColorStop(0.83, '#ff00ff');
|
| 375 |
+
grad.addColorStop(1, '#ff0000');
|
| 376 |
+
activeColor = grad;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
ctx.strokeStyle = activeColor;
|
| 380 |
+
ctx.fillStyle = activeColor;
|
| 381 |
+
ctx.lineCap = 'round';
|
| 382 |
+
ctx.lineJoin = 'round';
|
| 383 |
+
|
| 384 |
+
// Draw Logic
|
| 385 |
+
const drawVisualizerPath = () => {
|
| 386 |
+
ctx.beginPath();
|
| 387 |
+
|
| 388 |
+
if (vizType === 'bars') {
|
| 389 |
+
const step = thickness + spacing;
|
| 390 |
+
const maxBars = Math.floor((width / 2) / step);
|
| 391 |
+
const usefulLength = Math.floor(bufferLength * 0.75);
|
| 392 |
+
const numBars = Math.min(maxBars, usefulLength);
|
| 393 |
+
|
| 394 |
+
for (let i = 0; i < numBars; i++) {
|
| 395 |
+
const dataIndex = Math.floor((i / numBars) * usefulLength);
|
| 396 |
+
const boost = Math.pow(1 + (i / numBars), 1.5);
|
| 397 |
+
const value = dataArray[dataIndex] * boost * sensitivity;
|
| 398 |
+
|
| 399 |
+
const barHeight = Math.max(thickness / 2, (value / 255) * height * 0.8);
|
| 400 |
+
const xOffset = i * step + (step / 2);
|
| 401 |
+
|
| 402 |
+
ctx.moveTo(xOffset, height / 2 - (thickness / 2));
|
| 403 |
+
ctx.lineTo(xOffset, height / 2 - barHeight);
|
| 404 |
+
|
| 405 |
+
ctx.moveTo(-xOffset, height / 2 - (thickness / 2));
|
| 406 |
+
ctx.lineTo(-xOffset, height / 2 - barHeight);
|
| 407 |
+
}
|
| 408 |
+
} else if (vizType === 'wave') {
|
| 409 |
+
// FIX: Use fftSize instead of frequencyBinCount to draw the FULL waveform
|
| 410 |
+
const fftSize = analyserRef.current.fftSize;
|
| 411 |
+
const sliceWidth = width / fftSize;
|
| 412 |
+
let x = -width / 2;
|
| 413 |
+
|
| 414 |
+
for (let i = 0; i < fftSize; i++) {
|
| 415 |
+
const normalized = (dataArray[i] / 128.0) - 1;
|
| 416 |
+
const y = normalized * sensitivity * (height / 2);
|
| 417 |
+
|
| 418 |
+
if (i === 0) {
|
| 419 |
+
ctx.moveTo(x, y);
|
| 420 |
+
} else {
|
| 421 |
+
ctx.lineTo(x, y);
|
| 422 |
+
}
|
| 423 |
+
x += sliceWidth;
|
| 424 |
+
}
|
| 425 |
+
} else if (vizType === 'circle') {
|
| 426 |
+
const radius = height / 4;
|
| 427 |
+
const circumference = 2 * Math.PI * radius;
|
| 428 |
+
const stepSize = thickness + spacing;
|
| 429 |
+
const bars = Math.min(180, Math.floor(circumference / stepSize));
|
| 430 |
+
const step = (Math.PI * 2) / bars;
|
| 431 |
+
|
| 432 |
+
for (let i = 0; i < bars; i++) {
|
| 433 |
+
const dataIndex = Math.floor((i / bars) * (bufferLength / 2));
|
| 434 |
+
const value = (dataArray[dataIndex] / 255) * sensitivity;
|
| 435 |
+
const barHeight = Math.max(thickness / 2, value * (height / 3));
|
| 436 |
+
const angle = i * step;
|
| 437 |
+
|
| 438 |
+
const x1 = Math.cos(angle) * radius;
|
| 439 |
+
const y1 = Math.sin(angle) * radius;
|
| 440 |
+
const x2 = Math.cos(angle) * (radius + barHeight);
|
| 441 |
+
const y2 = Math.sin(angle) * (radius + barHeight);
|
| 442 |
+
|
| 443 |
+
ctx.moveTo(x1, y1);
|
| 444 |
+
ctx.lineTo(x2, y2);
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
ctx.stroke();
|
| 449 |
+
|
| 450 |
+
if (vizType === 'circle') {
|
| 451 |
+
const radius = height / 4;
|
| 452 |
+
ctx.beginPath();
|
| 453 |
+
ctx.arc(0, 0, radius - thickness, 0, Math.PI * 2);
|
| 454 |
+
const originalWidth = ctx.lineWidth;
|
| 455 |
+
ctx.lineWidth = originalWidth / 2;
|
| 456 |
+
ctx.stroke();
|
| 457 |
+
ctx.lineWidth = originalWidth;
|
| 458 |
+
}
|
| 459 |
+
};
|
| 460 |
+
|
| 461 |
+
// Glow logic
|
| 462 |
+
if (glow) {
|
| 463 |
+
ctx.globalCompositeOperation = 'lighter';
|
| 464 |
+
|
| 465 |
+
ctx.lineWidth = thickness * 3;
|
| 466 |
+
ctx.globalAlpha = 0.15;
|
| 467 |
+
drawVisualizerPath();
|
| 468 |
+
|
| 469 |
+
ctx.lineWidth = thickness * 1.5;
|
| 470 |
+
ctx.globalAlpha = 0.4;
|
| 471 |
+
drawVisualizerPath();
|
| 472 |
+
|
| 473 |
+
ctx.lineWidth = thickness;
|
| 474 |
+
ctx.globalAlpha = 1.0;
|
| 475 |
+
drawVisualizerPath();
|
| 476 |
+
|
| 477 |
+
ctx.globalCompositeOperation = 'source-over';
|
| 478 |
+
} else {
|
| 479 |
+
ctx.lineWidth = thickness;
|
| 480 |
+
ctx.globalAlpha = 1.0;
|
| 481 |
+
drawVisualizerPath();
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
ctx.restore();
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
reqIdRef.current = requestAnimationFrame(draw);
|
| 488 |
+
}, [vizType, color, thickness, spacing, sensitivity, smoothing, colorMode, color2, glow, resolution, bgType, bgColor, bgImageFit, scale, scaleX, scaleY, rotation, isExportingVideo, exportFps]);
|
| 489 |
+
|
| 490 |
+
useEffect(() => {
|
| 491 |
+
reqIdRef.current = requestAnimationFrame(draw);
|
| 492 |
+
return () => cancelAnimationFrame(reqIdRef.current);
|
| 493 |
+
}, [draw]);
|
| 494 |
+
|
| 495 |
+
const handleAudioEnded = () => {
|
| 496 |
+
setIsPlaying(false);
|
| 497 |
+
if (isExportingVideo) stopVideoExport();
|
| 498 |
+
};
|
| 499 |
+
|
| 500 |
+
const exportImage = () => {
|
| 501 |
+
if (!canvasRef.current) return;
|
| 502 |
+
const link = document.createElement('a');
|
| 503 |
+
link.download = `visualizer_${Date.now()}.png`;
|
| 504 |
+
link.href = canvasRef.current.toDataURL('image/png');
|
| 505 |
+
link.click();
|
| 506 |
+
};
|
| 507 |
+
|
| 508 |
+
const startVideoExport = async () => {
|
| 509 |
+
if (!audioSrc || !canvasRef.current || !audioCtxRef.current) {
|
| 510 |
+
alert("Please upload an audio file and press play at least once to initialize.");
|
| 511 |
+
return;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
setIsExportingVideo(true);
|
| 515 |
+
setExportProgress(0);
|
| 516 |
+
chunksRef.current = [];
|
| 517 |
+
|
| 518 |
+
audioRef.current.pause();
|
| 519 |
+
audioRef.current.currentTime = 0;
|
| 520 |
+
|
| 521 |
+
const canvasStream = canvasRef.current.captureStream(exportFps);
|
| 522 |
+
const audioStream = destRef.current.stream;
|
| 523 |
+
|
| 524 |
+
const combinedTracks = [...canvasStream.getTracks(), ...audioStream.getAudioTracks()];
|
| 525 |
+
const combinedStream = new MediaStream(combinedTracks);
|
| 526 |
+
|
| 527 |
+
let options = {};
|
| 528 |
+
let ext = 'webm';
|
| 529 |
+
|
| 530 |
+
const is4k = resolution.startsWith('4k');
|
| 531 |
+
const targetBitrate = is4k ? 15000000 : 8000000;
|
| 532 |
+
|
| 533 |
+
if (exportFormat === 'mp4') {
|
| 534 |
+
if (MediaRecorder.isTypeSupported('video/mp4; codecs=h264')) {
|
| 535 |
+
options = { mimeType: 'video/mp4; codecs=h264', videoBitsPerSecond: targetBitrate };
|
| 536 |
+
ext = 'mp4';
|
| 537 |
+
} else if (MediaRecorder.isTypeSupported('video/mp4')) {
|
| 538 |
+
options = { mimeType: 'video/mp4', videoBitsPerSecond: targetBitrate };
|
| 539 |
+
ext = 'mp4';
|
| 540 |
+
} else {
|
| 541 |
+
alert("Your browser doesn't natively support MP4 export. Falling back to WebM.");
|
| 542 |
+
options = { mimeType: 'video/webm; codecs=vp9', videoBitsPerSecond: targetBitrate };
|
| 543 |
+
}
|
| 544 |
+
} else {
|
| 545 |
+
options = { mimeType: 'video/webm; codecs=vp9', videoBitsPerSecond: targetBitrate };
|
| 546 |
+
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
| 547 |
+
options = { mimeType: 'video/webm; codecs=vp8', videoBitsPerSecond: targetBitrate };
|
| 548 |
+
}
|
| 549 |
+
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
| 550 |
+
options = { videoBitsPerSecond: targetBitrate };
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
try {
|
| 555 |
+
mediaRecorderRef.current = new MediaRecorder(combinedStream, options);
|
| 556 |
+
} catch (e) {
|
| 557 |
+
console.error(e);
|
| 558 |
+
alert("Error starting video recorder. See console.");
|
| 559 |
+
setIsExportingVideo(false);
|
| 560 |
+
return;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
mediaRecorderRef.current.ondataavailable = (e) => {
|
| 564 |
+
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
|
| 565 |
+
};
|
| 566 |
+
|
| 567 |
+
mediaRecorderRef.current.onstop = () => {
|
| 568 |
+
const blob = new Blob(chunksRef.current, { type: mediaRecorderRef.current.mimeType || 'video/mp4' });
|
| 569 |
+
const url = URL.createObjectURL(blob);
|
| 570 |
+
const link = document.createElement('a');
|
| 571 |
+
link.download = `visualizer_${resolution}_${exportFps}fps_${Date.now()}.${ext}`;
|
| 572 |
+
link.href = url;
|
| 573 |
+
link.click();
|
| 574 |
+
URL.revokeObjectURL(url);
|
| 575 |
+
setIsExportingVideo(false);
|
| 576 |
+
setExportProgress(0);
|
| 577 |
+
};
|
| 578 |
+
|
| 579 |
+
const duration = audioRef.current.duration;
|
| 580 |
+
const progressInterval = setInterval(() => {
|
| 581 |
+
if (audioRef.current && !audioRef.current.paused) {
|
| 582 |
+
setExportProgress((audioRef.current.currentTime / duration) * 100);
|
| 583 |
+
} else {
|
| 584 |
+
clearInterval(progressInterval);
|
| 585 |
+
}
|
| 586 |
+
}, 500);
|
| 587 |
+
|
| 588 |
+
mediaRecorderRef.current.start(1000);
|
| 589 |
+
await audioRef.current.play();
|
| 590 |
+
setIsPlaying(true);
|
| 591 |
+
};
|
| 592 |
+
|
| 593 |
+
const stopVideoExport = () => {
|
| 594 |
+
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
| 595 |
+
mediaRecorderRef.current.stop();
|
| 596 |
+
}
|
| 597 |
+
audioRef.current.pause();
|
| 598 |
+
setIsPlaying(false);
|
| 599 |
+
};
|
| 600 |
+
|
| 601 |
+
return (
|
| 602 |
+
<div className="min-h-screen bg-slate-950 text-slate-200 font-sans selection:bg-cyan-500/30 font-sans">
|
| 603 |
+
<header className="border-b border-slate-800 bg-slate-900/50 p-6 flex items-center justify-between">
|
| 604 |
+
<div className="flex items-center gap-3">
|
| 605 |
+
<div className="bg-cyan-500/20 p-2 rounded-lg">
|
| 606 |
+
<Video className="w-6 h-6 text-cyan-400" />
|
| 607 |
+
</div>
|
| 608 |
+
<h1 className="text-xl font-bold tracking-tight text-white">4K Transparent Visualizer</h1>
|
| 609 |
+
</div>
|
| 610 |
+
<div className="text-sm text-slate-400 hidden sm:block">
|
| 611 |
+
All processing is strictly local.
|
| 612 |
+
</div>
|
| 613 |
+
</header>
|
| 614 |
+
|
| 615 |
+
<main className="container mx-auto p-6 grid lg:grid-cols-12 gap-8">
|
| 616 |
+
|
| 617 |
+
<div className="lg:col-span-4 space-y-6">
|
| 618 |
+
|
| 619 |
+
<section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
|
| 620 |
+
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2">
|
| 621 |
+
<Upload className="w-4 h-4" /> Audio Input
|
| 622 |
+
</h2>
|
| 623 |
+
<label className="block w-full cursor-pointer bg-slate-800 hover:bg-slate-700 transition-colors border-2 border-dashed border-slate-600 rounded-xl p-8 text-center group">
|
| 624 |
+
<input
|
| 625 |
+
type="file"
|
| 626 |
+
accept="audio/*"
|
| 627 |
+
onChange={handleFileUpload}
|
| 628 |
+
className="hidden"
|
| 629 |
+
disabled={isExportingVideo}
|
| 630 |
+
/>
|
| 631 |
+
<div className="mx-auto w-12 h-12 bg-slate-900 rounded-full flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
|
| 632 |
+
<Upload className="w-6 h-6 text-cyan-400" />
|
| 633 |
+
</div>
|
| 634 |
+
<p className="font-medium text-slate-300">
|
| 635 |
+
{fileName ? fileName : 'Click to browse audio file'}
|
| 636 |
+
</p>
|
| 637 |
+
<p className="text-xs text-slate-500 mt-2">MP3, WAV, FLAC</p>
|
| 638 |
+
</label>
|
| 639 |
+
|
| 640 |
+
<audio
|
| 641 |
+
ref={audioRef}
|
| 642 |
+
src={audioSrc}
|
| 643 |
+
onEnded={handleAudioEnded}
|
| 644 |
+
onPlay={() => setIsPlaying(true)}
|
| 645 |
+
onPause={() => setIsPlaying(false)}
|
| 646 |
+
onTimeUpdate={handleTimeUpdate}
|
| 647 |
+
onLoadedMetadata={handleLoadedMetadata}
|
| 648 |
+
/>
|
| 649 |
+
</section>
|
| 650 |
+
|
| 651 |
+
<section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
|
| 652 |
+
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2">
|
| 653 |
+
<Settings2 className="w-4 h-4" /> Visual Settings
|
| 654 |
+
</h2>
|
| 655 |
+
|
| 656 |
+
<div className="space-y-5">
|
| 657 |
+
<div>
|
| 658 |
+
<label className="block text-sm font-medium text-slate-400 mb-2">Style</label>
|
| 659 |
+
<div className="grid grid-cols-3 gap-2">
|
| 660 |
+
{['bars', 'wave', 'circle'].map(type => (
|
| 661 |
+
<button
|
| 662 |
+
key={type}
|
| 663 |
+
onClick={() => setVizType(type)}
|
| 664 |
+
className={`py-2 px-3 rounded-lg text-sm font-medium capitalize transition-all ${
|
| 665 |
+
vizType === type
|
| 666 |
+
? 'bg-slate-700 text-white shadow-inner border border-slate-600'
|
| 667 |
+
: 'bg-slate-950 text-slate-400 border border-slate-800 hover:border-slate-600'
|
| 668 |
+
}`}
|
| 669 |
+
>
|
| 670 |
+
{type}
|
| 671 |
+
</button>
|
| 672 |
+
))}
|
| 673 |
+
</div>
|
| 674 |
+
</div>
|
| 675 |
+
|
| 676 |
+
<div>
|
| 677 |
+
<div className="flex justify-between items-center mb-2">
|
| 678 |
+
<label className="text-sm font-medium text-slate-400">Color Style</label>
|
| 679 |
+
<select
|
| 680 |
+
value={colorMode}
|
| 681 |
+
onChange={(e) => setColorMode(e.target.value)}
|
| 682 |
+
className="bg-slate-950 border border-slate-700 text-slate-300 text-xs rounded px-2 py-1 outline-none"
|
| 683 |
+
>
|
| 684 |
+
<option value="solid">Solid</option>
|
| 685 |
+
<option value="gradient">Gradient</option>
|
| 686 |
+
<option value="rainbow">Rainbow</option>
|
| 687 |
+
</select>
|
| 688 |
+
</div>
|
| 689 |
+
|
| 690 |
+
<div className="flex items-center gap-3">
|
| 691 |
+
{colorMode !== 'rainbow' && (
|
| 692 |
+
<input
|
| 693 |
+
type="color"
|
| 694 |
+
value={color}
|
| 695 |
+
onChange={(e) => setColor(e.target.value)}
|
| 696 |
+
className="h-10 w-14 rounded cursor-pointer bg-slate-950 border border-slate-700 shrink-0"
|
| 697 |
+
/>
|
| 698 |
+
)}
|
| 699 |
+
{colorMode === 'gradient' && (
|
| 700 |
+
<>
|
| 701 |
+
<span className="text-slate-500 text-xs font-medium">to</span>
|
| 702 |
+
<input
|
| 703 |
+
type="color"
|
| 704 |
+
value={color2}
|
| 705 |
+
onChange={(e) => setColor2(e.target.value)}
|
| 706 |
+
className="h-10 w-14 rounded cursor-pointer bg-slate-950 border border-slate-700 shrink-0"
|
| 707 |
+
/>
|
| 708 |
+
</>
|
| 709 |
+
)}
|
| 710 |
+
{colorMode === 'solid' && (
|
| 711 |
+
<input
|
| 712 |
+
type="text"
|
| 713 |
+
value={color}
|
| 714 |
+
onChange={(e) => setColor(e.target.value)}
|
| 715 |
+
className="flex-1 bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-cyan-500 outline-none uppercase font-mono"
|
| 716 |
+
/>
|
| 717 |
+
)}
|
| 718 |
+
</div>
|
| 719 |
+
</div>
|
| 720 |
+
|
| 721 |
+
<div className="flex items-center justify-between bg-slate-950 p-3 rounded-xl border border-slate-800">
|
| 722 |
+
<div className="flex items-center gap-2">
|
| 723 |
+
<Sparkles className="w-4 h-4 text-amber-400" />
|
| 724 |
+
<span className="text-sm font-medium text-slate-300">Neon Glow Effect</span>
|
| 725 |
+
</div>
|
| 726 |
+
<label className="relative inline-flex items-center cursor-pointer">
|
| 727 |
+
<input type="checkbox" checked={glow} onChange={(e) => setGlow(e.target.checked)} className="sr-only peer" />
|
| 728 |
+
<div className="w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div>
|
| 729 |
+
</label>
|
| 730 |
+
</div>
|
| 731 |
+
|
| 732 |
+
<div>
|
| 733 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 734 |
+
<span>Line Thickness</span>
|
| 735 |
+
<div className="flex items-center gap-2">
|
| 736 |
+
<span className="text-slate-500">{thickness}px</span>
|
| 737 |
+
<button onClick={() => setThickness(12)} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 738 |
+
</div>
|
| 739 |
+
</label>
|
| 740 |
+
<input
|
| 741 |
+
type="range"
|
| 742 |
+
min="2" max="64"
|
| 743 |
+
value={thickness}
|
| 744 |
+
onChange={(e) => setThickness(Number(e.target.value))}
|
| 745 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 746 |
+
/>
|
| 747 |
+
</div>
|
| 748 |
+
|
| 749 |
+
<div>
|
| 750 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 751 |
+
<span>Space Between Lines</span>
|
| 752 |
+
<div className="flex items-center gap-2">
|
| 753 |
+
<span className="text-slate-500">{spacing}px</span>
|
| 754 |
+
<button onClick={() => setSpacing(8)} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 755 |
+
</div>
|
| 756 |
+
</label>
|
| 757 |
+
<input
|
| 758 |
+
type="range"
|
| 759 |
+
min="0" max="64"
|
| 760 |
+
value={spacing}
|
| 761 |
+
onChange={(e) => setSpacing(Number(e.target.value))}
|
| 762 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 763 |
+
/>
|
| 764 |
+
</div>
|
| 765 |
+
|
| 766 |
+
<div>
|
| 767 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 768 |
+
<span>Amplitude (Height)</span>
|
| 769 |
+
<div className="flex items-center gap-2">
|
| 770 |
+
<span className="text-slate-500">{sensitivity.toFixed(1)}x</span>
|
| 771 |
+
<button onClick={() => setSensitivity(1.5)} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 772 |
+
</div>
|
| 773 |
+
</label>
|
| 774 |
+
<input
|
| 775 |
+
type="range"
|
| 776 |
+
min="0.5" max="3.0" step="0.1"
|
| 777 |
+
value={sensitivity}
|
| 778 |
+
onChange={(e) => setSensitivity(Number(e.target.value))}
|
| 779 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 780 |
+
/>
|
| 781 |
+
</div>
|
| 782 |
+
|
| 783 |
+
<div>
|
| 784 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 785 |
+
<span>Motion Smoothing</span>
|
| 786 |
+
<div className="flex items-center gap-2">
|
| 787 |
+
<span className="text-slate-500">{Math.round(smoothing * 100)}%</span>
|
| 788 |
+
<button onClick={() => setSmoothing(0.85)} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 789 |
+
</div>
|
| 790 |
+
</label>
|
| 791 |
+
<input
|
| 792 |
+
type="range"
|
| 793 |
+
min="0.1" max="0.99" step="0.01"
|
| 794 |
+
value={smoothing}
|
| 795 |
+
onChange={(e) => setSmoothing(Number(e.target.value))}
|
| 796 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 797 |
+
/>
|
| 798 |
+
</div>
|
| 799 |
+
|
| 800 |
+
<div className="pt-4 mt-4 border-t border-slate-800 space-y-5">
|
| 801 |
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">Transform & Position</h3>
|
| 802 |
+
|
| 803 |
+
<div>
|
| 804 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 805 |
+
<span>Size (Scale)</span>
|
| 806 |
+
<div className="flex items-center gap-2">
|
| 807 |
+
<span className="text-slate-500">{scale.toFixed(2)}x</span>
|
| 808 |
+
<button onClick={() => setScale(1.0)} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 809 |
+
</div>
|
| 810 |
+
</label>
|
| 811 |
+
<input
|
| 812 |
+
type="range"
|
| 813 |
+
min="0.1" max="3.0" step="0.1"
|
| 814 |
+
value={scale}
|
| 815 |
+
onChange={(e) => setScale(Number(e.target.value))}
|
| 816 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 817 |
+
/>
|
| 818 |
+
</div>
|
| 819 |
+
|
| 820 |
+
<div>
|
| 821 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 822 |
+
<span>Horizontal Size (Width)</span>
|
| 823 |
+
<div className="flex items-center gap-2">
|
| 824 |
+
<span className="text-slate-500">{scaleX.toFixed(2)}x</span>
|
| 825 |
+
<button onClick={() => setScaleX(1.0)} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 826 |
+
</div>
|
| 827 |
+
</label>
|
| 828 |
+
<input
|
| 829 |
+
type="range"
|
| 830 |
+
min="0.1" max="5.0" step="0.1"
|
| 831 |
+
value={scaleX}
|
| 832 |
+
onChange={(e) => setScaleX(Number(e.target.value))}
|
| 833 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 834 |
+
/>
|
| 835 |
+
</div>
|
| 836 |
+
|
| 837 |
+
<div>
|
| 838 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 839 |
+
<span>Vertical Size (Height)</span>
|
| 840 |
+
<div className="flex items-center gap-2">
|
| 841 |
+
<span className="text-slate-500">{scaleY.toFixed(2)}x</span>
|
| 842 |
+
<button onClick={() => setScaleY(1.0)} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 843 |
+
</div>
|
| 844 |
+
</label>
|
| 845 |
+
<input
|
| 846 |
+
type="range"
|
| 847 |
+
min="0.1" max="5.0" step="0.1"
|
| 848 |
+
value={scaleY}
|
| 849 |
+
onChange={(e) => setScaleY(Number(e.target.value))}
|
| 850 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 851 |
+
/>
|
| 852 |
+
</div>
|
| 853 |
+
|
| 854 |
+
<div>
|
| 855 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 856 |
+
<span>Rotation</span>
|
| 857 |
+
<div className="flex items-center gap-2">
|
| 858 |
+
<span className="text-slate-500">{rotation}°</span>
|
| 859 |
+
<button onClick={() => setRotation(0)} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 860 |
+
</div>
|
| 861 |
+
</label>
|
| 862 |
+
<input
|
| 863 |
+
type="range"
|
| 864 |
+
min="0" max="360" step="1"
|
| 865 |
+
value={rotation}
|
| 866 |
+
onChange={(e) => setRotation(Number(e.target.value))}
|
| 867 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 868 |
+
/>
|
| 869 |
+
</div>
|
| 870 |
+
|
| 871 |
+
<div>
|
| 872 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 873 |
+
<span>Horizontal Position</span>
|
| 874 |
+
<div className="flex items-center gap-2">
|
| 875 |
+
<span className="text-slate-500">{Math.round(offsetX)}%</span>
|
| 876 |
+
<button onClick={() => { setOffsetX(0); offsetRef.current.x = 0; }} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 877 |
+
</div>
|
| 878 |
+
</label>
|
| 879 |
+
<input
|
| 880 |
+
type="range"
|
| 881 |
+
min="-50" max="50" step="1"
|
| 882 |
+
value={offsetX}
|
| 883 |
+
onChange={(e) => {
|
| 884 |
+
const val = Number(e.target.value);
|
| 885 |
+
setOffsetX(val);
|
| 886 |
+
offsetRef.current.x = val;
|
| 887 |
+
}}
|
| 888 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 889 |
+
/>
|
| 890 |
+
</div>
|
| 891 |
+
|
| 892 |
+
<div>
|
| 893 |
+
<label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
|
| 894 |
+
<span>Vertical Position</span>
|
| 895 |
+
<div className="flex items-center gap-2">
|
| 896 |
+
<span className="text-slate-500">{Math.round(offsetY)}%</span>
|
| 897 |
+
<button onClick={() => { setOffsetY(0); offsetRef.current.y = 0; }} className="text-slate-500 hover:text-cyan-400 transition-colors" title="Reset to Default"><RotateCcw className="w-3 h-3" /></button>
|
| 898 |
+
</div>
|
| 899 |
+
</label>
|
| 900 |
+
<input
|
| 901 |
+
type="range"
|
| 902 |
+
min="-50" max="50" step="1"
|
| 903 |
+
value={offsetY}
|
| 904 |
+
onChange={(e) => {
|
| 905 |
+
const val = Number(e.target.value);
|
| 906 |
+
setOffsetY(val);
|
| 907 |
+
offsetRef.current.y = val;
|
| 908 |
+
}}
|
| 909 |
+
className="w-full accent-cyan-500 cursor-pointer"
|
| 910 |
+
/>
|
| 911 |
+
</div>
|
| 912 |
+
</div>
|
| 913 |
+
|
| 914 |
+
</div>
|
| 915 |
+
</section>
|
| 916 |
+
|
| 917 |
+
<section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
|
| 918 |
+
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2">
|
| 919 |
+
<Monitor className="w-4 h-4" /> Output Setup
|
| 920 |
+
</h2>
|
| 921 |
+
|
| 922 |
+
<div className="space-y-5">
|
| 923 |
+
<div>
|
| 924 |
+
<label className="block text-sm font-medium text-slate-400 mb-2">Aspect Ratio & Resolution</label>
|
| 925 |
+
<select
|
| 926 |
+
value={resolution}
|
| 927 |
+
onChange={(e) => setResolution(e.target.value)}
|
| 928 |
+
disabled={isExportingVideo}
|
| 929 |
+
className="w-full bg-slate-950 border border-slate-700 text-slate-300 text-sm rounded-lg px-3 py-2.5 outline-none focus:ring-1 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 930 |
+
>
|
| 931 |
+
<option value="4k_16_9">4K Landscape (3840x2160)</option>
|
| 932 |
+
<option value="1080p_16_9">1080p Landscape (1920x1080)</option>
|
| 933 |
+
<option value="4k_9_16">4K Vertical / Reels (2160x3840)</option>
|
| 934 |
+
<option value="1080p_9_16">1080p Vertical / Reels (1080x1920)</option>
|
| 935 |
+
</select>
|
| 936 |
+
</div>
|
| 937 |
+
|
| 938 |
+
<div>
|
| 939 |
+
<label className="block text-sm font-medium text-slate-400 mb-2">Background Type</label>
|
| 940 |
+
<div className="flex gap-2 mb-3">
|
| 941 |
+
{['transparent', 'color', 'image'].map(type => (
|
| 942 |
+
<button
|
| 943 |
+
key={type}
|
| 944 |
+
onClick={() => setBgType(type)}
|
| 945 |
+
className={`flex-1 py-2 px-2 rounded-lg text-xs font-medium capitalize transition-all ${
|
| 946 |
+
bgType === type
|
| 947 |
+
? 'bg-slate-700 text-white shadow-inner border border-slate-600'
|
| 948 |
+
: 'bg-slate-950 text-slate-400 border border-slate-800 hover:border-slate-600'
|
| 949 |
+
}`}
|
| 950 |
+
>
|
| 951 |
+
{type}
|
| 952 |
+
</button>
|
| 953 |
+
))}
|
| 954 |
+
</div>
|
| 955 |
+
|
| 956 |
+
{bgType === 'color' && (
|
| 957 |
+
<div className="flex items-center gap-3 mt-2 bg-slate-950 p-2 rounded-lg border border-slate-800">
|
| 958 |
+
<input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="h-8 w-12 rounded cursor-pointer bg-slate-950 border border-slate-700" />
|
| 959 |
+
<span className="text-sm font-mono text-slate-400 uppercase">{bgColor}</span>
|
| 960 |
+
</div>
|
| 961 |
+
)}
|
| 962 |
+
|
| 963 |
+
{bgType === 'image' && (
|
| 964 |
+
<div className="mt-2 space-y-3">
|
| 965 |
+
<label className="flex items-center justify-center gap-2 w-full cursor-pointer bg-slate-950 hover:bg-slate-800 transition-colors border border-dashed border-slate-600 rounded-lg p-3 text-center text-sm text-slate-300">
|
| 966 |
+
<ImagePlus className="w-4 h-4" />
|
| 967 |
+
{bgImageSrc ? 'Change Image' : 'Upload Background Image'}
|
| 968 |
+
<input type="file" accept="image/*" onChange={handleBgUpload} className="hidden" />
|
| 969 |
+
</label>
|
| 970 |
+
{bgImageSrc && (
|
| 971 |
+
<div className="flex justify-between items-center bg-slate-950 p-2 rounded-lg border border-slate-800">
|
| 972 |
+
<span className="text-xs font-medium text-slate-400">Image Fit</span>
|
| 973 |
+
<select
|
| 974 |
+
value={bgImageFit}
|
| 975 |
+
onChange={(e) => setBgImageFit(e.target.value)}
|
| 976 |
+
className="bg-slate-900 border border-slate-700 text-slate-300 text-xs rounded px-2 py-1 outline-none"
|
| 977 |
+
>
|
| 978 |
+
<option value="contain">Contain (No Crop)</option>
|
| 979 |
+
<option value="cover">Cover (Fill Canvas)</option>
|
| 980 |
+
<option value="fit-width">Fit to Width</option>
|
| 981 |
+
<option value="stretch">Stretch (Exact Fit)</option>
|
| 982 |
+
</select>
|
| 983 |
+
</div>
|
| 984 |
+
)}
|
| 985 |
+
</div>
|
| 986 |
+
)}
|
| 987 |
+
</div>
|
| 988 |
+
</div>
|
| 989 |
+
</section>
|
| 990 |
+
|
| 991 |
+
<section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
|
| 992 |
+
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">Export Options</h2>
|
| 993 |
+
|
| 994 |
+
<div className="flex flex-col gap-3">
|
| 995 |
+
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 mb-2">
|
| 996 |
+
<label className="block text-sm font-medium text-slate-400 mb-2">Video Format & Framerate</label>
|
| 997 |
+
<div className="flex flex-col 2xl:flex-row gap-3">
|
| 998 |
+
<select
|
| 999 |
+
value={exportFormat}
|
| 1000 |
+
onChange={(e) => setExportFormat(e.target.value)}
|
| 1001 |
+
className="w-full bg-slate-900 border border-slate-700 text-slate-300 text-sm rounded-lg px-3 py-2.5 outline-none focus:ring-1 focus:ring-cyan-500"
|
| 1002 |
+
>
|
| 1003 |
+
<option value="webm">WebM (Transparent)</option>
|
| 1004 |
+
<option value="mp4">MP4 (Solid BG / DaVinci)</option>
|
| 1005 |
+
</select>
|
| 1006 |
+
|
| 1007 |
+
<select
|
| 1008 |
+
value={exportFps}
|
| 1009 |
+
onChange={(e) => setExportFps(Number(e.target.value))}
|
| 1010 |
+
className="w-full 2xl:w-32 bg-slate-900 border border-slate-700 text-slate-300 text-sm rounded-lg px-3 py-2.5 outline-none focus:ring-1 focus:ring-cyan-500"
|
| 1011 |
+
>
|
| 1012 |
+
<option value={30}>30 FPS</option>
|
| 1013 |
+
<option value={60}>60 FPS</option>
|
| 1014 |
+
</select>
|
| 1015 |
+
</div>
|
| 1016 |
+
|
| 1017 |
+
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
| 1018 |
+
<p className="text-xs text-red-400 leading-relaxed">
|
| 1019 |
+
<strong>Video Freezing/Crashing?</strong> Real-time 4K encoding is extremely heavy. To fix this:<br/>
|
| 1020 |
+
1. Set Framerate to <strong>30 FPS</strong>.<br/>
|
| 1021 |
+
2. Turn off <strong>Neon Glow Effect</strong> (massive performance boost).<br/>
|
| 1022 |
+
3. Lower Resolution to <strong>1080p</strong>.
|
| 1023 |
+
</p>
|
| 1024 |
+
</div>
|
| 1025 |
+
|
| 1026 |
+
{exportFormat === 'mp4' && (
|
| 1027 |
+
<div className="mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg">
|
| 1028 |
+
<p className="text-xs text-amber-500/90 leading-relaxed">
|
| 1029 |
+
<strong>DaVinci Resolve Tip:</strong> MP4s cannot be transparent. Set your Background Type to <strong>Color (Black)</strong>, export the MP4, and in DaVinci, set your clip's Composite Mode to <strong>Screen</strong> or <strong>Add</strong>.
|
| 1030 |
+
</p>
|
| 1031 |
+
</div>
|
| 1032 |
+
)}
|
| 1033 |
+
</div>
|
| 1034 |
+
|
| 1035 |
+
<button
|
| 1036 |
+
onClick={exportImage}
|
| 1037 |
+
disabled={isExportingVideo}
|
| 1038 |
+
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-medium py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
| 1039 |
+
>
|
| 1040 |
+
<ImageIcon className="w-4 h-4" />
|
| 1041 |
+
Save Snapshot (PNG)
|
| 1042 |
+
</button>
|
| 1043 |
+
|
| 1044 |
+
{isExportingVideo ? (
|
| 1045 |
+
<div className="w-full space-y-3">
|
| 1046 |
+
<button
|
| 1047 |
+
onClick={stopVideoExport}
|
| 1048 |
+
className="w-full bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 font-bold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-colors"
|
| 1049 |
+
>
|
| 1050 |
+
<StopCircle className="w-5 h-5" />
|
| 1051 |
+
Stop & Save
|
| 1052 |
+
</button>
|
| 1053 |
+
<div className="w-full bg-slate-950 rounded-full h-2.5 border border-slate-800 overflow-hidden">
|
| 1054 |
+
<div className="bg-cyan-500 h-2.5 rounded-full transition-all duration-300" style={{ width: `${exportProgress}%` }}></div>
|
| 1055 |
+
</div>
|
| 1056 |
+
<p className="text-xs text-center text-slate-400">Recording video... {Math.round(exportProgress)}%</p>
|
| 1057 |
+
</div>
|
| 1058 |
+
) : (
|
| 1059 |
+
<button
|
| 1060 |
+
onClick={startVideoExport}
|
| 1061 |
+
disabled={!audioSrc}
|
| 1062 |
+
className="w-full bg-gradient-to-r from-indigo-500 to-cyan-500 hover:from-indigo-400 hover:to-cyan-400 text-white font-bold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-cyan-500/20 disabled:opacity-50 disabled:shadow-none"
|
| 1063 |
+
>
|
| 1064 |
+
<Video className="w-5 h-5" />
|
| 1065 |
+
Export Video ({exportFormat.toUpperCase()})
|
| 1066 |
+
</button>
|
| 1067 |
+
)}
|
| 1068 |
+
</div>
|
| 1069 |
+
</section>
|
| 1070 |
+
|
| 1071 |
+
</div>
|
| 1072 |
+
|
| 1073 |
+
<div className="lg:col-span-8 flex flex-col gap-4 lg:sticky lg:top-6 lg:h-[calc(100vh-3rem)]">
|
| 1074 |
+
<div className="bg-slate-900 rounded-2xl border border-slate-800 shadow-xl overflow-hidden flex-1 relative flex flex-col min-h-0">
|
| 1075 |
+
|
| 1076 |
+
<div className="p-4 border-b border-slate-800 bg-slate-900/80 flex justify-between items-center z-10 shrink-0">
|
| 1077 |
+
<span className="text-sm font-semibold text-slate-300 flex items-center gap-2">
|
| 1078 |
+
Live Preview
|
| 1079 |
+
<span className="bg-slate-800 text-xs px-2 py-0.5 rounded text-slate-400 border border-slate-700">
|
| 1080 |
+
{RESOLUTIONS[resolution]?.w}x{RESOLUTIONS[resolution]?.h}
|
| 1081 |
+
</span>
|
| 1082 |
+
</span>
|
| 1083 |
+
<span className="text-xs text-slate-500">
|
| 1084 |
+
{bgType === 'transparent' ? 'Checkerboard denotes transparency' : 'Background included in export'}
|
| 1085 |
+
</span>
|
| 1086 |
+
</div>
|
| 1087 |
+
|
| 1088 |
+
<div
|
| 1089 |
+
className="flex-1 w-full relative flex items-center justify-center p-4 sm:p-6 overflow-hidden bg-black/50 min-h-0"
|
| 1090 |
+
style={ bgType === 'transparent' ? {
|
| 1091 |
+
backgroundImage: 'repeating-linear-gradient(45deg, #0f172a 25%, transparent 25%, transparent 75%, #0f172a 75%, #0f172a), repeating-linear-gradient(45deg, #0f172a 25%, #1e293b 25%, #1e293b 75%, #0f172a 75%, #0f172a)',
|
| 1092 |
+
backgroundPosition: '0 0, 10px 10px',
|
| 1093 |
+
backgroundSize: '20px 20px'
|
| 1094 |
+
} : {}}
|
| 1095 |
+
>
|
| 1096 |
+
{/* FIX: Added `key` prop so context attributes are strictly respected upon background change */}
|
| 1097 |
+
<canvas
|
| 1098 |
+
key={bgType === 'transparent' ? 'alpha' : 'solid'}
|
| 1099 |
+
ref={canvasRef}
|
| 1100 |
+
width={RESOLUTIONS[resolution]?.w || 3840}
|
| 1101 |
+
height={RESOLUTIONS[resolution]?.h || 2160}
|
| 1102 |
+
onMouseDown={handleMouseDown}
|
| 1103 |
+
onMouseMove={handleMouseMove}
|
| 1104 |
+
onMouseUp={handleMouseUpOrLeave}
|
| 1105 |
+
onMouseLeave={handleMouseUpOrLeave}
|
| 1106 |
+
className={`max-w-full max-h-full object-contain drop-shadow-2xl rounded-lg ring-1 ring-white/10 bg-transparent ${RESOLUTIONS[resolution]?.isVertical ? 'aspect-[9/16]' : 'aspect-video'} ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
| 1107 |
+
/>
|
| 1108 |
+
|
| 1109 |
+
{!audioSrc && (
|
| 1110 |
+
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none bg-slate-900/80 backdrop-blur-sm z-20">
|
| 1111 |
+
<Loader2 className="w-12 h-12 text-slate-500 animate-spin mb-4 opacity-50" />
|
| 1112 |
+
<p className="text-slate-400 font-medium">Awaiting Audio Input</p>
|
| 1113 |
+
</div>
|
| 1114 |
+
)}
|
| 1115 |
+
</div>
|
| 1116 |
+
</div>
|
| 1117 |
+
|
| 1118 |
+
<div className="bg-slate-900 p-4 sm:p-5 rounded-2xl border border-slate-800 shadow-xl shrink-0 flex flex-col gap-3">
|
| 1119 |
+
<div className="flex items-center gap-4">
|
| 1120 |
+
<button
|
| 1121 |
+
ref={playButtonRef}
|
| 1122 |
+
onClick={togglePlay}
|
| 1123 |
+
disabled={!audioSrc || isExportingVideo}
|
| 1124 |
+
className="w-12 h-12 shrink-0 bg-cyan-500 hover:bg-cyan-400 text-slate-950 rounded-full flex items-center justify-center transition-colors disabled:opacity-50 disabled:hover:bg-cyan-500"
|
| 1125 |
+
>
|
| 1126 |
+
{isPlaying ? <Pause className="w-6 h-6 fill-current" /> : <Play className="w-6 h-6 fill-current ml-1" />}
|
| 1127 |
+
</button>
|
| 1128 |
+
|
| 1129 |
+
<div className="flex-1 flex flex-col gap-2">
|
| 1130 |
+
<div className="flex justify-between text-xs font-mono text-slate-400">
|
| 1131 |
+
<span>{formatTime(audioTime)}</span>
|
| 1132 |
+
<span className="text-slate-300 font-sans truncate px-4">{fileName || 'No audio selected'}</span>
|
| 1133 |
+
<span>{formatTime(audioDuration)}</span>
|
| 1134 |
+
</div>
|
| 1135 |
+
<input
|
| 1136 |
+
type="range"
|
| 1137 |
+
min="0"
|
| 1138 |
+
max={audioDuration || 100}
|
| 1139 |
+
value={audioTime}
|
| 1140 |
+
onChange={handleSeek}
|
| 1141 |
+
disabled={!audioSrc || isExportingVideo}
|
| 1142 |
+
className="w-full accent-cyan-500 cursor-pointer h-2 bg-slate-800 rounded-lg appearance-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-cyan-400 [&::-webkit-slider-thumb]:rounded-full"
|
| 1143 |
+
/>
|
| 1144 |
+
</div>
|
| 1145 |
+
</div>
|
| 1146 |
+
</div>
|
| 1147 |
+
|
| 1148 |
+
</div>
|
| 1149 |
+
|
| 1150 |
+
</main>
|
| 1151 |
+
</div>
|
| 1152 |
+
);
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
| 1156 |
+
root.render(<App />);
|
| 1157 |
+
</script>
|
| 1158 |
+
</body>
|
| 1159 |
+
</html>
|