@hyperframes/core 0.9.20 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -37,6 +37,7 @@ export { parseCubeLut, CubeLutParseError, type CubeLut3D, type ParseCubeLutOptio
37
37
  export { bakeHfColorGradingCube, type BakeHfColorGradingCubeOptions } from "./colorGradingBake.js";
38
38
  export { firstFrameColourArgs, hdrToSdrToneMapFilter, keyframeTransferArgs, parseKeyframeTransfers, readsKeyframeTransfers, toneMapReferenceNits, parseFirstFrameColour, type ToneMapFrameColour, type ToneMapSourceColour, } from "./hdrToneMap.js";
39
39
  export { HEAVY_BITRATE, HEAVY_PIXEL_SHARE, PREVIEW_PROXY_BOX_PARAM, PREVIEW_PROXY_LIGHT_PARAM, PREVIEW_PROXY_PRIORITY_HEADER, PREVIEW_PROXY_PROGRESS_HEADER, PREVIEW_PROXY_RETRY_PARAM, PREVIEW_PROXY_STILL_PARAM, PREVIEW_QUALITY_LEVELS, formatPreviewProxyBox, isHeavyForPreview, isPreviewQuality, parsePreviewProxyBox, quantizePreviewProxyBox, scalePreviewProxyBox, type PreviewProxyBox, type PreviewQuality, type PreviewSourceFacts, } from "./previewProxyBox.js";
40
+ export type { PausedVideoFrameProvider, PausedVideoFrameRequest, PausedVideoFrame, } from "./runtime/pausedVideoFrames.js";
40
41
  export { HYPERFRAME_RUNTIME_ARTIFACTS, HYPERFRAME_RUNTIME_CONTRACT, loadHyperframeRuntimeSource, type HyperframeRuntimeContract, } from "./inline-scripts/hyperframe.js";
41
42
  export { HYPERFRAME_RUNTIME_GLOBALS, HYPERFRAME_BRIDGE_SOURCES, HYPERFRAME_CONTROL_ACTIONS, type HyperframeControlAction, } from "./inline-scripts/runtimeContract.js";
42
43
  export { getHyperframeRuntimeScript } from "./generated/runtime-inline.js";
@@ -23,6 +23,8 @@ export type PreviewSourceFacts = {
23
23
  bitDepth: number;
24
24
  bitrate: number;
25
25
  codec?: string;
26
+ frameRate?: number;
27
+ variableFrameRate?: boolean;
26
28
  };
27
29
  export declare const HEAVY_PIXEL_SHARE = 0.5;
28
30
  export declare const HEAVY_BITRATE = 40000000;
@@ -1,3 +1,4 @@
1
+ import { wallSetTimeout } from "./wallClock.js";
1
2
  import { swallow } from "./diagnostics.js";
2
3
  import { inspectRuntimeProtocol, runtimeProtocolFpsToNumber, runtimeProtocolMetadata, } from "./protocol.js";
3
4
  let runtimeProtocolFps = 30;
@@ -134,7 +135,7 @@ function flashElements(selectors, duration) {
134
135
  const els = document.querySelectorAll(selector);
135
136
  els.forEach((el) => {
136
137
  el.classList.add("__hf-flash");
137
- setTimeout(() => el.classList.remove("__hf-flash"), duration);
138
+ wallSetTimeout(() => el.classList.remove("__hf-flash"), duration);
138
139
  });
139
140
  }
140
141
  catch (err) {
@@ -30,6 +30,8 @@ export declare class TransportClock {
30
30
  private _lastReadMs;
31
31
  private _lastNow;
32
32
  private _audioStart;
33
+ private _reading;
34
+ private _ahead;
33
35
  constructor(opts?: {
34
36
  initialTime?: number;
35
37
  rate?: number;
@@ -38,6 +40,9 @@ export declare class TransportClock {
38
40
  });
39
41
  now(): number;
40
42
  private _elementTimeNeverBehind;
43
+ private _betweenUpdates;
44
+ private _runAhead;
45
+ aheadOf(el: HTMLMediaElement): number;
41
46
  private _heldUntilAudioOutputStarts;
42
47
  private _pendingAudioStart;
43
48
  /** Folds a >500ms gap since the last read into `_playStartMs` so it's never reported as played time. */
@@ -6,6 +6,15 @@ const STALL_ADJUSTED_LAG_MS = 33;
6
6
  const HAVE_FUTURE_DATA = 3;
7
7
  const AUDIO_START_WAIT_MS = 1500;
8
8
  const MEDIA_SYNC_STEER_TOLERANCE_SECONDS = 0.04;
9
+ const MAX_MEDIA_STEP_SECONDS = 0.25;
10
+ const MEDIA_STEPS_KEPT = 8;
11
+ const furthestAhead = (reading) => reading ? Math.max(0, ...reading.steps) : 0;
12
+ function noteStep(reading, time) {
13
+ const step = time - reading.time;
14
+ if (step > 0 && step <= MAX_MEDIA_STEP_SECONDS)
15
+ reading.steps = [...reading.steps.slice(1 - MEDIA_STEPS_KEPT), step];
16
+ reading.time = time;
17
+ }
9
18
  export class TransportClock {
10
19
  _baseTime = 0;
11
20
  _playStartMs = null;
@@ -20,6 +29,8 @@ export class TransportClock {
20
29
  _lastReadMs = null;
21
30
  _lastNow = 0;
22
31
  _audioStart = null;
32
+ _reading = null;
33
+ _ahead = null;
23
34
  constructor(opts) {
24
35
  this._baseTime = opts?.initialTime ?? 0;
25
36
  this._rate = opts?.rate ?? 1;
@@ -27,6 +38,7 @@ export class TransportClock {
27
38
  this._nowMs = opts?.nowMs ?? (() => performance.now());
28
39
  }
29
40
  now() {
41
+ this._ahead = null;
30
42
  if (this._playStartMs === null)
31
43
  return this._baseTime;
32
44
  // Audio-master: drift is impossible because audio IS the clock. Clearing
@@ -63,13 +75,42 @@ export class TransportClock {
63
75
  const spec = rate ?? 1;
64
76
  const time = timeAtSourceTime(spec, el.currentTime - mediaStart) + compositionStart;
65
77
  const held = this._heldUntilAudioOutputStarts(el.seeking, time);
66
- if (held !== undefined)
78
+ if (held !== undefined) {
79
+ this._reading = null;
67
80
  return held;
68
- if (time >= this._lastNow)
69
- return time;
81
+ }
82
+ const between = this._betweenUpdates(el, time);
83
+ if (between !== null)
84
+ return between;
70
85
  const wrapped = el.loop && this._lastNow - time > timeAtSourceTime(spec, el.duration) / 2;
71
86
  return !wrapped && el.readyState < HAVE_FUTURE_DATA ? this._lastNow : null;
72
87
  }
88
+ // Chromium moves currentTime in 10-50 ms steps, so reading it as is holds a frame, then jumps. Run on at the play
89
+ // rate, kept within [element time, element time + its largest recent step], never back; null when time is behind
90
+ // the playhead by more than that step (a late clip, not a step).
91
+ _betweenUpdates(el, time) {
92
+ const nowMs = this._nowMs();
93
+ const last = this._reading?.el === el ? this._reading : null;
94
+ if (time < this._lastNow - furthestAhead(last))
95
+ return null;
96
+ if (!last) {
97
+ this._reading = { el, time, steps: [], from: time, fromMs: nowMs };
98
+ return this._runAhead(el, time, Math.max(time, this._lastNow));
99
+ }
100
+ noteStep(last, time);
101
+ const run = last.from + ((nowMs - last.fromMs) / 1000) * this._rate;
102
+ const held = Math.min(Math.max(run, time), time + furthestAhead(last));
103
+ if (held !== run)
104
+ Object.assign(last, { from: held, fromMs: nowMs });
105
+ return this._runAhead(el, time, Math.max(held, this._lastNow));
106
+ }
107
+ _runAhead(el, time, now) {
108
+ this._ahead = { el, seconds: now - time };
109
+ return now;
110
+ }
111
+ aheadOf(el) {
112
+ return this._ahead?.el === el ? this._ahead.seconds : 0;
113
+ }
73
114
  _heldUntilAudioOutputStarts(seeking, time) {
74
115
  const pending = this._pendingAudioStart();
75
116
  if (seeking)
@@ -110,6 +151,7 @@ export class TransportClock {
110
151
  this._playStartMs = this._nowMs();
111
152
  this._audioStart = { sinceMs: this._playStartMs, at: this._baseTime, from: null };
112
153
  this._lastNow = this._baseTime;
154
+ this._reading = null;
113
155
  // Not a stall: the gap since the clock was last read (possibly a long
114
156
  // paused idle) says nothing about lost playback time, since none was
115
157
  // playing. `_applyStallCorrection` treats null as "nothing to compare
@@ -122,6 +164,7 @@ export class TransportClock {
122
164
  return false;
123
165
  this._baseTime = this.now();
124
166
  this._playStartMs = null;
167
+ this._reading = null;
125
168
  return true;
126
169
  }
127
170
  seek(timeSeconds) {
@@ -130,6 +173,7 @@ export class TransportClock {
130
173
  : Math.max(0, timeSeconds);
131
174
  this._baseTime = clamped;
132
175
  this._lastNow = clamped;
176
+ this._reading = null;
133
177
  if (this._playStartMs !== null) {
134
178
  this._playStartMs = this._nowMs();
135
179
  this._audioStart = { sinceMs: this._playStartMs, at: clamped, from: null };
@@ -143,6 +187,7 @@ export class TransportClock {
143
187
  }
144
188
  setRate(rate) {
145
189
  const safe = Number.isFinite(rate) && rate > 0 ? Math.max(0.1, Math.min(5, rate)) : 1;
190
+ this._reading = null;
146
191
  if (this._playStartMs !== null) {
147
192
  this._baseTime = this.now();
148
193
  this._playStartMs = this._nowMs();
@@ -189,6 +234,7 @@ export class TransportClock {
189
234
  this._lastReadMs = null;
190
235
  }
191
236
  this._audioSource = null;
237
+ this._reading = null;
192
238
  }
193
239
  /** The element the playhead currently follows, if the source is one. */
194
240
  // fallow-ignore-next-line unused-class-member
@@ -3,7 +3,7 @@ export declare const MEDIA_SYNC_TOLERANCE_SECONDS = 0.04;
3
3
  export declare const MEDIA_HARD_SYNC_SECONDS = 0.5;
4
4
  export declare function markMediaRestarted(el: HTMLMediaElement): void;
5
5
  /** Rate or seek for media `offset` s behind (+) or ahead (-); a lag still growing (`settled` false) never seeks. */
6
- export declare function steerMedia(el: HTMLMediaElement, offset: number, baseRate: number, settled: boolean, audioOnly?: boolean, hidden?: boolean): {
6
+ export declare function steerMedia(el: HTMLMediaElement, offset: number, baseRate: number, settled: boolean, audioOnly?: boolean): {
7
7
  rate: number;
8
8
  seek: boolean;
9
9
  };
@@ -13,8 +13,6 @@ const MEDIA_STEER_RELEASE_SECONDS = 0.01;
13
13
  const AUDIO_MAX_STEER = 0.1;
14
14
  const AUDIO_STEER_LIMIT_SECONDS = 0.1;
15
15
  const WEBKIT_AUDIO_START_LAG_SECONDS = 0.65;
16
- // A video nobody sees runs up to twice as fast (half as slow) to reach the playhead: the lag in seconds times this.
17
- const HIDDEN_GAIN = 5;
18
16
  /** Direction (+1 fast, -1 slow) an element is being steered in; absent when it plays at its authored rate. */
19
17
  const steering = new WeakMap();
20
18
  /** Started or seeked, not yet moving in tolerance: its lag is its own output latency, which a seek only renews. */
@@ -23,7 +21,7 @@ export function markMediaRestarted(el) {
23
21
  catchingUp.add(el);
24
22
  }
25
23
  /** Rate or seek for media `offset` s behind (+) or ahead (-); a lag still growing (`settled` false) never seeks. */
26
- export function steerMedia(el, offset, baseRate, settled, audioOnly = el.tagName === "AUDIO", hidden = false) {
24
+ export function steerMedia(el, offset, baseRate, settled, audioOnly = el.tagName === "AUDIO") {
27
25
  const lag = Math.abs(offset);
28
26
  if (audioOnly && isWebKit()) {
29
27
  steering.delete(el);
@@ -38,8 +36,6 @@ export function steerMedia(el, offset, baseRate, settled, audioOnly = el.tagName
38
36
  return { rate: baseRate, seek: true };
39
37
  }
40
38
  const direction = Math.sign(offset);
41
- if (hidden && lag > MEDIA_SYNC_TOLERANCE_SECONDS)
42
- return steerHidden(el, direction, lag, baseRate);
43
39
  const current = steering.get(el);
44
40
  if (lag > MEDIA_SYNC_TOLERANCE_SECONDS)
45
41
  steering.set(el, direction);
@@ -51,11 +47,6 @@ export function steerMedia(el, offset, baseRate, settled, audioOnly = el.tagName
51
47
  const steer = audioOnly ? Math.min(step, AUDIO_MAX_STEER) : step;
52
48
  return { rate: baseRate * (1 + direction * steer), seek: false };
53
49
  }
54
- function steerHidden(el, direction, lag, baseRate) {
55
- steering.set(el, direction);
56
- const steer = Math.min(direction > 0 ? 1 : 0.5, lag * HIDDEN_GAIN);
57
- return { rate: baseRate * (1 + direction * steer), seek: false };
58
- }
59
50
  /** Drop `el`'s steering: it was seeked, paused or replaced, so its old offset says nothing. */
60
51
  export function forgetSteering(el) {
61
52
  steering.delete(el);
@@ -0,0 +1,10 @@
1
+ type Frame = Pick<VideoFrameCallbackMetadata, "mediaTime" | "width" | "height">;
2
+ /** Install before authored scripts: Three's VideoTexture registers its first callback in its constructor. */
3
+ export declare function installPausedVideoCallbacks(): void;
4
+ export declare function stagePausedVideoCallbacks(video: HTMLVideoElement, frame: Frame): void;
5
+ /** Notify once before composition redraw; callbacks registered from a callback wait for the next frame. */
6
+ export declare function flushPausedVideoCallbacks(video: HTMLVideoElement): void;
7
+ export declare function releasePausedVideoCallbacks(video: HTMLVideoElement): void;
8
+ /** Recovery must observe native frames even while authored callbacks still see staged pixels. */
9
+ export declare function observeNativeVideoFrames(video: HTMLVideoElement, onFrame: () => boolean): () => void;
10
+ export {};
@@ -0,0 +1,157 @@
1
+ const registry = () => HTMLVideoElement.prototype.requestVideoFrameCallback?.__hfPausedCallbacks;
2
+ function stateFor(owner, video) {
3
+ let state = owner.states.get(video);
4
+ if (!state) {
5
+ state = {
6
+ pending: new Map(),
7
+ frame: null,
8
+ count: 0,
9
+ nativeCount: 0,
10
+ synthetic: false,
11
+ dirty: false,
12
+ };
13
+ owner.states.set(video, state);
14
+ }
15
+ return state;
16
+ }
17
+ function requestNative(owner, video, state, pending, id) {
18
+ return owner.request.call(video, function (now, metadata) {
19
+ pending.native = null;
20
+ const advance = Math.max(0, metadata.presentedFrames - state.nativeCount);
21
+ state.nativeCount = metadata.presentedFrames;
22
+ if (state.frame)
23
+ return;
24
+ state.pending.delete(id());
25
+ if (!state.synthetic) {
26
+ state.count = metadata.presentedFrames;
27
+ pending.callback.call(this, now, metadata);
28
+ return;
29
+ }
30
+ state.count += advance;
31
+ pending.callback.call(this, now, { ...metadata, presentedFrames: state.count });
32
+ });
33
+ }
34
+ /** Install before authored scripts: Three's VideoTexture registers its first callback in its constructor. */
35
+ export function installPausedVideoCallbacks() {
36
+ const proto = HTMLVideoElement.prototype;
37
+ const request = proto.requestVideoFrameCallback;
38
+ if (typeof request !== "function" || request.__hfPausedCallbacks)
39
+ return;
40
+ const owner = {
41
+ states: new WeakMap(),
42
+ request,
43
+ cancel: proto.cancelVideoFrameCallback,
44
+ };
45
+ const patched = function (callback) {
46
+ const state = stateFor(owner, this);
47
+ const pending = { callback, native: null };
48
+ const id = requestNative(owner, this, state, pending, () => id);
49
+ pending.native = id;
50
+ state.pending.set(id, pending);
51
+ return id;
52
+ };
53
+ patched.__hfPausedCallbacks = owner;
54
+ proto.requestVideoFrameCallback = patched;
55
+ proto.cancelVideoFrameCallback = function (id) {
56
+ const pending = owner.states.get(this)?.pending.get(id);
57
+ if (!pending)
58
+ return owner.cancel.call(this, id);
59
+ owner.states.get(this)?.pending.delete(id);
60
+ if (pending.native !== null)
61
+ owner.cancel.call(this, pending.native);
62
+ };
63
+ }
64
+ export function stagePausedVideoCallbacks(video, frame) {
65
+ const owner = registry();
66
+ if (!owner)
67
+ return;
68
+ const state = stateFor(owner, video);
69
+ state.frame = frame;
70
+ state.dirty = true;
71
+ }
72
+ /** Notify once before composition redraw; callbacks registered from a callback wait for the next frame. */
73
+ export function flushPausedVideoCallbacks(video) {
74
+ const owner = registry();
75
+ const state = owner?.states.get(video);
76
+ if (!owner || !state?.frame || !state.dirty)
77
+ return;
78
+ state.dirty = false;
79
+ state.synthetic = true;
80
+ state.count++;
81
+ const now = performance.now();
82
+ const metadata = {
83
+ ...state.frame,
84
+ presentedFrames: state.count,
85
+ presentationTime: now,
86
+ expectedDisplayTime: now,
87
+ processingDuration: 0,
88
+ };
89
+ for (const [id, pending] of [...state.pending]) {
90
+ if (!state.pending.delete(id))
91
+ continue;
92
+ if (pending.native !== null)
93
+ owner.cancel.call(video, pending.native);
94
+ try {
95
+ pending.callback.call(undefined, now, metadata);
96
+ }
97
+ catch (error) {
98
+ reportError(error);
99
+ }
100
+ }
101
+ }
102
+ export function releasePausedVideoCallbacks(video) {
103
+ const owner = registry();
104
+ const state = owner?.states.get(video);
105
+ if (!owner || !state)
106
+ return;
107
+ state.frame = null;
108
+ state.dirty = false;
109
+ for (const [id, pending] of state.pending) {
110
+ if (pending.native === null)
111
+ pending.native = requestNative(owner, video, state, pending, () => id);
112
+ }
113
+ }
114
+ /** Recovery must observe native frames even while authored callbacks still see staged pixels. */
115
+ export function observeNativeVideoFrames(video, onFrame) {
116
+ const owner = registry();
117
+ const request = owner?.request ?? video.requestVideoFrameCallback;
118
+ const cancel = owner?.cancel ?? video.cancelVideoFrameCallback;
119
+ let handle = null;
120
+ let stopped = false;
121
+ let playingAt = video.paused ? null : video.currentTime;
122
+ const stop = () => {
123
+ if (stopped)
124
+ return;
125
+ stopped = true;
126
+ if (handle !== null && typeof cancel === "function")
127
+ cancel.call(video, handle);
128
+ video.removeEventListener("playing", playing);
129
+ video.removeEventListener("timeupdate", progress);
130
+ };
131
+ const arm = () => {
132
+ if (!stopped && typeof request === "function")
133
+ handle = request.call(video, presented);
134
+ };
135
+ const presented = () => {
136
+ handle = null;
137
+ if (stopped)
138
+ return;
139
+ if (onFrame())
140
+ stop();
141
+ else
142
+ arm();
143
+ };
144
+ const playing = () => {
145
+ playingAt = video.currentTime;
146
+ };
147
+ const progress = () => {
148
+ if (stopped || playingAt === null || video.paused || video.currentTime === playingAt)
149
+ return;
150
+ if (onFrame())
151
+ stop();
152
+ };
153
+ video.addEventListener("playing", playing);
154
+ video.addEventListener("timeupdate", progress);
155
+ arm();
156
+ return stop;
157
+ }
@@ -0,0 +1,52 @@
1
+ export interface PausedVideoFrameRequest {
2
+ video: HTMLVideoElement;
3
+ source: string;
4
+ time: number;
5
+ settled?: boolean;
6
+ }
7
+ export interface PausedVideoFrame {
8
+ source: CanvasImageSource;
9
+ timestamp: number;
10
+ duration: number;
11
+ width: number;
12
+ height: number;
13
+ release(): void;
14
+ }
15
+ /** Decoding lives in the host; core owns composition timing and presentation. */
16
+ export interface PausedVideoFrameProvider {
17
+ decode(requests: PausedVideoFrameRequest[], signal: AbortSignal): Promise<PausedVideoFrame[]>;
18
+ suspend?(): Promise<void>;
19
+ dispose(): void;
20
+ }
21
+ export interface PreparedPausedVideoFrames {
22
+ valid(): boolean;
23
+ present(): boolean;
24
+ refresh(): void;
25
+ release(): void;
26
+ }
27
+ export declare function getPausedVideoFrame(video: HTMLVideoElement): HTMLCanvasElement | null;
28
+ export declare function ownsPausedVideoTime(video: HTMLMediaElement): boolean;
29
+ export declare function getPausedExternalVideoFrame(video: HTMLVideoElement): VideoFrame | null;
30
+ export declare function captureNativeVideoFrame(video: HTMLVideoElement): HTMLCanvasElement | null;
31
+ export declare function nativeSnapshotStyle(video: HTMLVideoElement): CSSStyleDeclaration | null;
32
+ export declare function releasePausedVideoFrame(video: HTMLVideoElement): void;
33
+ /** Keeps original nodes, URLs, intrinsic sizes and the displayed source clock intact. */
34
+ export declare function createPausedVideoFrames(): {
35
+ readonly active: boolean;
36
+ holdNative(videos: HTMLVideoElement[]): () => void;
37
+ skipped: (el: HTMLMediaElement) => boolean;
38
+ retained: (el: HTMLMediaElement) => boolean;
39
+ available: () => boolean;
40
+ setProvider(next: PausedVideoFrameProvider | null): void;
41
+ prepare(requests: PausedVideoFrameRequest[], signal: AbortSignal): Promise<{
42
+ valid: () => boolean;
43
+ present(): boolean;
44
+ refresh(): void;
45
+ release: () => void;
46
+ } | null>;
47
+ suspendProvider: () => Promise<void> | undefined;
48
+ prepareNative(): void;
49
+ notify(): void;
50
+ clear: (keep?: Set<HTMLVideoElement>, current?: (video: HTMLVideoElement, presented: boolean) => boolean) => void;
51
+ destroy(): void;
52
+ };