VirtualLabo / src /components /lab /TimelapseRecorder.tsx
rinogeek's picture
Initial commit: Virtual Labo Chimique - Docker deployment
538d81e
import { useState, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Play, Pause, Square, Download, Video, Clock } from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
interface TimelapseFrame {
timestamp: number;
canvasData: string;
}
interface TimelapseRecorderProps {
onRequestSnapshot: () => string | null;
onLoadSnapshot: (data: string) => void;
}
export const TimelapseRecorder = ({ onRequestSnapshot, onLoadSnapshot }: TimelapseRecorderProps) => {
const [isRecording, setIsRecording] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [frames, setFrames] = useState<TimelapseFrame[]>([]);
const [currentFrame, setCurrentFrame] = useState(0);
const [recordingDuration, setRecordingDuration] = useState(0);
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const playbackIntervalRef = useRef<NodeJS.Timeout | null>(null);
const recordingStartTimeRef = useRef<number>(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordedChunksRef = useRef<Blob[]>([]);
const startRecording = useCallback(() => {
setIsRecording(true);
setFrames([]);
setCurrentFrame(0);
recordingStartTimeRef.current = Date.now();
// Capture frames every 500ms
recordingIntervalRef.current = setInterval(() => {
const snapshot = onRequestSnapshot();
if (snapshot) {
const timestamp = Date.now() - recordingStartTimeRef.current;
setFrames(prev => [...prev, { timestamp, canvasData: snapshot }]);
setRecordingDuration(timestamp);
}
}, 500);
toast.success("Enregistrement démarré");
}, [onRequestSnapshot]);
const stopRecording = useCallback(() => {
if (recordingIntervalRef.current) {
clearInterval(recordingIntervalRef.current);
recordingIntervalRef.current = null;
}
setIsRecording(false);
toast.success(`Enregistrement terminé: ${frames.length} frames capturées`);
}, [frames.length]);
const playTimelapse = useCallback(() => {
if (frames.length === 0) {
toast.error("Aucune séquence enregistrée");
return;
}
setIsPlaying(true);
setCurrentFrame(0);
let frameIndex = 0;
playbackIntervalRef.current = setInterval(() => {
if (frameIndex >= frames.length) {
setIsPlaying(false);
if (playbackIntervalRef.current) {
clearInterval(playbackIntervalRef.current);
}
toast.success("Lecture terminée");
return;
}
onLoadSnapshot(frames[frameIndex].canvasData);
setCurrentFrame(frameIndex);
frameIndex++;
}, 500);
}, [frames, onLoadSnapshot]);
const pauseTimelapse = useCallback(() => {
if (playbackIntervalRef.current) {
clearInterval(playbackIntervalRef.current);
playbackIntervalRef.current = null;
}
setIsPlaying(false);
}, []);
const seekToFrame = useCallback((frameIndex: number) => {
if (frameIndex >= 0 && frameIndex < frames.length) {
setCurrentFrame(frameIndex);
onLoadSnapshot(frames[frameIndex].canvasData);
}
}, [frames, onLoadSnapshot]);
const exportAsVideo = useCallback(async () => {
if (frames.length === 0) {
toast.error("Aucune séquence à exporter");
return;
}
try {
toast.loading("Préparation de l'export vidéo...");
// Create a temporary canvas for video export
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 600;
const ctx = canvas.getContext('2d');
if (!ctx) {
toast.error("Erreur lors de la création du canvas");
return;
}
// Setup MediaRecorder
const stream = canvas.captureStream(30);
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9',
videoBitsPerSecond: 2500000
});
recordedChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunksRef.current, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `experience_timelapse_${Date.now()}.webm`;
a.click();
URL.revokeObjectURL(url);
toast.success("Vidéo exportée avec succès");
};
mediaRecorder.start();
// Render each frame
for (let i = 0; i < frames.length; i++) {
const img = new Image();
await new Promise((resolve) => {
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
resolve(null);
};
img.src = frames[i].canvasData;
});
// Wait to maintain frame rate
await new Promise(resolve => setTimeout(resolve, 500));
}
mediaRecorder.stop();
} catch (error) {
console.error("Export error:", error);
toast.error("Erreur lors de l'export vidéo");
}
}, [frames]);
const formatDuration = (ms: number) => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
return (
<Card className="p-4 space-y-4">
<div className="flex items-center gap-2">
<Video className="w-5 h-5 text-primary" />
<h3 className="font-semibold text-lg">Enregistrement Time-lapse</h3>
</div>
<div className="space-y-3">
{/* Recording Controls */}
<div className="flex gap-2">
{!isRecording ? (
<Button onClick={startRecording} size="sm" disabled={isPlaying}>
<Clock className="w-4 h-4 mr-2" />
Démarrer l'enregistrement
</Button>
) : (
<Button onClick={stopRecording} variant="destructive" size="sm">
<Square className="w-4 h-4 mr-2" />
Arrêter
</Button>
)}
</div>
{/* Recording Status */}
{isRecording && (
<div className="flex items-center gap-2 text-sm text-muted-foreground animate-pulse">
<div className="w-2 h-2 bg-red-500 rounded-full" />
Enregistrement en cours... {formatDuration(recordingDuration)} - {frames.length} frames
</div>
)}
{/* Playback Controls */}
{frames.length > 0 && !isRecording && (
<>
<div className="space-y-2">
<div className="flex gap-2">
{!isPlaying ? (
<Button onClick={playTimelapse} size="sm" variant="secondary">
<Play className="w-4 h-4 mr-2" />
Lire
</Button>
) : (
<Button onClick={pauseTimelapse} size="sm" variant="secondary">
<Pause className="w-4 h-4 mr-2" />
Pause
</Button>
)}
<Button onClick={exportAsVideo} size="sm" variant="outline">
<Download className="w-4 h-4 mr-2" />
Exporter vidéo
</Button>
</div>
{/* Timeline Slider */}
<div className="space-y-1">
<Slider
value={[currentFrame]}
max={frames.length - 1}
step={1}
onValueChange={(value) => seekToFrame(value[0])}
disabled={isPlaying}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Frame {currentFrame + 1} / {frames.length}</span>
<span>{formatDuration(frames[currentFrame]?.timestamp || 0)}</span>
</div>
</div>
</div>
</>
)}
</div>
</Card>
);
};