Files
autodoc/app/stream-backend.js
Iisyourdad 34cc358902
Template tests / tests (push) Successful in 1m48s
Template tests / tests (pull_request) Successful in 1m55s
Never take a post-click screenshot when a pre-click frame exists
The remaining 'captured slightly after the click' reports came from the
fresh-shot fallback, which grabs the screen when the click is processed
(after it). The previous lead change made that fallback *more* likely: a
frame now had to be >=120ms before the click to qualify, so on machines
where the capture stream can't always keep a frame that old buffered, more
clicks fell through to the post-click shot.

Make the click-lead a two-tier preference instead of a hard gate in
selectFrameForClick:
1. newest frame captured at least leadMs before the click (ideal margin), else
2. newest frame captured before the click at all.
Only when no pre-click frame exists does the caller fresh-shot. leadMs is
threaded through the stream backend to the worker so both selection paths
agree. Verified end to end: frames land ~120-170ms before each click,
markers stay at 0.00%, and the 8-click burst still saves all 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:40:33 -05:00

363 lines
13 KiB
JavaScript

'use strict';
const path = require('node:path');
const { displayForDipPoint, pointInBounds } = require('./coords');
/**
* Off-main-process click-frame backend.
*
* The legacy design ran desktopCapturer.getSources() in a 200ms loop on the
* main process. That had two structural problems this backend removes:
* - every grab (and the occasional PNG encode) blocked the main-process
* event loop, which delayed delivery of OS click events — the very events
* the loop existed to serve — by up to whole seconds under load;
* - getSources() is a heavy thumbnail API, so the loop had to idle 200ms
* between grabs, leaving clicks to be matched against frames that could
* be hundreds of ms stale.
*
* Here, a hidden worker window opens a desktop media *stream* per display
* and samples it on a tight cadence into a timestamped ring buffer — all in
* the worker's renderer process. On click, the main process sends only a tiny
* IPC request carrying the hook-time click timestamp; the worker picks the
* newest frame captured at or before that instant (strict semantics from
* click-frames.js), PNG-encodes it off the main process, and ships the bytes
* back. The main process never grabs or encodes a frame while recording.
*
* Failure handling: the backend is an optimization, never a single point of
* failure. If streams don't come up (Wayland portals, WSLg quirks) start()
* reports false and the capture service falls back to the legacy loop; if
* frame requests start timing out mid-session, the backend declares itself
* unhealthy once and the service degrades the same way.
*/
const DEFAULT_SAMPLE_MS = 100;
// The reply protocol is two-stage so a *slow* worker is never mistaken for a
// *dead* one: the worker acknowledges frame selection within milliseconds
// (that pins the click↔frame pairing and proves liveness), then ships the
// PNG whenever the encode finishes — which can take seconds per frame on
// software-rendered hosts (WSLg, VMs). Only a missing ack marks the worker
// unhealthy; a slow payload merely arrives late but is still the exact
// frame chosen at click time.
const DEFAULT_ACK_TIMEOUT_MS = 2000;
const DEFAULT_ENCODE_TIMEOUT_MS = 30_000;
const DEFAULT_START_TIMEOUT_MS = 8000;
// Consecutive unanswered requests before the backend declares itself
// unhealthy and the capture service degrades to the in-process loop.
const MAX_CONSECUTIVE_FAILURES = 2;
class StreamCaptureBackend {
/**
* @param {object} opts
* @param {(onEvent: (msg) => void) => Promise<{send,destroy}>} opts.createHost
* Factory for the worker transport (the hidden BrowserWindow in
* production, a fake in tests).
* @param {(reason: string) => void} [opts.onUnhealthy]
*/
constructor({
createHost,
onUnhealthy = null,
ackTimeoutMs = DEFAULT_ACK_TIMEOUT_MS,
encodeTimeoutMs = DEFAULT_ENCODE_TIMEOUT_MS,
startTimeoutMs = DEFAULT_START_TIMEOUT_MS,
} = {}) {
this.createHost = createHost;
this.onUnhealthy = onUnhealthy;
this.ackTimeoutMs = ackTimeoutMs;
this.encodeTimeoutMs = encodeTimeoutMs;
this.startTimeoutMs = startTimeoutMs;
this.host = null;
this.active = false;
this.requests = new Map(); // requestId -> { resolve, timer }
this.streams = new Map(); // displayId(string) -> { display, ready }
this.nextRequestId = 1;
this.consecutiveFailures = 0;
this.startWaiters = [];
this.draining = false;
}
isActive() {
return this.active;
}
/**
* Spin up the worker and one stream per display that has a matching screen
* source. Resolves true when at least one stream is delivering frames.
*/
async start({ displays = [], sources = [], sampleMs = DEFAULT_SAMPLE_MS, retentionMs = null, frameLimit = null } = {}) {
if (this.host) return this.active;
const pairs = pairDisplaysToSources(displays, sources);
if (!pairs.length) return false;
try {
this.host = await this.createHost((msg) => this.handleWorkerEvent(msg));
} catch {
this.host = null;
return false;
}
for (const { display, sourceId } of pairs) {
this.streams.set(String(display.id), { display, ready: false, failed: false });
this.hostSend({
type: 'start-stream',
displayId: display.id,
sourceId,
// The worker needs the physical pixel size to request a full-res
// stream; bounds stay in DIP for marker math back in the main process.
display: {
id: display.id,
bounds: display.bounds,
scaleFactor: display.scaleFactor || 1,
},
sampleMs,
retentionMs,
frameLimit,
});
}
const anyReady = await this.waitForStreams();
this.active = anyReady;
if (!anyReady) this.stop();
return this.active;
}
/** Resolves true as soon as one stream reports ready, false on timeout/all-failed. */
waitForStreams() {
return new Promise((resolve) => {
const finish = (ok) => {
clearTimeout(timer);
this.startWaiters = this.startWaiters.filter((w) => w !== check);
resolve(ok);
};
const check = () => {
const states = [...this.streams.values()];
if (states.some((s) => s.ready)) return finish(true);
if (states.length && states.every((s) => s.failed)) return finish(false);
return null;
};
const timer = setTimeout(() => finish(false), this.startTimeoutMs);
this.startWaiters.push(check);
check();
});
}
hostSend(msg) {
if (!this.host) return;
try {
this.host.send(msg);
} catch {
// A dead host surfaces as request timeouts → unhealthy → degrade.
}
}
handleWorkerEvent(msg) {
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'stream-ready' || msg.type === 'stream-error') {
const stream = this.streams.get(String(msg.displayId));
if (stream) {
stream.ready = msg.type === 'stream-ready';
stream.failed = msg.type === 'stream-error';
}
for (const check of [...this.startWaiters]) check();
return;
}
if (msg.type === 'frame-selected') {
// Stage one: the worker picked a frame for this click. The pairing is
// now pinned and the worker is provably alive — swap the short ack
// deadline for the long encode deadline and wait for the pixels.
const pending = this.requests.get(msg.requestId);
if (!pending) return;
this.consecutiveFailures = 0;
clearTimeout(pending.timer);
pending.timer = setTimeout(() => {
this.settleRequest(msg.requestId, null);
this.noteFailure();
}, this.encodeTimeoutMs);
return;
}
if (msg.type === 'frame-response') {
const pending = this.requests.get(msg.requestId);
if (!pending) return; // late reply after timeout — already handled
// Any answer — even "no qualifying frame" — proves the worker is alive.
this.consecutiveFailures = 0;
const value = (!msg.ok || !msg.png) ? null : {
mode: 'fullscreen',
png: Buffer.from(msg.png),
size: { width: msg.width, height: msg.height },
display: pending.display,
startedAt: msg.startedAt,
capturedAt: msg.capturedAt,
source: 'stream',
};
this.settleRequest(msg.requestId, value);
}
}
/**
* Resolve one pending request and clean it up. When the backend is
* draining (stop() was called while requests were still in flight), the
* last settled request triggers the deferred worker teardown — this is
* what lets clicks queued at finish time still receive their frames
* instead of being cancelled to null.
*/
settleRequest(requestId, value) {
const pending = this.requests.get(requestId);
if (!pending) return;
this.requests.delete(requestId);
clearTimeout(pending.timer);
pending.resolve(value);
if (this.draining && this.requests.size === 0) this.finalizeTeardown();
}
/**
* Frame for one click, selected in the worker under the given strictness.
* Resolves null when no frame qualifies (caller falls back) — and also on
* timeout, which additionally counts toward unhealthiness.
*/
frameForClick({ clickPos = null, clickAt = Date.now(), strict = true, leadMs = 0 } = {}) {
if (!this.active || !this.host) return Promise.resolve(null);
const displays = [...this.streams.values()].filter((s) => s.ready).map((s) => s.display);
const display = clickPos ? displayForDipPoint(clickPos, displays) : (displays[0] || null);
if (!display) return Promise.resolve(null);
// Never serve a click from another monitor's stream: if the clicked
// display has no ready stream, a "nearest display" frame would show the
// wrong screen entirely and the marker fractions would be meaningless.
// Resolve null instead so the caller's fallback captures the right one.
if (clickPos && !pointInBounds(clickPos, display.bounds)) return Promise.resolve(null);
const requestId = this.nextRequestId++;
return new Promise((resolve) => {
const pending = { resolve, display, timer: null };
pending.timer = setTimeout(() => {
this.settleRequest(requestId, null);
this.noteFailure();
}, this.ackTimeoutMs);
this.requests.set(requestId, pending);
this.hostSend({
type: 'frame-request',
requestId,
displayId: display.id,
clickAt,
strict,
leadMs,
});
});
}
noteFailure() {
this.consecutiveFailures += 1;
if (this.consecutiveFailures < MAX_CONSECUTIVE_FAILURES) return;
const notify = this.onUnhealthy;
this.stop({ immediate: true });
if (notify) notify('frame requests timing out');
}
/**
* Stop the backend. By default this *drains*: it stops accepting new
* requests but keeps the worker alive so frames already selected for
* queued clicks finish encoding and resolve — without this, finishing a
* recording right after a fast click burst cancels every still-encoding
* frame to null and those clicks are lost ("only two screenshots saved").
* Pass { immediate: true } to abandon in-flight requests (used when the
* worker is already unhealthy).
*/
stop({ immediate = false } = {}) {
this.active = false;
for (const check of [...this.startWaiters]) check();
this.startWaiters = [];
if (immediate) {
for (const [, pending] of this.requests) {
clearTimeout(pending.timer);
pending.resolve(null);
}
this.requests.clear();
this.finalizeTeardown();
return;
}
if (this.requests.size === 0) {
this.finalizeTeardown();
return;
}
// Let pending requests resolve naturally (their own encode timers still
// bound the wait); finalizeTeardown fires from settleRequest when the
// last one completes.
this.draining = true;
}
finalizeTeardown() {
this.draining = false;
this.streams.clear();
if (this.host) {
try { this.host.destroy(); } catch { /* already gone */ }
this.host = null;
}
}
}
/** Match each display to its desktopCapturer screen source by display_id. */
function pairDisplaysToSources(displays, sources) {
const screens = (sources || []).filter((s) => s && typeof s.id === 'string' && s.id.startsWith('screen:'));
const pairs = [];
const used = new Set();
for (const display of displays || []) {
let source = screens.find((s) => !used.has(s.id) && String(s.display_id) === String(display.id));
if (!source && displays.length === 1 && screens.length === 1) {
// Single display, single source: some platforms leave display_id empty.
source = screens[0];
}
if (!source) continue;
used.add(source.id);
pairs.push({ display, sourceId: source.id });
}
return pairs;
}
/**
* Production worker host: a hidden BrowserWindow running the capture-worker
* page. Lazy-required Electron so this module stays loadable under node for
* unit tests.
*/
async function createElectronHost(onEvent) {
// eslint-disable-next-line global-require
const { BrowserWindow, ipcMain } = require('electron');
const win = new BrowserWindow({
show: false,
width: 320,
height: 240,
skipTaskbar: true,
webPreferences: {
preload: path.join(__dirname, 'capture-worker-preload.js'),
contextIsolation: true,
nodeIntegration: false,
// The worker must keep sampling while hidden — throttling a hidden
// window is exactly the wrong default for a frame recorder.
backgroundThrottling: false,
},
});
const listener = (event, msg) => {
if (event.sender === win.webContents) onEvent(msg);
};
ipcMain.on('capture-worker:event', listener);
try {
await win.loadFile(path.join(__dirname, 'renderer', 'capture-worker.html'));
} catch (err) {
ipcMain.removeListener('capture-worker:event', listener);
if (!win.isDestroyed()) win.destroy();
throw err;
}
return {
send(msg) {
if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg);
},
destroy() {
ipcMain.removeListener('capture-worker:event', listener);
if (!win.isDestroyed()) win.destroy();
},
};
}
module.exports = {
StreamCaptureBackend,
createElectronHost,
pairDisplaysToSources,
DEFAULT_SAMPLE_MS,
DEFAULT_ACK_TIMEOUT_MS,
DEFAULT_ENCODE_TIMEOUT_MS,
MAX_CONSECUTIVE_FAILURES,
};