/** Shared math for the motion core. */ /** * Frame-rate-independent exponential damping — identical feel at 30 and * 120fps. `k` is responsiveness per second; higher = tighter tracking. */ declare function damp(current: number, target: number, k: number, dt: number): number; declare function clamp01(v: number): number; interface RectLike { left: number; top: number; right: number; bottom: number; } /** * First time (seconds) at which a point moving at velocity (vx, vy) enters * the rect, or null if the ray misses. Slab method; a point already inside * returns 0. Zero velocity on an axis requires the point to already be * within that axis's span. */ declare function rayRectIntersect(x: number, y: number, vx: number, vy: number, rect: RectLike): number | null; /** * SensorBus — ONE set of input listeners for the whole page. * * Ten effects that each attach pointermove/scroll/resize listeners and each * compute their own velocities do ten times the work for one answer. The * bus attaches passive listeners once, computes smoothed derivatives once * per frame (conductor input lane — before any consumer runs), and exposes * a live snapshot consumers READ during their own frame work. * * Consumption contract (deliberate, GSAP-style pragmatism): `.state` * returns live internal objects — read fields each frame, never retain or * mutate them. There are no per-event callbacks; the conductor IS the * delivery mechanism. * * Lifecycle is ref-counted: `retain()` starts the sensors, the returned * release stops them when the last consumer lets go. */ interface PointerSensor { /** The current pointer X coordinate in client space (CSS pixels). */ x: number; /** The current pointer Y coordinate in client space (CSS pixels). */ y: number; /** The damped horizontal velocity of the pointer in pixels per second. */ vx: number; /** The damped vertical velocity of the pointer in pixels per second. */ vy: number; /** The current speed of the pointer in pixels per second (magnitude of velocity vector). */ speed: number; /** True if the primary pointer button (e.g., mouse left click or screen touch) is currently held down. */ down: boolean; /** Set to true after the first pointer movement is detected. Useful to prevent initial jump calculations. */ seen: boolean; } interface ScrollSensor { /** The current horizontal scroll position (window.scrollX). */ x: number; /** The current vertical scroll position (window.scrollY). */ y: number; /** The damped horizontal scroll velocity in pixels per second. */ vx: number; /** The damped vertical scroll velocity in pixels per second. */ vy: number; } interface ViewportSensor { /** The width of the viewport layout boundary (window.innerWidth). */ width: number; /** The height of the viewport layout boundary (window.innerHeight). */ height: number; /** The device pixel ratio (DPR) of the current display. */ dpr: number; } interface SensorState { /** Pointer movement and interaction metrics. */ pointer: PointerSensor; /** Window scroll position and scroll velocity metrics. */ scroll: ScrollSensor; /** Window layout bounds and device pixel ratio. */ viewport: ViewportSensor; } /** * SensorBus manages a single, centralized set of passive listeners for the entire page. * It coordinates mouse/touch inputs, page scrolls, and resize events, computing smoothed * velocities and device ratios exactly once per frame on the animation budget loop. * * Rather than creating multiple individual listeners that trigger layout thrashing, components * and hooks read from this singleton bus state inside their frame ticks. */ declare class SensorBus { #private; /** * Retrieves the current snapshot of all active sensor states. * * @readonly * @type {SensorState} */ get state(): SensorState; /** * Retains the sensor bus by incrementing its reference count. * If this is the first reference, it attaches all event listeners and schedules * the update loop. * * @returns {() => void} A release function that decrements the reference count and cleans up listeners if reference count reaches zero. */ retain(): () => void; } /** * Lazy singleton retriever for the unified SensorBus instance. * Safe for use in Server-Side Rendering (SSR) environments. * * @returns {SensorBus} The singleton SensorBus instance. */ declare function getSensorBus(): SensorBus; /** * AnimationBudget — the frame-headroom governor. * * Measures real frame intervals on the shared conductor and collapses them * into a coarse quality tier every effect can consume. The contract: * * - tier 0 "high": frames are healthy, run the designed look * - tier 1 "medium": sustained drops below ~54fps — shed extras * - tier 2 "low": sustained drops below ~30fps — survival mode * * Those lines are absolute, not a fraction of the display rate. See * QUALITY_FLOOR_S: a display faster than 60Hz does not get a stricter test, * because "not saturating a 240Hz panel" is not the same as "struggling". * * Hysteresis is asymmetric BY DESIGN: degrading is fast (users feel jank * within a second), upgrading is slow and cautious (8s of clean frames), * with a cooldown so quality never flaps. A burst of very-slow frames can * jump straight to tier 2. * * The decision logic lives in BudgetPolicy — a pure class with no browser * dependencies, unit-tested by feeding synthetic frame times. The exported * singleton is just conductor plumbing around it. * * ## Refresh-rate relative (v0.3) * * Thresholds were absolute (54fps / 30fps), which told a 120Hz display * limping at 70fps that everything was fine. They became multiples of the * measured frame budget, chosen to reproduce the old constants EXACTLY at * 60Hz (18.5ms and 34ms). * * ## Floored again (v4.0.1) * * Purely relative thresholds inverted the whole mechanism on good hardware: * a steady 100fps read as tier 0 on a 60Hz panel and tier 2 on a 240Hz one. * The multiples remain, but the budget they multiply is floored at 1/60 — * so a slow display still gets a relaxed line and a fast one never gets a * stricter one. See QUALITY_FLOOR_S for the reasoning and what it gives up. * * ## Honest headroom (v0.3) * * `headroom` used to be `frameBudget - dt`. Because rAF is pinned to vsync, * dt on a healthy 60Hz page is 16.6ms — so a perfectly idle page reported * ~0ms of headroom and the whole signal was unusable below 60Hz. It now * answers the question it always claimed to: **how much of this frame is * still free?** * * - While frames are landing on time, the only consumption we can attribute * is the runtime's own measured work, so headroom is `budget - work`. * - Once a frame runs slow, the wall clock is the truth — something off our * books is eating the frame — so consumption is the whole interval and * headroom goes negative by the overrun. * * Smoothed as an EMA so one spike doesn't tank it. Use it with * `useSafeToMount` to pre-check before mounting expensive components, * rather than waiting for the tier to degrade. */ type BudgetTier = 0 | 1 | 2; interface BudgetState { tier: BudgetTier; label: "high" | "medium" | "low"; /** Rolling average frame interval, in milliseconds. */ avgFrameMs: number; /** Share of recent frames slower than the degradation line, 0..1. */ slowRatio: number; /** * Estimated milliseconds still free in the current frame budget, smoothed. * Positive = room available; zero or negative = already over budget. Use * this as a leading indicator before mounting expensive components — see * `useSafeToMount`. */ headroom: number; /** * Milliseconds the motion runtime itself spent on the last frame, smoothed. * Everything else in `avgFrameMs` belongs to the browser, React, or * third-party scripts. */ workMs: number; /** One presented frame at the detected display rate, in milliseconds. */ frameBudgetMs: number; } type BudgetListener = (state: BudgetState) => void; declare class AnimationBudget { #private; constructor(); get state(): BudgetState; /** * Discard the rolling window. Call on SPA route changes: a new scene should * not be judged by the frames the previous one produced. */ reset(): void; /** Emits current state immediately, then on tier changes + a slow heartbeat. */ subscribe(fn: BudgetListener): () => void; } /** Lazy singleton — client-only construction, SSR-import-safe. */ declare function getAnimationBudget(): AnimationBudget; /** * FramePressure — what is actually eating the frame. * * AnimationBudget can tell you the page is late. It cannot tell you why, and * the difference decides what to do about it. Shedding our own work when the * GPU is the bottleneck removes motion and fixes nothing; lowering render * quality when a third-party script is blocking the main thread does the same. * * ## The decomposition * * One frame is split three ways: * * frameMs = runtimeMs + mainOtherMs + unattributedMs * * - `runtimeMs` — our own subscribers. The conductor already measures this, * because it has to read the clock between subscribers * anyway to know how much of the frame is left. * - `mainOtherMs` — main-thread work that is not ours, on both sides of our * tick: another library's rAF loop, style, layout, paint. * - `unattributedMs` — the remainder. Compositing, the GPU, and on a healthy * page, simply waiting for the next vsync. * * That last point is why this only classifies when a frame is over budget. On * a page hitting 60fps, `unattributedMs` is mostly idle, and reading idle as * "render pressure" would be worse than saying nothing. * * ## How the main-thread share is measured * * Two numbers, because work happens on both sides of us. * * **After us:** a message is posted to a `MessageChannel` from the input lane. * Messages are delivered as tasks, and a task only runs once the main thread * finishes what it is doing — at that point in the frame, the rest of the * rendering steps. Measured directly in Chrome: with a competing rAF callback * burning 24ms, the round trip came back at 24.3ms. Our own update and render * work is inside that number too, so the conductor's separate measurement is * subtracted back out. * * **Before us:** `ConductorStats.preRuntimeMs`. Every rAF callback in a frame * receives the same start timestamp, so the gap between it and the moment our * tick runs is whatever ran first. Without this, a third-party loop registered * ahead of ours is invisible to the probe and lands in `unattributedMs` — which * made a pure main-thread load report as `"render"` at 97% confidence on the * first real-browser run. * * The probe is sampled at roughly 10Hz rather than every frame. The verdict * only changes slowly, and a probe running at frame rate is measuring a cost * it is helping to create. * * ## What this deliberately does NOT claim * * **It does not measure GPU time.** No browser exposes that without an * `EXT_disjoint_timer_query` on a WebGL context the runtime does not own. * `"render"` means "the main thread was free and the frame was still late", * which points at compositing or the GPU without proving which. * * **Long tasks are a positive signal only.** The Long Task API fires above * 50ms, so a page spending 25ms per frame on main-thread JS produces no long * tasks at all. Seeing one is evidence of main-thread pressure; not seeing one * is evidence of nothing. * * **The decomposition is serial; the pipeline is not.** Frame time is closer to * the longer of the CPU and GPU paths than to their sum, so when the main * thread is the bottleneck, GPU time hides inside it and `unattributedMs` shrinks. * Measured under 6x CPU throttling, a GPU load that read as `"render"` at * normal speed correctly read as `"main-thread"` — the CPU could no longer feed * the GPU fast enough, so the CPU genuinely was the bottleneck. The verdict * stays actionable, because the answer in that case really is "fix the main * thread, do not reduce quality". But do not read `unattributedMs` as a measure of * how much GPU work exists; it measures how much of the frame the GPU was the * thing being waited on. * * **Nothing acts on this yet.** It reports. Shedding, quality tiers and mount * gates are unchanged. A classifier that is wrong is worse than no classifier, * so it earns the right to drive decisions by being read first. */ /** What is eating the frame. */ type PressureSource = /** Frames are healthy. Nothing is being eaten. */ "none" /** Our own subscribers are the largest cost. Shedding them will help. */ | "runtime" /** Someone else's main-thread work. Delay mounts and warm-up, do not shed. */ | "main-thread" /** The main thread was free and the frame was still late. Lower quality. */ | "render" /** Not enough evidence. Hold quality and change nothing. */ | "unknown"; interface PressureState { source: PressureSource; /** * How clearly the winner beat the runner-up, 0–1. Below ~0.3 the verdict is * a lean, not a finding. Anything acting on this should require a floor. */ confidence: number; /** Smoothed wall-clock frame interval, ms. */ frameMs: number; /** Smoothed time in our own subscribers, ms. */ runtimeMs: number; /** * Main-thread time this frame that is NOT ours, ms — work before our tick * plus work after it. `null` until the probe has reported at least once. */ mainOtherMs: number | null; /** Smoothed remainder — compositing, GPU, and vsync wait, ms. */ unattributedMs: number; /** One presented frame at the detected display rate, ms. */ budgetMs: number; /** Long tasks seen since the last emit. */ longTasks: number; } type PressureListener = (state: PressureState) => void; declare class FramePressure { #private; get state(): PressureState; subscribe(fn: PressureListener): () => void; } /** Lazy singleton. Client-only construction, SSR-import-safe. */ declare function getFramePressure(): FramePressure; /** * AdaptiveQuality — the complete quality signal: static device heuristics * fused with the live AnimationBudget. * * Why both: the budget measures TRUTH but only after frames accumulate — * the first seconds are blind. Device signals guess instantly but never * learn. Fusion rule: `tier = max(deviceTier, budgetTier)` — the device * tier is a FLOOR. Without it, weak devices oscillate: degrade → frames * recover (because we degraded) → upgrade → jank → degrade, forever. * A software renderer stays a software renderer; the floor encodes that. * * Own heuristics, zero deps (no GPU benchmark databases): conservative, * explainable (`reasons`), refined by measurement rather than trusted. */ interface DeviceSignals { webgl2: boolean; /** * Whether the browser exposes WebGPU at all. * * Detected synchronously from `navigator.gpu`, which says the API exists and * nothing more. Adapter limits and the device tier behind them need * `requestAdapter()`, which is async and therefore cannot inform the first * synchronous probe. This flag is here so a scene can choose a WebGPU path * before that answer arrives; it deliberately does not move the tier. */ webgpu: boolean; /** GPU renderer string (unmasked where available), "" if unknown. */ renderer: string; /** navigator.deviceMemory in GB, null if unsupported. */ deviceMemory: number | null; /** navigator.hardwareConcurrency, null if unsupported. */ cores: number | null; reducedMotion: boolean; } interface AdaptiveState { /** * Effective tier. Consume THIS one. * * Up to 2.x this was `max(deviceTier, budgetTier)` and nothing more, which * meant it degraded whenever the frame rate sagged, including when the cause * was somebody else's script blocking the main thread. A smaller scene does * nothing for that: it makes the page uglier and exactly as slow. * * Since 3.0 the budget half is ignored while the classifier can prove the * frame is blocked by something reducing quality cannot fix. The device floor * is never ignored. */ tier: BudgetTier; label: "high" | "medium" | "low"; deviceTier: BudgetTier; budgetTier: BudgetTier; /** * Why `tier` is what it is, in one machine-readable word. Branch on this. * * `device` and `reduced-motion` are floors. `render` means rendering is * measurably the bottleneck, which is the one case reducing quality helps. * `frame-rate` means the frame is slow but the classifier cannot attribute * it yet, so the budget tier is trusted. `held` means the budget tier wants * to degrade and was overruled because the frame is blocked elsewhere. */ cause: QualityCause; /** Human-readable trail of why the device tier is what it is. */ reasons: string[]; reducedMotion: boolean; } /** * Would reducing quality actually help? * * Pure, and exported for tests, because this is the rule the whole runtime is * built around and it used to live inside useSceneGate where only one caller * could reach it. Twenty-one components read the governor directly and got the * naive answer. * * The order matters: * * 1. **Floors first.** Reduced motion and a weak device are not opinions about * the current frame, they are standing facts, and no frame measurement * overrules them. * 2. **Then the specific diagnosis.** Rendering being the bottleneck is the one * case a smaller scene fixes. * 3. **Then the general one.** If the budget tier wants to degrade and the * classifier has no verdict, trust the budget tier. Silence from the * classifier means the frame is under its attribution line, not that * nothing is wrong. * 4. **Refuse only when we can prove it would not help.** A confident * main-thread or runtime verdict is the one case where degrading is known * to be useless, so the budget tier is overruled and reported as `held`. */ /** * Why the governor arrived at the tier it did. * * Branch on this when a slow device and a slow frame call for different * responses. `held` is the interesting one: frames are dropping, but something * other than drawing is to blame, so reducing quality would make the page * uglier without making it faster. */ type QualityCause = "ok" | "device" | "reduced-motion" | "render" | "frame-rate" | "held"; /** * The fusion rule, as a pure function. * * Exported for the same reason `SAFE_TO_MOUNT_COST` and * `POINTER_INTENT_SENSITIVITY` are: a devtools panel or a docs page explaining * a verdict needs the real rule, and the alternative is every such surface * keeping a copy that drifts out of date. The adaptive-quality documentation * page had exactly that, still computing `max(deviceTier, budgetTier)` long * after 3.0 replaced it. */ declare function fuse(deviceTier: BudgetTier, budgetTier: BudgetTier, reducedMotion: boolean, pressure: { source: PressureSource; confidence: number; }): { effective: BudgetTier; cause: QualityCause; }; type AdaptiveListener = (state: AdaptiveState) => void; declare class AdaptiveQuality { #private; get state(): AdaptiveState; subscribe(fn: AdaptiveListener): () => void; } /** Lazy singleton — client-only construction, SSR-import-safe. */ declare function getAdaptiveQuality(): AdaptiveQuality; /** * RendererHealth — has the graphics context died, and how many times. * * A browser can take a WebGL context away at any moment: a GPU driver reset, a * tab backgrounded on a phone, too many live contexts on one page, the OS * reclaiming memory. When it happens the canvas fires `webglcontextlost`, three * passes the event along, and **React Three Fiber does nothing with it** — * verified against 9.6.1, there is no handler anywhere in the bundle. The * result is a permanently black canvas with no error in the console. * * ## Why this is a page-level singleton and not per-canvas * * A driver reset takes every context on the page at once, so a page-level * signal is the truthful one for the common case. It also has to be readable * from *outside* the canvas — R3F runs its own reconciler, so React context * does not cross the `` boundary, and the component that decides what * to render instead is on the other side of it. * * The cost is that a single lost context bumps the generation for every scene * on the page. Given a lost context is rare and a remount is cheap next to a * black canvas, that trade is deliberate. * * ## What recovery actually is * * Not `webglcontextrestored`. That event only helps if every buffer, texture, * program and render target is rebuilt by hand in the right order, which * almost nothing does correctly, and R3F does not do at all. * * The recovery here is a **generation counter**. It increments on loss, a * scene puts it on its ``, React unmounts the dead tree and mounts * a fresh one, and R3F builds a new context with every resource re-created * from the React tree it already has. The rebuild is something React is * already good at; the only missing piece was knowing when to ask for it. * * `preventDefault()` on the lost event still matters and the adapter calls it — * without it the browser will not even attempt a restore, and some drivers * refuse a new context on the same page afterwards. */ interface RendererHealthState { /** True between a context being lost and a replacement being mounted. */ lost: boolean; /** * Increments once per loss. Put it on a `` — changing it is what * makes React throw away the dead tree and build a working one. */ generation: number; /** When the most recent loss happened, or `null` if there has not been one. */ lastLostAt: number | null; } type HealthListener = (state: RendererHealthState) => void; declare class RendererHealth { #private; get state(): RendererHealthState; subscribe(fn: HealthListener): () => void; /** * Report a lost context. Called by the R3F adapter. * * Repeated calls while already lost do not stack. A driver reset fires the * event on every canvas on the page, and one reset should mean one rebuild, * not one rebuild per canvas. */ reportLost(): void; /** * Report a working context. Called when a renderer mounts successfully, * which after a loss means the replacement is up. */ reportHealthy(): void; } /** Lazy singleton. Client-only construction, SSR-import-safe. */ declare function getRendererHealth(): RendererHealth; /** * PointerIntent — predicts that the pointer is COMING to an element before * it arrives, from the SensorBus's smoothed velocity: cast the pointer's * trajectory forward and measure time-to-impact against the (inflated) * element rect. Confidence rises as impact nears; being inside is * confidence 1. Hysteresis (enter/exit thresholds) keeps the boolean calm. * * Use it to pre-warm expensive hovers: start the video, compile the shader, * begin the magnetic pull — 100–300ms before the cursor lands. */ /** * How eager the prediction is. * * This replaced five separate numbers — look-ahead horizon, rect inflation, a * minimum pointer speed, and a pair of confidence thresholds for gaining and * losing intent. The last two are hysteresis: correct to have, impossible to * pick by hand, and meaningless in isolation. */ type PointerIntentSensitivity = "low" | "normal" | "high"; interface PointerIntentOptions { /** * How readily the pointer is judged to be heading here. Default `"normal"`. * * - `"low"` — only a clear, committed approach counts * - `"normal"` — a good default for a link or a card * - `"high"` — fires early and more often, for something cheap to prepare */ sensitivity?: PointerIntentSensitivity; /** * Re-measure the element every frame instead of caching its position. Needed * only when the element itself moves — a carousel, something being animated. * Default false. */ dynamic?: boolean; } /** * What one sensitivity setting actually resolves to. * * These five numbers were the public options before 2.0. They are tuning, not * a control surface — "how far ahead do I look, in seconds" is not a question * a developer can answer without reading the implementation, which is why they * collapsed into three named presets. * * They are still readable, because something has to be able to show them: a * devtools panel explaining why a preset fired, or a demo drawing the * prediction geometry. The alternative is every such tool keeping its own copy * of the table and drifting from the one the runtime uses. */ interface PointerIntentTuning { /** How far ahead the pointer's path is projected, in seconds. */ readonly horizon: number; /** How far outside the element still counts as a hit, in px. */ readonly extend: number; /** Below this speed the pointer is drifting, not heading somewhere, in px/s. */ readonly minSpeed: number; /** Confidence needed to gain intent. */ readonly enter: number; /** Confidence it must fall below to lose it. Lower than `enter` — hysteresis. */ readonly exit: number; } /** * The tuning each sensitivity resolves to. Frozen: this is the runtime's own * table, not a template to copy and edit. */ declare const POINTER_INTENT_SENSITIVITY: Readonly>; declare class PointerIntent { readonly el: HTMLElement; #private; constructor(el: HTMLElement, options?: PointerIntentOptions, onChange?: (intent: boolean) => void); /** Smoothed 0..1 — how sure we are the pointer is coming (or here). */ get confidence(): number; /** Hysteresis-gated boolean. */ get intent(): boolean; update(options: PointerIntentOptions): void; destroy(): void; } /** * MagneticElement — attraction with anticipation. * * The thousand free magnetic-button tutorials react to hover. This one * rides the engine: engagement = max(PointerIntent confidence, proximity * falloff), so the element starts reaching WHILE THE CURSOR IS STILL ON * ITS WAY — the pre-touch that makes award-site buttons feel alive. * * Anchor correctness: the element's rect moves as it translates, so the * true anchor is rect-center MINUS the current offset. Without this the * target drifts and the element chases its own tail. * * Writes only `transform` (compositor-safe); records and restores the * previous inline transform on destroy. * * ## Read/write split (v0.3) * * v0.1 called `getBoundingClientRect()` inside the render lane, immediately * before writing `transform`. With two magnetic elements on a page that * becomes read → write → read, and the second read is a forced synchronous * layout because the first write dirtied the tree — layout thrash scaling * with the number of magnetic elements, in the engine whose whole premise is * reads-before-writes. * * The measurement now lives in the input lane, so every magnetic element on * the page has finished reading before any of them writes. The anchor is also * cached and only re-measured when something could plausibly have moved it * (scroll, resize) or the heartbeat elapses, so the steady state costs no * layout reads at all. */ interface MagneticOptions { /** Max translation in px at full engagement. Default 12. */ strength?: number; /** Distance (px from center) where proximity pull begins. Default 90. */ reach?: number; /** How quickly the element follows the pointer. Higher is snappier. Default 12. */ speed?: number; /** Scale at full engagement (1 = off). Default 1.04. */ scale?: number; /** Start reaching while the pointer is still approaching. Default true. */ anticipate?: boolean; } declare class MagneticElement { readonly el: HTMLElement; #private; /** Resting centre of the element, with our own translation removed. */ constructor(el: HTMLElement, options?: MagneticOptions); /** * Retune in place. Keys explicitly set to `undefined` are IGNORED rather than * applied, because the React adapter destructures the caller's options object * and passes every key on every change — so an option the caller simply never * supplied arrives here as `undefined`. * * A naive `{ ...this.opts, ...options }` let that wipe a default. With `damp` * gone, `damp(current, target, undefined, dt)` evaluates `Math.exp(-undefined)` * → NaN, the element's transform became `translate3d(NaNpx, NaNpx, 0)`, the * browser discarded it as invalid, and the magnet silently never moved. The * documented zero-argument call `useMagneticIntent()` hit this. */ update(options: MagneticOptions): void; /** * Build or tear down the approach detector to match `anticipate`. * * Called from `update` as well as the constructor: this used to be read only * at construction, so turning anticipation on or off after mount silently * did nothing. * * Sensitivity is derived from `reach` rather than passed through. A wider * magnetic field should start reaching from further out, and the detector no * longer takes a raw pixel inflation — it takes a band. */ destroy(): void; } /** * VideoScrubber — maps scroll, pointer, or manual progress onto a video's * timeline. Framework-agnostic core; React (and future) adapters are thin * lifecycle bridges. * * What it handles that naive `currentTime = x` does not: * - Seek discipline: never issues a new seek while the previous one is * in-flight (Safari queues them and stutters); sub-frame deltas are * skipped; large jumps use `fastSeek` where available (keyframe-fast). * - iOS buffering: videos are primed with a muted play()/pause() round trip * so Safari actually loads data before the first scrub. * - Attribute etiquette: `muted`/`playsInline`/`preload` are set for * scrubbing but recorded first and restored exactly on destroy(). * - Smoothing: frame-rate-independent damping on the shared conductor — * no private rAF. Under prefers-reduced-motion the scrub still works * (it is direct manipulation, not autonomous motion) but tracks * instantly, with no trailing lag. */ type ScrubDriver = "scroll" | "pointer" | "manual"; /** * How scroll position maps to progress when `driver: "scroll"`: * - "pin": for tall tracks with sticky content — 0 when the track top * docks at the viewport top, 1 when its bottom reaches the * viewport bottom (the Apple scrollytelling pattern). * - "cross": 0 as the track top enters at the viewport bottom, 1 as its * bottom exits at the top. * - "auto": "pin" when the track is taller than ~1.2 viewports, else "cross". */ type ScrubMapping = "auto" | "pin" | "cross"; interface VideoScrubberOptions { /** What drives progress. Fixed for the instance lifetime. */ driver?: ScrubDriver; /** Element whose geometry defines progress. Default: the video's parent. */ track?: HTMLElement; /** Scroll→progress mapping (scroll driver only). */ mapping?: ScrubMapping; /** * How quickly the video catches up to the scrub position. Higher arrives * sooner; `0` is instant with no trailing at all. Default 8. * * The old name for this was `smooth`, which read backwards — a lower number * produced *more* smoothing. */ speed?: number; /** Pointer driver: which axis of the track maps to progress. */ pointerAxis?: "x" | "y"; /** Fired when smoothed progress changes (per frame, deduplicated). */ onProgress?: (progress: number, time: number) => void; } declare const VIDEO_SCRUBBER_DEFAULTS: { readonly driver: "scroll"; readonly mapping: "auto"; readonly speed: 8; readonly pointerAxis: "x"; }; /** * Pure scroll→progress mapping (exported for tests). * `top` is the track's viewport-relative top (getBoundingClientRect().top). */ declare function scrollProgress(top: number, height: number, viewportHeight: number, mapping: ScrubMapping): number; declare class VideoScrubber { readonly video: HTMLVideoElement; readonly track: HTMLElement; #private; /** * Sampled once, at construction, and honoured by every later write to * `speed`. Without it the reduced-motion clamp applied only in the * constructor and any subsequent `update({ speed })` silently restored the * trailing lag — which is exactly what a React wrapper does when a `speed` * prop changes. */ constructor(video: HTMLVideoElement, options?: VideoScrubberOptions); /** Muted play/pause round trip so iOS Safari buffers before the first scrub. */ /** Smoothed progress, 0..1. */ get progress(): number; /** Where progress is heading before smoothing settles. */ get targetProgress(): number; /** * Set progress directly. The natural API for `driver: "manual"`; under * other drivers the next frame's read overwrites it. */ set(progress: number): void; /** * Reduced motion wins over any requested smoothing. Scrubbing itself is * direct manipulation, so it stays enabled — but the trailing lag is * autonomous motion, and that is the part to drop. */ /** Live-tunable options. `driver` and `track` are fixed by design. */ update(options: Pick): void; /** Stops everything and restores the video element exactly as found. */ destroy(): void; } export { type AdaptiveState as A, type BudgetState as B, scrollProgress as C, type DeviceSignals as D, MagneticElement as M, POINTER_INTENT_SENSITIVITY as P, type QualityCause as Q, type RectLike as R, type ScrollSensor as S, VIDEO_SCRUBBER_DEFAULTS as V, type BudgetTier as a, type MagneticOptions as b, PointerIntent as c, type PointerIntentOptions as d, type PointerIntentSensitivity as e, type PointerIntentTuning as f, type PointerSensor as g, type PressureSource as h, type PressureState as i, type RendererHealthState as j, type ScrubDriver as k, type ScrubMapping as l, SensorBus as m, type SensorState as n, VideoScrubber as o, type VideoScrubberOptions as p, type ViewportSensor as q, clamp01 as r, damp as s, fuse as t, getAdaptiveQuality as u, getAnimationBudget as v, getFramePressure as w, getRendererHealth as x, getSensorBus as y, rayRectIntersect as z };