Fixed an issue where clicking wouldn't line up with screenshot
Template tests / tests (push) Successful in 1m49s
Template tests / tests (push) Successful in 1m49s
This commit is contained in:
+10
-67
@@ -23,8 +23,6 @@ const { encodePng } = require('../core/png');
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const CLICK_DEBOUNCE_MS = 700;
|
const CLICK_DEBOUNCE_MS = 700;
|
||||||
const CLICK_CAPTURE_CACHE_MS = 75;
|
|
||||||
const CLICK_CAPTURE_CACHE_MAX_AGE_MS = 400;
|
|
||||||
const CLICK_CAPTURE_HIDE_DELAY_MS = 25;
|
const CLICK_CAPTURE_HIDE_DELAY_MS = 25;
|
||||||
|
|
||||||
function hasBinary(name) {
|
function hasBinary(name) {
|
||||||
@@ -45,9 +43,6 @@ 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.captureCacheTimer = null;
|
|
||||||
this.captureCache = null;
|
|
||||||
this.captureCacheRunning = false;
|
|
||||||
this.lastClickCapture = 0;
|
this.lastClickCapture = 0;
|
||||||
this.shooting = false;
|
this.shooting = false;
|
||||||
}
|
}
|
||||||
@@ -189,27 +184,15 @@ 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) and arms the click-capture
|
// a brief delay so the user sees it happen).
|
||||||
// cache so the very next click is captured instantly.
|
|
||||||
if (wasPaused && !this.session.paused) {
|
if (wasPaused && !this.session.paused) {
|
||||||
const win = this.getWindow();
|
const win = this.getWindow();
|
||||||
const arm = () => {
|
const tuck = () => {
|
||||||
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.startClickCaptureCache();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if (this.hiddenForSession && win && !win.isDestroyed()) setTimeout(arm, 400);
|
if (this.hiddenForSession && win && !win.isDestroyed()) setTimeout(tuck, 400);
|
||||||
else arm();
|
else tuck();
|
||||||
} else if (!wasPaused && this.session.paused) {
|
|
||||||
// Pausing stops the background cache refresh loop, but it does so by
|
|
||||||
// returning early (see startClickCaptureCache's refresh) without
|
|
||||||
// resetting captureCacheRunning or clearing captureCache. Without this
|
|
||||||
// call, resuming would find captureCacheRunning still true, so
|
|
||||||
// startClickCaptureCache() would no-op and every click afterwards
|
|
||||||
// would reuse the stale pre-pause frame instead of a fresh one.
|
|
||||||
this.stopClickCaptureCache();
|
|
||||||
}
|
}
|
||||||
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
|
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
|
||||||
this.notify('capture:state', this.state());
|
this.notify('capture:state', this.state());
|
||||||
@@ -221,7 +204,6 @@ class CaptureService {
|
|||||||
this.intervalTimer = null;
|
this.intervalTimer = null;
|
||||||
}
|
}
|
||||||
this.stopClickWatcher();
|
this.stopClickWatcher();
|
||||||
this.stopClickCaptureCache();
|
|
||||||
this.destroySessionTray();
|
this.destroySessionTray();
|
||||||
this.session = null;
|
this.session = null;
|
||||||
if (this.hiddenForSession) {
|
if (this.hiddenForSession) {
|
||||||
@@ -258,16 +240,11 @@ class CaptureService {
|
|||||||
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;
|
||||||
// The background refresh loop (startClickCaptureCache) keeps this
|
// Always a fresh shot, started right now. Click captures pass the
|
||||||
// updated every ~75ms; if it's gone stale (refresh errors silently and
|
// cursor position grabbed at the instant of the click (onOsClick), so
|
||||||
// stops updating, or the cache was never refreshed after a resume),
|
// the marker always lands on the screen the user actually clicked —
|
||||||
// fall back to a fresh shot rather than reusing an old background.
|
// never on a pre-click frame from some moment earlier.
|
||||||
const cacheFresh = this.captureCache && this.captureCache.mode === grabMode
|
const finalResult = await this.shoot({
|
||||||
&& Date.now() - this.captureCache.capturedAt <= CLICK_CAPTURE_CACHE_MAX_AGE_MS;
|
|
||||||
const cached = trigger === 'click' && cacheFresh ? this.captureCache : null;
|
|
||||||
const finalResult = cached
|
|
||||||
? this.storeFrameAsStep(this.session.guideId, grabMode, cached, clickPos)
|
|
||||||
: await this.shoot({
|
|
||||||
guideId: this.session.guideId,
|
guideId: this.session.guideId,
|
||||||
mode: grabMode,
|
mode: grabMode,
|
||||||
delayMs: 0,
|
delayMs: 0,
|
||||||
@@ -293,40 +270,6 @@ class CaptureService {
|
|||||||
|
|
||||||
// ---- click-triggered capture --------------------------------------------
|
// ---- click-triggered capture --------------------------------------------
|
||||||
|
|
||||||
startClickCaptureCache() {
|
|
||||||
if (this.captureCacheRunning) return;
|
|
||||||
this.captureCacheRunning = true;
|
|
||||||
const refresh = async () => {
|
|
||||||
if (!this.session || this.session.paused || !this.captureCacheRunning) 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.captureCacheRunning && this.session && !this.session.paused) {
|
|
||||||
this.captureCache = frame;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Cache misses are fine; click capture falls back to a fresh shot.
|
|
||||||
} finally {
|
|
||||||
if (this.session && !this.session.paused && this.captureCacheRunning) {
|
|
||||||
this.captureCacheTimer = setTimeout(refresh, CLICK_CAPTURE_CACHE_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.captureCacheTimer = setTimeout(refresh, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopClickCaptureCache() {
|
|
||||||
if (this.captureCacheTimer) {
|
|
||||||
clearTimeout(this.captureCacheTimer);
|
|
||||||
this.captureCacheTimer = null;
|
|
||||||
}
|
|
||||||
this.captureCacheRunning = false;
|
|
||||||
this.captureCache = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
startClickWatcher() {
|
startClickWatcher() {
|
||||||
this.stopClickWatcher();
|
this.stopClickWatcher();
|
||||||
try {
|
try {
|
||||||
@@ -383,7 +326,7 @@ while ($true) {
|
|||||||
this.lastClickCapture = now;
|
this.lastClickCapture = now;
|
||||||
// Grab the cursor position synchronously, right when the click is
|
// Grab the cursor position synchronously, right when the click is
|
||||||
// detected, so the marker lands exactly where the user clicked even if
|
// detected, so the marker lands exactly where the user clicked even if
|
||||||
// the cached frame is a beat stale or a fresh shot takes a moment.
|
// the shot itself takes a moment to grab.
|
||||||
const clickPos = screen.getCursorScreenPoint();
|
const clickPos = screen.getCursorScreenPoint();
|
||||||
this.sessionCapture('click', clickPos).catch(() => {});
|
this.sessionCapture('click', clickPos).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-6
@@ -92,12 +92,13 @@ 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 no longer reuse the same stale background screenshot
|
- Click captures now always take a brand-new screenshot at the instant
|
||||||
for every step (only the click marker moved). Pausing now fully resets
|
of the click, with the click marker at the click-time cursor position.
|
||||||
the click-capture cache so resuming starts a fresh background refresh
|
Previously they reused a pre-click frame from a background cache that
|
||||||
loop, and a cached frame older than 400ms (e.g. if the background
|
could be stale by the time of the click — so the background image and
|
||||||
refresh silently stops working) is now discarded in favor of a fresh
|
the click marker didn't always line up, and after pause/resume the
|
||||||
screenshot.
|
same frozen frame could be reused for every step with only the marker
|
||||||
|
moving. The pre-click frame cache has been removed entirely.
|
||||||
|
|
||||||
### Added (initial feature set)
|
### Added (initial feature set)
|
||||||
|
|
||||||
|
|||||||
+24
-114
@@ -50,127 +50,41 @@ test('click-triggered session capture uses the low-latency hide pause', async ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('click-triggered session capture prefers the cached frame when ready', async () => {
|
test('every click capture takes a fresh shot — pre-grabbed frames are never reused', async () => {
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
service.settings.get = (key) => {
|
|
||||||
if (key === 'capture.mode') return 'fullscreen';
|
|
||||||
if (key === 'capture.delayMs') return 0;
|
|
||||||
if (key === 'capture.clickMarker') return true;
|
|
||||||
if (key === 'capture.clickMarkerColor') return '#E5484D';
|
|
||||||
if (key === 'editor.focusedViewDefaultForNewSteps') return false;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
service.session = { guideId: 'guide-2', paused: false, count: 0, intervalSec: 0 };
|
service.session = { guideId: 'guide-2', paused: false, count: 0, intervalSec: 0 };
|
||||||
service.captureCache = {
|
|
||||||
mode: 'fullscreen',
|
let shots = 0;
|
||||||
png: Buffer.from('cached-png'),
|
service.captureCurrentFrame = async (mode) => {
|
||||||
size: { width: 120, height: 80 },
|
shots += 1;
|
||||||
display: { bounds: { x: 10, y: 20, width: 120, height: 80 } },
|
return {
|
||||||
cursor: { x: 70, y: 40 },
|
mode,
|
||||||
|
png: Buffer.from(`frame-${shots}`),
|
||||||
|
size: { width: 100, height: 100 },
|
||||||
|
display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
|
||||||
|
cursor: { x: 50, y: 50 },
|
||||||
capturedAt: Date.now(),
|
capturedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let shootCalled = false;
|
|
||||||
service.shoot = async () => {
|
|
||||||
shootCalled = true;
|
|
||||||
throw new Error('fresh shot should not run when cache is ready');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const added = [];
|
const captured = [];
|
||||||
service.store.addStep = (guideId, fields, png, size) => {
|
service.store.addStep = (guideId, fields, png) => {
|
||||||
added.push({ guideId, fields, png, size });
|
captured.push(png.toString());
|
||||||
return { stepId: 'step-2', ...fields };
|
return { stepId: `step-${captured.length}` };
|
||||||
};
|
|
||||||
service.notify = (channel, payload) => {
|
|
||||||
added.push({ channel, payload });
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.sessionCapture('click');
|
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
|
||||||
assert.equal(shootCalled, false);
|
|
||||||
assert.equal(service.session.count, 1);
|
|
||||||
assert.equal(added[0].guideId, 'guide-2');
|
|
||||||
assert.deepEqual(added[0].png, Buffer.from('cached-png'));
|
|
||||||
assert.deepEqual(added[0].size, { width: 120, height: 80 });
|
|
||||||
assert.equal(added[0].fields.annotations.length, 1);
|
|
||||||
assert.equal(added[0].fields.annotations[0].type, 'oval');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('click-triggered capture marks the click-time cursor position, not the cached frame\'s (possibly stale) cursor', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
service.settings.get = (key) => {
|
|
||||||
if (key === 'capture.mode') return 'fullscreen';
|
|
||||||
if (key === 'capture.delayMs') return 0;
|
|
||||||
if (key === 'capture.clickMarker') return true;
|
|
||||||
if (key === 'capture.clickMarkerColor') return '#E5484D';
|
|
||||||
if (key === 'editor.focusedViewDefaultForNewSteps') return false;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
service.session = { guideId: 'guide-3', paused: false, count: 0, intervalSec: 0 };
|
|
||||||
service.captureCache = {
|
|
||||||
mode: 'fullscreen',
|
|
||||||
png: Buffer.from('cached-png'),
|
|
||||||
size: { width: 120, height: 80 },
|
|
||||||
display: { bounds: { x: 0, y: 0, width: 120, height: 80 } },
|
|
||||||
// Stale cursor position from the cache-refresh loop, well outside the
|
|
||||||
// display — if this were used for the marker, no annotation would be
|
|
||||||
// placed at all.
|
|
||||||
cursor: { x: 9999, y: 9999 },
|
|
||||||
capturedAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
service.shoot = async () => {
|
|
||||||
throw new Error('fresh shot should not run when cache is ready');
|
|
||||||
};
|
|
||||||
|
|
||||||
let added = null;
|
|
||||||
service.store.addStep = (guideId, fields, png, size) => {
|
|
||||||
added = { guideId, fields, png, size };
|
|
||||||
return { stepId: 'step-3', ...fields };
|
|
||||||
};
|
};
|
||||||
service.notify = () => {};
|
service.notify = () => {};
|
||||||
|
|
||||||
// The user clicked dead center of the display.
|
await service.sessionCapture('click', { x: 10, y: 10 });
|
||||||
const result = await service.sessionCapture('click', { x: 60, y: 40 });
|
await service.sessionCapture('click', { x: 20, y: 20 });
|
||||||
|
service.togglePause(true);
|
||||||
|
service.togglePause(false);
|
||||||
|
await service.sessionCapture('click', { x: 30, y: 30 });
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.deepEqual(captured, ['frame-1', 'frame-2', 'frame-3'],
|
||||||
assert.equal(added.fields.annotations.length, 1);
|
'each click must capture a brand-new frame, including after pause/resume');
|
||||||
const marker = added.fields.annotations[0];
|
|
||||||
assert.equal(marker.type, 'oval');
|
|
||||||
const d = 0.035;
|
|
||||||
assert.ok(Math.abs(marker.x - (0.5 - d / 2)) < 1e-9);
|
|
||||||
assert.ok(Math.abs(marker.y - (0.5 - (d * 120 / 80) / 2)) < 1e-9);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('click-triggered session capture falls back to a fresh shot when the cached frame is stale', async () => {
|
test('click capture marks the click-time cursor position', async () => {
|
||||||
const service = makeService();
|
|
||||||
service.session = { guideId: 'guide-stale', paused: false, count: 0, intervalSec: 0 };
|
|
||||||
// A frame that's well past the cache's max age — e.g. the background
|
|
||||||
// refresh loop died (errored silently, or never restarted after a
|
|
||||||
// pause/resume) and left a frozen, increasingly-stale frame behind.
|
|
||||||
service.captureCache = {
|
|
||||||
mode: 'fullscreen',
|
|
||||||
png: Buffer.from('stale-png'),
|
|
||||||
size: { width: 120, height: 80 },
|
|
||||||
display: { bounds: { x: 0, y: 0, width: 120, height: 80 } },
|
|
||||||
cursor: { x: 60, y: 40 },
|
|
||||||
capturedAt: Date.now() - 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 cached frame must not be reused');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('live-shot click capture also marks the click-time cursor position', async () => {
|
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
service.settings.get = (key) => {
|
service.settings.get = (key) => {
|
||||||
if (key === 'capture.mode') return 'fullscreen';
|
if (key === 'capture.mode') return 'fullscreen';
|
||||||
@@ -206,7 +120,7 @@ test('live-shot click capture also marks the click-time cursor position', async
|
|||||||
assert.equal(added.fields.annotations[0].type, 'oval');
|
assert.equal(added.fields.annotations[0].type, 'oval');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a new session starts paused and does not hide the window or arm the click cache until "Start recording" is pressed', async () => {
|
test('a new session starts paused and does not hide the window until "Start recording" is pressed', async () => {
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
const win = {
|
const win = {
|
||||||
destroyed: false, visible: true, minimized: false, hidden: 0, shown: 0,
|
destroyed: false, visible: true, minimized: false, hidden: 0, shown: 0,
|
||||||
@@ -222,8 +136,6 @@ test('a new session starts paused and does not hide the window or arm the click
|
|||||||
};
|
};
|
||||||
service.getWindow = () => win;
|
service.getWindow = () => win;
|
||||||
service.clickCaptureAvailable = () => true;
|
service.clickCaptureAvailable = () => true;
|
||||||
let cacheStarted = 0;
|
|
||||||
service.startClickCaptureCache = () => { cacheStarted += 1; };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
service.startSession('guide-5');
|
service.startSession('guide-5');
|
||||||
@@ -231,7 +143,6 @@ test('a new session starts paused and does not hide the window or arm the click
|
|||||||
assert.equal(service.session.paused, true, 'sessions start paused');
|
assert.equal(service.session.paused, true, 'sessions start paused');
|
||||||
assert.equal(service.state().paused, true);
|
assert.equal(service.state().paused, true);
|
||||||
assert.equal(win.hidden, 0, 'window must stay visible until recording starts');
|
assert.equal(win.hidden, 0, 'window must stay visible until recording starts');
|
||||||
assert.equal(cacheStarted, 0, 'click-capture cache must not start before recording starts');
|
|
||||||
|
|
||||||
// User clicks "Start recording" (the resume action).
|
// User clicks "Start recording" (the resume action).
|
||||||
service.togglePause(false);
|
service.togglePause(false);
|
||||||
@@ -240,7 +151,6 @@ test('a new session starts paused and does not hide the window or arm the click
|
|||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 450));
|
await new Promise((r) => setTimeout(r, 450));
|
||||||
assert.equal(win.hidden, 1, 'window hides once recording actually starts');
|
assert.equal(win.hidden, 1, 'window hides once recording actually starts');
|
||||||
assert.equal(cacheStarted, 1, 'click-capture cache is armed once recording starts');
|
|
||||||
} finally {
|
} finally {
|
||||||
service.finishSession();
|
service.finishSession();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user