tmp files & video editor preview

This commit is contained in:
Siddharth
2025-10-14 23:16:03 -07:00
parent 5459eb3bc2
commit a578e659e6
10 changed files with 349 additions and 83 deletions
+78 -14
View File
@@ -1,8 +1,8 @@
import { BrowserWindow, screen, ipcMain, desktopCapturer, app } from "electron";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { uIOhook } from "uiohook-napi";
import fs from "node:fs/promises";
import { uIOhook } from "uiohook-napi";
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.join(__dirname$1, "..");
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"];
@@ -54,7 +54,8 @@ function createEditorWindow() {
webPreferences: {
preload: path.join(__dirname$1, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true
contextIsolation: true,
webSecurity: false
}
});
win.webContents.on("did-finish-load", () => {
@@ -214,14 +215,13 @@ let selectedSource = null;
function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow) {
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
const processedSources = sources.map((source) => ({
return sources.map((source) => ({
id: source.id,
name: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
}));
return processedSources;
});
ipcMain.handle("select-source", (_, source) => {
selectedSource = source;
@@ -255,31 +255,90 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
ipcMain.handle("stop-mouse-tracking", () => {
return stopMouseTracking();
});
ipcMain.handle("save-mouse-tracking-data", async (_, videoFileName) => {
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
return {
success: true,
path: videoPath,
message: "Video stored successfully"
};
} catch (error) {
console.error("Failed to store video:", error);
return {
success: false,
message: "Failed to store video",
error: String(error)
};
}
});
ipcMain.handle("store-mouse-tracking-data", async (_, fileName) => {
try {
const data = getTrackingData();
if (data.length === 0) {
return { success: false, message: "No tracking data to save" };
}
const jsonFileName = videoFileName.replace(".webm", "_tracking.json");
const filePath = path.join(process.env.HOME || "", "Downloads", jsonFileName);
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
const trackingPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), "utf-8");
return {
success: true,
message: "Tracking data saved",
filePath,
eventCount: data.length
path: trackingPath,
eventCount: data.length,
message: "Mouse tracking data stored successfully"
};
} catch (error) {
console.error("Failed to store mouse tracking data:", error);
return {
success: false,
message: "Failed to save tracking data",
message: "Failed to store mouse tracking data",
error: String(error)
};
}
});
ipcMain.handle("get-recorded-video-path", async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const videoFiles = files.filter((file) => file.endsWith(".webm"));
if (videoFiles.length === 0) {
return { success: false, message: "No recorded video found" };
}
const latestVideo = videoFiles.sort().reverse()[0];
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
return { success: true, path: videoPath };
} catch (error) {
console.error("Failed to get video path:", error);
return { success: false, message: "Failed to get video path", error: String(error) };
}
});
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function cleanupOldRecordings() {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const now = Date.now();
const maxAge = 7 * 24 * 60 * 60 * 1e3;
for (const file of files) {
const filePath = path.join(RECORDINGS_DIR, file);
const stats = await fs.stat(filePath);
if (now - stats.mtimeMs > maxAge) {
await fs.unlink(filePath);
console.log(`Deleted old recording: ${file}`);
}
}
} catch (error) {
console.error("Failed to cleanup old recordings:", error);
}
}
async function ensureRecordingsDir() {
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
console.log("Recordings directory ready:", RECORDINGS_DIR);
} catch (error) {
console.error("Failed to create recordings directory:", error);
}
}
process.env.APP_ROOT = path.join(__dirname, "..");
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
@@ -316,10 +375,14 @@ app.on("activate", () => {
createWindow();
}
});
app.on("will-quit", () => {
app.on("before-quit", async (event) => {
event.preventDefault();
cleanupMouseTracking();
await cleanupOldRecordings();
app.exit(0);
});
app.whenReady().then(() => {
app.whenReady().then(async () => {
await ensureRecordingsDir();
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
@@ -330,6 +393,7 @@ app.whenReady().then(() => {
});
export {
MAIN_DIST,
RECORDINGS_DIR,
RENDERER_DIST,
VITE_DEV_SERVER_URL
};
+8 -2
View File
@@ -22,7 +22,13 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
stopMouseTracking: () => {
return electron.ipcRenderer.invoke("stop-mouse-tracking");
},
saveMouseTrackingData: (videoFileName) => {
return electron.ipcRenderer.invoke("save-mouse-tracking-data", videoFileName);
storeRecordedVideo: (videoData, fileName) => {
return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
storeMouseTrackingData: (fileName) => {
return electron.ipcRenderer.invoke("store-mouse-tracking-data", fileName);
},
getRecordedVideoPath: () => {
return electron.ipcRenderer.invoke("get-recorded-video-path");
}
});
+53 -26
View File
@@ -2,8 +2,8 @@ import { ipcMain, desktopCapturer, BrowserWindow } from 'electron'
import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking'
import fs from 'node:fs/promises'
import path from 'node:path'
import { RECORDINGS_DIR } from '../main'
// Store selected source
let selectedSource: any = null
export function registerIpcHandlers(
@@ -12,21 +12,17 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null
) {
// Get available desktop capturer sources
ipcMain.handle('get-sources', async (_, opts) => {
const sources = await desktopCapturer.getSources(opts)
const processedSources = sources.map(source => ({
return sources.map(source => ({
id: source.id,
name: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
}))
return processedSources
})
// Select a source for recording
ipcMain.handle('select-source', (_, source) => {
selectedSource = source
const sourceSelectorWin = getSourceSelectorWindow()
@@ -36,12 +32,10 @@ export function registerIpcHandlers(
return selectedSource
})
// Get the currently selected source
ipcMain.handle('get-selected-source', () => {
return selectedSource
})
// Open the source selector window
ipcMain.handle('open-source-selector', () => {
const sourceSelectorWin = getSourceSelectorWindow()
if (sourceSelectorWin) {
@@ -51,7 +45,6 @@ export function registerIpcHandlers(
createSourceSelectorWindow()
})
// Switch from HUD overlay to editor window
ipcMain.handle('switch-to-editor', () => {
const mainWin = getMainWindow()
if (mainWin) {
@@ -60,18 +53,35 @@ export function registerIpcHandlers(
createEditorWindow()
})
// Start mouse tracking
ipcMain.handle('start-mouse-tracking', () => {
return startMouseTracking()
})
// Stop mouse tracking
ipcMain.handle('stop-mouse-tracking', () => {
return stopMouseTracking()
})
// Save mouse tracking data to file
ipcMain.handle('save-mouse-tracking-data', async (_, videoFileName: string) => {
ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName)
await fs.writeFile(videoPath, Buffer.from(videoData))
return {
success: true,
path: videoPath,
message: 'Video stored successfully'
}
} catch (error) {
console.error('Failed to store video:', error)
return {
success: false,
message: 'Failed to store video',
error: String(error)
}
}
})
ipcMain.handle('store-mouse-tracking-data', async (_, fileName: string) => {
try {
const data = getTrackingData()
@@ -79,24 +89,41 @@ export function registerIpcHandlers(
return { success: false, message: 'No tracking data to save' }
}
// Save to the same directory as the video, with .json extension
const jsonFileName = videoFileName.replace('.webm', '_tracking.json')
const filePath = path.join(process.env.HOME || '', 'Downloads', jsonFileName)
const trackingPath = path.join(RECORDINGS_DIR, fileName)
await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), 'utf-8')
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
return {
success: true,
message: 'Tracking data saved',
filePath,
eventCount: data.length
return {
success: true,
path: trackingPath,
eventCount: data.length,
message: 'Mouse tracking data stored successfully'
}
} catch (error) {
return {
success: false,
message: 'Failed to save tracking data',
console.error('Failed to store mouse tracking data:', error)
return {
success: false,
message: 'Failed to store mouse tracking data',
error: String(error)
}
}
})
ipcMain.handle('get-recorded-video-path', async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR)
const videoFiles = files.filter(file => file.endsWith('.webm'))
if (videoFiles.length === 0) {
return { success: false, message: 'No recorded video found' }
}
const latestVideo = videoFiles.sort().reverse()[0]
const videoPath = path.join(RECORDINGS_DIR, latestVideo)
return { success: true, path: videoPath }
} catch (error) {
console.error('Failed to get video path:', error)
return { success: false, message: 'Failed to get video path', error: String(error) }
}
})
}
+42 -2
View File
@@ -1,12 +1,45 @@
import { app, BrowserWindow } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import fs from 'node:fs/promises'
import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows'
import { registerIpcHandlers } from './ipc/handlers'
import { cleanupMouseTracking } from './ipc/mouseTracking'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings')
// Cleanup old recordings (older than 1 day)
async function cleanupOldRecordings() {
try {
const files = await fs.readdir(RECORDINGS_DIR)
const now = Date.now()
const maxAge = 1 * 24 * 60 * 60 * 1000
for (const file of files) {
const filePath = path.join(RECORDINGS_DIR, file)
const stats = await fs.stat(filePath)
if (now - stats.mtimeMs > maxAge) {
await fs.unlink(filePath)
console.log(`Deleted old recording: ${file}`)
}
}
} catch (error) {
console.error('Failed to cleanup old recordings:', error)
}
}
async function ensureRecordingsDir() {
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true })
console.log('Recordings directory ready:', RECORDINGS_DIR)
} catch (error) {
console.error('Failed to create recordings directory:', error)
}
}
// The built directory structure
//
// ├─┬─┬ dist
@@ -68,12 +101,19 @@ app.on('activate', () => {
}
})
app.on('will-quit', () => {
// Cleanup old recordings on quit (both macOS and other platforms)
app.on('before-quit', async (event) => {
event.preventDefault()
cleanupMouseTracking()
await cleanupOldRecordings()
app.exit(0)
})
// Register all IPC handlers when app is ready
app.whenReady().then(() => {
app.whenReady().then(async () => {
// Ensure recordings directory exists
await ensureRecordingsDir()
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
+8 -2
View File
@@ -22,7 +22,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
stopMouseTracking: () => {
return ipcRenderer.invoke('stop-mouse-tracking')
},
saveMouseTrackingData: (videoFileName: string) => {
return ipcRenderer.invoke('save-mouse-tracking-data', videoFileName)
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke('store-recorded-video', videoData, fileName)
},
storeMouseTrackingData: (fileName: string) => {
return ipcRenderer.invoke('store-mouse-tracking-data', fileName)
},
getRecordedVideoPath: () => {
return ipcRenderer.invoke('get-recorded-video-path')
}
})
+1
View File
@@ -61,6 +61,7 @@ export function createEditorWindow(): BrowserWindow {
preload: path.join(__dirname, 'preload.mjs'),
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
},
})
+2 -6
View File
@@ -1,5 +1,6 @@
import { LaunchWindow } from "./components/LaunchWindow";
import { SourceSelector } from "./components/SourceSelector";
import { VideoEditor } from "./components/VideoEditor";
import { useEffect, useState } from "react";
export default function App() {
@@ -28,12 +29,7 @@ export default function App() {
}
if (windowType === 'editor') {
return (
<div className="w-full h-full bg-background text-foreground p-6">
<h1 className="text-2xl font-bold mb-4">Video Editor</h1>
<p>Recording stopped. Video editor interface coming soon...</p>
</div>
);
return <VideoEditor />;
}
return (
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
export function VideoEditor() {
const [videoPath, setVideoPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadVideo();
}, []);
const loadVideo = async () => {
try {
const result = await window.electronAPI.getRecordedVideoPath();
if (result.success && result.path) {
setVideoPath(`file://${result.path}`);
console.log('Loading video from:', result.path);
} else {
setError(result.message || 'Failed to load video');
}
} catch (err) {
setError('Error loading video: ' + String(err));
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-foreground">Loading video...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-destructive">{error}</div>
</div>
);
}
return (
<div className="flex flex-col h-screen bg-background p-6">
<h1 className="text-2xl font-bold mb-4 text-foreground">Video Editor</h1>
<div className="flex-1 flex items-center justify-center bg-black rounded-lg overflow-hidden">
{videoPath && (
<video
src={videoPath}
controls
autoPlay
className="max-w-full max-h-full"
onError={(e) => {
console.error('Video playback error:', e);
setError('Failed to play video');
}}
onLoadedMetadata={(e) => {
const video = e.currentTarget;
console.log('Video loaded:', {
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight
});
}}
/>
)}
</div>
<div className="mt-4 text-sm text-muted-foreground">
Video path: {videoPath}
</div>
</div>
);
}
+65 -29
View File
@@ -28,7 +28,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const startRecording = async () => {
try {
// Get the selected source from the main process
const selectedSource = await window.electronAPI.getSelectedSource();
if (!selectedSource) {
@@ -36,10 +35,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
// Start mouse tracking
await window.electronAPI.startMouseTracking();
// Use the selected source
const stream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: {
@@ -55,55 +52,90 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
throw new Error("Failed to get media stream");
}
const videoTrack = streamRef.current.getVideoTracks()[0];
const settings = videoTrack.getSettings();
const width = settings.width || 1920;
const height = settings.height || 1080;
const totalPixels = width * height;
let bitrate: number;
if (totalPixels <= 1920 * 1080) {
bitrate = 150_000_000; // 150 Mbps for 1080p
} else if (totalPixels <= 2560 * 1440) {
bitrate = 250_000_000; // 250 Mbps for 1440p
} else {
bitrate = 400_000_000; // 400 Mbps for 4K
}
console.log(`Recording at ${width}x${height} with bitrate: ${bitrate / 1_000_000} Mbps`);
chunksRef.current = [];
let mimeType = "video/webm;codecs=vp9";
const mimeType = "video/webm;codecs=vp9";
const recorder = new MediaRecorder(streamRef.current, {
mimeType,
videoBitsPerSecond: 16_000_000,
videoBitsPerSecond: bitrate,
});
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
recorder.onstop = async () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
// 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 blob = new Blob(chunksRef.current, { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const videoBlob = new Blob(chunksRef.current, { type: mimeType });
const timestamp = Date.now();
const videoFileName = `recording-${timestamp}.webm`;
const trackingFileName = `recording-${timestamp}_tracking.json`;
// Generate filename with timestamp
const videoFileName = `recording-${Date.now()}.webm`;
a.download = videoFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
// Save mouse tracking data alongside the video
try {
const result = await window.electronAPI.saveMouseTrackingData(videoFileName);
if (!result.success) {
console.warn('Failed to save tracking data:', result.message);
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);
}
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);
}
console.log('Opening editor window...');
await window.electronAPI.switchToEditor();
} catch (error) {
console.error('Error saving tracking data:', error);
console.error('Error saving recording:', error);
}
};
recorder.onerror = () => {
setRecording(false);
};
recorder.start(1000);
setRecording(true);
} catch {
} catch (error) {
console.error('Failed to start recording:', error);
setRecording(false);
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
@@ -117,10 +149,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
mediaRecorderRef.current &&
mediaRecorderRef.current.state === "recording"
) {
// Stop stream tracks IMMEDIATELY to remove macOS status indicator
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
mediaRecorderRef.current.stop();
setRecording(false);
// Stop mouse tracking
window.electronAPI.stopMouseTracking();
}
};
+15 -2
View File
@@ -17,11 +17,24 @@ interface Window {
getSelectedSource: () => Promise<any>
startMouseTracking: () => Promise<{ success: boolean; startTime?: number }>
stopMouseTracking: () => Promise<{ success: boolean; data?: any }>
saveMouseTrackingData: (videoFileName: string) => Promise<{
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
success: boolean
path?: string
message: string
filePath?: string
error?: string
}>
storeMouseTrackingData: (fileName: string) => Promise<{
success: boolean
path?: string
eventCount?: number
message: string
error?: string
}>
getRecordedVideoPath: () => Promise<{
success: boolean
path?: string
message?: string
error?: string
}>
}
}