mirror of
https://github.com/JorySeverijnse/ui-fixer-supreme.git
synced 2026-01-29 16:18:38 +00:00
Fix intro video and visuals
Introduce reliable intro.webm playback before main video, switch to webm-first intro with proper frame handling; reorganize oscilloscope controls, add spectrum visualization support, and adjust audio inputs placement and mic controls. Update oscilloscope to render spectrum mode and side-by-side layout with live display options. X-Lovable-Edit-ID: edt-8fca3b32-298d-48b9-97c3-d077b9c82892
This commit is contained in:
commit
f3263cd29e
@ -6,7 +6,7 @@ import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer';
|
||||
import { useOscilloscopeRenderer } from '@/hooks/useOscilloscopeRenderer';
|
||||
import { useVideoExporter } from '@/hooks/useVideoExporter';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { Mic, MicOff, Pause, Play } from 'lucide-react';
|
||||
import { Pause, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
|
||||
@ -24,6 +24,7 @@ export function Oscilloscope() {
|
||||
showGrid: true,
|
||||
glowIntensity: 1,
|
||||
displayMode: 'combined',
|
||||
visualizationMode: 'waveform',
|
||||
});
|
||||
const [isMicActive, setIsMicActive] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@ -339,54 +340,21 @@ export function Oscilloscope() {
|
||||
onExportQualityChange={handleExportQualityChange}
|
||||
liveSettings={liveSettings}
|
||||
onLiveSettingsChange={setLiveSettings}
|
||||
isMicActive={isMicActive}
|
||||
onToggleMic={toggleMic}
|
||||
/>
|
||||
|
||||
{/* Audio Uploader - below controls on desktop */}
|
||||
<div className="mt-4">
|
||||
<AudioUploader
|
||||
onFileSelect={handleFileSelect}
|
||||
isLoading={isLoading}
|
||||
fileName={fileName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio Input Row - Now at bottom */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="flex-1 w-full sm:w-auto">
|
||||
<AudioUploader
|
||||
onFileSelect={handleFileSelect}
|
||||
isLoading={isLoading}
|
||||
fileName={fileName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Microphone Toggle */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={toggleMic}
|
||||
variant={isMicActive ? "default" : "outline"}
|
||||
className={`flex items-center gap-2 font-crt ${
|
||||
isMicActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border-primary/50 hover:bg-primary/10'
|
||||
}`}
|
||||
>
|
||||
{isMicActive ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
{isMicActive ? 'STOP MIC' : 'USE MICROPHONE'}
|
||||
</Button>
|
||||
|
||||
{isMicActive && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground font-mono-crt">
|
||||
Real-time input active
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowMicCalibration(!showMicCalibration)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-mono-crt text-xs"
|
||||
>
|
||||
Calibrate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Microphone Calibration */}
|
||||
{showMicCalibration && isMicActive && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 max-w-md mx-auto">
|
||||
@ -438,6 +406,56 @@ export function Oscilloscope() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showMicCalibration && isMicActive && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 max-w-md mx-auto">
|
||||
<h3 className="font-crt text-lg text-primary text-glow mb-4">Microphone Calibration</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Level Indicator */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm font-mono-crt mb-2">
|
||||
<span>Input Level</span>
|
||||
<span>{Math.round(micLevel * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary h-3 rounded-full transition-all duration-100"
|
||||
style={{ width: `${micLevel * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>Quiet</span>
|
||||
<span className={micLevel > 0.7 ? 'text-red-400' : micLevel > 0.5 ? 'text-yellow-400' : 'text-green-400'}>
|
||||
{micLevel > 0.7 ? 'Too Loud' : micLevel > 0.5 ? 'Good' : 'Too Quiet'}
|
||||
</span>
|
||||
<span>Loud</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gain Control */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm font-mono-crt mb-2">
|
||||
<span>Gain</span>
|
||||
<span>{micGain.toFixed(1)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={micGain}
|
||||
onChange={(e) => setMicGain(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground font-mono-crt">
|
||||
Speak into your microphone and adjust gain until the level shows "Good" (green).
|
||||
The bar should peak around 50-70% when speaking normally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Info */}
|
||||
{(isLoading || isExporting) && (
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { Play, Download, RotateCcw } from 'lucide-react';
|
||||
import { Play, Download, RotateCcw, Mic, MicOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer';
|
||||
|
||||
export type VisualizationMode = 'waveform' | 'spectrum' | 'both';
|
||||
|
||||
export interface LiveDisplaySettings {
|
||||
lineThickness: number;
|
||||
showGrid: boolean;
|
||||
glowIntensity: number;
|
||||
displayMode: OscilloscopeMode;
|
||||
visualizationMode: VisualizationMode;
|
||||
}
|
||||
|
||||
interface ControlPanelProps {
|
||||
@ -36,6 +39,8 @@ interface ControlPanelProps {
|
||||
onExportQualityChange: (quality: string) => void;
|
||||
liveSettings: LiveDisplaySettings;
|
||||
onLiveSettingsChange: (settings: LiveDisplaySettings) => void;
|
||||
isMicActive: boolean;
|
||||
onToggleMic: () => void;
|
||||
}
|
||||
|
||||
export function OscilloscopeControls({
|
||||
@ -62,6 +67,8 @@ export function OscilloscopeControls({
|
||||
onExportQualityChange,
|
||||
liveSettings,
|
||||
onLiveSettingsChange,
|
||||
isMicActive,
|
||||
onToggleMic,
|
||||
}: ControlPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 bg-card border border-border rounded-lg">
|
||||
@ -69,6 +76,26 @@ export function OscilloscopeControls({
|
||||
<div className="space-y-3">
|
||||
<Label className="font-crt text-lg text-primary text-glow">LIVE DISPLAY</Label>
|
||||
|
||||
{/* Visualization Mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono-crt text-sm text-foreground/90">Visualization</span>
|
||||
<div className="flex gap-1">
|
||||
{(['waveform', 'spectrum', 'both'] as VisualizationMode[]).map((vizMode) => (
|
||||
<button
|
||||
key={vizMode}
|
||||
onClick={() => onLiveSettingsChange({ ...liveSettings, visualizationMode: vizMode })}
|
||||
className={`px-2 py-1 text-xs font-mono-crt border transition-all duration-300 capitalize ${
|
||||
liveSettings.visualizationMode === vizMode
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-primary/50 text-primary/70 hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{vizMode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Mode */}
|
||||
<RadioGroup
|
||||
value={liveSettings.displayMode}
|
||||
@ -147,6 +174,31 @@ export function OscilloscopeControls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio Input */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-crt text-lg text-primary text-glow">AUDIO INPUT</Label>
|
||||
|
||||
{/* Microphone Toggle */}
|
||||
<Button
|
||||
onClick={onToggleMic}
|
||||
variant={isMicActive ? "default" : "outline"}
|
||||
className={`w-full flex items-center justify-center gap-2 font-crt ${
|
||||
isMicActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border-primary/50 hover:bg-primary/10'
|
||||
}`}
|
||||
>
|
||||
{isMicActive ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
{isMicActive ? 'STOP MICROPHONE' : 'USE MICROPHONE'}
|
||||
</Button>
|
||||
|
||||
{isMicActive && (
|
||||
<p className="text-xs text-muted-foreground font-mono-crt text-center">
|
||||
Real-time input active
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-crt text-lg text-primary text-glow">PLAYBACK CONTROLS</Label>
|
||||
|
||||
@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
|
||||
import type { AudioData } from '@/hooks/useAudioAnalyzer';
|
||||
import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer';
|
||||
import { useAudioAnalyzer as useSharedAudioAnalyzer } from '@/contexts/AudioAnalyzerContext';
|
||||
import type { LiveDisplaySettings } from './OscilloscopeControls';
|
||||
import type { LiveDisplaySettings, VisualizationMode } from './OscilloscopeControls';
|
||||
|
||||
interface OscilloscopeDisplayProps {
|
||||
audioData: AudioData | null;
|
||||
@ -56,6 +56,7 @@ export function OscilloscopeDisplay({
|
||||
const showGrid = liveSettings?.showGrid ?? true;
|
||||
const glowIntensity = liveSettings?.glowIntensity ?? 1;
|
||||
const liveDisplayMode = liveSettings?.displayMode ?? 'combined';
|
||||
const visualizationMode = liveSettings?.visualizationMode ?? 'waveform';
|
||||
|
||||
const drawGraticule = useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
if (!showGrid) return;
|
||||
@ -80,6 +81,36 @@ export function OscilloscopeDisplay({
|
||||
ctx.globalAlpha = 1;
|
||||
}, [showGrid]);
|
||||
|
||||
// Draw spectrum bars
|
||||
const drawSpectrum = useCallback((ctx: CanvasRenderingContext2D, frequencyData: Uint8Array, yOffset: number = 0, heightRatio: number = 1) => {
|
||||
const primaryColor = getThemeColor('--primary', '#00ff00');
|
||||
const accentColor = getThemeColor('--accent', '#00ccff');
|
||||
|
||||
const barCount = 64;
|
||||
const barWidth = (WIDTH / barCount) - 2;
|
||||
const maxBarHeight = (HEIGHT * heightRatio) * 0.8;
|
||||
|
||||
// Sample frequency data for the bar count
|
||||
const step = Math.floor(frequencyData.length / barCount);
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const dataIndex = i * step;
|
||||
const value = frequencyData[dataIndex] / 255;
|
||||
const barHeight = value * maxBarHeight;
|
||||
|
||||
const x = i * (barWidth + 2);
|
||||
const y = yOffset + (HEIGHT * heightRatio) - barHeight;
|
||||
|
||||
// Create gradient for each bar
|
||||
const gradient = ctx.createLinearGradient(x, y + barHeight, x, y);
|
||||
gradient.addColorStop(0, primaryColor);
|
||||
gradient.addColorStop(1, accentColor);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x, y, barWidth, barHeight);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const drawFrame = useCallback(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
@ -104,20 +135,24 @@ export function OscilloscopeDisplay({
|
||||
if (activeAnalyzer && !audioData) {
|
||||
// Real-time audio data (mic or music player)
|
||||
const bufferLength = activeAnalyzer.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
activeAnalyzer.getByteTimeDomainData(dataArray);
|
||||
const timeDomainData = new Uint8Array(bufferLength);
|
||||
const frequencyData = new Uint8Array(bufferLength);
|
||||
activeAnalyzer.getByteTimeDomainData(timeDomainData);
|
||||
activeAnalyzer.getByteFrequencyData(frequencyData);
|
||||
|
||||
// Clear to background color
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// Draw graticule first
|
||||
drawGraticule(ctx);
|
||||
// Draw graticule first (only for waveform modes)
|
||||
if (visualizationMode !== 'spectrum') {
|
||||
drawGraticule(ctx);
|
||||
}
|
||||
|
||||
// Convert to Float32Array-like for consistency
|
||||
const liveData = new Float32Array(dataArray.length);
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
liveData[i] = (dataArray[i] - 128) / 128; // Normalize to -1 to 1
|
||||
const liveData = new Float32Array(timeDomainData.length);
|
||||
for (let i = 0; i < timeDomainData.length; i++) {
|
||||
liveData[i] = (timeDomainData[i] - 128) / 128; // Normalize to -1 to 1
|
||||
}
|
||||
|
||||
// Apply glow effect
|
||||
@ -131,44 +166,85 @@ export function OscilloscopeDisplay({
|
||||
ctx.strokeStyle = primaryColor;
|
||||
ctx.lineWidth = lineThickness;
|
||||
|
||||
// Draw based on live display mode
|
||||
if (liveDisplayMode === 'all') {
|
||||
// XY / Lissajous mode - treat odd/even samples as L/R
|
||||
ctx.beginPath();
|
||||
const centerX = WIDTH / 2;
|
||||
const centerY = HEIGHT / 2;
|
||||
const scale = Math.min(WIDTH, HEIGHT) * 0.4;
|
||||
// Draw based on visualization mode
|
||||
if (visualizationMode === 'spectrum') {
|
||||
// Spectrum bars only
|
||||
ctx.shadowBlur = 0;
|
||||
drawSpectrum(ctx, frequencyData, 0, 1);
|
||||
} else if (visualizationMode === 'both') {
|
||||
// Waveform on top half, spectrum on bottom half
|
||||
// Draw waveform
|
||||
if (liveDisplayMode === 'all') {
|
||||
// XY mode in top half
|
||||
ctx.beginPath();
|
||||
const centerX = WIDTH / 2;
|
||||
const centerY = HEIGHT / 4;
|
||||
const scale = Math.min(WIDTH, HEIGHT / 2) * 0.35;
|
||||
|
||||
for (let i = 0; i < liveData.length - 1; i += 2) {
|
||||
const x = centerX + liveData[i] * scale;
|
||||
const y = centerY - liveData[i + 1] * scale;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
for (let i = 0; i < liveData.length - 1; i += 2) {
|
||||
const x = centerX + liveData[i] * scale;
|
||||
const y = centerY - liveData[i + 1] * scale;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Combined waveform in top half
|
||||
ctx.beginPath();
|
||||
const sliceWidth = WIDTH / liveData.length;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < liveData.length; i++) {
|
||||
const v = liveData[i];
|
||||
const y = (v * HEIGHT * 0.4) / 2 + HEIGHT / 4;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
x += sliceWidth;
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Spectrum in bottom half
|
||||
ctx.shadowBlur = 0;
|
||||
drawSpectrum(ctx, frequencyData, HEIGHT / 2, 0.5);
|
||||
|
||||
// Divider line
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, HEIGHT / 2);
|
||||
ctx.lineTo(WIDTH, HEIGHT / 2);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Combined waveform mode (default)
|
||||
ctx.beginPath();
|
||||
const sliceWidth = WIDTH / liveData.length;
|
||||
let x = 0;
|
||||
// Waveform only (default)
|
||||
if (liveDisplayMode === 'all') {
|
||||
// XY / Lissajous mode - treat odd/even samples as L/R
|
||||
ctx.beginPath();
|
||||
const centerX = WIDTH / 2;
|
||||
const centerY = HEIGHT / 2;
|
||||
const scale = Math.min(WIDTH, HEIGHT) * 0.4;
|
||||
|
||||
for (let i = 0; i < liveData.length; i++) {
|
||||
const v = liveData[i];
|
||||
const y = (v * HEIGHT) / 2 + HEIGHT / 2;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
for (let i = 0; i < liveData.length - 1; i += 2) {
|
||||
const x = centerX + liveData[i] * scale;
|
||||
const y = centerY - liveData[i + 1] * scale;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// Combined waveform mode
|
||||
ctx.beginPath();
|
||||
const sliceWidth = WIDTH / liveData.length;
|
||||
let x = 0;
|
||||
|
||||
x += sliceWidth;
|
||||
for (let i = 0; i < liveData.length; i++) {
|
||||
const v = liveData[i];
|
||||
const y = (v * HEIGHT) / 2 + HEIGHT / 2;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
x += sliceWidth;
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
@ -346,7 +422,7 @@ export function OscilloscopeDisplay({
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(drawFrame);
|
||||
}, [audioData, micAnalyzer, liveAnalyzer, mode, drawGraticule, onPlaybackEnd, isPlaying, playbackSpeed, isLooping, lineThickness, glowIntensity, liveDisplayMode, audioElementRef]);
|
||||
}, [audioData, micAnalyzer, liveAnalyzer, mode, drawGraticule, drawSpectrum, onPlaybackEnd, isPlaying, playbackSpeed, isLooping, lineThickness, glowIntensity, liveDisplayMode, visualizationMode, audioElementRef]);
|
||||
|
||||
// Initialize canvas
|
||||
useEffect(() => {
|
||||
|
||||
@ -61,41 +61,29 @@ export const useOfflineVideoExport = () => {
|
||||
|
||||
setState(prev => ({ ...prev, stage: 'rendering', progress: 5 }));
|
||||
|
||||
// Load intro video
|
||||
// Load intro video - always use webm
|
||||
console.log('📹 Loading intro video...');
|
||||
const introVideo = document.createElement('video');
|
||||
introVideo.muted = true;
|
||||
introVideo.playsInline = true;
|
||||
introVideo.preload = 'auto';
|
||||
introVideo.src = '/intro.webm';
|
||||
|
||||
// Try webm first, fallback to mp4
|
||||
let introLoaded = false;
|
||||
let introDuration = 0;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
introVideo.onloadedmetadata = () => {
|
||||
introDuration = introVideo.duration;
|
||||
console.log(`✅ Intro video loaded: ${introDuration.toFixed(2)}s`);
|
||||
introLoaded = true;
|
||||
resolve();
|
||||
};
|
||||
introVideo.onerror = () => {
|
||||
console.warn('⚠️ Could not load intro.webm, trying intro.mp4');
|
||||
introVideo.src = '/intro.mp4';
|
||||
};
|
||||
introVideo.src = '/intro.webm';
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (!introLoaded) {
|
||||
console.warn('⚠️ Intro video loading timed out');
|
||||
resolve();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
} catch (introError) {
|
||||
console.warn('⚠️ Could not load intro video:', introError);
|
||||
}
|
||||
// Wait for video to be fully loaded
|
||||
await new Promise<void>((resolve) => {
|
||||
introVideo.onloadeddata = () => {
|
||||
introDuration = introVideo.duration;
|
||||
console.log(`✅ Intro video loaded: ${introDuration.toFixed(2)}s, ${introVideo.videoWidth}x${introVideo.videoHeight}`);
|
||||
resolve();
|
||||
};
|
||||
introVideo.onerror = (e) => {
|
||||
console.error('❌ Failed to load intro video:', e);
|
||||
resolve();
|
||||
};
|
||||
introVideo.load();
|
||||
});
|
||||
|
||||
setState(prev => ({ ...prev, progress: 10 }));
|
||||
|
||||
@ -215,7 +203,7 @@ export const useOfflineVideoExport = () => {
|
||||
recorder.start(1000); // 1 second chunks
|
||||
|
||||
// Calculate total frames including intro
|
||||
const introFrames = introLoaded ? Math.ceil(introDuration * fps) : 0;
|
||||
const introFrames = introDuration > 0 ? Math.ceil(introDuration * fps) : 0;
|
||||
const mainFrames = Math.ceil(duration * fps);
|
||||
const fadeFrames = Math.ceil(fps * 0.5); // 0.5 second fade
|
||||
const totalFrames = introFrames + mainFrames;
|
||||
@ -223,22 +211,31 @@ export const useOfflineVideoExport = () => {
|
||||
|
||||
console.log(`🎬 Total frames: ${totalFrames} (intro: ${introFrames}, main: ${mainFrames}, fade: ${fadeFrames})`);
|
||||
|
||||
// Render intro frames first (if loaded)
|
||||
if (introLoaded && introFrames > 0) {
|
||||
// Render intro frames first
|
||||
if (introFrames > 0) {
|
||||
console.log('📹 Rendering intro frames...');
|
||||
introVideo.currentTime = 0;
|
||||
await introVideo.play();
|
||||
|
||||
for (let frameIndex = 0; frameIndex < introFrames; frameIndex++) {
|
||||
if (cancelledRef.current) {
|
||||
introVideo.pause();
|
||||
recorder.stop();
|
||||
setState({ isExporting: false, progress: 0, error: 'Cancelled', stage: 'idle', fps: 0 });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Seek to correct time
|
||||
introVideo.currentTime = frameIndex / fps;
|
||||
// Seek to correct time and wait for frame
|
||||
const targetTime = frameIndex / fps;
|
||||
introVideo.currentTime = targetTime;
|
||||
|
||||
// Wait for the seek to complete
|
||||
await new Promise<void>((resolve) => {
|
||||
const onSeeked = () => {
|
||||
introVideo.removeEventListener('seeked', onSeeked);
|
||||
resolve();
|
||||
};
|
||||
introVideo.addEventListener('seeked', onSeeked);
|
||||
// Fallback timeout
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
|
||||
// Draw intro video frame scaled to canvas
|
||||
ctx.fillStyle = '#0a0f0a';
|
||||
@ -268,7 +265,6 @@ export const useOfflineVideoExport = () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 / fps));
|
||||
}
|
||||
|
||||
introVideo.pause();
|
||||
console.log('✅ Intro frames complete');
|
||||
}
|
||||
|
||||
@ -338,7 +334,7 @@ export const useOfflineVideoExport = () => {
|
||||
}
|
||||
|
||||
// Apply fade-in effect from intro (first fadeFrames of main content)
|
||||
if (introLoaded && frameIndex < fadeFrames) {
|
||||
if (introDuration > 0 && frameIndex < fadeFrames) {
|
||||
const fadeProgress = frameIndex / fadeFrames;
|
||||
// Draw a semi-transparent black overlay that fades out
|
||||
ctx.fillStyle = `rgba(10, 15, 10, ${1 - fadeProgress})`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user