@hyperframes/core 0.8.207 → 0.8.209

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.
Files changed (34) hide show
  1. package/dist/atomicFile.js +18 -19
  2. package/dist/compiler/compositionScoping.d.ts +2 -0
  3. package/dist/compiler/compositionScoping.js +9 -0
  4. package/dist/compiler/htmlBundler.js +2 -8
  5. package/dist/compiler/htmlDocument.d.ts +4 -0
  6. package/dist/compiler/htmlDocument.js +22 -0
  7. package/dist/compiler/index.d.ts +1 -1
  8. package/dist/compiler/index.js +1 -1
  9. package/dist/generated/runtime-inline.js +1 -1
  10. package/dist/hyperframe.runtime.iife.js +67 -67
  11. package/dist/removeScratch.d.ts +5 -0
  12. package/dist/removeScratch.js +23 -0
  13. package/dist/renameWithRetry.d.ts +16 -0
  14. package/dist/renameWithRetry.js +81 -0
  15. package/dist/runtime/applyVariableBindings.d.ts +6 -4
  16. package/dist/runtime/applyVariableBindings.js +23 -11
  17. package/dist/runtime/clock.js +1 -1
  18. package/dist/runtime/getVariables.js +5 -4
  19. package/dist/runtime/mediaSteer.d.ts +1 -0
  20. package/dist/runtime/mediaSteer.js +1 -0
  21. package/dist/runtime/state.d.ts +1 -2
  22. package/dist/runtime/timeline.js +4 -4
  23. package/dist/runtime/types.d.ts +21 -0
  24. package/dist/runtime/variableScope.d.ts +2 -0
  25. package/dist/runtime/variableScope.js +9 -3
  26. package/package.json +12 -4
  27. package/dist/runtime/adapters/seek-dispatch.d.ts +0 -37
  28. package/dist/runtime/adapters/seek-dispatch.js +0 -143
  29. package/dist/runtime/media.d.ts +0 -131
  30. package/dist/runtime/media.js +0 -586
  31. package/dist/runtime/preloadMedia.d.ts +0 -12
  32. package/dist/runtime/preloadMedia.js +0 -108
  33. package/dist/runtime/timedClipHide.d.ts +0 -12
  34. package/dist/runtime/timedClipHide.js +0 -93
