fix webm metadata duration
This commit is contained in:
Generated
+26
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function VideoEditor() {
|
||||
// --- State ---
|
||||
const [videoPath, setVideoPath] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -10,11 +9,9 @@ export default function VideoEditor() {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
// --- Refs ---
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
@@ -108,6 +92,7 @@ export default function VideoEditor() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
@@ -116,7 +101,6 @@ export default function VideoEditor() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main render ---
|
||||
return (
|
||||
<div className="flex h-screen bg-background p-6 gap-6">
|
||||
<div className="flex flex-col flex-[7] min-w-0 h-full">
|
||||
@@ -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)}
|
||||
|
||||
@@ -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<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const startTimeRef = useRef<number>(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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user