Files
Recordly/electron/ipc/cursor/telemetry.ts

170 lines
4.8 KiB
TypeScript

import fs from "node:fs/promises";
import { getTelemetryPathForVideo, getScreen } from "../utils";
import {
CURSOR_TELEMETRY_VERSION,
MAX_CURSOR_SAMPLES,
CURSOR_SAMPLE_INTERVAL_MS,
} from "../constants";
import type { CursorVisualType, CursorInteractionType, CursorTelemetryPoint } from "../types";
import {
cursorCaptureInterval,
setCursorCaptureInterval,
cursorCaptureStartTimeMs,
activeCursorSamples,
pendingCursorSamples,
setPendingCursorSamples,
isCursorCaptureActive,
currentCursorVisualType,
linuxCursorScreenPoint,
selectedSource,
selectedWindowBounds,
} from "../state";
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function stopCursorCapture() {
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval);
setCursorCaptureInterval(null);
}
}
export function getNormalizedCursorPoint() {
const fallbackCursor = getScreen().getCursorScreenPoint();
const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null;
const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000;
const primarySf =
process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1;
const cursor = isLinuxCacheFresh
? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf }
: fallbackCursor;
const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null;
if (windowBounds) {
const sf =
process.platform !== "darwin"
? getScreen().getDisplayNearestPoint({
x: windowBounds.x / primarySf,
y: windowBounds.y / primarySf,
}).scaleFactor || 1
: 1;
const width = Math.max(1, windowBounds.width / sf);
const height = Math.max(1, windowBounds.height / sf);
return {
cx: clamp((cursor.x - windowBounds.x / sf) / width, 0, 1),
cy: clamp((cursor.y - windowBounds.y / sf) / height, 0, 1),
};
}
const sourceDisplayId = Number(selectedSource?.display_id);
const sourceDisplay = Number.isFinite(sourceDisplayId)
? (getScreen()
.getAllDisplays()
.find((display) => display.id === sourceDisplayId) ?? null)
: null;
const display = sourceDisplay ?? getScreen().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);
return { cx, cy };
}
export function getHookCursorScreenPoint(
event: { x?: number; y?: number; data?: { x?: number; y?: number; screenX?: number; screenY?: number }; screenX?: number; screenY?: number } | null | undefined,
): { x: number; y: number } | null {
const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX;
const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY;
if (
typeof rawX !== "number" ||
!Number.isFinite(rawX) ||
typeof rawY !== "number" ||
!Number.isFinite(rawY)
) {
return null;
}
return { x: rawX, y: rawY };
}
export function pushCursorSample(
cx: number,
cy: number,
timeMs: number,
interactionType: CursorInteractionType = "move",
cursorType?: CursorVisualType,
) {
activeCursorSamples.push({
timeMs: Math.max(0, timeMs),
cx,
cy,
interactionType,
cursorType: cursorType ?? currentCursorVisualType,
} as CursorTelemetryPoint);
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
activeCursorSamples.shift();
}
}
export function sampleCursorPoint() {
const point = getNormalizedCursorPoint();
pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, "move");
}
export async function persistPendingCursorTelemetry(videoPath: string) {
const telemetryPath = getTelemetryPathForVideo(videoPath);
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify(
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples },
null,
2,
),
"utf-8",
);
}
setPendingCursorSamples([]);
}
export function snapshotCursorTelemetryForPersistence() {
if (activeCursorSamples.length === 0) {
return;
}
if (pendingCursorSamples.length === 0) {
setPendingCursorSamples([...activeCursorSamples]);
return;
}
const lastPendingTimeMs = pendingCursorSamples[pendingCursorSamples.length - 1]?.timeMs ?? -1;
setPendingCursorSamples([
...pendingCursorSamples,
...activeCursorSamples.filter((sample) => sample.timeMs > lastPendingTimeMs),
]);
}
export function startCursorSampling() {
stopCursorCapture();
setCursorCaptureInterval(
setInterval(() => {
if (isCursorCaptureActive) {
sampleCursorPoint();
}
}, CURSOR_SAMPLE_INTERVAL_MS),
);
}
// Re-export for consumers that use it from this module
export { getTelemetryPathForVideo } from "../utils";
export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants";