diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index bda33cdf2..8b634980b 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -151,6 +151,16 @@ interface Window { message?: string; error?: string; }>; + pauseCursorCapture: (boundaryMs?: number) => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + resumeCursorCapture: (boundaryMs?: number) => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; startFfmpegRecording: ( source: ProcessedDesktopSource, ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>; diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 37cfc633d..c11258f10 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -2,7 +2,6 @@ import { createRequire } from "node:module"; import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types"; import { isCursorCaptureActive, - cursorCaptureStartTimeMs, interactionCaptureCleanup, setInteractionCaptureCleanup, hasLoggedInteractionHookFailure, @@ -13,7 +12,9 @@ import { } from "../state"; import { getNormalizedCursorPoint, + getCursorCaptureElapsedMs, getHookCursorScreenPoint, + isCursorCapturePaused, pushCursorSample, } from "./telemetry"; @@ -119,7 +120,7 @@ export async function startInteractionCapture() { } const onMouseDown = (event: HookMouseEvent) => { - if (!isCursorCaptureActive) { + if (!isCursorCaptureActive || isCursorCapturePaused()) { return; } @@ -128,7 +129,7 @@ export async function startInteractionCapture() { return; } - const timeMs = Date.now() - cursorCaptureStartTimeMs; + const timeMs = getCursorCaptureElapsedMs(); const button = getHookMouseButton(event); let interactionType: CursorInteractionType = "click"; @@ -157,7 +158,7 @@ export async function startInteractionCapture() { }; const onMouseUp = () => { - if (!isCursorCaptureActive) { + if (!isCursorCaptureActive || isCursorCapturePaused()) { return; } @@ -166,12 +167,16 @@ export async function startInteractionCapture() { return; } - const timeMs = Date.now() - cursorCaptureStartTimeMs; + const timeMs = getCursorCaptureElapsedMs(); pushCursorSample(point.cx, point.cy, timeMs, "mouseup"); }; const onMouseMove = (event: HookMouseEvent) => { - if (process.platform !== "linux" || !isCursorCaptureActive) { + if ( + process.platform !== "linux" || + !isCursorCaptureActive || + isCursorCapturePaused() + ) { return; } diff --git a/electron/ipc/cursor/telemetry.test.ts b/electron/ipc/cursor/telemetry.test.ts new file mode 100644 index 000000000..de9b65e7b --- /dev/null +++ b/electron/ipc/cursor/telemetry.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + }, +})); + +vi.mock("../utils", () => ({ + getTelemetryPathForVideo: vi.fn(() => "/tmp/recording.cursor.json"), + getScreen: vi.fn(() => ({ + getCursorScreenPoint: () => ({ x: 0, y: 0 }), + getPrimaryDisplay: () => ({ scaleFactor: 1 }), + getDisplayNearestPoint: () => ({ bounds: { x: 0, y: 0, width: 1, height: 1 } }), + getAllDisplays: () => [], + })), +})); + +import { + getCursorCaptureElapsedMs, + pauseCursorCapture, + resetCursorCaptureClock, + resumeCursorCapture, +} from "./telemetry"; +import { setCursorCaptureStartTimeMs } from "../state"; + +describe("cursor telemetry pause clock", () => { + beforeEach(() => { + setCursorCaptureStartTimeMs(1_000); + resetCursorCaptureClock(); + }); + + it("subtracts paused time from elapsed cursor timestamps", () => { + expect(getCursorCaptureElapsedMs(1_120)).toBe(120); + + pauseCursorCapture(1_200); + expect(getCursorCaptureElapsedMs(1_450)).toBe(200); + + resumeCursorCapture(1_700); + expect(getCursorCaptureElapsedMs(1_900)).toBe(400); + }); + + it("ignores duplicate pause or resume transitions", () => { + pauseCursorCapture(1_150); + pauseCursorCapture(1_250); + resumeCursorCapture(1_500); + resumeCursorCapture(1_650); + + expect(getCursorCaptureElapsedMs(1_900)).toBe(550); + }); +}); diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index aa6f47848..18b8a1748 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -9,6 +9,8 @@ import type { CursorVisualType, CursorInteractionType, CursorTelemetryPoint } fr import { cursorCaptureInterval, setCursorCaptureInterval, + cursorCaptureAccumulatedPausedMs, + cursorCapturePauseStartedAtMs, cursorCaptureStartTimeMs, activeCursorSamples, pendingCursorSamples, @@ -18,6 +20,8 @@ import { linuxCursorScreenPoint, selectedSource, selectedWindowBounds, + setCursorCaptureAccumulatedPausedMs, + setCursorCapturePauseStartedAtMs, } from "../state"; export function clamp(value: number, min: number, max: number) { @@ -31,6 +35,55 @@ export function stopCursorCapture() { } } +export function resetCursorCaptureClock() { + setCursorCaptureAccumulatedPausedMs(0); + setCursorCapturePauseStartedAtMs(null); +} + +export function isCursorCapturePaused() { + return cursorCapturePauseStartedAtMs !== null; +} + +export function pauseCursorCapture(pausedAtMs: number) { + if (cursorCapturePauseStartedAtMs !== null) { + return; + } + + setCursorCapturePauseStartedAtMs(pausedAtMs); +} + +export function resumeCursorCapture(resumedAtMs: number) { + if (cursorCapturePauseStartedAtMs === null) { + return; + } + + const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs); + setCursorCaptureAccumulatedPausedMs( + cursorCaptureAccumulatedPausedMs + pauseDurationMs, + ); + setCursorCapturePauseStartedAtMs(null); +} + +export function getCursorCaptureElapsedMs(nowMs = Date.now()) { + if (!Number.isFinite(cursorCaptureStartTimeMs) || cursorCaptureStartTimeMs <= 0) { + return 0; + } + + const safeNowMs = Math.max(cursorCaptureStartTimeMs, nowMs); + const activePauseDurationMs = + cursorCapturePauseStartedAtMs === null + ? 0 + : Math.max(0, safeNowMs - cursorCapturePauseStartedAtMs); + + return Math.max( + 0, + safeNowMs - + cursorCaptureStartTimeMs - + Math.max(0, cursorCaptureAccumulatedPausedMs) - + activePauseDurationMs, + ); +} + export function getNormalizedCursorPoint() { const fallbackCursor = getScreen().getCursorScreenPoint(); const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null; @@ -115,9 +168,9 @@ export function pushCursorSample( } } -export function sampleCursorPoint() { +export function sampleCursorPoint(sampledAtMs = Date.now()) { const point = getNormalizedCursorPoint(); - pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, "move"); + pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(sampledAtMs), "move"); } export async function persistPendingCursorTelemetry(videoPath: string) { @@ -163,7 +216,7 @@ export function startCursorSampling() { let nextExpectedMs = Date.now() + CURSOR_SAMPLE_INTERVAL_MS; const tick = () => { - if (isCursorCaptureActive) { + if (isCursorCaptureActive && !isCursorCapturePaused()) { sampleCursorPoint(); } diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 9286efaba..0e491b801 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -19,6 +19,9 @@ import { startInteractionCapture, stopInteractionCapture } from "../cursor/inter import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor"; import { clamp, + pauseCursorCapture, + resumeCursorCapture, + resetCursorCaptureClock, sampleCursorPoint, snapshotCursorTelemetryForPersistence, startCursorSampling, @@ -1275,6 +1278,7 @@ export function registerRecordingHandlers( setActiveCursorSamples([]); setPendingCursorSamples([]); setCursorCaptureStartTimeMs(Date.now()); + resetCursorCaptureClock(); setLinuxCursorScreenPoint(null); setLastLeftClick(null); sampleCursorPoint(); @@ -1288,6 +1292,7 @@ export function registerRecordingHandlers( stopNativeCursorMonitor(); showCursor(); setLinuxCursorScreenPoint(null); + resetCursorCaptureClock(); snapshotCursorTelemetryForPersistence(); setActiveCursorSamples([]); } @@ -1307,6 +1312,26 @@ export function registerRecordingHandlers( } }); + ipcMain.handle("pause-cursor-capture", (_event, boundaryMs?: number) => { + const timestamp = + typeof boundaryMs === "number" && Number.isFinite(boundaryMs) + ? boundaryMs + : Date.now(); + sampleCursorPoint(timestamp); + pauseCursorCapture(timestamp); + return { success: true }; + }); + + ipcMain.handle("resume-cursor-capture", (_event, boundaryMs?: number) => { + const timestamp = + typeof boundaryMs === "number" && Number.isFinite(boundaryMs) + ? boundaryMs + : Date.now(); + resumeCursorCapture(timestamp); + sampleCursorPoint(timestamp); + return { success: true }; + }); + ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => { const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath); if (!targetVideoPath) { diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts index b1d809b88..a0a41744e 100644 --- a/electron/ipc/state.ts +++ b/electron/ipc/state.ts @@ -76,6 +76,8 @@ export let currentCursorVisualType: CursorVisualType | undefined = undefined; // ── Cursor telemetry ────────────────────────────────────────────────────────── export let cursorCaptureInterval: NodeJS.Timeout | null = null; export let cursorCaptureStartTimeMs = 0; +export let cursorCaptureAccumulatedPausedMs = 0; +export let cursorCapturePauseStartedAtMs: number | null = null; export let activeCursorSamples: CursorTelemetryPoint[] = []; export let pendingCursorSamples: CursorTelemetryPoint[] = []; export let isCursorCaptureActive = false; @@ -237,6 +239,12 @@ export function setCursorCaptureInterval(v: NodeJS.Timeout | null) { export function setCursorCaptureStartTimeMs(v: number) { cursorCaptureStartTimeMs = v; } +export function setCursorCaptureAccumulatedPausedMs(v: number) { + cursorCaptureAccumulatedPausedMs = v; +} +export function setCursorCapturePauseStartedAtMs(v: number | null) { + cursorCapturePauseStartedAtMs = v; +} export function setActiveCursorSamples(v: CursorTelemetryPoint[]) { activeCursorSamples = v; } diff --git a/electron/preload.ts b/electron/preload.ts index e41acc268..c9e464f2a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -293,6 +293,12 @@ contextBridge.exposeInMainWorld("electronAPI", { resumeNativeScreenRecording: () => { return ipcRenderer.invoke("resume-native-screen-recording"); }, + pauseCursorCapture: (boundaryMs?: number) => { + return ipcRenderer.invoke("pause-cursor-capture", boundaryMs); + }, + resumeCursorCapture: (boundaryMs?: number) => { + return ipcRenderer.invoke("resume-cursor-capture", boundaryMs); + }, startFfmpegRecording: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("start-ffmpeg-recording", source); }, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8edd3d5f9..994d3e02c 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1094,7 +1094,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } - const wantsAudioCapture = microphoneEnabled || systemAudioEnabled; const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource); if ( @@ -1441,7 +1440,32 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (webcamRecorder.current?.state === "recording") { webcamRecorder.current.pause(); } - markRecordingPaused(Date.now()); + const boundaryMs = Date.now(); + try { + await window.electronAPI.pauseCursorCapture(boundaryMs); + } catch (error) { + console.warn("Failed to pause cursor capture:", error); + try { + const rollbackResult = + await window.electronAPI.resumeNativeScreenRecording(); + if (!rollbackResult.success) { + console.warn( + "Failed to roll back native pause after cursor pause failure:", + rollbackResult.error ?? rollbackResult.message, + ); + } + } catch (rollbackError) { + console.warn( + "Failed to roll back native pause after cursor pause failure:", + rollbackError, + ); + } + if (webcamRecorder.current?.state === "paused") { + webcamRecorder.current.resume(); + } + return; + } + markRecordingPaused(boundaryMs); setPaused(true); })(); return; @@ -1451,8 +1475,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (webcamRecorder.current?.state === "recording") { webcamRecorder.current.pause(); } - markRecordingPaused(Date.now()); - setPaused(true); + const boundaryMs = Date.now(); + void (async () => { + try { + await window.electronAPI.pauseCursorCapture(boundaryMs); + } catch (error) { + console.warn("Failed to pause cursor capture:", error); + if (mediaRecorder.current?.state === "paused") { + mediaRecorder.current.resume(); + } + if (webcamRecorder.current?.state === "paused") { + webcamRecorder.current.resume(); + } + return; + } + markRecordingPaused(boundaryMs); + setPaused(true); + })(); } }, [markRecordingPaused, paused, recording]); @@ -1472,7 +1511,32 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (webcamRecorder.current?.state === "paused") { webcamRecorder.current.resume(); } - markRecordingResumed(Date.now()); + const boundaryMs = Date.now(); + try { + await window.electronAPI.resumeCursorCapture(boundaryMs); + } catch (error) { + console.warn("Failed to resume cursor capture:", error); + try { + const rollbackResult = + await window.electronAPI.pauseNativeScreenRecording(); + if (!rollbackResult.success) { + console.warn( + "Failed to roll back native resume after cursor resume failure:", + rollbackResult.error ?? rollbackResult.message, + ); + } + } catch (rollbackError) { + console.warn( + "Failed to roll back native resume after cursor resume failure:", + rollbackError, + ); + } + if (webcamRecorder.current?.state === "recording") { + webcamRecorder.current.pause(); + } + return; + } + markRecordingResumed(boundaryMs); setPaused(false); })(); return; @@ -1482,8 +1546,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (webcamRecorder.current?.state === "paused") { webcamRecorder.current.resume(); } - markRecordingResumed(Date.now()); - setPaused(false); + const boundaryMs = Date.now(); + void (async () => { + try { + await window.electronAPI.resumeCursorCapture(boundaryMs); + } catch (error) { + console.warn("Failed to resume cursor capture:", error); + if (mediaRecorder.current?.state === "recording") { + mediaRecorder.current.pause(); + } + if (webcamRecorder.current?.state === "recording") { + webcamRecorder.current.pause(); + } + return; + } + markRecordingResumed(boundaryMs); + setPaused(false); + })(); } }, [markRecordingResumed, paused, recording]);