diff --git a/package-lock.json b/package-lock.json index 5b2a9c9..571a253 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "pangolin", "version": "0.0.0", "dependencies": { + "@fix-webm-duration/fix": "^1.0.1", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", @@ -1134,6 +1135,25 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fix-webm-duration/fix": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@fix-webm-duration/fix/-/fix-1.0.1.tgz", + "integrity": "sha512-rRN4CpWQaXRbCXYqKIxnsUq8OSWSGq/SVlnxxkx0HxMZZ1u0qnB24P766o8QS5YaMMqAOFAzmsMmfZ2OWucOLQ==", + "license": "MIT", + "dependencies": { + "@fix-webm-duration/parser": "1.0.1", + "tslib": "^2.3.0" + } + }, + "node_modules/@fix-webm-duration/parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@fix-webm-duration/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-Z6el7ohBBpym4iOmxDxumN715cHzLcAbtOtYfIbvoyToVtAuxvHt3ELXGff83iAXEU1Yj7g+obQBdiKJYVhbWw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -8801,6 +8821,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index acb9102..5510882 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@fix-webm-duration/fix": "^1.0.1", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 4c1bac8..13189e0 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react"; export default function VideoEditor() { - // --- State --- const [videoPath, setVideoPath] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -10,11 +9,9 @@ export default function VideoEditor() { const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); - // --- Refs --- const videoRef = useRef(null); const canvasRef = useRef(null); - // --- Load video path on mount --- useEffect(() => { async function loadVideo() { try { @@ -33,21 +30,21 @@ export default function VideoEditor() { loadVideo(); }, []); - // --- Canvas drawing and video event listeners --- useEffect(() => { const video = videoRef.current; const canvas = canvasRef.current; if (!video || !canvas) return; + let animationId: number; function drawFrame() { - if (!video || !canvas) return; - if (video.paused || video.ended) return; - // Keep canvas size in sync with video + if (!video || !canvas || video.paused || video.ended) return; + if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) { canvas.width = video.videoWidth; canvas.height = video.videoHeight; } + const ctx = canvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); @@ -56,12 +53,8 @@ export default function VideoEditor() { animationId = requestAnimationFrame(drawFrame); } - function handlePlay() { - drawFrame(); - } - function handlePause() { - cancelAnimationFrame(animationId); - } + const handlePlay = () => drawFrame(); + const handlePause = () => cancelAnimationFrame(animationId); video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); @@ -75,32 +68,23 @@ export default function VideoEditor() { }; }, [videoPath]); - // --- Handlers --- function togglePlayPause() { if (!videoRef.current) return; - if (isPlaying) { - videoRef.current.pause(); - } else { - videoRef.current.play(); - } + isPlaying ? videoRef.current.pause() : videoRef.current.play(); } function handleSeek(e: React.ChangeEvent) { if (!videoRef.current) return; - const newTime = parseFloat(e.target.value); - videoRef.current.currentTime = newTime; + videoRef.current.currentTime = parseFloat(e.target.value); } function formatTime(seconds: number) { - if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) { - return '0:00'; - } + if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } - // --- Early returns for loading/error --- if (loading) { return (
@@ -108,6 +92,7 @@ export default function VideoEditor() {
); } + if (error) { return (
@@ -116,7 +101,6 @@ export default function VideoEditor() { ); } - // --- Main render --- return (
@@ -142,23 +126,21 @@ export default function VideoEditor() { preload="metadata" onLoadedMetadata={e => { const video = e.currentTarget; - if (isFinite(video.duration) && !isNaN(video.duration) && video.duration > 0) { + if (isFinite(video.duration) && video.duration > 0) { setDuration(video.duration); } }} - onCanPlay={() => { - const video = videoRef.current; - if (video && isFinite(video.duration) && video.duration > 0) { + onDurationChange={e => { + const video = e.currentTarget; + if (isFinite(video.duration) && video.duration > 0) { setDuration(video.duration); } }} onTimeUpdate={e => { const time = e.currentTarget.currentTime; - if (isFinite(time) && !isNaN(time)) { - setCurrentTime(time); - } + if (isFinite(time)) setCurrentTime(time); }} - onError={() => setError('Failed to play video')} + onError={() => setError('Failed to load video')} onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8f16f18..d7a2d43 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from "react"; +import { fixWebmDuration } from "@fix-webm-duration/fix"; type UseScreenRecorderReturn = { recording: boolean; @@ -10,13 +11,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const mediaRecorderRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); + const startTimeRef = useRef(0); useEffect(() => { return () => { - if ( - mediaRecorderRef.current && - mediaRecorderRef.current.state === "recording" - ) { + if (mediaRecorderRef.current?.state === "recording") { mediaRecorderRef.current.stop(); } if (streamRef.current) { @@ -60,15 +59,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { let bitrate: number; if (totalPixels <= 1920 * 1080) { - bitrate = 150_000_000; // 150 Mbps for 1080p + bitrate = 150_000_000; } else if (totalPixels <= 2560 * 1440) { - bitrate = 250_000_000; // 250 Mbps for 1440p + bitrate = 250_000_000; } else { - bitrate = 400_000_000; // 400 Mbps for 4K + bitrate = 400_000_000; } - console.log(`Recording at ${width}x${height} with bitrate: ${bitrate / 1_000_000} Mbps`); - chunksRef.current = []; const mimeType = "video/webm;codecs=vp9"; const recorder = new MediaRecorder(streamRef.current, { @@ -84,45 +81,37 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; recorder.onstop = async () => { - // Don't stop stream here - already stopped in stopRecording for immediate indicator removal - // Just cleanup the ref streamRef.current = null; if (chunksRef.current.length === 0) return; - const videoBlob = new Blob(chunksRef.current, { type: mimeType }); + const duration = Date.now() - startTimeRef.current; + const buggyBlob = new Blob(chunksRef.current, { type: mimeType }); const timestamp = Date.now(); const videoFileName = `recording-${timestamp}.webm`; const trackingFileName = `recording-${timestamp}_tracking.json`; try { + const videoBlob = await fixWebmDuration(buggyBlob, duration); const arrayBuffer = await videoBlob.arrayBuffer(); - console.log(`Saving video: ${videoFileName} (${(arrayBuffer.byteLength / 1024 / 1024).toFixed(2)} MB)`); - const videoResult = await window.electronAPI.storeRecordedVideo( arrayBuffer, videoFileName ); - if (videoResult.success) { - console.log('✅ Video stored:', videoResult.path); - } else { - console.error('❌ Failed to store video:', videoResult.message); + if (!videoResult.success) { + console.error('Failed to store video:', videoResult.message); + return; } const trackingResult = await window.electronAPI.storeMouseTrackingData(trackingFileName); - if (trackingResult.success) { - console.log('Mouse tracking stored:', trackingResult.path); - console.log(`Captured ${trackingResult.eventCount} mouse events`); - } else { - console.warn('Failed to store tracking:', trackingResult.message); + if (!trackingResult.success) { + console.warn('Failed to store mouse tracking:', trackingResult.message); } - console.log('Opening editor window...'); await window.electronAPI.switchToEditor(); - } catch (error) { console.error('Error saving recording:', error); } @@ -133,6 +122,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; recorder.start(1000); + startTimeRef.current = Date.now(); setRecording(true); } catch (error) { console.error('Failed to start recording:', error); @@ -145,18 +135,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const stopRecording = () => { - if ( - mediaRecorderRef.current && - mediaRecorderRef.current.state === "recording" - ) { - // Stop stream tracks IMMEDIATELY to remove macOS status indicator + if (mediaRecorderRef.current?.state === "recording") { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); } mediaRecorderRef.current.stop(); setRecording(false); - window.electronAPI.stopMouseTracking(); } };