Gaze Gesture Library

Webcam gaze tracking reduced to one coarse screen-region dwell gesture

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/589409/1890547/Gaze%20Gesture%20Library.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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
);