Fixed an issue where clicking wouldn't line up with screenshot part 2
Template tests / tests (push) Successful in 1m56s
Template tests / tests (push) Successful in 1m56s
This commit is contained in:
+140
-16
@@ -22,7 +22,18 @@ const { encodePng } = require('../core/png');
|
|||||||
* failures surface as { ok: false, reason } instead of crashing.
|
* failures surface as { ok: false, reason } instead of crashing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CLICK_DEBOUNCE_MS = 700;
|
// Dedupe duplicate watcher events for one physical click while still
|
||||||
|
// allowing intentionally fast clicking.
|
||||||
|
const CLICK_DEBOUNCE_MS = 150;
|
||||||
|
// Idle gap between frame-loop grabs; the effective refresh rate is
|
||||||
|
// grab-duration + this.
|
||||||
|
const FRAME_LOOP_IDLE_MS = 50;
|
||||||
|
// A buffered frame older than this is too stale to pass off as "the screen
|
||||||
|
// at the instant of the click".
|
||||||
|
const CLICK_FRAME_MAX_AGE_MS = 600;
|
||||||
|
// How long a click waits for the in-flight grab before falling back to a
|
||||||
|
// one-off fresh shot.
|
||||||
|
const CLICK_FRAME_WAIT_MS = 2000;
|
||||||
const CLICK_CAPTURE_HIDE_DELAY_MS = 25;
|
const CLICK_CAPTURE_HIDE_DELAY_MS = 25;
|
||||||
|
|
||||||
function hasBinary(name) {
|
function hasBinary(name) {
|
||||||
@@ -43,6 +54,10 @@ class CaptureService {
|
|||||||
this.session = null; // { guideId, paused, count, intervalSec }
|
this.session = null; // { guideId, paused, count, intervalSec }
|
||||||
this.intervalTimer = null;
|
this.intervalTimer = null;
|
||||||
this.clickWatcher = null;
|
this.clickWatcher = null;
|
||||||
|
this.frameLoopTimer = null;
|
||||||
|
this.frameLoopRunning = false;
|
||||||
|
this.frameWaiters = [];
|
||||||
|
this.latestFrame = null;
|
||||||
this.lastClickCapture = 0;
|
this.lastClickCapture = 0;
|
||||||
this.shooting = false;
|
this.shooting = false;
|
||||||
}
|
}
|
||||||
@@ -184,15 +199,22 @@ class CaptureService {
|
|||||||
const wasPaused = this.session.paused;
|
const wasPaused = this.session.paused;
|
||||||
this.session.paused = typeof force === 'boolean' ? force : !this.session.paused;
|
this.session.paused = typeof force === 'boolean' ? force : !this.session.paused;
|
||||||
// Starting/resuming tucks the window away again for clean shots (after
|
// Starting/resuming tucks the window away again for clean shots (after
|
||||||
// a brief delay so the user sees it happen).
|
// a brief delay so the user sees it happen) and starts the frame loop
|
||||||
|
// that serves click captures. Pausing stops the loop and discards the
|
||||||
|
// buffered frame, so a resume can never serve a pre-pause screen.
|
||||||
if (wasPaused && !this.session.paused) {
|
if (wasPaused && !this.session.paused) {
|
||||||
const win = this.getWindow();
|
const win = this.getWindow();
|
||||||
const tuck = () => {
|
const arm = () => {
|
||||||
if (!this.session || this.session.paused) return;
|
if (!this.session || this.session.paused) return;
|
||||||
if (this.hiddenForSession && win && !win.isDestroyed() && win.isVisible()) win.hide();
|
if (this.hiddenForSession && win && !win.isDestroyed() && win.isVisible()) win.hide();
|
||||||
|
if (this.settings.get('capture.captureOutsideClicks') !== false && this.clickCaptureAvailable()) {
|
||||||
|
this.startFrameLoop();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (this.hiddenForSession && win && !win.isDestroyed()) setTimeout(tuck, 400);
|
if (this.hiddenForSession && win && !win.isDestroyed()) setTimeout(arm, 400);
|
||||||
else tuck();
|
else arm();
|
||||||
|
} else if (!wasPaused && this.session.paused) {
|
||||||
|
this.stopFrameLoop();
|
||||||
}
|
}
|
||||||
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
|
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
|
||||||
this.notify('capture:state', this.state());
|
this.notify('capture:state', this.state());
|
||||||
@@ -204,6 +226,7 @@ class CaptureService {
|
|||||||
this.intervalTimer = null;
|
this.intervalTimer = null;
|
||||||
}
|
}
|
||||||
this.stopClickWatcher();
|
this.stopClickWatcher();
|
||||||
|
this.stopFrameLoop();
|
||||||
this.destroySessionTray();
|
this.destroySessionTray();
|
||||||
this.session = null;
|
this.session = null;
|
||||||
if (this.hiddenForSession) {
|
if (this.hiddenForSession) {
|
||||||
@@ -230,20 +253,34 @@ class CaptureService {
|
|||||||
/** One capture inside the active session (hotkey/click/interval/manual). */
|
/** One capture inside the active session (hotkey/click/interval/manual). */
|
||||||
async sessionCapture(trigger = 'hotkey', clickPos = null) {
|
async sessionCapture(trigger = 'hotkey', clickPos = null) {
|
||||||
if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
|
if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
|
||||||
if (this.shooting) return { ok: false, reason: 'capture already in progress' };
|
|
||||||
// Automatic triggers stand down while the user is in StepForge, so the
|
// Automatic triggers stand down while the user is in StepForge, so the
|
||||||
// app stays clickable mid-session and never screenshots itself.
|
// app stays clickable mid-session and never screenshots itself.
|
||||||
if (trigger !== 'manual' && this.userIsInApp()) {
|
if (trigger !== 'manual' && this.userIsInApp()) {
|
||||||
return { ok: false, reason: 'skipped — StepForge is focused' };
|
return { ok: false, reason: 'skipped — StepForge is focused' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clicks are served from the frame loop: the buffered frame was grabbed
|
||||||
|
// at (or moments before) the click instant, so the background matches
|
||||||
|
// what the user clicked on. A click that lands while a grab is in
|
||||||
|
// flight waits for that frame instead of being dropped, so fast
|
||||||
|
// clicking still yields one step per click.
|
||||||
|
if (trigger === 'click') {
|
||||||
|
const frame = await this.frameForClick();
|
||||||
|
if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
|
||||||
|
if (frame) {
|
||||||
|
const result = this.storeFrameAsStep(this.session.guideId, frame.mode, frame, clickPos);
|
||||||
|
if (result.ok) this.noteStepAdded(result.step, trigger);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// No usable frame (loop not running or grab failing): fall through
|
||||||
|
// to a one-off fresh shot.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shooting) return { ok: false, reason: 'capture already in progress' };
|
||||||
this.shooting = true;
|
this.shooting = true;
|
||||||
try {
|
try {
|
||||||
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||||
const grabMode = mode === 'region' ? 'fullscreen' : mode;
|
const grabMode = mode === 'region' ? 'fullscreen' : mode;
|
||||||
// Always a fresh shot, started right now. Click captures pass the
|
|
||||||
// cursor position grabbed at the instant of the click (onOsClick), so
|
|
||||||
// the marker always lands on the screen the user actually clicked —
|
|
||||||
// never on a pre-click frame from some moment earlier.
|
|
||||||
const finalResult = await this.shoot({
|
const finalResult = await this.shoot({
|
||||||
guideId: this.session.guideId,
|
guideId: this.session.guideId,
|
||||||
mode: grabMode,
|
mode: grabMode,
|
||||||
@@ -252,24 +289,111 @@ class CaptureService {
|
|||||||
refocus: false, // don't steal focus from the app the user is documenting
|
refocus: false, // don't steal focus from the app the user is documenting
|
||||||
clickPos,
|
clickPos,
|
||||||
});
|
});
|
||||||
if (finalResult.ok) {
|
if (finalResult.ok) this.noteStepAdded(finalResult.step, trigger);
|
||||||
this.session.count += 1;
|
|
||||||
this.notify('capture:added', { guideId: this.session.guideId, step: finalResult.step, trigger });
|
|
||||||
this.notify('capture:state', this.state());
|
|
||||||
if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
|
|
||||||
}
|
|
||||||
return finalResult;
|
return finalResult;
|
||||||
} finally {
|
} finally {
|
||||||
this.shooting = false;
|
this.shooting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noteStepAdded(step, trigger) {
|
||||||
|
this.session.count += 1;
|
||||||
|
this.notify('capture:added', { guideId: this.session.guideId, step, trigger });
|
||||||
|
this.notify('capture:state', this.state());
|
||||||
|
if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
|
||||||
|
}
|
||||||
|
|
||||||
hotkeyCapture() {
|
hotkeyCapture() {
|
||||||
return this.sessionCapture('hotkey');
|
return this.sessionCapture('hotkey');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- click-triggered capture --------------------------------------------
|
// ---- click-triggered capture --------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continuous screen-grab loop that runs while recording. It keeps the most
|
||||||
|
* recent frame in `latestFrame` so a click can be served from a frame
|
||||||
|
* grabbed at (or moments before) the instant of the click — a fresh grab
|
||||||
|
* started after the click would land hundreds of ms late and show the
|
||||||
|
* click's effects instead of what the user clicked on.
|
||||||
|
*/
|
||||||
|
startFrameLoop() {
|
||||||
|
if (this.frameLoopRunning) return;
|
||||||
|
this.frameLoopRunning = true;
|
||||||
|
const tick = async () => {
|
||||||
|
if (!this.frameLoopRunning) return;
|
||||||
|
if (!this.session || this.session.paused) {
|
||||||
|
this.frameLoopRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!this.shooting) {
|
||||||
|
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||||
|
const grabMode = mode === 'region' ? 'fullscreen' : mode;
|
||||||
|
const frame = await this.captureCurrentFrame(grabMode);
|
||||||
|
if (this.frameLoopRunning) this.acceptFrame(frame);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Grab failures are fine — clicks fall back to a one-off fresh shot.
|
||||||
|
} finally {
|
||||||
|
if (this.frameLoopRunning && this.session && !this.session.paused) {
|
||||||
|
this.frameLoopTimer = setTimeout(tick, FRAME_LOOP_IDLE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.frameLoopTimer = setTimeout(tick, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store a grabbed frame and hand it to any clicks waiting on it. */
|
||||||
|
acceptFrame(frame) {
|
||||||
|
this.latestFrame = frame;
|
||||||
|
const waiters = this.frameWaiters;
|
||||||
|
this.frameWaiters = [];
|
||||||
|
for (const resolve of waiters) resolve(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves with the next frame the loop grabs (null on timeout/stop). */
|
||||||
|
nextFrame(timeoutMs) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const entry = (frame) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(frame);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.frameWaiters = this.frameWaiters.filter((w) => w !== entry);
|
||||||
|
resolve(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
this.frameWaiters.push(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopFrameLoop() {
|
||||||
|
if (this.frameLoopTimer) {
|
||||||
|
clearTimeout(this.frameLoopTimer);
|
||||||
|
this.frameLoopTimer = null;
|
||||||
|
}
|
||||||
|
this.frameLoopRunning = false;
|
||||||
|
this.latestFrame = null;
|
||||||
|
const waiters = this.frameWaiters;
|
||||||
|
this.frameWaiters = [];
|
||||||
|
for (const resolve of waiters) resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freshest frame usable for a click capture: the buffered frame when it's
|
||||||
|
* recent enough, otherwise the next frame the loop delivers. Null when the
|
||||||
|
* loop isn't running or can't deliver in time.
|
||||||
|
*/
|
||||||
|
async frameForClick() {
|
||||||
|
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||||
|
const grabMode = mode === 'region' ? 'fullscreen' : mode;
|
||||||
|
const usable = (f) => f && f.mode === grabMode
|
||||||
|
&& Date.now() - f.capturedAt <= CLICK_FRAME_MAX_AGE_MS;
|
||||||
|
if (usable(this.latestFrame)) return this.latestFrame;
|
||||||
|
if (!this.frameLoopRunning) return null;
|
||||||
|
const next = await this.nextFrame(CLICK_FRAME_WAIT_MS);
|
||||||
|
return usable(next) ? next : null;
|
||||||
|
}
|
||||||
|
|
||||||
startClickWatcher() {
|
startClickWatcher() {
|
||||||
this.stopClickWatcher();
|
this.stopClickWatcher();
|
||||||
try {
|
try {
|
||||||
|
|||||||
+9
-7
@@ -92,13 +92,15 @@ Initial release.
|
|||||||
literal text "undefined" by an old bug); a corrupted file is now
|
literal text "undefined" by an old bug); a corrupted file is now
|
||||||
treated as empty instead of crashing the dialog, and is overwritten
|
treated as empty instead of crashing the dialog, and is overwritten
|
||||||
with valid JSON the next time settings are saved.
|
with valid JSON the next time settings are saved.
|
||||||
- Click captures now always take a brand-new screenshot at the instant
|
- Click captures now line up with the click. While recording, a
|
||||||
of the click, with the click marker at the click-time cursor position.
|
continuous screen-grab loop keeps the latest frame buffered, and each
|
||||||
Previously they reused a pre-click frame from a background cache that
|
click becomes a step from a frame grabbed at (or moments before) the
|
||||||
could be stale by the time of the click — so the background image and
|
click instant, with the marker at the click-time cursor position — a
|
||||||
the click marker didn't always line up, and after pause/resume the
|
frame older than 600ms is never used. Fast clicks are no longer
|
||||||
same frozen frame could be reused for every step with only the marker
|
dropped: a click that lands while a grab is in flight waits for that
|
||||||
moving. The pre-click frame cache has been removed entirely.
|
frame instead of being discarded, and the click debounce was lowered
|
||||||
|
from 700ms to 150ms. Pausing stops the loop and discards the buffered
|
||||||
|
frame, so resuming can never reuse a stale pre-pause screenshot.
|
||||||
|
|
||||||
### Added (initial feature set)
|
### Added (initial feature set)
|
||||||
|
|
||||||
|
|||||||
+73
-21
@@ -50,38 +50,90 @@ test('click-triggered session capture uses the low-latency hide pause', async ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every click capture takes a fresh shot — pre-grabbed frames are never reused', async () => {
|
function makeFrame(name, ageMs = 0) {
|
||||||
const service = makeService();
|
|
||||||
service.session = { guideId: 'guide-2', paused: false, count: 0, intervalSec: 0 };
|
|
||||||
|
|
||||||
let shots = 0;
|
|
||||||
service.captureCurrentFrame = async (mode) => {
|
|
||||||
shots += 1;
|
|
||||||
return {
|
return {
|
||||||
mode,
|
mode: 'fullscreen',
|
||||||
png: Buffer.from(`frame-${shots}`),
|
png: Buffer.from(name),
|
||||||
size: { width: 100, height: 100 },
|
size: { width: 100, height: 100 },
|
||||||
display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
|
display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
|
||||||
cursor: { x: 50, y: 50 },
|
cursor: { x: 50, y: 50 },
|
||||||
capturedAt: Date.now(),
|
capturedAt: Date.now() - ageMs,
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const captured = [];
|
test('a click is served instantly from the freshly buffered frame', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
service.session = { guideId: 'guide-2', paused: false, count: 0, intervalSec: 0 };
|
||||||
|
service.latestFrame = makeFrame('buffered-png');
|
||||||
|
service.shoot = async () => {
|
||||||
|
throw new Error('must not take a fresh shot when a buffered frame is ready');
|
||||||
|
};
|
||||||
|
const added = [];
|
||||||
service.store.addStep = (guideId, fields, png) => {
|
service.store.addStep = (guideId, fields, png) => {
|
||||||
captured.push(png.toString());
|
added.push(png.toString());
|
||||||
return { stepId: `step-${captured.length}` };
|
return { stepId: 'step-1' };
|
||||||
};
|
};
|
||||||
service.notify = () => {};
|
|
||||||
|
|
||||||
await service.sessionCapture('click', { x: 10, y: 10 });
|
const result = await service.sessionCapture('click', { x: 10, y: 10 });
|
||||||
await service.sessionCapture('click', { x: 20, y: 20 });
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(added, ['buffered-png']);
|
||||||
|
assert.equal(service.session.count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale buffered frame is not reused — the click falls back to a fresh shot', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
service.session = { guideId: 'guide-stale', paused: false, count: 0, intervalSec: 0 };
|
||||||
|
service.latestFrame = makeFrame('stale-png', 10_000);
|
||||||
|
|
||||||
|
let shootCalled = false;
|
||||||
|
service.shoot = async () => {
|
||||||
|
shootCalled = true;
|
||||||
|
return { ok: true, step: { stepId: 'fresh-step' } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.sessionCapture('click', { x: 1, y: 1 });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(shootCalled, true, 'a stale buffered frame must not be reused');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicks during an in-flight grab wait for the frame instead of being dropped', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
service.session = { guideId: 'guide-fast', paused: false, count: 0, intervalSec: 0 };
|
||||||
|
service.frameLoopRunning = true; // a grab is in flight, no frame buffered yet
|
||||||
|
service.shoot = async () => {
|
||||||
|
throw new Error('waiting clicks must use the loop frame, not a competing shot');
|
||||||
|
};
|
||||||
|
const added = [];
|
||||||
|
service.store.addStep = (guideId, fields, png) => {
|
||||||
|
added.push(png.toString());
|
||||||
|
return { stepId: `step-${added.length}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two rapid clicks land before the grab completes.
|
||||||
|
const first = service.sessionCapture('click', { x: 1, y: 1 });
|
||||||
|
const second = service.sessionCapture('click', { x: 2, y: 2 });
|
||||||
|
service.acceptFrame(makeFrame('loop-frame'));
|
||||||
|
const [r1, r2] = await Promise.all([first, second]);
|
||||||
|
|
||||||
|
assert.equal(r1.ok, true);
|
||||||
|
assert.equal(r2.ok, true);
|
||||||
|
assert.deepEqual(added, ['loop-frame', 'loop-frame'],
|
||||||
|
'both clicks must become steps from the frame that was in flight');
|
||||||
|
assert.equal(service.session.count, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pausing stops the frame loop and discards the buffered frame', () => {
|
||||||
|
const service = makeService();
|
||||||
|
service.session = { guideId: 'guide-pause', paused: false, count: 0, intervalSec: 0 };
|
||||||
|
service.frameLoopRunning = true;
|
||||||
|
service.latestFrame = makeFrame('pre-pause');
|
||||||
|
|
||||||
service.togglePause(true);
|
service.togglePause(true);
|
||||||
service.togglePause(false);
|
|
||||||
await service.sessionCapture('click', { x: 30, y: 30 });
|
|
||||||
|
|
||||||
assert.deepEqual(captured, ['frame-1', 'frame-2', 'frame-3'],
|
assert.equal(service.frameLoopRunning, false);
|
||||||
'each click must capture a brand-new frame, including after pause/resume');
|
assert.equal(service.latestFrame, null, 'a resume must never serve a pre-pause frame');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('click capture marks the click-time cursor position', async () => {
|
test('click capture marks the click-time cursor position', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user