Webcam gaze tracking reduced to one coarse screen-region dwell gesture
Hindi dapat direktang i-install ang script na ito. Ito ay isang library para sa iba pang mga script na isasama sa meta directive. // @require https://update.greasyfork.org/scripts/589409/1890547/Gaze%20Gesture%20Library.js
// ==UserScript==
// @name Gaze Gesture Library
// @description Webcam gaze tracking reduced to one coarse screen-region dwell gesture
// @version 1.3.0
// @author Anonymous, Claude Opus 5
// @namespace 861ddd094884eac5bea7a3b12e074f34
// @license MIT
// @grant none
// ==/UserScript==
/* Scope
********
*
* A webcam cannot resolve gaze to a word or a link — that needs IR hardware.
* It can resolve gaze to a large screen region, which is all a "glance at the
* top edge" gesture asks for. This library therefore deliberately does not
* implement screen-coordinate eye tracking: it maps gaze to a normalized point
* with a two-point calibration, and everything downstream is a dwell gate over
* one rectangular target.
*
* The split below is what makes the thing testable. `createGate` is pure — it
* consumes (timestamp, sample) and emits triggers, with no camera, no DOM and
* no timers of its own — so the debounce/re-arm behaviour, which is where the
* real bugs live, is exercised headlessly by tests/gaze-gesture.lib.test.js.
* The MediaPipe layer above it only turns frames into samples.
*/
(function (root) {
'use strict';
const IS_NODE = typeof module !== 'undefined' && module.exports;
const DEFAULTS = {
// Target region in normalized viewport coordinates: x/y both 0..1 with
// the origin at the top-left, as a rectangle spanning x ± xRadius and
// yMin..yMax. Generous, because webcam gaze is not precise. Either axis
// may be the discriminating one — a horizontal band across the bottom,
// or a column down one side — and calibration solves whichever it is.
//
// The band defaults to the *bottom* rather than the top for a reason
// that is easy to get wrong: the trigger must not overlap whatever the
// reader is looking at while reading. A consumer that seats content at
// the top of the viewport has its reading material in the top fraction,
// so a top-anchored trigger cannot distinguish reading from gesturing —
// and, worse, never clears, so the gate cannot re-arm. The bottom edge
// is empty during that read, and on content long enough to reach it,
// arriving at the bottom means the reader is done anyway.
//
// Any bound that runs past the viewport is open-ended — see
// targetBounds — so a glance that overshoots the edge still counts, no
// matter how far the estimate lands past it.
target: { x: 0.5, xRadius: 0.3, yMin: 0.82, yMax: 1.6 },
// Uninterrupted time inside the target before the gesture fires. The
// dominant cost of lowering this is accidental fires from glances that
// merely cross the region.
dwellMs: 350,
// Dead time after a trigger, during which samples are consumed but
// cannot fire. Bounds the worst case to one trigger per cooldown even
// if re-arming misbehaves.
cooldownMs: 1000,
// After firing, gaze must sit *outside* the target for this long before
// the gesture can fire again. Without it, holding a stare at the top of
// the screen would advance repeatedly.
rearmMs: 250,
// Dropped frames and blinks both read as "no face". Tolerating a short
// gap stops a blink mid-dwell from resetting progress.
missGraceMs: 200,
// Suspend inference when the tab is not visible, and release the camera
// device outright rather than merely ignoring its frames — the latter
// leaves the hardware indicator lit while the reader is elsewhere.
suspendOnHidden: true,
releaseCameraOnHidden: true,
// One-euro filter terms. `beta` buys responsiveness during a saccade at
// the cost of jitter; `minCutoff` sets how hard a still gaze is filtered.
// Raise beta if the gesture feels laggy, raise minCutoff if it rattles.
smoothing: { minCutoff: 1.2, beta: 1.8, dCutoff: 1.0 },
// Draw the estimated gaze point, the target region and dwell progress
// over the page. The only practical way to tell a mis-calibration from a
// badly placed target, so it is worth a menu command in the consumer.
debug: false,
// Gaze is dominated by eye rotation, but the head turns too, and the
// blendshapes do not account for it. This weights head pitch/yaw into
// the estimate. Calibration scales the sum, so this only sets the ratio.
headWeight: 0.35,
// Fallback gains, used until a calibration exists. Deliberately timid:
// an uncalibrated tracker should under-trigger, not over-trigger.
gain: { x: 0.8, y: 0.8 },
// Vision assets. No CSP on the target origin, so these load directly;
// see the README for the @require fallback if that ever changes.
// Pinned deliberately: the module and the wasm directory must come from
// the same build, and a floating tag would eventually pair mismatched
// halves. Verify both URLs resolve before bumping.
visionModuleUrl:
'https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/vision_bundle.mjs',
wasmBase:
'https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/wasm',
modelUrl:
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task',
// GPU delegate falls back to CPU automatically on failure.
delegate: 'GPU',
};
// gate
///////
const STATES = {
// Gaze is outside the target; a dwell may begin.
IDLE: 'idle',
// Gaze is inside the target and accumulating toward dwellMs.
DWELL: 'dwell',
// Fired. Blocked until cooldownMs elapses *and* gaze clears the target
// for rearmMs. Both conditions, not either.
SPENT: 'spent',
};
/**
* The dwell gate: pure state machine over gaze samples.
*
* `feed(now, sample)` takes a monotonic millisecond timestamp and either a
* `{ x, y }` normalized gaze point or null for "no face this frame". It
* returns a transition record, whose `fired` flag is the gesture.
*/
function createGate(options) {
const cfg = Object.assign({}, DEFAULTS, options || {});
const target = Object.assign({}, DEFAULTS.target, cfg.target);
let state = STATES.IDLE;
// Start of the current uninterrupted dwell, null when not dwelling.
let dwellStart = null;
// While SPENT: when gaze most recently *left* the target, or null if it
// has not left since firing. Re-arming measures from here.
let clearedSince = null;
let firedAt = null;
// A miss is only forgiven relative to the last *good* frame, so a long
// absence cannot be papered over by grace repeatedly.
let lastSample = null;
const bounds = targetBounds(target);
const inTarget = (p) =>
!!p &&
p.x >= bounds.xMin &&
p.x <= bounds.xMax &&
p.y >= bounds.yMin &&
p.y <= bounds.yMax;
function reset() {
state = STATES.IDLE;
dwellStart = null;
clearedSince = null;
firedAt = null;
lastSample = null;
}
function feed(now, sample) {
// Treat a brief absence as a continuation of the previous sample so
// blinks do not reset an in-progress dwell; a longer one is a real
// loss of tracking and drops us to IDLE.
let point = sample;
// A substituted sample sustains a dwell but may not complete one:
// the reader's eyes are shut, so there is no evidence they are
// still on target, and a gesture that can fire mid-blink is a
// gesture that fires by accident.
let synthetic = false;
if (!point) {
if (lastSample && now - lastSample.at <= cfg.missGraceMs) {
point = lastSample.point;
synthetic = true;
} else {
// Tracking is genuinely gone. Start over from every state,
// not just mid-dwell — resuming into a half-held gesture
// after an absence is never what the reader meant.
reset();
return { state, fired: false, progress: 0, lost: true };
}
} else {
lastSample = { at: now, point };
}
const inside = inTarget(point);
if (state === STATES.SPENT) {
// Any re-entry restarts the clearance window: a stare that
// drifts out and back has not been released.
if (inside) clearedSince = null;
else if (clearedSince === null) clearedSince = now;
const cooled = now - firedAt >= cfg.cooldownMs;
const cleared =
clearedSince !== null && now - clearedSince >= cfg.rearmMs;
if (cooled && cleared) {
state = STATES.IDLE;
dwellStart = null;
clearedSince = null;
}
return { state, fired: false, progress: 0, inside };
}
if (!inside) {
state = STATES.IDLE;
dwellStart = null;
return { state, fired: false, progress: 0, inside };
}
if (dwellStart === null) dwellStart = now;
state = STATES.DWELL;
const held = now - dwellStart;
if (held >= cfg.dwellMs && !synthetic) {
state = STATES.SPENT;
firedAt = now;
dwellStart = null;
clearedSince = null;
return { state, fired: true, progress: 1, inside };
}
return {
state,
fired: false,
progress: cfg.dwellMs > 0 ? held / cfg.dwellMs : 1,
inside,
};
}
return {
feed,
reset,
inTarget,
get state() {
return state;
},
};
}
// gaze estimation
//////////////////
const blend = (map, key) => (map && typeof map[key] === 'number' ? map[key] : 0);
/**
* Collapse a FaceLandmarker result into a raw two-axis gaze offset, before
* calibration gains. Positive x is the subject's right, positive y is up.
*
* Blendshape names are anatomical (the model resolves which eye is which
* from the face itself), so this is independent of whether the video feed
* is mirrored. Calibration handles the sign anyway.
*/
function rawOffset(blendshapes, matrix, headWeight) {
const lookRight =
(blend(blendshapes, 'eyeLookInLeft') +
blend(blendshapes, 'eyeLookOutRight')) / 2;
const lookLeft =
(blend(blendshapes, 'eyeLookOutLeft') +
blend(blendshapes, 'eyeLookInRight')) / 2;
const lookUp =
(blend(blendshapes, 'eyeLookUpLeft') +
blend(blendshapes, 'eyeLookUpRight')) / 2;
const lookDown =
(blend(blendshapes, 'eyeLookDownLeft') +
blend(blendshapes, 'eyeLookDownRight')) / 2;
let headYaw = 0;
let headPitch = 0;
if (matrix && matrix.length === 16) {
// Column-major 4x4. Extracting yaw/pitch from the rotation basis is
// enough here; roll is irrelevant to a vertical-strip target.
headPitch = Math.asin(Math.max(-1, Math.min(1, -matrix[9]))) / (Math.PI / 2);
headYaw = Math.atan2(matrix[8], matrix[10]) / (Math.PI / 2);
}
return {
x: (lookRight - lookLeft) + headWeight * headYaw,
y: (lookUp - lookDown) + headWeight * headPitch,
};
}
/**
* Velocity-adaptive smoothing (a one-euro filter).
*
* A fixed alpha forces a choice between jitter and lag, and gaze needs
* neither: fixations must be steady or the dwell rattles, while saccades
* must be tracked immediately or the gesture feels slow. Since a saccade is
* precisely a high-velocity sample, the cutoff is raised in proportion to
* the observed speed — still gaze is filtered hard, moving gaze is barely
* filtered at all.
*/
function createSmoother({ minCutoff = 1.2, beta = 1.8, dCutoff = 1.0 } = {}) {
let value = null;
let derivative = { x: 0, y: 0 };
let lastAt = null;
const alphaFor = (cutoff, dt) => {
const tau = 1 / (2 * Math.PI * cutoff);
return 1 / (1 + tau / dt);
};
return {
push(next, now) {
if (!next) return value;
if (value === null || lastAt === null) {
value = { x: next.x, y: next.y };
derivative = { x: 0, y: 0 };
lastAt = now;
return value;
}
// Guard the first frame after a resume, where dt can be huge or
// zero depending on how the loop was interrupted.
const dt = Math.min(0.1, Math.max(0.001, (now - lastAt) / 1000));
lastAt = now;
const out = { x: 0, y: 0 };
const ad = alphaFor(dCutoff, dt);
['x', 'y'].forEach((axis) => {
const rate = (next[axis] - value[axis]) / dt;
derivative[axis] += ad * (rate - derivative[axis]);
const cutoff = minCutoff + beta * Math.abs(derivative[axis]);
const a = alphaFor(cutoff, dt);
out[axis] = value[axis] + a * (next[axis] - value[axis]);
});
value = out;
return value;
},
reset() {
value = null;
derivative = { x: 0, y: 0 };
lastAt = null;
},
};
}
/**
* Resolve a target into the bounds the gate actually tests.
*
* An edge that already runs past the viewport becomes open-ended rather
* than merely wide. A region anchored to an edge is a *direction* — "look
* left", "look down" — and a gaze estimate that lands beyond it has, if
* anything, committed to that direction harder. Bounding it there is the
* failure the wide default bounds were reaching for and missing: a flick
* that overshoots the estimate off-screen dropped out of the region and
* silently reset the dwell.
*/
function targetBounds(target) {
const xMin = target.x - target.xRadius;
const xMax = target.x + target.xRadius;
return {
xMin: xMin <= 0 ? -Infinity : xMin,
xMax: xMax >= 1 ? Infinity : xMax,
yMin: target.yMin <= 0 ? -Infinity : target.yMin,
yMax: target.yMax >= 1 ? Infinity : target.yMax,
};
}
/**
* Where the reader is asked to look during calibration: the middle of the
* target band, clamped into the viewport so the marker is actually visible
* even when the band runs past the edge.
*/
function targetCentre(target) {
return {
x: Math.min(0.94, Math.max(0.06, target.x)),
y: Math.min(0.94, Math.max(0.06, (target.yMin + target.yMax) / 2)),
};
}
/**
* Derive calibration from two sampled means: gaze at the target marker and
* gaze at the viewport centre. Two points fix a gain and an origin per
* axis, which is all the model needs — the target is one region, not a
* coordinate space, so a full 9-point regression would buy nothing.
*
* The same formula serves a target above or below (or left or right of)
* centre: both the wanted displacement and the measured one change sign
* together.
*
* Only an axis the target actually sits off-centre on carries signal — on
* the other one the two captures coincide by construction, so that axis
* keeps its fallback gain. A vertical band solves y; a column down one side
* of the viewport solves x.
*/
function solveCalibration(aimSample, centreSample, target) {
const aim = targetCentre(target);
const gain = { x: DEFAULTS.gain.x, y: DEFAULTS.gain.y };
const solve = (axis, wanted, measured) => {
if (Math.abs(wanted) < 0.05) return false;
// A degenerate spread means the two captures did not differ: usually
// the reader did not actually move their eyes. Refuse rather than
// emit a wild gain.
if (Math.abs(measured) < 0.02) return null;
gain[axis] = wanted / measured;
return true;
};
const x = solve('x', aim.x - 0.5, aimSample.x - centreSample.x);
const y = solve('y', 0.5 - aim.y, aimSample.y - centreSample.y);
if (x === null || y === null || !(x || y)) return null;
return {
origin: { x: centreSample.x, y: centreSample.y },
gain,
// Stamped so a calibration cannot outlive the target it was solved
// against: moving the region invalidates the gain, and a silently
// wrong calibration is worse than none.
aim,
};
}
/** Map a raw offset into normalized viewport coordinates. */
function project(offset, calibration) {
const origin = calibration ? calibration.origin : { x: 0, y: 0 };
const gain = calibration ? calibration.gain : DEFAULTS.gain;
return {
x: 0.5 + (offset.x - origin.x) * gain.x,
y: 0.5 - (offset.y - origin.y) * gain.y,
};
}
// tracker
//////////
function create(options) {
const cfg = Object.assign({}, DEFAULTS, options || {});
cfg.target = Object.assign({}, DEFAULTS.target, (options || {}).target);
const log = cfg.logger || { info() {}, warn() {}, error() {} };
const store = cfg.storage || defaultStorage(cfg.prefsKey);
const gate = createGate(cfg);
const smoother = createSmoother(cfg.smoothing);
let landmarker = null;
let stream = null;
let video = null;
let running = false;
let suspended = false;
let rafId = null;
let lastVideoTime = -1;
let calibration = (() => {
const stored = store.read();
if (!stored) return null;
const aim = targetCentre(cfg.target);
const solvedFor = stored.aim;
// Pre-stamp calibrations, and any solved against a different band,
// describe a mapping that no longer holds.
if (
!solvedFor ||
Math.abs(solvedFor.y - aim.y) > 0.02 ||
Math.abs(solvedFor.x - aim.x) > 0.02
) {
log.warn('gaze: stored calibration was solved for a different target — recalibrate');
return null;
}
return stored;
})();
// Populated only while calibrate() is collecting.
let capture = null;
let hud = null;
async function loadVision() {
if (landmarker) return landmarker;
const vision = await import(cfg.visionModuleUrl);
const fileset = await vision.FilesetResolver.forVisionTasks(cfg.wasmBase);
landmarker = await vision.FaceLandmarker.createFromOptions(fileset, {
baseOptions: {
modelAssetPath: cfg.modelUrl,
delegate: cfg.delegate,
},
runningMode: 'VIDEO',
numFaces: 1,
outputFaceBlendshapes: true,
outputFacialTransformationMatrixes: true,
});
return landmarker;
}
/**
* Firefox refuses getUserMedia outright when the requesting document is
* not focused, and a userscript-manager menu command is exactly that
* case: the click lands in the extension popup, so by the time the
* callback runs the page holds neither focus nor user activation, and
* the request fails as NotAllowedError before any prompt is shown.
*
* So the camera is never opened straight from a menu callback. When the
* page is not already focused and activated, this puts a button in the
* page and waits for a real click on it. Resolves true once the request
* may proceed, false if the reader dismissed it.
*/
function ensurePageGesture(reason) {
const activated =
typeof navigator.userActivation === 'undefined' ||
navigator.userActivation.isActive;
if (document.hasFocus() && activated) return Promise.resolve(true);
return new Promise((resolve) => {
const prompt = buildPrompt(reason);
prompt.mount(
() => {
prompt.unmount();
resolve(true);
},
() => {
prompt.unmount();
resolve(false);
}
);
});
}
async function openCamera() {
if (stream) return stream;
stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480, facingMode: 'user' },
audio: false,
});
if (!video) {
video = document.createElement('video');
video.playsInline = true;
video.muted = true;
// Kept out of the layout entirely; nothing here is meant to be
// seen, and a hidden-but-present element would still reflow.
video.style.cssText =
'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none;left:-9999px;';
document.body.appendChild(video);
}
video.srcObject = stream;
await video.play();
return stream;
}
function closeCamera() {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
if (video) video.srcObject = null;
lastVideoTime = -1;
}
function sampleFrame(now) {
if (!landmarker || !video || video.readyState < 2) return null;
// The model rejects a repeated timestamp, and the camera delivers
// frames slower than rAF fires, so skip unchanged frames outright.
if (video.currentTime === lastVideoTime) return undefined;
lastVideoTime = video.currentTime;
const result = landmarker.detectForVideo(video, now);
const shapes = result && result.faceBlendshapes && result.faceBlendshapes[0];
if (!shapes) return null;
const map = {};
shapes.categories.forEach((c) => {
map[c.categoryName] = c.score;
});
const matrix =
result.facialTransformationMatrixes &&
result.facialTransformationMatrixes[0] &&
result.facialTransformationMatrixes[0].data;
return smoother.push(rawOffset(map, matrix, cfg.headWeight), now);
}
function tick() {
if (!running) return;
rafId = requestAnimationFrame(tick);
if (suspended) return;
const now = performance.now();
let offset;
try {
offset = sampleFrame(now);
} catch (err) {
log.warn('gaze: inference failed —', err && err.message);
return;
}
// undefined means "no new frame", which is not evidence of a lost
// face and must not be fed to the gate as one.
if (offset === undefined) return;
if (capture) {
if (offset) capture.samples.push(offset);
return;
}
const point = offset ? project(offset, calibration) : null;
const step = gate.feed(now, point);
if (hud) hud.update(point, step);
if (cfg.onSample) cfg.onSample(point, step);
if (step.fired) {
log.info('gaze: gesture fired');
try {
cfg.onTrigger();
} catch (err) {
log.warn('gaze: trigger threw —', err && err.message);
}
}
}
async function start() {
if (running) return true;
if (!(await ensurePageGesture('Start gaze scrolling'))) {
log.info('gaze: start dismissed');
return false;
}
try {
await loadVision();
await openCamera();
} catch (err) {
log.warn('gaze: could not start —', describeStartFailure(err));
closeCamera();
return false;
}
running = true;
suspended = false;
gate.reset();
smoother.reset();
if (cfg.debug) showHud(true);
rafId = requestAnimationFrame(tick);
log.info(
calibration
? 'gaze: tracking active'
: 'gaze: tracking active (uncalibrated — run calibration for reliable triggering)'
);
if (cfg.onStateChange) cfg.onStateChange(true);
return true;
}
function stop() {
if (!running) return;
running = false;
suspended = false;
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = null;
closeCamera();
gate.reset();
smoother.reset();
showHud(false);
log.info('gaze: tracking stopped');
if (cfg.onStateChange) cfg.onStateChange(false);
}
// Leaving the tab must not leave the camera live. Releasing the device
// (rather than dropping frames) is what actually extinguishes the
// hardware indicator; the permission grant survives, so resuming does
// not re-prompt.
async function suspend() {
if (!running || suspended) return;
suspended = true;
gate.reset();
smoother.reset();
if (cfg.releaseCameraOnHidden) closeCamera();
log.info('gaze: suspended');
}
async function resume() {
if (!running || !suspended) return;
try {
if (cfg.releaseCameraOnHidden) await openCamera();
suspended = false;
log.info('gaze: resumed');
} catch (err) {
log.warn('gaze: could not resume —', describeStartFailure(err));
stop();
}
}
if (cfg.suspendOnHidden && typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.hidden) suspend();
else resume();
});
window.addEventListener('blur', () => {
if (cfg.suspendOnBlur) suspend();
});
window.addEventListener('focus', () => {
if (cfg.suspendOnBlur) resume();
});
}
/**
* Two-point calibration. Shows a marker, samples for `holdMs`, repeats
* at the viewport centre, then solves. Returns true on success; a
* degenerate spread (reader did not move their eyes) fails loudly
* rather than persisting a nonsense gain.
*/
async function calibrate({ holdMs = 1500 } = {}) {
const startedHere = !running;
if (startedHere && !(await start())) return false;
const marker = buildMarker();
const collect = async (label, x, y) => {
marker.show(label, x, y);
capture = { samples: [] };
await wait(holdMs);
const taken = capture.samples;
capture = null;
if (!taken.length) return null;
return {
x: mean(taken.map((s) => s.x)),
y: mean(taken.map((s) => s.y)),
};
};
let solved = null;
try {
marker.mount();
await wait(400);
const aim = targetCentre(cfg.target);
const at = await collect('Look here', aim.x, aim.y);
const centre = await collect('Now look here', 0.5, 0.5);
if (at && centre) solved = solveCalibration(at, centre, cfg.target);
} finally {
capture = null;
marker.unmount();
}
if (!solved) {
log.warn('gaze: calibration failed — hold your gaze on each marker');
if (startedHere) stop();
return false;
}
calibration = solved;
store.write(solved);
gate.reset();
log.info('gaze: calibrated');
if (startedHere) stop();
return true;
}
function showHud(on) {
if (on) {
if (!hud) hud = buildHud(cfg.target);
hud.mount();
} else if (hud) {
hud.unmount();
}
}
return {
start,
stop,
calibrate,
toggle: () => (running ? (stop(), false) : start()),
isRunning: () => running,
isCalibrated: () => !!calibration,
/** Flip the overlay at runtime; only visible while tracking. */
toggleDebug() {
cfg.debug = !cfg.debug;
if (running) showHud(cfg.debug);
return cfg.debug;
},
clearCalibration() {
calibration = null;
store.write(null);
},
};
}
// support
//////////
const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* getUserMedia's error names are far more actionable than its messages,
* which are deliberately vague to avoid fingerprinting. Say what to do.
*/
function describeStartFailure(err) {
const name = err && err.name;
const detail = (err && err.message) || String(err);
switch (name) {
case 'NotAllowedError':
return `camera permission refused (${detail}) — check the camera ` +
'icon in the address bar; if the site is blocked there, clear ' +
'it and retry';
case 'NotFoundError':
case 'OverconstrainedError':
return `no usable camera found (${detail})`;
case 'NotReadableError':
return `the camera is in use by another application (${detail})`;
case 'SecurityError':
return `blocked by the page's security policy (${detail})`;
default:
return detail;
}
}
/** GM-backed when the requiring script granted it, in-memory otherwise. */
function defaultStorage(key) {
const name = key || 'gaze_calibration';
const hasGM =
typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
if (!hasGM) {
let held = null;
return { read: () => held, write: (v) => { held = v; } };
}
return {
read() {
try {
const raw = GM_getValue(name, null);
return raw ? JSON.parse(raw) : null;
} catch (err) {
return null;
}
},
write(value) {
try {
GM_setValue(name, value === null ? '' : JSON.stringify(value));
} catch (err) {
/* best effort */
}
},
};
}
/**
* Live overlay: the target region, the estimated gaze point, and how far the
* current dwell has progressed. Tuning thresholds without this is guesswork
* — it is what distinguishes "the estimate is wrong" from "the target is in
* the wrong place".
*/
function buildHud(target) {
let host = null;
let dot = null;
let ring = null;
let readout = null;
return {
mount() {
if (host) return;
host = document.createElement('div');
host.style.cssText =
'position:fixed;inset:0;z-index:2147483646;pointer-events:none;';
// Clamped to the viewport on every side: an open-ended edge is
// drawn flush to it, which is what the gate effectively tests.
const b = targetBounds(target);
const left = Math.max(0, b.xMin);
const right = Math.min(1, b.xMax);
const top = Math.max(0, b.yMin);
const bottom = Math.min(1, b.yMax);
const zone = document.createElement('div');
zone.style.cssText =
`position:absolute;left:${left * 100}%;` +
`width:${Math.max(0, right - left) * 100}%;top:${top * 100}%;` +
`height:${Math.max(0, bottom - top) * 100}%;` +
'border:2px dashed rgba(78,161,255,.75);background:rgba(78,161,255,.10);' +
'box-sizing:border-box;';
ring = document.createElement('div');
ring.style.cssText =
'position:absolute;left:0;top:0;height:4px;width:0;background:#4ea1ff;';
zone.appendChild(ring);
dot = document.createElement('div');
dot.style.cssText =
'position:absolute;width:16px;height:16px;margin:-8px 0 0 -8px;' +
'border-radius:50%;background:#ff4e6a;box-shadow:0 0 0 4px rgba(255,78,106,.3);';
readout = document.createElement('div');
readout.style.cssText =
'position:absolute;right:8px;bottom:8px;padding:.35rem .6rem;' +
'border-radius:6px;background:rgba(0,0,0,.75);color:#e8e8e8;' +
'font:500 12px/1.4 ui-monospace,monospace;white-space:pre;';
host.append(zone, dot, readout);
document.body.appendChild(host);
},
update(point, step) {
if (!host) return;
if (point) {
dot.style.display = '';
dot.style.left = `${point.x * 100}%`;
dot.style.top = `${point.y * 100}%`;
} else {
dot.style.display = 'none';
}
ring.style.width = `${Math.round((step.progress || 0) * 100)}%`;
readout.textContent = point
? `x ${point.x.toFixed(2)} y ${point.y.toFixed(2)}\n${step.state}`
: 'no face';
},
unmount() {
if (host && host.parentNode) host.parentNode.removeChild(host);
host = null;
},
};
}
/**
* Consent button. Its only job is to be clicked *in the page*, so that the
* getUserMedia call that follows carries focus and user activation.
*/
function buildPrompt(reason) {
let host = null;
return {
mount(onAccept, onDismiss) {
host = document.createElement('div');
host.style.cssText =
'position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.72);' +
'display:flex;align-items:center;justify-content:center;' +
'font:400 14px/1.55 system-ui,sans-serif;color:#e8e8e8;';
const card = document.createElement('div');
card.style.cssText =
'max-width:22rem;padding:1.25rem 1.4rem;border-radius:10px;' +
'background:#1e1f22;box-shadow:0 8px 30px rgba(0,0,0,.5);text-align:center;';
const title = document.createElement('div');
title.textContent = reason;
title.style.cssText = 'font-weight:600;font-size:15px;margin-bottom:.5rem;';
const body = document.createElement('div');
body.textContent =
'Your browser will ask for camera access. The video never ' +
'leaves this page and is not recorded.';
body.style.cssText = 'margin-bottom:1rem;color:#b9b9b9;';
const accept = document.createElement('button');
accept.textContent = 'Enable camera';
accept.style.cssText =
'padding:.5rem 1rem;margin-right:.5rem;border:0;border-radius:6px;' +
'background:#4ea1ff;color:#0b1220;font-weight:600;cursor:pointer;';
const cancel = document.createElement('button');
cancel.textContent = 'Cancel';
cancel.style.cssText =
'padding:.5rem 1rem;border:0;border-radius:6px;background:#3a3b3f;' +
'color:#e8e8e8;cursor:pointer;';
accept.addEventListener('click', onAccept);
cancel.addEventListener('click', onDismiss);
host.addEventListener('click', (e) => {
if (e.target === host) onDismiss();
});
card.append(title, body, accept, cancel);
host.appendChild(card);
document.body.appendChild(host);
// Focus the page itself, not just the button: the document has
// to hold focus for the upcoming request to be permitted.
window.focus();
accept.focus();
},
unmount() {
if (host && host.parentNode) host.parentNode.removeChild(host);
host = null;
},
};
}
/** Full-viewport overlay carrying a single calibration dot. */
function buildMarker() {
let host = null;
let dot = null;
let label = null;
return {
mount() {
host = document.createElement('div');
host.style.cssText =
'position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.72);';
dot = document.createElement('div');
dot.style.cssText =
'position:absolute;width:22px;height:22px;margin:-11px 0 0 -11px;' +
'border-radius:50%;background:#4ea1ff;box-shadow:0 0 0 6px rgba(78,161,255,.25);';
label = document.createElement('div');
label.style.cssText =
'position:absolute;left:0;right:0;top:50%;text-align:center;' +
'font:600 15px/1.5 system-ui,sans-serif;color:#e8e8e8;';
host.append(dot, label);
document.body.appendChild(host);
},
show(text, x, y) {
if (!host) return;
dot.style.left = `${x * 100}%`;
dot.style.top = `${y * 100}%`;
label.textContent = text;
// Keep the caption clear of the dot when the dot is near it.
label.style.top = y > 0.35 ? '70%' : '50%';
},
unmount() {
if (host && host.parentNode) host.parentNode.removeChild(host);
host = null;
},
};
}
const API = {
create,
createGate,
rawOffset,
solveCalibration,
targetBounds,
targetCentre,
project,
STATES,
DEFAULTS,
};
if (IS_NODE) module.exports = API;
if (root) root.GazeGesture = API;
})(
typeof unsafeWindow !== 'undefined'
? unsafeWindow
: typeof globalThis !== 'undefined'
? globalThis
: null
);