trysem commited on
Commit
2a276be
·
verified ·
1 Parent(s): ffd9b92

Create m4pro-greatcopy

Browse files

I will introduce the two new options precisely as requested:

Taper Edges (Minimal Spikes): Added below the Neon Glow option to smoothly damp out the edges of the 'bars' and 'wave' visualizations.

Scale Aspect Ratio Lock: Added lock/unlock toggle buttons right inside the "Horizontal Size" and "Vertical Size" labels that maintain the aspect ratio between X and Y dynamically.

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