Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion dist-electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,42 @@ function createSourceSelectorWindow() {
return win;
}
let selectedSource = null;
let currentVideoPath = null;
const CURSOR_TELEMETRY_VERSION = 1;
const CURSOR_SAMPLE_INTERVAL_MS = 100;
const MAX_CURSOR_SAMPLES = 60 * 60 * 10;
let cursorCaptureInterval = null;
let cursorCaptureStartTimeMs = 0;
let activeCursorSamples = [];
let pendingCursorSamples = [];
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function stopCursorCapture() {
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval);
cursorCaptureInterval = null;
}
}
function sampleCursorPoint() {
const cursor = screen.getCursorScreenPoint();
const sourceDisplayId = Number(selectedSource == null ? void 0 : selectedSource.display_id);
const sourceDisplay = Number.isFinite(sourceDisplayId) ? screen.getAllDisplays().find((display2) => display2.id === sourceDisplayId) ?? null : null;
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor);
const bounds = display.bounds;
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
activeCursorSamples.push({
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
cx,
cy
});
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
activeCursorSamples.shift();
}
}
function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) {
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
Expand Down Expand Up @@ -169,6 +205,15 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
currentVideoPath = videoPath;
const telemetryPath = `${videoPath}.cursor.json`;
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
"utf-8"
);
}
pendingCursorSamples = [];
return {
success: true,
path: videoPath,
Expand Down Expand Up @@ -199,11 +244,51 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
}
});
ipcMain.handle("set-recording-state", (_, recording) => {
if (recording) {
stopCursorCapture();
activeCursorSamples = [];
pendingCursorSamples = [];
cursorCaptureStartTimeMs = Date.now();
sampleCursorPoint();
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
} else {
stopCursorCapture();
pendingCursorSamples = [...activeCursorSamples];
activeCursorSamples = [];
}
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
ipcMain.handle("get-cursor-telemetry", async (_, videoPath) => {
const targetVideoPath = videoPath ?? currentVideoPath;
if (!targetVideoPath) {
return { success: true, samples: [] };
}
const telemetryPath = `${targetVideoPath}.cursor.json`;
try {
const content = await fs.readFile(telemetryPath, "utf-8");
const parsed = JSON.parse(content);
const rawSamples = Array.isArray(parsed) ? parsed : Array.isArray(parsed == null ? void 0 : parsed.samples) ? parsed.samples : [];
const samples = rawSamples.filter((sample) => Boolean(sample && typeof sample === "object")).map((sample) => {
const point = sample;
return {
timeMs: typeof point.timeMs === "number" && Number.isFinite(point.timeMs) ? Math.max(0, point.timeMs) : 0,
cx: typeof point.cx === "number" && Number.isFinite(point.cx) ? clamp(point.cx, 0, 1) : 0.5,
cy: typeof point.cy === "number" && Number.isFinite(point.cy) ? clamp(point.cy, 0, 1) : 0.5
};
}).sort((a, b) => a.timeMs - b.timeMs);
return { success: true, samples };
} catch (error) {
const nodeError = error;
if (nodeError.code === "ENOENT") {
return { success: true, samples: [] };
}
console.error("Failed to load cursor telemetry:", error);
return { success: false, message: "Failed to load cursor telemetry", error: String(error), samples: [] };
}
});
ipcMain.handle("open-external-url", async (_, url) => {
try {
await shell.openExternal(url);
Expand Down Expand Up @@ -283,7 +368,6 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
};
}
});
let currentVideoPath = null;
ipcMain.handle("set-current-video-path", (_, path2) => {
currentVideoPath = path2;
return { success: true };
Expand Down
3 changes: 3 additions & 0 deletions dist-electron/preload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
setRecordingState: (recording) => {
return electron.ipcRenderer.invoke("set-recording-state", recording);
},
getCursorTelemetry: (videoPath) => {
return electron.ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
onStopRecordingFromTray: (callback) => {
const listener = () => callback();
electron.ipcRenderer.on("stop-recording-from-tray", listener);
Expand Down
45 changes: 28 additions & 17 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,36 +451,47 @@ export default function VideoEditor() {
// Global Tab prevention
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'Tab') {
// Allow tab only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
e.preventDefault();
return;
}

if (e.key === ' ' || e.code === 'Space') {
// Allow space only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
e.preventDefault();

const playback = videoPlaybackRef.current;
if (playback?.video) {
if (playback.video.paused) {
playback.play().catch(console.error);
} else {
playback.pause();
}
const video = playback?.video;
if (!playback || !video) return;
if (video.paused) {
playback.play().catch(() => {});
} else {
playback.pause();
}
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
seekRelative(-1);
}
if (e.key === 'ArrowRight') {
e.preventDefault();
seekRelative(1);
}
};

window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, []);

function seekRelative(seconds: number) {
const video = videoPlaybackRef.current?.video;
if (!video) return;
let newTime = video.currentTime + seconds;
if (Number.isNaN(newTime) || !Number.isFinite(newTime)) return;
if (newTime < 0) newTime = 0;
if (duration > 0 && newTime > duration) newTime = duration;
if (Math.abs(newTime - video.currentTime) < 0.001) return;
video.currentTime = newTime;
if (video.paused) setCurrentTime(newTime);
}

useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
setSelectedZoomId(null);
Expand Down