make click captures instant
Template tests / tests (push) Failing after 15s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-11 08:30:39 -05:00
parent 65fcf4af24
commit 451a831eb6
2 changed files with 161 additions and 44 deletions
+109 -39
View File
@@ -23,6 +23,7 @@ const { encodePng } = require('../core/png');
*/ */
const CLICK_DEBOUNCE_MS = 700; const CLICK_DEBOUNCE_MS = 700;
const CLICK_CAPTURE_CACHE_MS = 75;
const CLICK_CAPTURE_HIDE_DELAY_MS = 25; const CLICK_CAPTURE_HIDE_DELAY_MS = 25;
function hasBinary(name) { function hasBinary(name) {
@@ -43,6 +44,9 @@ 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;
} }
@@ -89,12 +93,22 @@ class CaptureService {
if (!process.env.STEPFORGE_SCREENSHOT) { if (!process.env.STEPFORGE_SCREENSHOT) {
this.createSessionTray(); this.createSessionTray();
const win = this.getWindow(); const win = this.getWindow();
const startClickCache = () => {
if (this.settings.get('capture.captureOutsideClicks') !== false && this.clickCaptureAvailable()) {
this.startClickCaptureCache();
}
};
if (win && !win.isDestroyed() && win.isVisible()) { if (win && !win.isDestroyed() && win.isVisible()) {
this.hiddenForSession = true; this.hiddenForSession = true;
setTimeout(() => { setTimeout(() => {
// Re-check: the session may have been finished within the delay. // Re-check: the session may have been finished within the delay.
if (this.session && this.hiddenForSession && !win.isDestroyed()) win.hide(); if (this.session && this.hiddenForSession && !win.isDestroyed()) {
win.hide();
startClickCache();
}
}, 1200); // let the user read the "session started" toast first }, 1200); // let the user read the "session started" toast first
} else {
startClickCache();
} }
try { try {
new Notification({ new Notification({
@@ -203,6 +217,7 @@ 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) {
@@ -238,20 +253,26 @@ class CaptureService {
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 result = await this.shoot({ const grabMode = mode === 'region' ? 'fullscreen' : mode;
const cached = trigger === 'click' && this.captureCache && this.captureCache.mode === grabMode
? this.captureCache
: null;
const finalResult = cached
? this.storeFrameAsStep(this.session.guideId, grabMode, cached)
: await this.shoot({
guideId: this.session.guideId, guideId: this.session.guideId,
mode: mode === 'region' ? 'fullscreen' : mode, mode: grabMode,
delayMs: 0, delayMs: 0,
hideWindowDelayMs: trigger === 'click' ? CLICK_CAPTURE_HIDE_DELAY_MS : null, hideWindowDelayMs: trigger === 'click' ? CLICK_CAPTURE_HIDE_DELAY_MS : null,
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
}); });
if (result.ok) { if (finalResult.ok) {
this.session.count += 1; this.session.count += 1;
this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger }); this.notify('capture:added', { guideId: this.session.guideId, step: finalResult.step, trigger });
this.notify('capture:state', this.state()); this.notify('capture:state', this.state());
if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter if (this.rebuildTrayMenu) this.rebuildTrayMenu(); // refresh step counter
} }
return result; return finalResult;
} finally { } finally {
this.shooting = false; this.shooting = false;
} }
@@ -263,6 +284,40 @@ 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 {
@@ -320,6 +375,49 @@ while ($true) {
this.sessionCapture('click').catch(() => {}); this.sessionCapture('click').catch(() => {});
} }
async captureCurrentFrame(mode) {
const grabbed = await this.grab(mode);
return {
mode,
png: grabbed.image.toPNG(),
size: grabbed.image.getSize(),
display: grabbed.display,
cursor: grabbed.cursor,
capturedAt: Date.now(),
};
}
storeFrameAsStep(guideId, mode, frame) {
if (!frame) return { ok: false, reason: 'no capture frame available' };
const annotations = [];
if (mode !== 'window' && this.settings.get('capture.clickMarker')) {
const fx = (frame.cursor.x - frame.display.bounds.x) / frame.display.bounds.width;
const fy = (frame.cursor.y - frame.display.bounds.y) / frame.display.bounds.height;
if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) {
const d = 0.035;
annotations.push({
type: 'oval',
x: fx - d / 2, y: fy - (d * frame.size.width / frame.size.height) / 2,
w: d, h: d * frame.size.width / frame.size.height,
style: {
stroke: this.settings.get('capture.clickMarkerColor') || '#E5484D',
strokeWidth: 4, fill: 'transparent',
},
});
}
}
const step = this.store.addStep(guideId, {
title: this.autoTitle(mode),
annotations,
focusedView: {
enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
zoom: 1, panX: 0.5, panY: 0.5,
},
}, frame.png, frame.size);
return { ok: true, step };
}
autoTitle(mode) { autoTitle(mode) {
const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]'; const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]';
const now = new Date(); const now = new Date();
@@ -404,46 +502,18 @@ while ($true) {
}) { }) {
const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs; const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs;
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
let grabbed; let frame;
try { try {
grabbed = hideWindow frame = hideWindow
? await this.withWindowHidden(() => this.grab(mode), { ? await this.withWindowHidden(() => this.captureCurrentFrame(mode), {
refocus, refocus,
pauseMs: hideWindowDelayMs == null ? 350 : hideWindowDelayMs, pauseMs: hideWindowDelayMs == null ? 350 : hideWindowDelayMs,
}) })
: await this.grab(mode); : await this.captureCurrentFrame(mode);
} catch (err) { } catch (err) {
return { ok: false, reason: err.message }; return { ok: false, reason: err.message };
} }
const { image, display, cursor } = grabbed; return this.storeFrameAsStep(guideId, mode, frame);
const size = image.getSize();
const annotations = [];
if (mode !== 'window' && this.settings.get('capture.clickMarker')) {
const fx = (cursor.x - display.bounds.x) / display.bounds.width;
const fy = (cursor.y - display.bounds.y) / display.bounds.height;
if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) {
const d = 0.035;
annotations.push({
type: 'oval',
x: fx - d / 2, y: fy - (d * size.width / size.height) / 2,
w: d, h: d * size.width / size.height,
style: {
stroke: this.settings.get('capture.clickMarkerColor') || '#E5484D',
strokeWidth: 4, fill: 'transparent',
},
});
}
}
const step = this.store.addStep(guideId, {
title: this.autoTitle(mode),
annotations,
focusedView: {
enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
zoom: 1, panX: 0.5, panY: 0.5,
},
}, image.toPNG(), size);
return { ok: true, step };
} }
/** /**
+47
View File
@@ -48,3 +48,50 @@ test('click-triggered session capture uses the low-latency hide pause', async ()
refocus: false, refocus: false,
}); });
}); });
test('click-triggered session capture prefers the cached frame when ready', 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-2', paused: false, count: 0, intervalSec: 0 };
service.captureCache = {
mode: 'fullscreen',
png: Buffer.from('cached-png'),
size: { width: 120, height: 80 },
display: { bounds: { x: 10, y: 20, width: 120, height: 80 } },
cursor: { x: 70, y: 40 },
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 = [];
service.store.addStep = (guideId, fields, png, size) => {
added.push({ guideId, fields, png, size });
return { stepId: 'step-2', ...fields };
};
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');
});