Fix cursor sync after recording pause

This commit is contained in:
webadderall
2026-05-01 16:47:42 +10:00
parent bebac5cc60
commit 667efdc02e
8 changed files with 186 additions and 13 deletions
+10
View File
@@ -151,6 +151,16 @@ interface Window {
message?: string;
error?: string;
}>;
pauseCursorCapture: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
resumeCursorCapture: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
startFfmpegRecording: (
source: ProcessedDesktopSource,
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
+11 -6
View File
@@ -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;
}
+51
View File
@@ -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);
});
});
+55 -2
View File
@@ -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;
@@ -117,7 +170,7 @@ export function pushCursorSample(
export function sampleCursorPoint() {
const point = getNormalizedCursorPoint();
pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, "move");
pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "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();
}
+17
View File
@@ -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,18 @@ export function registerRecordingHandlers(
}
});
ipcMain.handle("pause-cursor-capture", () => {
sampleCursorPoint();
pauseCursorCapture(Date.now());
return { success: true };
});
ipcMain.handle("resume-cursor-capture", () => {
resumeCursorCapture(Date.now());
sampleCursorPoint();
return { success: true };
});
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
if (!targetVideoPath) {
+8
View File
@@ -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;
}
+6
View File
@@ -293,6 +293,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
resumeNativeScreenRecording: () => {
return ipcRenderer.invoke("resume-native-screen-recording");
},
pauseCursorCapture: () => {
return ipcRenderer.invoke("pause-cursor-capture");
},
resumeCursorCapture: () => {
return ipcRenderer.invoke("resume-cursor-capture");
},
startFfmpegRecording: (source: ProcessedDesktopSource) => {
return ipcRenderer.invoke("start-ffmpeg-recording", source);
},
+28 -5
View File
@@ -1094,7 +1094,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
const wantsAudioCapture = microphoneEnabled || systemAudioEnabled;
const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource);
if (
@@ -1441,6 +1440,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
try {
await window.electronAPI.pauseCursorCapture();
} catch (error) {
console.warn("Failed to pause cursor capture:", error);
}
markRecordingPaused(Date.now());
setPaused(true);
})();
@@ -1451,8 +1455,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
markRecordingPaused(Date.now());
setPaused(true);
void (async () => {
try {
await window.electronAPI.pauseCursorCapture();
} catch (error) {
console.warn("Failed to pause cursor capture:", error);
}
markRecordingPaused(Date.now());
setPaused(true);
})();
}
}, [markRecordingPaused, paused, recording]);
@@ -1472,6 +1483,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
try {
await window.electronAPI.resumeCursorCapture();
} catch (error) {
console.warn("Failed to resume cursor capture:", error);
}
markRecordingResumed(Date.now());
setPaused(false);
})();
@@ -1482,8 +1498,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
markRecordingResumed(Date.now());
setPaused(false);
void (async () => {
try {
await window.electronAPI.resumeCursorCapture();
} catch (error) {
console.warn("Failed to resume cursor capture:", error);
}
markRecordingResumed(Date.now());
setPaused(false);
})();
}
}, [markRecordingResumed, paused, recording]);