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

Create m3pro-greatcopy

Browse files

Gemini 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:

Files changed (1) hide show
  1. 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>