@@ -0,0 +1,5 @@
1
+ /** Removes a scratch folder or file the mix is already done with. A refusal (Windows temp folders that deny
2
+ * delete) leaves it for the render's own cleanup and never fails the audio it served. */
3
+ export declare function removeScratch(path: string): void;
4
+ /** Runs `run`; whatever removeScratch left behind inside it (this run's only) goes to `report` once it ends. */
5
+ export declare function collectScratchLeftovers<T>(run: () => Promise<T>, report: (left: string[]) => void): Promise<T>;
@@ -0,0 +1,23 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { rmSync } from "node:fs";
3
+ const collecting = new AsyncLocalStorage();
4
+ /** Removes a scratch folder or file the mix is already done with. A refusal (Windows temp folders that deny
5
+ * delete) leaves it for the render's own cleanup and never fails the audio it served. */
6
+ export function removeScratch(path) {
7
+ try {
8
+ rmSync(path, { recursive: true, force: true });
9
+ }
10
+ catch {
11
+ collecting.getStore()?.push(path);
12
+ }
13
+ }
14
+ /** Runs `run`; whatever removeScratch left behind inside it (this run's only) goes to `report` once it ends. */
15
+ export async function collectScratchLeftovers(run, report) {
16
+ const left = [];
17
+ try {
18
+ return await collecting.run(left, run);
19
+ }
20
+ finally {
21
+ report(left);
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ interface Options {
2
+ platform?: NodeJS.Platform;
3
+ /** Copy the bytes when Windows keeps refusing (a TEMP that denies delete). Not atomic: scratch files only. */
4
+ copyFallback?: boolean;
5
+ }
6
+ /** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
7
+ export declare function renameWithRetry(from: string, to: string, options?: Options & {
8
+ rename?: (from: string, to: string) => void;
9
+ copy?: (from: string, to: string) => void;
10
+ }): void;
11
+ /** Async twin of {@link renameWithRetry}. */
12
+ export declare function renameWithRetryAsync(from: string, to: string, options?: Options & {
13
+ rename?: (from: string, to: string) => Promise<void>;
14
+ copy?: (from: string, to: string) => Promise<void>;
15
+ }): Promise<void>;
16
+ export {};
@@ -0,0 +1,81 @@
1
+ import { copyFileSync, renameSync } from "node:fs";
2
+ import { copyFile, rename } from "node:fs/promises";
3
+ import { removeScratch } from "./removeScratch.js";
4
+ // Windows reports EPERM/EACCES/EBUSY while antivirus or a just-exited process still holds the
5
+ // file; the hold clears within milliseconds. Total wait before giving up: about 1.3 s.
6
+ const RETRYABLE = new Set(["EPERM", "EACCES", "EBUSY"]);
7
+ const BACKOFF_MS = [10, 20, 40, 80, 160, 320, 640];
8
+ function isRetryable(error, platform) {
9
+ if (platform !== "win32")
10
+ return false;
11
+ return RETRYABLE.has(error?.code ?? "");
12
+ }
13
+ /** What a refused rename does next: wait and try again, fall back to a copy, or give up with the error. */
14
+ function nextStep(error, attempt, options, platform) {
15
+ if (!isRetryable(error, platform))
16
+ return "throw";
17
+ if (attempt < BACKOFF_MS.length)
18
+ return "wait";
19
+ return options.copyFallback ? "copy" : "throw";
20
+ }
21
+ function sleepSync(ms) {
22
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23
+ }
24
+ /** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
25
+ export function renameWithRetry(from, to, options = {}) {
26
+ const doRename = options.rename ?? renameSync;
27
+ const doCopy = options.copy ?? copyFileSync;
28
+ const platform = options.platform ?? process.platform;
29
+ for (let attempt = 0;; attempt++) {
30
+ try {
31
+ doRename(from, to);
32
+ return;
33
+ }
34
+ catch (error) {
35
+ const step = nextStep(error, attempt, options, platform);
36
+ if (step === "throw")
37
+ throw error;
38
+ if (step === "wait") {
39
+ sleepSync(BACKOFF_MS[attempt]);
40
+ continue;
41
+ }
42
+ try {
43
+ doCopy(from, to);
44
+ }
45
+ catch {
46
+ throw error;
47
+ }
48
+ removeScratch(from);
49
+ return;
50
+ }
51
+ }
52
+ }
53
+ /** Async twin of {@link renameWithRetry}. */
54
+ export async function renameWithRetryAsync(from, to, options = {}) {
55
+ const doRename = options.rename ?? rename;
56
+ const doCopy = options.copy ?? copyFile;
57
+ const platform = options.platform ?? process.platform;
58
+ for (let attempt = 0;; attempt++) {
59
+ try {
60
+ await doRename(from, to);
61
+ return;
62
+ }
63
+ catch (error) {
64
+ const step = nextStep(error, attempt, options, platform);
65
+ if (step === "throw")
66
+ throw error;
67
+ if (step === "wait") {
68
+ await new Promise((resolve) => setTimeout(resolve, BACKOFF_MS[attempt]));
69
+ continue;
70
+ }
71
+ try {
72
+ await doCopy(from, to);
73
+ }
74
+ catch {
75
+ throw error;
76
+ }
77
+ removeScratch(from);
78
+ return;
79
+ }
80
+ }
81
+ }
@@ -21,10 +21,10 @@
21
21
  * chain the color-grading runtime uses: `__hfVariablesByComp[compId]` for
22
22
  * inlined sub-compositions, then the top-level merged `getVariables()`.
23
23
  *
24
- * Applied at init AND re-applied after the composition loader inlines
25
- * external / template sub-compositions (their DOM and per-instance scoped
26
- * values don't exist at init). Idempotent: re-applying writes the same
27
- * values.
24
+ * Text is bound before composition scripts run; all of it at init and after
25
+ * the composition loader inlines external / template sub-compositions (their
26
+ * DOM and per-instance scoped values don't exist at init). Idempotent:
27
+ * re-applying writes the same values.
28
28
  */
29
29
  /**
30
30
  * Strip characters that could smuggle additional declarations or markup out of
@@ -45,3 +45,5 @@ export declare function sanitizeCssValue(value: string): string;
45
45
  export declare function unproxiedMediaSrc(el: Element): string | null;
46
46
  /** Applies the bindings in `doc`, or only those inside `within` and `within` itself. */
47
47
  export declare function applyVariableBindings(doc: Document, within?: Element): void;
48
+ /** Binds only text, in `doc` or inside `within`: what composition scripts read before they run. */
49
+ export declare function applyTextBindings(doc: Document, within?: Element): void;
@@ -21,10 +21,10 @@
21
21
  * chain the color-grading runtime uses: `__hfVariablesByComp[compId]` for
22
22
  * inlined sub-compositions, then the top-level merged `getVariables()`.
23
23
  *
24
- * Applied at init AND re-applied after the composition loader inlines
25
- * external / template sub-compositions (their DOM and per-instance scoped
26
- * values don't exist at init). Idempotent: re-applying writes the same
27
- * values.
24
+ * Text is bound before composition scripts run; all of it at init and after
25
+ * the composition loader inlines external / template sub-compositions (their
26
+ * DOM and per-instance scoped values don't exist at init). Idempotent:
27
+ * re-applying writes the same values.
28
28
  */
29
29
  import { readVariablesForElement } from "./variableScope.js";
30
30
  import { isScalarVariableValue as isScalar, isSafeMediaUrl, } from "@hyperframes/parsers/composition";
@@ -154,12 +154,13 @@ function variableSrcFor(el, cache, warn = true) {
154
154
  export function unproxiedMediaSrc(el) {
155
155
  return variableSrcFor(el, new Map(), false) ?? unproxiedSrc(el);
156
156
  }
157
+ const matching = (doc, within, selector) => within
158
+ ? [...(within.matches(selector) ? [within] : []), ...within.querySelectorAll(selector)]
159
+ : Array.from(doc.querySelectorAll(selector));
157
160
  /** Applies the bindings in `doc`, or only those inside `within` and `within` itself. */
158
161
  export function applyVariableBindings(doc, within) {
159
162
  const cache = new Map();
160
- const all = (selector) => within
161
- ? [...(within.matches(selector) ? [within] : []), ...within.querySelectorAll(selector)]
162
- : Array.from(doc.querySelectorAll(selector));
163
+ const all = (selector) => matching(doc, within, selector);
163
164
  const topRoot = within ? null : findTopRoot(doc);
164
165
  applyCssCustomProperties([...(topRoot ? [topRoot] : []), ...all("[data-composition-id]")], cache);
165
166
  for (const el of all("[data-var-src]")) {
@@ -167,12 +168,23 @@ export function applyVariableBindings(doc, within) {
167
168
  if (url !== null && unproxiedSrc(el) !== url)
168
169
  el.setAttribute("src", url);
169
170
  }
170
- for (const el of all("[data-var-text]")) {
171
+ bindText(all("[data-var-text]"), cache);
172
+ }
173
+ /** Binds only text, in `doc` or inside `within`: what composition scripts read before they run. */
174
+ export function applyTextBindings(doc, within) {
175
+ bindText(matching(doc, within, "[data-var-text]"), new Map());
176
+ }
177
+ const textWrittenOnce = new WeakSet();
178
+ /** Writes each binding once: a script may then reshape the text, which a second write would duplicate. */
179
+ function bindText(elements, cache) {
180
+ for (const el of elements) {
171
181
  const id = el.getAttribute("data-var-text")?.trim();
172
- if (!id)
182
+ if (!id || textWrittenOnce.has(el))
173
183
  continue;
174
184
  const value = valuesForElement(el, cache)[id];
175
- if (isScalar(value))
176
- setOwnTextPreservingChildren(el, String(value));
185
+ if (!isScalar(value))
186
+ continue;
187
+ setOwnTextPreservingChildren(el, String(value));
188
+ textWrittenOnce.add(el);
177
189
  }
178
190
  }
@@ -1,5 +1,5 @@
1
1
  import { timeAtSourceTime } from "../speedRamp.js";
2
- import { MEDIA_HARD_SYNC_SECONDS } from "./media.js";
2
+ import { MEDIA_HARD_SYNC_SECONDS } from "./mediaSteer.js";
3
3
  import { playRangeHoldTime } from "./protocol.js";
4
4
  /** GSAP's own `lagSmoothing` default — see the PR body for why this clock needs its own copy. */
5
5
  const STALL_THRESHOLD_MS = 500;
@@ -20,18 +20,19 @@
20
20
  * const { title = "Untitled", theme = "light" } = getVariables<MyVars>();
21
21
  */
22
22
  import { cssVariableName, detectSlugCollisions } from "../tokenSlug.js";
23
+ import { scopedVariablesFor } from "./variableScope.js";
23
24
  export function getVariables() {
24
25
  if (typeof document === "undefined")
25
26
  return {};
26
- // Same collection the CSS-variable injection uses: <html> first, then any
27
- // composition element carrying the attribute (later declarers win), then
28
- // render-time overrides.
27
+ // <html> first, then any element carrying the attribute (later declarers win), then render-time
28
+ // overrides. A sub-composition's declaration is its own scope's, read from its scoped table.
29
29
  const declarers = new Set();
30
30
  if (document.documentElement?.hasAttribute("data-composition-variables")) {
31
31
  declarers.add(document.documentElement);
32
32
  }
33
33
  for (const el of Array.from(document.querySelectorAll("[data-composition-variables]"))) {
34
- declarers.add(el);
34
+ if (!scopedVariablesFor(el))
35
+ declarers.add(el);
35
36
  }
36
37
  const declaredDefaults = {};
37
38
  for (const el of declarers) {
@@ -1,5 +1,6 @@
1
1
  /** Drift a playing element may carry before sync pulls it back onto the playhead. */
2
2
  export declare const MEDIA_SYNC_TOLERANCE_SECONDS = 0.04;
3
+ export declare const MEDIA_HARD_SYNC_SECONDS = 0.5;
3
4
  /** Rate or seek for media `offset` s behind (+) or ahead (-); a lag still growing (`settled` false) never seeks. */
4
5
  export declare function steerMedia(el: HTMLMediaElement, offset: number, baseRate: number, settled: boolean, audioOnly?: boolean): {
5
6
  rate: number;
@@ -1,5 +1,6 @@
1
1
  /** Drift a playing element may carry before sync pulls it back onto the playhead. */
2
2
  export const MEDIA_SYNC_TOLERANCE_SECONDS = 0.04;
3
+ export const MEDIA_HARD_SYNC_SECONDS = 0.5;
3
4
  // Steered by rate, not seeked (a seek resets a video's decoder and clicks in audio); steps keep rate writes rare.
4
5
  const MEDIA_STEER = 0.03;
5
6
  const MEDIA_STEER_STEPS = [
@@ -1,5 +1,4 @@
1
- import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "./types.js";
2
- import type { RuntimeMediaClip } from "./media.js";
1
+ import type { RuntimeDeterministicAdapter, RuntimeMediaClip, RuntimeTimelineLike } from "./types.js";
3
2
  import type { TransportClock } from "./clock.js";
4
3
  export type RuntimeState = {
5
4
  capturedTimeline: RuntimeTimelineLike | null;
@@ -3,7 +3,7 @@ import { findRootCompositionElement, parseCompositionDimension } from "./composi
3
3
  import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR, resolveAuthoredTimingWindow, } from "./authoredTiming.js";
4
4
  import { swallow } from "./diagnostics.js";
5
5
  import { readTimelineDurationSeconds } from "./timelineDuration.js";
6
- import { readElementPlaybackRate, readElementPlaybackStart } from "./media.js";
6
+ import { readElementPlaybackRate, readMediaStart } from "./playbackRate.js";
7
7
  import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds, resolveNaturalMediaTimelineDuration, resolveTimedImageDurationSeconds, } from "./playbackRate.js";
8
8
  import { resolveCssStackingContextId } from "./stackingContext.js";
9
9
  import { createRuntimeStartTimeResolver } from "./startResolver.js";
@@ -426,7 +426,7 @@ export function collectRuntimeTimelinePayload(params) {
426
426
  parentCompositionId: compositionContext.parentCompositionId,
427
427
  nodePath: null,
428
428
  compositionSrc: toAbsoluteAssetUrl(node.getAttribute("data-composition-src")),
429
- playbackStart: readElementPlaybackStart(node),
429
+ playbackStart: readMediaStart(node),
430
430
  playbackRate: readElementPlaybackRate(node),
431
431
  assetUrl: resolveNodeAssetUrl(node),
432
432
  timelineRole: node.getAttribute("data-timeline-role"),
@@ -560,7 +560,7 @@ export function collectRuntimeTimelinePayload(params) {
560
560
  parentCompositionId: rootCompositionIdForGsap,
561
561
  nodePath: null,
562
562
  compositionSrc: null,
563
- playbackStart: readElementPlaybackStart(el),
563
+ playbackStart: readMediaStart(el),
564
564
  playbackRate: readElementPlaybackRate(el),
565
565
  assetUrl: null,
566
566
  timelineRole: el.getAttribute("data-timeline-role"),
@@ -622,7 +622,7 @@ export function collectRuntimeTimelinePayload(params) {
622
622
  parentCompositionId: rootCompositionIdForGsap,
623
623
  nodePath: null,
624
624
  compositionSrc: null,
625
- playbackStart: readElementPlaybackStart(el),
625
+ playbackStart: readMediaStart(el),
626
626
  playbackRate: readElementPlaybackRate(el),
627
627
  assetUrl: null,
628
628
  timelineRole,
@@ -1,5 +1,8 @@
1
1
  import type { HfColorGradingTarget } from "../colorGrading.js";
2
2
  import type { RuntimeAnalyticsEvent } from "./analytics.js";
3
+ import type { AudioFades } from "../audioFade.js";
4
+ import type { RateSpec } from "../speedRamp.js";
5
+ import type { VolumeKeyframe } from "./mediaVolumeEnvelope.js";
3
6
  export type RuntimeJson = string | number | boolean | null | RuntimeJson[] | {
4
7
  [key: string]: RuntimeJson;
5
8
  };
@@ -378,4 +381,22 @@ type RuntimeDataControlFields = {
378
381
  };
379
382
  type RuntimeBridgeControlAction = RuntimeBridgeControlActionBase | "set-runtime-data" | "clear-runtime-data";
380
383
  export type RuntimeBridgeControlMessage = RuntimeBridgeControlMessageBase & RuntimeDataControlFields;
384
+ export type RuntimeMediaClip = {
385
+ el: HTMLVideoElement | HTMLAudioElement;
386
+ start: number;
387
+ /** The clip's own t=0 when a host's in-point cut its head; source time, lanes and fades run from it. */
388
+ origin?: number;
389
+ mediaStart: number;
390
+ duration: number;
391
+ end: number;
392
+ volume: number | null;
393
+ playbackRate: number;
394
+ /** The rate lane when the clip has one; otherwise `playbackRate`. */
395
+ rate?: RateSpec;
396
+ loop: boolean;
397
+ sourceDuration: number | null;
398
+ /** Probed from the GSAP timeline as the renderer does; when set they drive volume, not `data-volume`. */
399
+ volumeKeyframes?: VolumeKeyframe[];
400
+ fades?: AudioFades;
401
+ };
381
402
  export {};
@@ -5,4 +5,6 @@
5
5
  * runtime consumer of variables (color grading, declarative bindings) so the
6
6
  * scope chain can never diverge between channels.
7
7
  */
8
+ /** The per-instance values of the inlined sub-composition an element belongs to, if any. */
9
+ export declare function scopedVariablesFor(element: Element): Record<string, unknown> | undefined;
8
10
  export declare function readVariablesForElement(element: Element): Record<string, unknown>;
@@ -5,11 +5,17 @@
5
5
  * runtime consumer of variables (color grading, declarative bindings) so the
6
6
  * scope chain can never diverge between channels.
7
7
  */
8
- export function readVariablesForElement(element) {
9
- const win = window;
8
+ /** The per-instance values of the inlined sub-composition an element belongs to, if any. */
9
+ export function scopedVariablesFor(element) {
10
10
  const scope = element.closest("[data-composition-id]");
11
11
  const compositionId = scope?.getAttribute("data-composition-id")?.trim() ?? "";
12
- const scoped = compositionId ? win.__hfVariablesByComp?.[compositionId] : undefined;
12
+ return compositionId
13
+ ? window.__hfVariablesByComp?.[compositionId]
14
+ : undefined;
15
+ }
16
+ export function readVariablesForElement(element) {
17
+ const win = window;
18
+ const scoped = scopedVariablesFor(element);
13
19
  if (scoped)
14
20
  return scoped;
15
21
  const fromHelper = win.__hyperframes?.getVariables?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/core",
3
- "version": "0.8.207",
3
+ "version": "0.8.209",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -64,6 +64,14 @@
64
64
  "import": "./dist/removeTree.js",
65
65
  "types": "./dist/removeTree.d.ts"
66
66
  },
67
+ "./rename-retry": {
68
+ "import": "./dist/renameWithRetry.js",
69
+ "types": "./dist/renameWithRetry.d.ts"
70
+ },
71
+ "./remove-scratch": {
72
+ "import": "./dist/removeScratch.js",
73
+ "types": "./dist/removeScratch.d.ts"
74
+ },
67
75
  "./project-folders": {
68
76
  "import": "./dist/projectFolders.js",
69
77
  "types": "./dist/projectFolders.d.ts"
@@ -417,9 +425,9 @@
417
425
  "linkedom": "^0.18.12",
418
426
  "postcss": "^8.5.8",
419
427
  "wawoff2": "^2.0.1",
420
- "@hyperframes/lint": "0.8.207",
421
- "@hyperframes/studio-server": "0.8.207",
422
- "@hyperframes/parsers": "0.8.207"
428
+ "@hyperframes/lint": "0.8.209",
429
+ "@hyperframes/parsers": "0.8.209",
430
+ "@hyperframes/studio-server": "0.8.209"
423
431
  },
424
432
  "devDependencies": {
425
433
  "@fontsource/archivo-black": "^5.2.8",
@@ -1,37 +0,0 @@
1
- export interface HfSeekEventDetail {
2
- time: number;
3
- waitUntil: (promise: PromiseLike<unknown>) => void;
4
- }
5
- /**
6
- * Join `promise` to the seek-completion barrier: `waitForSeekCompletion` will
7
- * not resolve until it settles, and its rejection (the first one) becomes the
8
- * barrier's.
9
- *
10
- * Exported because the `"hf-seek"` event's synchronous `waitUntil()` is not
11
- * the only asynchronous seek work a frame can owe. `runtime/vfx.ts` registers
12
- * its preview-side capture here, so the CLI seek paths that already await this
13
- * barrier wait for a `self` chain's paint instead of racing it.
14
- */
15
- export declare function registerSeekCompletion(promise: Promise<unknown>, kind?: "video-frame"): void;
16
- export declare function dispatchSeekEvent(time: number): void;
17
- /**
18
- * Force-dispatch a `"hf-seek"` event even if `time` equals the last dispatched
19
- * time, bypassing the dedup guard.
20
- *
21
- * Needed for the post-video-injection GPU re-render: the engine seeks to time
22
- * T (GPU adapters render once, before video frames are injected), then injects
23
- * the decoded `__render_frame__` images, then must re-render GPU compositions
24
- * at the *same* T so they re-upload textures from the now-present frames. The
25
- * normal dedup would swallow that second dispatch.
26
- */
27
- export declare function forceDispatchSeekEvent(time: number): void;
28
- export declare function isSeekCompletionBarrierActive(): boolean;
29
- /** Settles with every pending completion, without consuming a failure the barrier reports. */
30
- export declare function seekCompletionsSettled(): Promise<void>;
31
- export declare function hasPendingSeekCompletion(): boolean;
32
- /** `videoFrames: false` skips only the video-frame holds: a geometry read needs every other completion. */
33
- export declare function waitForSeekCompletion(options?: {
34
- videoFrames?: boolean;
35
- }): Promise<void>;
36
- /** Reset internal state — used in tests to prevent cross-test contamination. */
37
- export declare function resetSeekDispatchState(): void;
@@ -1,143 +0,0 @@
1
- import { swallow } from "../diagnostics.js";
2
- /**
3
- * Shared, deduplicated `"hf-seek"` CustomEvent dispatcher for GPU adapters.
4
- *
5
- * Both the Three.js and TypeGPU adapters dispatch the same `"hf-seek"` event
6
- * so that compositions need not know which GPU library they're paired with.
7
- * Without deduplication, a seek to time T would fire two events (one from each
8
- * adapter), doubling per-scrub work in any composition that has both present.
9
- *
10
- * This module deduplicates by tracking the last dispatched time. If the same
11
- * time value is dispatched twice in the same synchronous call stack (e.g. two
12
- * adapters both calling `dispatchSeekEvent(5.0)` without yielding), only the
13
- * first call fires the event.
14
- *
15
- * The deduplication is intentionally coarse (exact float equality). Adapter
16
- * seek paths clamp and normalise time before calling this function, so the
17
- * values that arrive here are already stable.
18
- */
19
- let _lastDispatchedTime = -1;
20
- let _pendingCompletions = new Set();
21
- let _videoFrameCompletions = new WeakSet();
22
- let _pendingFailure;
23
- let _activeCompletionBarriers = 0;
24
- function dispatch(time) {
25
- const pending = [];
26
- let accepting = true;
27
- const detail = {
28
- time,
29
- waitUntil: (promise) => {
30
- if (!accepting) {
31
- throw new Error("hf-seek waitUntil() must be called synchronously from the event listener");
32
- }
33
- pending.push(promise);
34
- },
35
- };
36
- try {
37
- window.dispatchEvent(new CustomEvent("hf-seek", { detail }));
38
- }
39
- catch (err) {
40
- swallow("runtime.adapters.seek-dispatch.site1", err);
41
- }
42
- finally {
43
- accepting = false;
44
- }
45
- if (pending.length === 0)
46
- return;
47
- registerSeekCompletion(Promise.all(pending));
48
- }
49
- /**
50
- * Join `promise` to the seek-completion barrier: `waitForSeekCompletion` will
51
- * not resolve until it settles, and its rejection (the first one) becomes the
52
- * barrier's.
53
- *
54
- * Exported because the `"hf-seek"` event's synchronous `waitUntil()` is not
55
- * the only asynchronous seek work a frame can owe. `runtime/vfx.ts` registers
56
- * its preview-side capture here, so the CLI seek paths that already await this
57
- * barrier wait for a `self` chain's paint instead of racing it.
58
- */
59
- export function registerSeekCompletion(promise, kind) {
60
- const completion = promise
61
- .then(() => ({ status: "fulfilled" }))
62
- .catch((reason) => ({ status: "rejected", reason }));
63
- _pendingCompletions.add(completion);
64
- if (kind === "video-frame")
65
- _videoFrameCompletions.add(completion);
66
- void completion.then((result) => {
67
- if (!_pendingCompletions.delete(completion))
68
- return;
69
- if (result.status === "rejected" && _pendingFailure === undefined) {
70
- _pendingFailure = { reason: result.reason };
71
- }
72
- });
73
- }
74
- export function dispatchSeekEvent(time) {
75
- if (time === _lastDispatchedTime)
76
- return;
77
- _lastDispatchedTime = time;
78
- dispatch(time);
79
- }
80
- /**
81
- * Force-dispatch a `"hf-seek"` event even if `time` equals the last dispatched
82
- * time, bypassing the dedup guard.
83
- *
84
- * Needed for the post-video-injection GPU re-render: the engine seeks to time
85
- * T (GPU adapters render once, before video frames are injected), then injects
86
- * the decoded `__render_frame__` images, then must re-render GPU compositions
87
- * at the *same* T so they re-upload textures from the now-present frames. The
88
- * normal dedup would swallow that second dispatch.
89
- */
90
- export function forceDispatchSeekEvent(time) {
91
- _lastDispatchedTime = time;
92
- dispatch(time);
93
- }
94
- export function isSeekCompletionBarrierActive() {
95
- return _activeCompletionBarriers > 0;
96
- }
97
- /** Settles with every pending completion, without consuming a failure the barrier reports. */
98
- export async function seekCompletionsSettled() {
99
- for (let pending = [..._pendingCompletions]; pending.length > 0; pending = [..._pendingCompletions]) {
100
- await Promise.all(pending);
101
- }
102
- }
103
- export function hasPendingSeekCompletion() {
104
- return _pendingCompletions.size > 0;
105
- }
106
- /** `videoFrames: false` skips only the video-frame holds: a geometry read needs every other completion. */
107
- export async function waitForSeekCompletion(options) {
108
- const awaited = () => [..._pendingCompletions].filter((completion) => options?.videoFrames !== false || !_videoFrameCompletions.has(completion));
109
- _activeCompletionBarriers += 1;
110
- let failed = _pendingFailure;
111
- try {
112
- // Let concurrently started barriers snapshot the same retained failure
113
- // before either one consumes it.
114
- await Promise.resolve();
115
- for (let pending = awaited(); pending.length > 0; pending = awaited()) {
116
- const results = await Promise.all(pending);
117
- const rejected = results.find((result) => result.status === "rejected");
118
- if (failed === undefined && rejected?.status === "rejected") {
119
- failed = { reason: rejected.reason };
120
- }
121
- }
122
- const retainedFailure = _pendingFailure;
123
- if (failed === undefined) {
124
- failed = retainedFailure;
125
- }
126
- if (_pendingFailure === retainedFailure) {
127
- _pendingFailure = undefined;
128
- }
129
- if (failed)
130
- throw failed.reason;
131
- }
132
- finally {
133
- _activeCompletionBarriers -= 1;
134
- }
135
- }
136
- /** Reset internal state — used in tests to prevent cross-test contamination. */
137
- export function resetSeekDispatchState() {
138
- _lastDispatchedTime = -1;
139
- _pendingCompletions = new Set();
140
- _videoFrameCompletions = new WeakSet();
141
- _pendingFailure = undefined;
142
- _activeCompletionBarriers = 0;
143
- }