trysem commited on
Commit
6c5464c
·
verified ·
1 Parent(s): 7493ded

Create OGCOPY-CSS-30fps-layout-fix-

Browse files

css 30fps layout fixed
full rendering possible


1. The Layout Fix
I updated the CSS flexbox classes on the "Video Format & Framerate" section. They will now elegantly stack vertically if the sidebar gets too narrow, completely preventing them from pushing into the live preview window.

2. The Freezing Fix (Render Throttling)
The video was freezing because running a 4K canvas drawing loop at 60Hz (especially with the Neon Glow effect turned on) was completely maxing out your graphics card. When the GPU hits 100%, the browser's video encoder is starved of resources, causing it to drop frames, freeze, and eventually crash (leaving you with a black screen and only audio).

To fix this, I added a Render Throttle. Now, when you select 30 FPS and click export, the app physically limits the canvas drawing speed to 30 frames per second. This instantly cuts your computer's workload in half, leaving plenty of breathing room for the encoder to finish the video smoothly!

Here are the specific updates to apply to your index.html file:

Troubleshooting Tip: If you have a lower-spec computer and 4K still freezes even at 30 FPS, turning off the Neon Glow Effect is the absolute biggest thing you can do to guarantee a smooth export. Rendering the glow requires drawing 3 independent passes per frame—turning it off instantly reduces the rendering time by nearly 70%!

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