diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 51b901cd..4ab9be89 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -26,7 +26,7 @@ export function clamp(value: number, min: number, max: number) { export function stopCursorCapture() { if (cursorCaptureInterval) { - clearInterval(cursorCaptureInterval); + clearTimeout(cursorCaptureInterval); setCursorCaptureInterval(null); } } @@ -155,13 +155,33 @@ export function snapshotCursorTelemetryForPersistence() { export function startCursorSampling() { stopCursorCapture(); - setCursorCaptureInterval( - setInterval(() => { - if (isCursorCaptureActive) { - sampleCursorPoint(); - } - }, CURSOR_SAMPLE_INTERVAL_MS), - ); + + // Use recursive setTimeout with drift compensation instead of setInterval. + // Under CPU load setInterval bunches or skips callbacks, creating large gaps + // in telemetry data. This approach measures wall-clock drift each tick and + // adjusts the next delay so samples stay close to the target interval. + let nextExpectedMs = Date.now() + CURSOR_SAMPLE_INTERVAL_MS; + + const tick = () => { + if (isCursorCaptureActive) { + sampleCursorPoint(); + } + + const now = Date.now(); + const drift = now - nextExpectedMs; + nextExpectedMs += CURSOR_SAMPLE_INTERVAL_MS; + + // If we fell behind by more than one full interval, reset the baseline + // so we don't try to "catch up" with a burst of rapid samples. + if (drift > CURSOR_SAMPLE_INTERVAL_MS) { + nextExpectedMs = now + CURSOR_SAMPLE_INTERVAL_MS; + } + + const delay = Math.max(1, CURSOR_SAMPLE_INTERVAL_MS - drift); + setCursorCaptureInterval(setTimeout(tick, delay)); + }; + + setCursorCaptureInterval(setTimeout(tick, CURSOR_SAMPLE_INTERVAL_MS)); } // Re-export for consumers that use it from this module diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 40e60cc1..26215922 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -2336,21 +2336,53 @@ export class FrameRenderer { return null; } - let closest = telemetry[0]; - let minDist = Math.abs(telemetry[0].timeMs - timeMs); - for (let i = 1; i < telemetry.length; i++) { - const dist = Math.abs(telemetry[i].timeMs - timeMs); - if (dist < minDist) { - minDist = dist; - closest = telemetry[i]; - } - if (telemetry[i].timeMs > timeMs) { - break; + // Clamp to first/last sample when out of range + if (timeMs <= telemetry[0].timeMs) { + const s = telemetry[0]; + return mapCursorToCanvasNormalized( + { cx: s.cx, cy: s.cy, interactionType: s.interactionType }, + { + maskRect: this.layoutCache?.maskRect, + canvasWidth: this.config.width, + canvasHeight: this.config.height, + }, + ); + } + if (timeMs >= telemetry[telemetry.length - 1].timeMs) { + const s = telemetry[telemetry.length - 1]; + return mapCursorToCanvasNormalized( + { cx: s.cx, cy: s.cy, interactionType: s.interactionType }, + { + maskRect: this.layoutCache?.maskRect, + canvasWidth: this.config.width, + canvasHeight: this.config.height, + }, + ); + } + + // Binary search for surrounding samples + let lo = 0; + let hi = telemetry.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (telemetry[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid; } } + const a = telemetry[lo]; + const b = telemetry[hi]; + const span = b.timeMs - a.timeMs; + + // Linear interpolation between samples + const t = span > 0 ? (timeMs - a.timeMs) / span : 0; + const cx = a.cx + (b.cx - a.cx) * t; + const cy = a.cy + (b.cy - a.cy) * t; + return mapCursorToCanvasNormalized( - { cx: closest.cx, cy: closest.cy, interactionType: closest.interactionType }, + { cx, cy, interactionType: a.interactionType }, { maskRect: this.layoutCache?.maskRect, canvasWidth: this.config.width, diff --git a/src/lib/extensions/extensionHost.ts b/src/lib/extensions/extensionHost.ts index 32b327df..f7f7919e 100644 --- a/src/lib/extensions/extensionHost.ts +++ b/src/lib/extensions/extensionHost.ts @@ -854,17 +854,31 @@ export class ExtensionHost { const t = host._cursorTelemetry; if (!t || t.length === 0) return null; - let closest = t[0]; - let minDist = Math.abs(t[0].timeMs - timeMs); - for (let i = 1; i < t.length; i++) { - const dist = Math.abs(t[i].timeMs - timeMs); - if (dist < minDist) { - minDist = dist; - closest = t[i]; + if (timeMs <= t[0].timeMs) return { ...t[0] }; + if (timeMs >= t[t.length - 1].timeMs) return { ...t[t.length - 1] }; + + let lo = 0; + let hi = t.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (t[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid; } - if (t[i].timeMs > timeMs) break; } - return { ...closest }; + + const a = t[lo]; + const b = t[hi]; + const span = b.timeMs - a.timeMs; + const frac = span > 0 ? (timeMs - a.timeMs) / span : 0; + + return { + ...a, + cx: a.cx + (b.cx - a.cx) * frac, + cy: a.cy + (b.cy - a.cy) * frac, + timeMs, + }; }, getSmoothedCursor() {