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

Create m2provisuals-more-options-added

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