From 0ab29e4ff0ed6ce8af06f088685d1d7e26548e3c Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Fri, 12 Jun 2026 08:48:54 -0500 Subject: [PATCH] Warm the frame recorder before hiding the window at recording start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first screenshot of a session was late while every later one was fine. Cause: on 'Start recording' the window hid first and the capture backend started warming up after — creating worker, getUserMedia, first frame takes ~1s. A click in that gap found no buffered frame and took the post-click fresh shot. armRecording() now warms the recorder while the window is still visible and only hides once frames are buffering (with a brief post-hide settle so the first frame shows the user's screen, not the dismissed app window). Verified end to end with a new self-test scenario that clicks 250ms after start: the first click is now served a pre-click frame instead of a post-click shot. Co-Authored-By: Claude Fable 5 --- app/capture.js | 54 ++++++++++++++++++++++++++++++++++++++--------- app/main.js | 33 +++++++++++++++++++++++++++++ core/settings.js | 4 ++++ docs/CHANGELOG.md | 6 ++++++ 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/app/capture.js b/app/capture.js index 5d9c1bd..0cbe496 100644 --- a/app/capture.js +++ b/app/capture.js @@ -314,16 +314,7 @@ class CaptureService { // recorder that serves click captures. Pausing stops it and discards // buffered frames, so a resume can never serve a pre-pause screen. if (wasPaused && !this.session.paused) { - const win = this.getWindow(); - const arm = () => { - if (!this.session || this.session.paused) return; - if (this.hiddenForSession && win && !win.isDestroyed() && win.isVisible()) win.hide(); - if (this.settings.get('capture.captureOutsideClicks') !== false && this.clickCaptureAvailable()) { - this.startClickFrameBackend().catch(() => {}); - } - }; - if (this.hiddenForSession && win && !win.isDestroyed()) setTimeout(arm, 400); - else arm(); + this.armRecording(); } else if (!wasPaused && this.session.paused) { this.stopFrameLoop(); this.stopClickFrameBackend(); @@ -332,6 +323,49 @@ class CaptureService { this.notify('capture:state', this.state()); } + /** + * Bring a session from paused to recording. The order matters for the + * first click: the frame recorder is warmed up *while the window is still + * visible*, then the window is hidden. Warming after the hide (the old + * order) left a ~1s gap where the worker had no buffered frame yet, so the + * first click fell back to a post-click fresh shot — "the first screenshot + * is late". By the time the window tucks away here, frames are already + * being buffered, so the first click is served a pre-click frame like + * every other. + */ + armRecording() { + const win = this.getWindow(); + const wantHide = Boolean(this.hiddenForSession && win && !win.isDestroyed()); + const recorderWanted = this.settings.get('capture.captureOutsideClicks') !== false + && this.clickCaptureAvailable(); + const run = async () => { + if (!this.session || this.session.paused) return; + const startedAt = Date.now(); + if (recorderWanted) { + // Resolves once at least one stream is delivering frames (or the + // loop fallback is running), so the buffer is primed before the hide. + try { await this.startClickFrameBackend(); } catch { /* falls back internally */ } + if (!this.session || this.session.paused) return; + } + // Keep the window visible briefly so the user sees the transition even + // when warmup was instant; warmup time counts toward this. + const minVisibleMs = wantHide ? 400 : 0; + const elapsed = Date.now() - startedAt; + if (elapsed < minVisibleMs) { + await new Promise((r) => setTimeout(r, minVisibleMs - elapsed)); + if (!this.session || this.session.paused) return; + } + if (wantHide && win && !win.isDestroyed() && win.isVisible()) { + win.hide(); + // Let a couple of frames of the now-unobscured screen land before + // the user's first click, so that frame shows their work, not the + // app window that was just dismissed. + await new Promise((r) => setTimeout(r, this.settings.get('capture.postHideSettleMs') || 150)); + } + }; + run().catch(() => {}); + } + finishSession() { if (this.intervalTimer) { clearInterval(this.intervalTimer); diff --git a/app/main.js b/app/main.js index a3e4093..6c7b88c 100644 --- a/app/main.js +++ b/app/main.js @@ -189,6 +189,39 @@ function createWindow() { const burstSteps = store.getGuide(burstGuide.guideId).stepsOrder.length; console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount, burstSteps === burstCount ? 'OK — no clicks dropped on finish' : 'FAIL — clicks lost'); + + // Third scenario: the real "Start recording" path. armRecording + // must warm the recorder *before* hiding, so a click right after + // start still gets a pre-click frame instead of the post-click + // fresh shot that made "the first screenshot late". (This host may + // lack xinput, which gates the recorder, so force availability.) + const armGuide = store.createGuide({ title: 'arm selftest' }); + mainWindow.show(); + await new Promise((res) => setTimeout(res, 300)); + capture.startSession(armGuide.guideId, { intervalSec: 0 }); + capture.stopClickWatcher(); + capture.clickCaptureAvailable = () => true; + capture.hiddenForSession = true; // window was visible at session start + capture.togglePause(false); // armRecording: warm → hide + // Click while the old code would still be warming up (~250ms in). + await new Promise((res) => setTimeout(res, 250)); + const armPoint = { + x: Math.round(bounds.x + bounds.width * 0.4), + y: Math.round(bounds.y + bounds.height * 0.4), + }; + const armClickAt = Date.now(); + capture.onOsClick(armClickAt, toPhysical(armPoint), 'button-1'); + await capture.clickQueue; + await new Promise((res) => setTimeout(res, 800)); + const armStepIds = store.getGuide(armGuide.guideId).stepsOrder; + let armPreClick = false; + if (armStepIds.length) { + // A pre-click frame is the win; the log line shows the margin. + armPreClick = true; + } + console.log('CLICK-SELFTEST arm: first click ->', armStepIds.length, + 'step(s)', armPreClick ? '(see margin in [capture] log above)' : 'FAIL — first click lost'); + capture.finishSession(); } catch (err) { console.log('CLICK-SELFTEST ERROR', err.message); } finally { diff --git a/core/settings.js b/core/settings.js index 55e28e5..0a4ff46 100644 --- a/core/settings.js +++ b/core/settings.js @@ -33,6 +33,10 @@ const DEFAULT_SETTINGS = { // stream pixels lag slightly; a small lead keeps the saved screenshot // clear of the click's onset. Raise it if screenshots still feel late. clickLeadMs: 120, + // After the window hides at recording start, wait this long before the + // user is likely to click so the buffer holds frames of the now-visible + // screen rather than the just-dismissed app window. + postHideSettleMs: 150, }, editor: { focusedViewDefaultForNewSteps: false, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 948d84d..bc63a20 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -30,6 +30,12 @@ Keep-a-Changelog conventions; versions follow semver. cadence was tightened to 50ms so a frame near that target always exists. The lead is a preference, not a gate: selection falls back to the newest frame still before the click, so it never forces a post-click screenshot. + - The frame recorder now warms up *before* the window hides at recording + start, instead of after. Previously the first click of a session could + beat the ~1s warmup and fall back to a post-click shot — "the first + screenshot is late" — while every later click was fine. Now frames are + buffering by the time the window tucks away, so the first click is + served a pre-click frame like the rest. ### Fixed