trysem commited on
Commit
9467a83
·
verified ·
1 Parent(s): d4118d0

Create best-next-copy-jsx

Browse files
Files changed (1) hide show
  1. best-next-copy-jsx +923 -0
best-next-copy-jsx ADDED
@@ -0,0 +1,923 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { Upload, Play, Pause, Image as ImageIcon, Video, Settings2, Loader2, StopCircle, Sparkles, Monitor, ImagePlus } from 'lucide-react';
3
+
4
+ export default function App() {
5
+ const canvasRef = useRef(null);
6
+ const audioRef = useRef(null);
7
+ const audioCtxRef = useRef(null);
8
+ const analyserRef = useRef(null);
9
+ const sourceRef = useRef(null);
10
+ const destRef = useRef(null);
11
+ const reqIdRef = useRef(null);
12
+ const mediaRecorderRef = useRef(null);
13
+ const chunksRef = useRef([]);
14
+ const bgImgRef = useRef(null);
15
+
16
+ // State
17
+ const [audioSrc, setAudioSrc] = useState(null);
18
+ const [fileName, setFileName] = useState('');
19
+ const [isPlaying, setIsPlaying] = useState(false);
20
+ const [isExportingVideo, setIsExportingVideo] = useState(false);
21
+ const [exportProgress, setExportProgress] = useState(0);
22
+
23
+ // Audio Player State
24
+ const [audioTime, setAudioTime] = useState(0);
25
+ const [audioDuration, setAudioDuration] = useState(0);
26
+
27
+ // Settings
28
+ const [vizType, setVizType] = useState('bars'); // 'bars', 'wave', 'circle'
29
+ const [color, setColor] = useState('#00ffcc');
30
+ const [thickness, setThickness] = useState(12);
31
+ const [spacing, setSpacing] = useState(8);
32
+ const [sensitivity, setSensitivity] = useState(1.5);
33
+ const [smoothing, setSmoothing] = useState(0.85);
34
+
35
+ // Transform Settings
36
+ const [offsetX, setOffsetX] = useState(0);
37
+ const [offsetY, setOffsetY] = useState(0);
38
+ const [scale, setScale] = useState(1.0);
39
+ const [rotation, setRotation] = useState(0);
40
+
41
+ // Drag State
42
+ const [isDragging, setIsDragging] = useState(false);
43
+ const dragRef = useRef({ startX: 0, startY: 0, initX: 0, initY: 0 });
44
+
45
+ // Advanced Settings
46
+ const [colorMode, setColorMode] = useState('solid');
47
+ const [color2, setColor2] = useState('#b829ff');
48
+ const [glow, setGlow] = useState(false);
49
+ const [resolution, setResolution] = useState('4k_16_9');
50
+ const [bgType, setBgType] = useState('transparent');
51
+ const [bgColor, setBgColor] = useState('#0f172a');
52
+ const [bgImageSrc, setBgImageSrc] = useState(null);
53
+
54
+ const RESOLUTIONS = {
55
+ '4k_16_9': { w: 3840, h: 2160, label: '4K (16:9)', isVertical: false },
56
+ '1080p_16_9': { w: 1920, h: 1080, label: '1080p (16:9)', isVertical: false },
57
+ '4k_9_16': { w: 2160, h: 3840, label: '4K Vertical (9:16)', isVertical: true },
58
+ '1080p_9_16': { w: 1080, h: 1920, label: '1080p Vertical (9:16)', isVertical: true }
59
+ };
60
+
61
+ // Load background image
62
+ useEffect(() => {
63
+ if (bgImageSrc) {
64
+ const img = new Image();
65
+ img.onload = () => { bgImgRef.current = img; };
66
+ img.src = bgImageSrc;
67
+ } else {
68
+ bgImgRef.current = null;
69
+ }
70
+ }, [bgImageSrc]);
71
+
72
+ const handleBgUpload = (e) => {
73
+ const file = e.target.files[0];
74
+ if (file) {
75
+ if (bgImageSrc) URL.revokeObjectURL(bgImageSrc);
76
+ setBgImageSrc(URL.createObjectURL(file));
77
+ setBgType('image');
78
+ }
79
+ };
80
+
81
+ // Initialize Web Audio API
82
+ const initAudio = useCallback(() => {
83
+ if (!audioCtxRef.current) {
84
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
85
+ audioCtxRef.current = new AudioContext();
86
+ analyserRef.current = audioCtxRef.current.createAnalyser();
87
+ destRef.current = audioCtxRef.current.createMediaStreamDestination();
88
+
89
+ if (!sourceRef.current && audioRef.current) {
90
+ sourceRef.current = audioCtxRef.current.createMediaElementSource(audioRef.current);
91
+ sourceRef.current.connect(analyserRef.current);
92
+ analyserRef.current.connect(audioCtxRef.current.destination);
93
+ analyserRef.current.connect(destRef.current);
94
+ }
95
+ }
96
+
97
+ if (audioCtxRef.current.state === 'suspended') {
98
+ audioCtxRef.current.resume();
99
+ }
100
+ }, []);
101
+
102
+ const handleFileUpload = (e) => {
103
+ const file = e.target.files[0];
104
+ if (file) {
105
+ if (audioSrc) URL.revokeObjectURL(audioSrc);
106
+ const url = URL.createObjectURL(file);
107
+ setAudioSrc(url);
108
+ setFileName(file.name);
109
+ setIsPlaying(false);
110
+ setAudioTime(0);
111
+ if (audioRef.current) {
112
+ audioRef.current.pause();
113
+ audioRef.current.currentTime = 0;
114
+ }
115
+ }
116
+ };
117
+
118
+ const togglePlay = () => {
119
+ if (!audioSrc) return;
120
+ initAudio();
121
+
122
+ if (isPlaying) {
123
+ audioRef.current.pause();
124
+ } else {
125
+ audioRef.current.play();
126
+ }
127
+ setIsPlaying(!isPlaying);
128
+ };
129
+
130
+ const handleTimeUpdate = () => {
131
+ if (audioRef.current) setAudioTime(audioRef.current.currentTime);
132
+ };
133
+
134
+ const handleLoadedMetadata = () => {
135
+ if (audioRef.current) setAudioDuration(audioRef.current.duration);
136
+ };
137
+
138
+ const handleSeek = (e) => {
139
+ const time = Number(e.target.value);
140
+ if (audioRef.current) audioRef.current.currentTime = time;
141
+ setAudioTime(time);
142
+ };
143
+
144
+ const formatTime = (time) => {
145
+ if (isNaN(time)) return "0:00";
146
+ const m = Math.floor(time / 60);
147
+ const s = Math.floor(time % 60).toString().padStart(2, '0');
148
+ return `${m}:${s}`;
149
+ };
150
+
151
+ // Drag Handlers
152
+ const handleMouseDown = (e) => {
153
+ setIsDragging(true);
154
+ dragRef.current = {
155
+ startX: e.clientX,
156
+ startY: e.clientY,
157
+ initX: offsetX,
158
+ initY: offsetY
159
+ };
160
+ };
161
+
162
+ const handleMouseMove = (e) => {
163
+ if (!isDragging || !canvasRef.current) return;
164
+ const rect = canvasRef.current.getBoundingClientRect();
165
+
166
+ const deltaX = e.clientX - dragRef.current.startX;
167
+ const deltaY = e.clientY - dragRef.current.startY;
168
+
169
+ const percentX = (deltaX / rect.width) * 100;
170
+ const percentY = (deltaY / rect.height) * 100;
171
+
172
+ setOffsetX(Math.max(-50, Math.min(50, dragRef.current.initX + percentX)));
173
+ setOffsetY(Math.max(-50, Math.min(50, dragRef.current.initY + percentY)));
174
+ };
175
+
176
+ const handleMouseUpOrLeave = () => {
177
+ setIsDragging(false);
178
+ };
179
+
180
+ // The Animation Loop
181
+ const draw = useCallback(() => {
182
+ if (!canvasRef.current || !analyserRef.current) {
183
+ reqIdRef.current = requestAnimationFrame(draw);
184
+ return;
185
+ }
186
+
187
+ const canvas = canvasRef.current;
188
+ const ctx = canvas.getContext('2d');
189
+ const res = RESOLUTIONS[resolution] || RESOLUTIONS['4k_16_9'];
190
+
191
+ if (canvas.width !== res.w || canvas.height !== res.h) {
192
+ canvas.width = res.w;
193
+ canvas.height = res.h;
194
+ }
195
+
196
+ const width = canvas.width;
197
+ const height = canvas.height;
198
+
199
+ // Background handling
200
+ ctx.clearRect(0, 0, width, height);
201
+ if (bgType === 'color') {
202
+ ctx.fillStyle = bgColor;
203
+ ctx.fillRect(0, 0, width, height);
204
+ } else if (bgType === 'image' && bgImgRef.current) {
205
+ const img = bgImgRef.current;
206
+ const imgRatio = img.width / img.height;
207
+ const canvasRatio = width / height;
208
+ let drawW, drawH, drawX, drawY;
209
+ if (imgRatio > canvasRatio) {
210
+ drawH = height;
211
+ drawW = height * imgRatio;
212
+ drawX = (width - drawW) / 2;
213
+ drawY = 0;
214
+ } else {
215
+ drawW = width;
216
+ drawH = width / imgRatio;
217
+ drawX = 0;
218
+ drawY = (height - drawH) / 2;
219
+ }
220
+ ctx.drawImage(img, drawX, drawY, drawW, drawH);
221
+ }
222
+
223
+ analyserRef.current.smoothingTimeConstant = smoothing;
224
+ analyserRef.current.fftSize = 2048;
225
+
226
+ const bufferLength = analyserRef.current.frequencyBinCount;
227
+ const dataArray = new Uint8Array(bufferLength);
228
+
229
+ // Fetch frequency data ONCE per frame for optimization
230
+ if (vizType === 'bars' || vizType === 'circle') {
231
+ analyserRef.current.getByteFrequencyData(dataArray);
232
+ } else if (vizType === 'wave') {
233
+ analyserRef.current.getByteTimeDomainData(dataArray);
234
+ }
235
+
236
+ ctx.save();
237
+
238
+ // Apply Transformations
239
+ const centerX = width / 2 + (width * (offsetX / 100));
240
+ const centerY = height / 2 + (height * (offsetY / 100));
241
+ ctx.translate(centerX, centerY);
242
+ ctx.scale(scale, scale);
243
+ ctx.rotate((rotation * Math.PI) / 180);
244
+
245
+ // Color Setup
246
+ let activeColor = color;
247
+ if (colorMode === 'gradient') {
248
+ const grad = ctx.createLinearGradient(-width/2, -height/2, width/2, height/2);
249
+ grad.addColorStop(0, color);
250
+ grad.addColorStop(1, color2);
251
+ activeColor = grad;
252
+ } else if (colorMode === 'rainbow') {
253
+ const grad = ctx.createLinearGradient(-width/2, 0, width/2, 0);
254
+ grad.addColorStop(0, '#ff0000');
255
+ grad.addColorStop(0.16, '#ffff00');
256
+ grad.addColorStop(0.33, '#00ff00');
257
+ grad.addColorStop(0.5, '#00ffff');
258
+ grad.addColorStop(0.66, '#0000ff');
259
+ grad.addColorStop(0.83, '#ff00ff');
260
+ grad.addColorStop(1, '#ff0000');
261
+ activeColor = grad;
262
+ }
263
+
264
+ ctx.strokeStyle = activeColor;
265
+ ctx.fillStyle = activeColor;
266
+ ctx.lineCap = 'round';
267
+ ctx.lineJoin = 'round';
268
+
269
+ // Helper to draw the mathematical path based on selected viz type
270
+ const drawVisualizerPath = () => {
271
+ ctx.beginPath();
272
+
273
+ if (vizType === 'bars') {
274
+ const step = thickness + spacing;
275
+ const maxBars = Math.floor((width / 2) / step);
276
+ const usefulLength = Math.floor(bufferLength * 0.75);
277
+ const numBars = Math.min(maxBars, usefulLength);
278
+
279
+ for (let i = 0; i < numBars; i++) {
280
+ const dataIndex = Math.floor((i / numBars) * usefulLength);
281
+ const boost = Math.pow(1 + (i / numBars), 1.5);
282
+ const value = dataArray[dataIndex] * boost * sensitivity;
283
+
284
+ const barHeight = Math.max(thickness / 2, (value / 255) * height * 0.8);
285
+ const xOffset = i * step + (step / 2);
286
+
287
+ ctx.moveTo(xOffset, height / 2 - (thickness / 2));
288
+ ctx.lineTo(xOffset, height / 2 - barHeight);
289
+
290
+ ctx.moveTo(-xOffset, height / 2 - (thickness / 2));
291
+ ctx.lineTo(-xOffset, height / 2 - barHeight);
292
+ }
293
+ } else if (vizType === 'wave') {
294
+ const sliceWidth = width / bufferLength;
295
+ let x = -width / 2;
296
+
297
+ for (let i = 0; i < bufferLength; i++) {
298
+ const normalized = (dataArray[i] / 128.0) - 1;
299
+ const y = normalized * sensitivity * (height / 2);
300
+
301
+ if (i === 0) {
302
+ ctx.moveTo(x, y);
303
+ } else {
304
+ ctx.lineTo(x, y);
305
+ }
306
+ x += sliceWidth;
307
+ }
308
+ } else if (vizType === 'circle') {
309
+ const radius = height / 4;
310
+ const circumference = 2 * Math.PI * radius;
311
+ const stepSize = thickness + spacing;
312
+ const bars = Math.min(180, Math.floor(circumference / stepSize));
313
+ const step = (Math.PI * 2) / bars;
314
+
315
+ for (let i = 0; i < bars; i++) {
316
+ const dataIndex = Math.floor((i / bars) * (bufferLength / 2));
317
+ const value = (dataArray[dataIndex] / 255) * sensitivity;
318
+ const barHeight = Math.max(thickness / 2, value * (height / 3));
319
+ const angle = i * step;
320
+
321
+ const x1 = Math.cos(angle) * radius;
322
+ const y1 = Math.sin(angle) * radius;
323
+ const x2 = Math.cos(angle) * (radius + barHeight);
324
+ const y2 = Math.sin(angle) * (radius + barHeight);
325
+
326
+ ctx.moveTo(x1, y1);
327
+ ctx.lineTo(x2, y2);
328
+ }
329
+ }
330
+
331
+ ctx.stroke();
332
+
333
+ // Additional inner circle pass for 'circle' mode
334
+ if (vizType === 'circle') {
335
+ const radius = height / 4;
336
+ ctx.beginPath();
337
+ ctx.arc(0, 0, radius - thickness, 0, Math.PI * 2);
338
+ // Half thickness for the inner ring so it looks clean
339
+ const originalWidth = ctx.lineWidth;
340
+ ctx.lineWidth = originalWidth / 2;
341
+ ctx.stroke();
342
+ ctx.lineWidth = originalWidth; // Restore
343
+ }
344
+ };
345
+
346
+ // Highly Optimized Neon Glow (Composite Blending instead of shadowBlur)
347
+ if (glow) {
348
+ ctx.globalCompositeOperation = 'lighter';
349
+
350
+ // Pass 1: Outer faint glow
351
+ ctx.lineWidth = thickness * 3;
352
+ ctx.globalAlpha = 0.15;
353
+ drawVisualizerPath();
354
+
355
+ // Pass 2: Mid glow
356
+ ctx.lineWidth = thickness * 1.5;
357
+ ctx.globalAlpha = 0.4;
358
+ drawVisualizerPath();
359
+
360
+ // Pass 3: Core sharp line
361
+ ctx.lineWidth = thickness;
362
+ ctx.globalAlpha = 1.0;
363
+ drawVisualizerPath();
364
+
365
+ ctx.globalCompositeOperation = 'source-over'; // Reset blend mode
366
+ } else {
367
+ ctx.lineWidth = thickness;
368
+ ctx.globalAlpha = 1.0;
369
+ drawVisualizerPath();
370
+ }
371
+
372
+ ctx.restore();
373
+ reqIdRef.current = requestAnimationFrame(draw);
374
+ }, [vizType, color, thickness, spacing, sensitivity, smoothing, colorMode, color2, glow, resolution, bgType, bgColor, offsetX, offsetY, scale, rotation]);
375
+
376
+ // Handle Play/Pause side effects and loop
377
+ useEffect(() => {
378
+ reqIdRef.current = requestAnimationFrame(draw);
379
+ return () => cancelAnimationFrame(reqIdRef.current);
380
+ }, [draw]);
381
+
382
+ const handleAudioEnded = () => {
383
+ setIsPlaying(false);
384
+ if (isExportingVideo) stopVideoExport();
385
+ };
386
+
387
+ const exportImage = () => {
388
+ if (!canvasRef.current) return;
389
+ const link = document.createElement('a');
390
+ link.download = `visualizer_${Date.now()}.png`;
391
+ link.href = canvasRef.current.toDataURL('image/png');
392
+ link.click();
393
+ };
394
+
395
+ const startVideoExport = async () => {
396
+ if (!audioSrc || !canvasRef.current || !audioCtxRef.current) {
397
+ alert("Please upload an audio file and press play at least once to initialize.");
398
+ return;
399
+ }
400
+
401
+ setIsExportingVideo(true);
402
+ setExportProgress(0);
403
+ chunksRef.current = [];
404
+
405
+ audioRef.current.pause();
406
+ audioRef.current.currentTime = 0;
407
+
408
+ const canvasStream = canvasRef.current.captureStream(60);
409
+ const audioStream = destRef.current.stream;
410
+
411
+ const combinedTracks = [...canvasStream.getTracks(), ...audioStream.getAudioTracks()];
412
+ const combinedStream = new MediaStream(combinedTracks);
413
+
414
+ let options = { mimeType: 'video/webm; codecs=vp9' };
415
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
416
+ options = { mimeType: 'video/webm; codecs=vp8' };
417
+ }
418
+
419
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
420
+ alert("Your browser does not support transparent video export (WebM with VP8/VP9 codecs). Export will proceed but may not be transparent.");
421
+ options = {};
422
+ }
423
+
424
+ try {
425
+ mediaRecorderRef.current = new MediaRecorder(combinedStream, options);
426
+ } catch (e) {
427
+ console.error(e);
428
+ alert("Error starting video recorder. See console.");
429
+ setIsExportingVideo(false);
430
+ return;
431
+ }
432
+
433
+ mediaRecorderRef.current.ondataavailable = (e) => {
434
+ if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
435
+ };
436
+
437
+ mediaRecorderRef.current.onstop = () => {
438
+ const blob = new Blob(chunksRef.current, { type: mediaRecorderRef.current.mimeType });
439
+ const url = URL.createObjectURL(blob);
440
+ const link = document.createElement('a');
441
+ link.download = `visualizer_${resolution}_${Date.now()}.webm`;
442
+ link.href = url;
443
+ link.click();
444
+ URL.revokeObjectURL(url);
445
+ setIsExportingVideo(false);
446
+ setExportProgress(0);
447
+ };
448
+
449
+ const duration = audioRef.current.duration;
450
+ const progressInterval = setInterval(() => {
451
+ if (audioRef.current && !audioRef.current.paused) {
452
+ setExportProgress((audioRef.current.currentTime / duration) * 100);
453
+ } else {
454
+ clearInterval(progressInterval);
455
+ }
456
+ }, 500);
457
+
458
+ mediaRecorderRef.current.start(100);
459
+ await audioRef.current.play();
460
+ setIsPlaying(true);
461
+ };
462
+
463
+ const stopVideoExport = () => {
464
+ if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
465
+ mediaRecorderRef.current.stop();
466
+ }
467
+ audioRef.current.pause();
468
+ setIsPlaying(false);
469
+ };
470
+
471
+ return (
472
+ <div className="min-h-screen bg-slate-950 text-slate-200 font-sans selection:bg-cyan-500/30">
473
+ {/* Header */}
474
+ <header className="border-b border-slate-800 bg-slate-900/50 p-6 flex items-center justify-between">
475
+ <div className="flex items-center gap-3">
476
+ <div className="bg-cyan-500/20 p-2 rounded-lg">
477
+ <Video className="w-6 h-6 text-cyan-400" />
478
+ </div>
479
+ <h1 className="text-xl font-bold tracking-tight text-white">4K Transparent Visualizer</h1>
480
+ </div>
481
+ <div className="text-sm text-slate-400 hidden sm:block">
482
+ All processing is strictly local.
483
+ </div>
484
+ </header>
485
+
486
+ <main className="container mx-auto p-6 grid lg:grid-cols-12 gap-8">
487
+
488
+ {/* Left Column: Controls */}
489
+ <div className="lg:col-span-4 space-y-6">
490
+
491
+ {/* Upload Section */}
492
+ <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
493
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2">
494
+ <Upload className="w-4 h-4" /> Audio Input
495
+ </h2>
496
+ <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">
497
+ <input
498
+ type="file"
499
+ accept="audio/*"
500
+ onChange={handleFileUpload}
501
+ className="hidden"
502
+ disabled={isExportingVideo}
503
+ />
504
+ <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">
505
+ <Upload className="w-6 h-6 text-cyan-400" />
506
+ </div>
507
+ <p className="font-medium text-slate-300">
508
+ {fileName ? fileName : 'Click to browse audio file'}
509
+ </p>
510
+ <p className="text-xs text-slate-500 mt-2">MP3, WAV, FLAC</p>
511
+ </label>
512
+
513
+ {/* Hidden Audio Element */}
514
+ <audio
515
+ ref={audioRef}
516
+ src={audioSrc}
517
+ onEnded={handleAudioEnded}
518
+ onPlay={() => setIsPlaying(true)}
519
+ onPause={() => setIsPlaying(false)}
520
+ onTimeUpdate={handleTimeUpdate}
521
+ onLoadedMetadata={handleLoadedMetadata}
522
+ />
523
+ </section>
524
+
525
+ {/* Settings Section */}
526
+ <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
527
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2">
528
+ <Settings2 className="w-4 h-4" /> Visual Settings
529
+ </h2>
530
+
531
+ <div className="space-y-5">
532
+ {/* Type */}
533
+ <div>
534
+ <label className="block text-sm font-medium text-slate-400 mb-2">Style</label>
535
+ <div className="grid grid-cols-3 gap-2">
536
+ {['bars', 'wave', 'circle'].map(type => (
537
+ <button
538
+ key={type}
539
+ onClick={() => setVizType(type)}
540
+ className={`py-2 px-3 rounded-lg text-sm font-medium capitalize transition-all ${
541
+ vizType === type
542
+ ? 'bg-slate-700 text-white shadow-inner border border-slate-600'
543
+ : 'bg-slate-950 text-slate-400 border border-slate-800 hover:border-slate-600'
544
+ }`}
545
+ >
546
+ {type}
547
+ </button>
548
+ ))}
549
+ </div>
550
+ </div>
551
+
552
+ {/* Color & Style */}
553
+ <div>
554
+ <div className="flex justify-between items-center mb-2">
555
+ <label className="text-sm font-medium text-slate-400">Color Style</label>
556
+ <select
557
+ value={colorMode}
558
+ onChange={(e) => setColorMode(e.target.value)}
559
+ className="bg-slate-950 border border-slate-700 text-slate-300 text-xs rounded px-2 py-1 outline-none"
560
+ >
561
+ <option value="solid">Solid</option>
562
+ <option value="gradient">Gradient</option>
563
+ <option value="rainbow">Rainbow</option>
564
+ </select>
565
+ </div>
566
+
567
+ <div className="flex items-center gap-3">
568
+ {colorMode !== 'rainbow' && (
569
+ <input
570
+ type="color"
571
+ value={color}
572
+ onChange={(e) => setColor(e.target.value)}
573
+ className="h-10 w-14 rounded cursor-pointer bg-slate-950 border border-slate-700 shrink-0"
574
+ />
575
+ )}
576
+ {colorMode === 'gradient' && (
577
+ <>
578
+ <span className="text-slate-500 text-xs font-medium">to</span>
579
+ <input
580
+ type="color"
581
+ value={color2}
582
+ onChange={(e) => setColor2(e.target.value)}
583
+ className="h-10 w-14 rounded cursor-pointer bg-slate-950 border border-slate-700 shrink-0"
584
+ />
585
+ </>
586
+ )}
587
+ {colorMode === 'solid' && (
588
+ <input
589
+ type="text"
590
+ value={color}
591
+ onChange={(e) => setColor(e.target.value)}
592
+ 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"
593
+ />
594
+ )}
595
+ </div>
596
+ </div>
597
+
598
+ {/* Glow Effect */}
599
+ <div className="flex items-center justify-between bg-slate-950 p-3 rounded-xl border border-slate-800">
600
+ <div className="flex items-center gap-2">
601
+ <Sparkles className="w-4 h-4 text-amber-400" />
602
+ <span className="text-sm font-medium text-slate-300">Neon Glow Effect</span>
603
+ </div>
604
+ <label className="relative inline-flex items-center cursor-pointer">
605
+ <input type="checkbox" checked={glow} onChange={(e) => setGlow(e.target.checked)} className="sr-only peer" />
606
+ <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>
607
+ </label>
608
+ </div>
609
+
610
+ {/* Thickness */}
611
+ <div>
612
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
613
+ <span>Line Thickness</span>
614
+ <span className="text-slate-500">{thickness}px</span>
615
+ </label>
616
+ <input
617
+ type="range"
618
+ min="2" max="64"
619
+ value={thickness}
620
+ onChange={(e) => setThickness(Number(e.target.value))}
621
+ className="w-full accent-cyan-500 cursor-pointer"
622
+ />
623
+ </div>
624
+
625
+ {/* Spacing */}
626
+ <div>
627
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
628
+ <span>Space Between Lines</span>
629
+ <span className="text-slate-500">{spacing}px</span>
630
+ </label>
631
+ <input
632
+ type="range"
633
+ min="0" max="64"
634
+ value={spacing}
635
+ onChange={(e) => setSpacing(Number(e.target.value))}
636
+ className="w-full accent-cyan-500 cursor-pointer"
637
+ />
638
+ </div>
639
+
640
+ {/* Amplitude (Sensitivity) */}
641
+ <div>
642
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
643
+ <span>Amplitude (Height)</span>
644
+ <span className="text-slate-500">{sensitivity.toFixed(1)}x</span>
645
+ </label>
646
+ <input
647
+ type="range"
648
+ min="0.5" max="3.0" step="0.1"
649
+ value={sensitivity}
650
+ onChange={(e) => setSensitivity(Number(e.target.value))}
651
+ className="w-full accent-cyan-500 cursor-pointer"
652
+ />
653
+ </div>
654
+
655
+ {/* Smoothing */}
656
+ <div>
657
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
658
+ <span>Motion Smoothing</span>
659
+ <span className="text-slate-500">{Math.round(smoothing * 100)}%</span>
660
+ </label>
661
+ <input
662
+ type="range"
663
+ min="0.1" max="0.99" step="0.01"
664
+ value={smoothing}
665
+ onChange={(e) => setSmoothing(Number(e.target.value))}
666
+ className="w-full accent-cyan-500 cursor-pointer"
667
+ />
668
+ </div>
669
+
670
+ {/* Transform Settings */}
671
+ <div className="pt-4 mt-4 border-t border-slate-800 space-y-5">
672
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">Transform & Position</h3>
673
+
674
+ {/* Scale */}
675
+ <div>
676
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
677
+ <span>Size (Scale)</span>
678
+ <span className="text-slate-500">{scale.toFixed(2)}x</span>
679
+ </label>
680
+ <input
681
+ type="range"
682
+ min="0.1" max="3.0" step="0.1"
683
+ value={scale}
684
+ onChange={(e) => setScale(Number(e.target.value))}
685
+ className="w-full accent-cyan-500 cursor-pointer"
686
+ />
687
+ </div>
688
+
689
+ {/* Rotation */}
690
+ <div>
691
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
692
+ <span>Rotation</span>
693
+ <span className="text-slate-500">{rotation}°</span>
694
+ </label>
695
+ <input
696
+ type="range"
697
+ min="0" max="360" step="1"
698
+ value={rotation}
699
+ onChange={(e) => setRotation(Number(e.target.value))}
700
+ className="w-full accent-cyan-500 cursor-pointer"
701
+ />
702
+ </div>
703
+
704
+ {/* Offset X */}
705
+ <div>
706
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
707
+ <span>Horizontal Position</span>
708
+ <span className="text-slate-500">{Math.round(offsetX)}%</span>
709
+ </label>
710
+ <input
711
+ type="range"
712
+ min="-50" max="50" step="1"
713
+ value={offsetX}
714
+ onChange={(e) => setOffsetX(Number(e.target.value))}
715
+ className="w-full accent-cyan-500 cursor-pointer"
716
+ />
717
+ </div>
718
+
719
+ {/* Offset Y */}
720
+ <div>
721
+ <label className="flex justify-between text-sm font-medium text-slate-400 mb-2">
722
+ <span>Vertical Position</span>
723
+ <span className="text-slate-500">{Math.round(offsetY)}%</span>
724
+ </label>
725
+ <input
726
+ type="range"
727
+ min="-50" max="50" step="1"
728
+ value={offsetY}
729
+ onChange={(e) => setOffsetY(Number(e.target.value))}
730
+ className="w-full accent-cyan-500 cursor-pointer"
731
+ />
732
+ </div>
733
+ </div>
734
+
735
+ </div>
736
+ </section>
737
+
738
+ {/* Advanced Layout Section */}
739
+ <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
740
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4 flex items-center gap-2">
741
+ <Monitor className="w-4 h-4" /> Output Setup
742
+ </h2>
743
+
744
+ <div className="space-y-5">
745
+ {/* Resolution */}
746
+ <div>
747
+ <label className="block text-sm font-medium text-slate-400 mb-2">Aspect Ratio & Resolution</label>
748
+ <select
749
+ value={resolution}
750
+ onChange={(e) => setResolution(e.target.value)}
751
+ 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"
752
+ >
753
+ <option value="4k_16_9">4K Landscape (3840x2160)</option>
754
+ <option value="1080p_16_9">1080p Landscape (1920x1080)</option>
755
+ <option value="4k_9_16">4K Vertical / Reels (2160x3840)</option>
756
+ <option value="1080p_9_16">1080p Vertical / Reels (1080x1920)</option>
757
+ </select>
758
+ </div>
759
+
760
+ {/* Background */}
761
+ <div>
762
+ <label className="block text-sm font-medium text-slate-400 mb-2">Background Type</label>
763
+ <div className="flex gap-2 mb-3">
764
+ {['transparent', 'color', 'image'].map(type => (
765
+ <button
766
+ key={type}
767
+ onClick={() => setBgType(type)}
768
+ className={`flex-1 py-2 px-2 rounded-lg text-xs font-medium capitalize transition-all ${
769
+ bgType === type
770
+ ? 'bg-slate-700 text-white shadow-inner border border-slate-600'
771
+ : 'bg-slate-950 text-slate-400 border border-slate-800 hover:border-slate-600'
772
+ }`}
773
+ >
774
+ {type}
775
+ </button>
776
+ ))}
777
+ </div>
778
+
779
+ {bgType === 'color' && (
780
+ <div className="flex items-center gap-3 mt-2 bg-slate-950 p-2 rounded-lg border border-slate-800">
781
+ <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" />
782
+ <span className="text-sm font-mono text-slate-400 uppercase">{bgColor}</span>
783
+ </div>
784
+ )}
785
+
786
+ {bgType === 'image' && (
787
+ <div className="mt-2">
788
+ <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">
789
+ <ImagePlus className="w-4 h-4" />
790
+ {bgImageSrc ? 'Change Image' : 'Upload Background Image'}
791
+ <input type="file" accept="image/*" onChange={handleBgUpload} className="hidden" />
792
+ </label>
793
+ </div>
794
+ )}
795
+ </div>
796
+ </div>
797
+ </section>
798
+
799
+ {/* Export Section */}
800
+ <section className="bg-slate-900 p-6 rounded-2xl border border-slate-800 shadow-xl">
801
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">Export Options</h2>
802
+
803
+ <div className="grid grid-cols-2 gap-3">
804
+ <button
805
+ onClick={exportImage}
806
+ disabled={isExportingVideo}
807
+ className="col-span-2 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"
808
+ >
809
+ <ImageIcon className="w-4 h-4" />
810
+ Save Snapshot (PNG)
811
+ </button>
812
+
813
+ {isExportingVideo ? (
814
+ <div className="col-span-2 space-y-3">
815
+ <button
816
+ onClick={stopVideoExport}
817
+ 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"
818
+ >
819
+ <StopCircle className="w-5 h-5" />
820
+ Stop & Save
821
+ </button>
822
+ <div className="w-full bg-slate-950 rounded-full h-2.5 border border-slate-800 overflow-hidden">
823
+ <div className="bg-cyan-500 h-2.5 rounded-full transition-all duration-300" style={{ width: `${exportProgress}%` }}></div>
824
+ </div>
825
+ <p className="text-xs text-center text-slate-400">Recording video... {Math.round(exportProgress)}%</p>
826
+ </div>
827
+ ) : (
828
+ <button
829
+ onClick={startVideoExport}
830
+ disabled={!audioSrc}
831
+ className="col-span-2 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"
832
+ >
833
+ <Video className="w-5 h-5" />
834
+ Export Video (WebM)
835
+ </button>
836
+ )}
837
+ </div>
838
+ </section>
839
+
840
+ </div>
841
+
842
+ {/* Right Column: Preview & Player (Made Sticky and constrained height) */}
843
+ <div className="lg:col-span-8 flex flex-col gap-4 lg:sticky lg:top-6 lg:h-[calc(100vh-3rem)]">
844
+ <div className="bg-slate-900 rounded-2xl border border-slate-800 shadow-xl overflow-hidden flex-1 relative flex flex-col min-h-0">
845
+
846
+ <div className="p-4 border-b border-slate-800 bg-slate-900/80 flex justify-between items-center z-10 shrink-0">
847
+ <span className="text-sm font-semibold text-slate-300 flex items-center gap-2">
848
+ Live Preview
849
+ <span className="bg-slate-800 text-xs px-2 py-0.5 rounded text-slate-400 border border-slate-700">
850
+ {RESOLUTIONS[resolution]?.w}x{RESOLUTIONS[resolution]?.h}
851
+ </span>
852
+ </span>
853
+ <span className="text-xs text-slate-500">
854
+ {bgType === 'transparent' ? 'Checkerboard denotes transparency' : 'Background included in export'}
855
+ </span>
856
+ </div>
857
+
858
+ {/* Checkerboard Background for Transparency check */}
859
+ <div
860
+ className="flex-1 w-full relative flex items-center justify-center p-4 sm:p-6 overflow-hidden bg-black/50 min-h-0"
861
+ style={ bgType === 'transparent' ? {
862
+ 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)',
863
+ backgroundPosition: '0 0, 10px 10px',
864
+ backgroundSize: '20px 20px'
865
+ } : {}}
866
+ >
867
+ {/* Actual Canvas */}
868
+ <canvas
869
+ ref={canvasRef}
870
+ width={RESOLUTIONS[resolution]?.w || 3840}
871
+ height={RESOLUTIONS[resolution]?.h || 2160}
872
+ onMouseDown={handleMouseDown}
873
+ onMouseMove={handleMouseMove}
874
+ onMouseUp={handleMouseUpOrLeave}
875
+ onMouseLeave={handleMouseUpOrLeave}
876
+ 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'}`}
877
+ />
878
+
879
+ {!audioSrc && (
880
+ <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none bg-slate-900/80 backdrop-blur-sm z-20">
881
+ <Loader2 className="w-12 h-12 text-slate-500 animate-spin mb-4 opacity-50" />
882
+ <p className="text-slate-400 font-medium">Awaiting Audio Input</p>
883
+ </div>
884
+ )}
885
+ </div>
886
+ </div>
887
+
888
+ {/* Playback Controls Footer */}
889
+ <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">
890
+ <div className="flex items-center gap-4">
891
+ <button
892
+ onClick={togglePlay}
893
+ disabled={!audioSrc || isExportingVideo}
894
+ 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"
895
+ >
896
+ {isPlaying ? <Pause className="w-6 h-6 fill-current" /> : <Play className="w-6 h-6 fill-current ml-1" />}
897
+ </button>
898
+
899
+ <div className="flex-1 flex flex-col gap-2">
900
+ <div className="flex justify-between text-xs font-mono text-slate-400">
901
+ <span>{formatTime(audioTime)}</span>
902
+ <span className="text-slate-300 font-sans truncate px-4">{fileName || 'No audio selected'}</span>
903
+ <span>{formatTime(audioDuration)}</span>
904
+ </div>
905
+ <input
906
+ type="range"
907
+ min="0"
908
+ max={audioDuration || 100}
909
+ value={audioTime}
910
+ onChange={handleSeek}
911
+ disabled={!audioSrc || isExportingVideo}
912
+ 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"
913
+ />
914
+ </div>
915
+ </div>
916
+ </div>
917
+
918
+ </div>
919
+
920
+ </main>
921
+ </div>
922
+ );
923
+ }