Files
autodoc/app/renderer/capture-worker.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

211 lines
7.9 KiB
JavaScript

'use strict';
/**
* Capture worker: runs in a hidden renderer window and owns all continuous
* screen capture during a recording session.
*
* Per display it opens a desktop media stream (the desktopCapturer source id
* comes from the main process) and samples it on a fixed cadence into a
* timestamped ring buffer of ImageBitmaps. Sampling and PNG encoding happen
* entirely in this process, so the main-process event loop — which must stay
* responsive to deliver OS click events on time — never blocks on capture
* work. ImageBitmaps are GPU-backed and cheap to create from a <video>
* element, which is what lets the cadence be much tighter than the old
* 200ms main-process desktopCapturer loop.
*
* On a frame request the worker applies the shared strict selection rule
* (newest frame captured at or before the click; never one whose grab
* started after it), encodes that single frame to PNG, and ships the bytes
* to the main process.
*/
/* global StepForgeClickFrames, captureWorkerBridge */
(() => {
const FALLBACK_SAMPLE_MS = 50;
// Tight cadence means more frames per second; keep enough of them to span
// the click-lead window plus any encode/IPC hiccup, without hoarding GPU
// memory. 16 frames at the 50ms cadence is ~800ms of history.
const FALLBACK_FRAME_LIMIT = 16;
const FALLBACK_RETENTION_MS = 2000;
const streams = new Map(); // displayId(string) -> stream state
function send(msg) {
try {
captureWorkerBridge.send(msg);
return true;
} catch (err) {
// Either the main process is gone or the payload didn't survive the
// bridge; log it — a silently dropped frame-response would otherwise
// look like a worker hang from the main process.
console.error('capture-worker send failed:', err && err.message, 'type:', msg && msg.type);
return false;
}
}
async function startStream(cmd) {
const key = String(cmd.displayId);
stopStream(key);
const display = cmd.display || {};
const scale = display.scaleFactor || 1;
const bounds = display.bounds || { width: 1280, height: 720 };
const physWidth = Math.round(bounds.width * scale);
const physHeight = Math.round(bounds.height * scale);
const state = {
displayId: cmd.displayId,
media: null,
video: null,
timer: null,
sampling: false,
ring: new StepForgeClickFrames.FrameRing({
limit: cmd.frameLimit || FALLBACK_FRAME_LIMIT,
retentionMs: cmd.retentionMs || FALLBACK_RETENTION_MS,
onEvict: (frame) => {
if (frame && frame.bitmap && frame.bitmap.close) frame.bitmap.close();
},
}),
};
streams.set(key, state);
try {
// The chromeMediaSource constraint set is Electron's documented bridge
// from a desktopCapturer source id to a live media stream.
state.media = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: cmd.sourceId,
minWidth: physWidth,
maxWidth: physWidth,
minHeight: physHeight,
maxHeight: physHeight,
maxFrameRate: 30,
},
},
});
const video = document.createElement('video');
video.muted = true;
video.srcObject = state.media;
state.video = video;
await video.play();
const sampleMs = cmd.sampleMs || FALLBACK_SAMPLE_MS;
state.timer = setInterval(() => sampleFrame(state), sampleMs);
// Buffer a frame immediately so a click right after "Start recording"
// already has something captured before it.
await sampleFrame(state);
send({ type: 'stream-ready', displayId: cmd.displayId });
} catch (err) {
stopStream(key);
send({ type: 'stream-error', displayId: cmd.displayId, reason: String(err && err.message || err) });
}
}
async function sampleFrame(state) {
if (state.sampling || !state.video || state.video.readyState < 2) return;
state.sampling = true;
// startedAt/capturedAt bracket the grab so strict selection can tell
// pre-click frames from post-click ones.
const startedAt = Date.now();
try {
const bitmap = await createImageBitmap(state.video);
state.ring.push({
mode: 'fullscreen',
bitmap,
width: bitmap.width,
height: bitmap.height,
startedAt,
capturedAt: Date.now(),
});
} catch {
// A failed sample only means a slightly older best frame.
} finally {
state.sampling = false;
}
}
function stopStream(key) {
const state = streams.get(key);
if (!state) return;
if (state.timer) clearInterval(state.timer);
if (state.media) {
for (const track of state.media.getTracks()) {
try { track.stop(); } catch { /* already stopped */ }
}
}
state.ring.clear();
streams.delete(key);
}
async function handleFrameRequest(cmd) {
const state = streams.get(String(cmd.displayId));
const reply = (extra) => send({ type: 'frame-response', requestId: cmd.requestId, ...extra });
if (!state) return reply({ ok: false, reason: 'no stream for display' });
// One last sample: if the compositor delivered a newer video frame since
// the previous tick, a sub-millisecond grab here can only improve (never
// worsen) the match — its startedAt is still checked against the click.
await sampleFrame(state);
const frame = StepForgeClickFrames.selectFrameForClick(state.ring.frames(), {
clickAt: cmd.clickAt,
leadMs: cmd.leadMs || 0,
mode: 'fullscreen',
strict: cmd.strict !== false,
});
if (!frame) return reply({ ok: false, reason: 'no frame at or before the click' });
// Stage one: confirm the selection immediately. The encode below can
// take seconds on software-rendered hosts; without this ack the main
// process couldn't tell a slow encode from a dead worker.
send({
type: 'frame-selected',
requestId: cmd.requestId,
startedAt: frame.startedAt,
capturedAt: frame.capturedAt,
});
try {
const canvas = new OffscreenCanvas(frame.width, frame.height);
canvas.getContext('2d').drawImage(frame.bitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/png' });
const png = await blob.arrayBuffer();
return reply({
ok: true,
png: new Uint8Array(png),
width: frame.width,
height: frame.height,
startedAt: frame.startedAt,
capturedAt: frame.capturedAt,
});
} catch (err) {
return reply({ ok: false, reason: String(err && err.message || err) });
}
}
/** Health/diagnostic snapshot of every stream. */
function reportStats(cmd) {
const stats = {};
for (const [key, state] of streams) {
stats[key] = {
frames: state.ring.frames().length,
latestCapturedAt: state.ring.latest() ? state.ring.latest().capturedAt : null,
videoReadyState: state.video ? state.video.readyState : null,
videoSize: state.video ? `${state.video.videoWidth}x${state.video.videoHeight}` : null,
sampling: state.sampling,
};
}
send({ type: 'stats', requestId: cmd && cmd.requestId, stats });
}
captureWorkerBridge.onCommand((msg) => {
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'start-stream') startStream(msg);
else if (msg.type === 'stop-stream') stopStream(String(msg.displayId));
else if (msg.type === 'frame-request') {
// A request must always produce a response — an unanswered click
// counts toward backend unhealthiness in the main process.
handleFrameRequest(msg).catch((err) => {
console.error('capture-worker frame-request failed:', err && err.message);
send({ type: 'frame-response', requestId: msg.requestId, ok: false, reason: String(err && err.message || err) });
});
} else if (msg.type === 'stats-request') reportStats(msg);
});
})();