chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -0,0 +1,239 @@
|
||||
# @hyperframes/player
|
||||
|
||||
Embeddable web component for playing HyperFrames compositions. Zero dependencies, works with any framework.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @hyperframes/player
|
||||
```
|
||||
|
||||
Or load directly via CDN:
|
||||
|
||||
```html
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>
|
||||
```
|
||||
|
||||
If you need a classic `<script>` tag instead of ESM, use the explicit global build:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player/dist/hyperframes-player.global.js"></script>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<hyperframes-player src="./my-composition/index.html" controls></hyperframes-player>
|
||||
```
|
||||
|
||||
The player loads the composition in a sandboxed iframe, auto-detects its dimensions and duration, and scales it responsively to fit the container.
|
||||
|
||||
### With a framework
|
||||
|
||||
```typescript
|
||||
import "@hyperframes/player";
|
||||
|
||||
// The custom element is now registered — use it in your markup
|
||||
// React: <hyperframes-player src="..." controls />
|
||||
// Vue: <hyperframes-player :src="url" controls />
|
||||
```
|
||||
|
||||
### Poster image
|
||||
|
||||
Show a static image before playback starts:
|
||||
|
||||
```html
|
||||
<hyperframes-player
|
||||
src="./composition/index.html"
|
||||
poster="./thumbnail.jpg"
|
||||
controls
|
||||
></hyperframes-player>
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Type | Default | Description |
|
||||
| ---------------------- | ------------------------------- | ------------- | --------------------------------------------------------------------------- |
|
||||
| `src` | string | — | URL to the composition HTML file |
|
||||
| `audio-src` | string | — | Audio URL for parent-frame playback (mobile) |
|
||||
| `width` | number | 1920 | Composition width in pixels (aspect ratio) |
|
||||
| `height` | number | 1080 | Composition height in pixels (aspect ratio) |
|
||||
| `controls` | boolean | false | Show play/pause, scrubber, and time display |
|
||||
| `muted` | boolean | false | Mute audio playback |
|
||||
| `audio-locked` | boolean | false | Force-mute and hide the volume controls so the viewer cannot turn sound on |
|
||||
| `poster` | string | — | Image URL shown before playback starts |
|
||||
| `playback-rate` | number | 1 | Speed multiplier (0.5 = half, 2 = double) |
|
||||
| `autoplay` | boolean | false | Start playing when ready |
|
||||
| `loop` | boolean | false | Restart when the composition ends |
|
||||
| `shader-capture-scale` | number | — | Shader transition snapshot scale forwarded to browser previews (`0.25`-`1`) |
|
||||
| `shader-loading` | `composition \| player \| none` | `composition` | Controls shader transition prep loading UI ownership |
|
||||
|
||||
### Shader transition previews
|
||||
|
||||
When a composition uses `@hyperframes/shader-transitions`, the player can own preview-only shader capture settings:
|
||||
|
||||
```html
|
||||
<hyperframes-player
|
||||
src="./composition/index.html"
|
||||
shader-capture-scale="1"
|
||||
shader-loading="player"
|
||||
controls
|
||||
></hyperframes-player>
|
||||
```
|
||||
|
||||
`shader-loading="player"` shows the player-owned transition-prep overlay from shader progress messages. `composition` leaves direct composition fallback behavior alone, and `none` suppresses the loader.
|
||||
|
||||
### Audio lock (host-mandated silent playback)
|
||||
|
||||
`audio-locked` forces `muted` on and hides the volume controls, with no UI path for the viewer to turn sound back on. Use it when embedding in a chat host (Claude.ai, ChatGPT, etc.) where audio must stay off regardless of viewer intent. Setting `muted` directly is _not_ enough — viewers can flip it back via the controls bar.
|
||||
|
||||
Removing `audio-locked` only unhides the controls; it does **not** auto-unmute. Callers manage `muted` explicitly after unlocking.
|
||||
|
||||
**Host-environment fallback.** Some host renderers — notably the Claude desktop Electron client — strip unknown custom-element attributes before they reach the DOM, defeating the attribute. As a safety net, the player also self-imposes the lock when it detects such an environment via `navigator.userAgent`, so audio stays muted even if the attribute never arrives. The public `audioLocked` property still reflects only the attribute, so external consumers (e.g. host widgets that mirror state) are not affected by the fallback.
|
||||
|
||||
### Mobile audio
|
||||
|
||||
Mobile browsers block `audio.play()` inside iframes when the user gesture happened in the parent frame (the [User Activation spec](https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation) does not propagate activation across frame boundaries via `postMessage`).
|
||||
|
||||
The player handles this automatically for same-origin iframes (the default — `sandbox` includes `allow-same-origin`):
|
||||
|
||||
1. When the composition is ready, the player extracts all timed media (`audio[data-start]`, `video[data-start]`) from the iframe DOM and creates parent-frame copies.
|
||||
2. The iframe originals are disabled (`src` and `data-start` removed) so the runtime doesn't try to play them.
|
||||
3. When `play()` is called (from a user gesture), parent media `.play()` runs synchronously in the gesture call stack, satisfying mobile autoplay policy.
|
||||
4. Both parent media and the GSAP timeline start simultaneously and free-run — no active sync needed since both are real-time systems.
|
||||
|
||||
No changes are required by consumers — this works out of the box.
|
||||
|
||||
The optional `audio-src` attribute can be used to start preloading a primary audio track before the iframe loads (useful on slow connections), but is not required for mobile playback.
|
||||
|
||||
## JavaScript API
|
||||
|
||||
```js
|
||||
const player = document.querySelector("hyperframes-player");
|
||||
|
||||
// Playback
|
||||
player.play();
|
||||
player.pause();
|
||||
player.seek(2.5); // jump to 2.5 seconds
|
||||
|
||||
// Properties
|
||||
player.currentTime; // number (read/write)
|
||||
player.duration; // number (read-only)
|
||||
player.paused; // boolean (read-only)
|
||||
player.ready; // boolean (read-only)
|
||||
player.playbackRate; // number (read/write)
|
||||
player.muted; // boolean (read/write)
|
||||
player.audioLocked; // boolean (read/write) — force-mute + hide volume controls
|
||||
player.loop; // boolean (read/write)
|
||||
player.shaderCaptureScale; // number (read/write)
|
||||
player.shaderLoading; // "composition" | "player" | "none" (read/write)
|
||||
|
||||
// Inner iframe access (for advanced consumers — see "Advanced: iframe access" below)
|
||||
player.iframeElement; // HTMLIFrameElement (read-only)
|
||||
```
|
||||
|
||||
## Advanced: iframe access
|
||||
|
||||
The composition runs inside a sandboxed `<iframe>` in the player's Shadow DOM. For most use cases you don't need direct access — the JavaScript API above is enough. But if you're building an editor, recorder, or custom timeline that needs to inspect the composition's DOM or read its `__player` / `__timelines` runtime objects, use the `iframeElement` getter:
|
||||
|
||||
```js
|
||||
const player = document.querySelector("hyperframes-player");
|
||||
const iframe = player.iframeElement;
|
||||
|
||||
// Now you can reach into the composition's DOM and runtime
|
||||
iframe.contentDocument.querySelectorAll("[data-composition-id]");
|
||||
iframe.contentWindow.__timelines;
|
||||
```
|
||||
|
||||
This is the canonical way to bridge the player into tools like [`@hyperframes/studio`](../studio). The studio exports a `resolveIframe` helper that works with both iframe refs and web-component refs:
|
||||
|
||||
```ts
|
||||
import { useTimelinePlayer, resolveIframe } from "@hyperframes/studio";
|
||||
|
||||
const { iframeRef } = useTimelinePlayer();
|
||||
const player = document.createElement("hyperframes-player");
|
||||
player.setAttribute("src", src);
|
||||
container.appendChild(player);
|
||||
|
||||
// Forward the inner iframe so useTimelinePlayer can drive play/pause/seek.
|
||||
iframeRef.current = resolveIframe(player);
|
||||
```
|
||||
|
||||
### React: declarative ref pattern
|
||||
|
||||
If you prefer JSX over imperative element creation, attach a ref directly to the web component and resolve the iframe inside an effect:
|
||||
|
||||
```tsx
|
||||
import "@hyperframes/player";
|
||||
import type { HyperframesPlayer } from "@hyperframes/player";
|
||||
import { useTimelinePlayer, resolveIframe } from "@hyperframes/studio";
|
||||
|
||||
function StudioPreview({ src }: { src: string }) {
|
||||
const { iframeRef, onIframeLoad } = useTimelinePlayer();
|
||||
const playerRef = useRef<HyperframesPlayer>(null);
|
||||
|
||||
useEffect(() => {
|
||||
iframeRef.current = resolveIframe(playerRef.current);
|
||||
});
|
||||
|
||||
return <hyperframes-player ref={playerRef} src={src} onLoad={onIframeLoad} />;
|
||||
}
|
||||
```
|
||||
|
||||
> **Heads up — common gotcha**
|
||||
>
|
||||
> If you pass the `<hyperframes-player>` element itself (not `iframeElement`) into a hook that expects an `<iframe>`, every `.contentWindow` / `.contentDocument` access returns `null` because the iframe lives inside the player's Shadow DOM. Always extract `iframeElement` first, or use `resolveIframe` from `@hyperframes/studio` which handles both iframe and web-component hosts transparently.
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Detail | Fired when |
|
||||
| ----------------------- | -------------------------- | ------------------------------------------ |
|
||||
| `ready` | `{ duration }` | Composition loaded and duration determined |
|
||||
| `play` | — | Playback started |
|
||||
| `pause` | — | Playback paused |
|
||||
| `timeupdate` | `{ currentTime }` | Playback position changed (~10 fps) |
|
||||
| `ended` | — | Reached the end (when not looping) |
|
||||
| `error` | `{ message }` | Composition failed to load |
|
||||
| `shadertransitionstate` | `{ compositionId, state }` | Shader transition cache/capture progress |
|
||||
|
||||
```js
|
||||
player.addEventListener("ready", (e) => {
|
||||
console.log(`Duration: ${e.detail.duration}s`);
|
||||
});
|
||||
|
||||
player.addEventListener("ended", () => {
|
||||
console.log("Done!");
|
||||
});
|
||||
```
|
||||
|
||||
## Sizing
|
||||
|
||||
The player fills its container and scales the composition to fit while preserving aspect ratio. Set a size on the element or its parent:
|
||||
|
||||
```css
|
||||
hyperframes-player {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
```
|
||||
|
||||
The `width` and `height` attributes define the composition's native resolution for aspect ratio calculation — they don't set the player's display size.
|
||||
|
||||
## How it works
|
||||
|
||||
The player renders compositions in a sandboxed `<iframe>` inside a Shadow DOM. It communicates with the HyperFrames runtime via `postMessage`. If the composition has GSAP timelines (`window.__timelines`) but no runtime, the player auto-injects it from CDN.
|
||||
|
||||
## Distribution
|
||||
|
||||
| Format | File | Use case |
|
||||
| ------ | ------------------------------ | ------------------------------ |
|
||||
| ESM | `hyperframes-player.js` | Bundlers (Vite, webpack, etc.) |
|
||||
| CJS | `hyperframes-player.cjs` | Node.js / require() |
|
||||
| IIFE | `hyperframes-player.global.js` | `<script>` tag, CDN |
|
||||
|
||||
All formats are minified with source maps. TypeScript definitions included.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.7.55",
|
||||
"description": "Embeddable web component for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
"directory": "packages/player"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/hyperframes-player.js",
|
||||
"types": "./dist/hyperframes-player.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/hyperframes-player.d.ts",
|
||||
"script": "./dist/hyperframes-player.global.js",
|
||||
"import": "./dist/hyperframes-player.js",
|
||||
"require": "./dist/hyperframes-player.cjs"
|
||||
},
|
||||
"./slideshow": {
|
||||
"types": "./dist/slideshow/hyperframes-slideshow.d.ts",
|
||||
"script": "./dist/slideshow/hyperframes-slideshow.global.js",
|
||||
"import": "./dist/slideshow/hyperframes-slideshow.js",
|
||||
"require": "./dist/slideshow/hyperframes-slideshow.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tests/perf/tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"perf": "bun run tests/perf/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.0",
|
||||
"gsap": "^3.12.5",
|
||||
"puppeteer-core": "^25.2.1",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readCompositionSizeFromDocument } from "./composition-probe.js";
|
||||
|
||||
describe("readCompositionSizeFromDocument", () => {
|
||||
it("reads dimensions from the composition root", () => {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.body.innerHTML =
|
||||
'<div data-composition-id="main" data-width="1080" data-height="1920"></div>';
|
||||
|
||||
expect(readCompositionSizeFromDocument(doc)).toEqual({ width: 1080, height: 1920 });
|
||||
});
|
||||
|
||||
it("falls back to plain data-width/data-height compositions", () => {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.body.innerHTML = '<div class="clip" data-width="1080" data-height="1920"></div>';
|
||||
|
||||
expect(readCompositionSizeFromDocument(doc)).toEqual({ width: 1080, height: 1920 });
|
||||
});
|
||||
|
||||
it("ignores invalid dimensions", () => {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.body.innerHTML = '<div data-width="0" data-height="1920"></div>';
|
||||
|
||||
expect(readCompositionSizeFromDocument(doc)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Probes an iframe document to discover the composition's playback adapter
|
||||
* and detect whether the HyperFrames runtime needs to be injected.
|
||||
*
|
||||
* The probe interval polls every 200 ms until one of:
|
||||
* - A `PlaybackDurationAdapter` resolves with a positive duration, or
|
||||
* - 40 attempts (~8 s) expire without a result.
|
||||
*
|
||||
* The `CompositionProbe` class owns the interval; the caller must call
|
||||
* `stop()` on disconnect or src change.
|
||||
*/
|
||||
|
||||
import { shouldInjectRuntime } from "./shouldInjectRuntime.js";
|
||||
import {
|
||||
type DirectTimelineAdapter,
|
||||
type PlaybackDurationAdapter,
|
||||
isDirectTimelineAdapter,
|
||||
isObjectRecord,
|
||||
isRuntimeDurationAdapter,
|
||||
} from "./timeline-adapters.js";
|
||||
|
||||
const RUNTIME_CDN_URL =
|
||||
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
|
||||
|
||||
export interface ProbeResult {
|
||||
duration: number;
|
||||
adapter: PlaybackDurationAdapter;
|
||||
/** Resolved composition dimensions, if present in the document. */
|
||||
compositionSize: { width: number; height: number } | null;
|
||||
}
|
||||
|
||||
export interface ProbeCallbacks {
|
||||
onReady: (result: ProbeResult) => void;
|
||||
onError: (message: string) => void;
|
||||
/** Called when runtime is successfully injected (informational). */
|
||||
onRuntimeInjected?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a composition dimension, rejecting anything that isn't a positive
|
||||
* finite number. Exported because the `width`/`height` attribute handlers in
|
||||
* hyperframes-player.ts need the same guard: dimensions feed
|
||||
* scaleIframeToFit's `w / compositionWidth` division, where NaN produces an
|
||||
* invalid `scale(NaN)` transform and zero a division by zero — both render
|
||||
* the player blank with no signal.
|
||||
*/
|
||||
export function readPositiveDimension(value: string | null): number | null {
|
||||
if (value === null) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
export function readCompositionSizeFromDocument(
|
||||
doc: Document | null | undefined,
|
||||
): { width: number; height: number } | null {
|
||||
const root =
|
||||
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
|
||||
doc?.querySelector("[data-width][data-height]");
|
||||
if (!root) return null;
|
||||
const width = readPositiveDimension(root.getAttribute("data-width"));
|
||||
const height = readPositiveDimension(root.getAttribute("data-height"));
|
||||
return width !== null && height !== null ? { width, height } : null;
|
||||
}
|
||||
|
||||
export class CompositionProbe {
|
||||
private _interval: ReturnType<typeof setInterval> | null = null;
|
||||
private _runtimeInjected = false;
|
||||
|
||||
constructor(
|
||||
private readonly _iframe: HTMLIFrameElement,
|
||||
private readonly _callbacks: ProbeCallbacks,
|
||||
) {}
|
||||
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
get runtimeInjected(): boolean {
|
||||
return this._runtimeInjected;
|
||||
}
|
||||
|
||||
/** Start (or restart) the probe. Stops any previously running probe first. */
|
||||
start(): void {
|
||||
this.stop();
|
||||
this._runtimeInjected = false;
|
||||
let attempts = 0;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
this._interval = setInterval(() => {
|
||||
attempts++;
|
||||
try {
|
||||
const win = this._iframe.contentWindow as Window & {
|
||||
__player?: { getDuration: () => number };
|
||||
__timelines?: Record<string, { duration: () => number }>;
|
||||
__hf?: unknown;
|
||||
};
|
||||
if (!win) return;
|
||||
|
||||
const hasRuntime = !!(win.__hf || win.__player);
|
||||
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
|
||||
const hasNestedCompositions =
|
||||
!!this._iframe.contentDocument?.querySelector("[data-composition-src]");
|
||||
|
||||
if (
|
||||
shouldInjectRuntime({
|
||||
hasRuntime,
|
||||
hasTimelines,
|
||||
hasNestedCompositions,
|
||||
runtimeInjected: this._runtimeInjected,
|
||||
attempts,
|
||||
})
|
||||
) {
|
||||
this._injectRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._runtimeInjected && !hasRuntime) return;
|
||||
|
||||
const adapter = this._resolvePlaybackDurationAdapter(win);
|
||||
if (adapter && adapter.getDuration() > 0) {
|
||||
this.stop();
|
||||
|
||||
const compositionSize = readCompositionSizeFromDocument(this._iframe.contentDocument);
|
||||
|
||||
this._callbacks.onReady({
|
||||
duration: adapter.getDuration(),
|
||||
adapter,
|
||||
compositionSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
|
||||
if (attempts >= 40) {
|
||||
this.stop();
|
||||
this._callbacks.onError("Composition timeline not found after 8s");
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this._interval !== null) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adapter resolution (same-origin only) ────────────────────────────────
|
||||
|
||||
resolveDirectTimelineAdapter(): DirectTimelineAdapter | null {
|
||||
try {
|
||||
const win = this._iframe.contentWindow;
|
||||
if (!win) return null;
|
||||
return this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
|
||||
return this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
}
|
||||
|
||||
hasRuntimeBridge(win: Window): boolean {
|
||||
return Reflect.get(win, "__hf") !== undefined || isObjectRecord(Reflect.get(win, "__player"));
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private _injectRuntime(): void {
|
||||
this._runtimeInjected = true;
|
||||
try {
|
||||
const doc = this._iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
const script = doc.createElement("script");
|
||||
script.src = RUNTIME_CDN_URL;
|
||||
(doc.head || doc.documentElement).appendChild(script);
|
||||
this._callbacks.onRuntimeInjected?.();
|
||||
} catch {
|
||||
/* cross-origin — can't inject */
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
|
||||
if (this.hasRuntimeBridge(win)) return null;
|
||||
|
||||
const timelines = Reflect.get(win, "__timelines");
|
||||
if (!isObjectRecord(timelines)) return null;
|
||||
|
||||
const keys = Object.keys(timelines);
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const rootId = this._iframe.contentDocument
|
||||
?.querySelector("[data-composition-id]")
|
||||
?.getAttribute("data-composition-id");
|
||||
const key = rootId && rootId in timelines ? rootId : keys[keys.length - 1];
|
||||
const timeline = timelines[key];
|
||||
return isDirectTimelineAdapter(timeline) ? timeline : null;
|
||||
}
|
||||
|
||||
private _resolvePlaybackDurationAdapter(win: Window): PlaybackDurationAdapter | null {
|
||||
const runtimePlayer = Reflect.get(win, "__player");
|
||||
if (isRuntimeDurationAdapter(runtimePlayer)) {
|
||||
return { kind: "runtime", getDuration: () => runtimePlayer.getDuration() };
|
||||
}
|
||||
|
||||
const timeline = this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
if (timeline) {
|
||||
return {
|
||||
kind: "direct-timeline",
|
||||
timeline,
|
||||
getDuration: () => timeline.duration(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Helpers for wiring the player's optional UI elements: the playback controls
|
||||
* bar, the poster image overlay, and input-filtering utilities.
|
||||
*
|
||||
* Extracted from the web component so the setup logic doesn't inflate the
|
||||
* class body. These are pure imperative DOM operations; they carry no
|
||||
* persistent state beyond what the caller tracks.
|
||||
*/
|
||||
|
||||
import { createControls, type ControlsCallbacks, type ControlsOptions } from "./controls.js";
|
||||
|
||||
/**
|
||||
* Create the playback controls overlay and attach it to `parent`.
|
||||
* Returns the controls API object. A no-op guard (returns the existing API)
|
||||
* must be enforced by the caller — this function always constructs.
|
||||
*/
|
||||
export function setupControls(
|
||||
parent: ShadowRoot,
|
||||
muted: boolean,
|
||||
volume: number,
|
||||
speedPresetsAttr: string | null,
|
||||
callbacks: ControlsCallbacks,
|
||||
audioLocked = false,
|
||||
): ReturnType<typeof createControls> {
|
||||
const speedPresets = speedPresetsAttr
|
||||
? speedPresetsAttr
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter((n) => !isNaN(n) && n > 0)
|
||||
: undefined;
|
||||
const options: ControlsOptions = {
|
||||
...(speedPresets ? { speedPresets } : {}),
|
||||
audioLocked,
|
||||
};
|
||||
const api = createControls(parent, callbacks, options);
|
||||
api.updateMuted(muted);
|
||||
api.updateVolume(volume);
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up or remove the poster image element in `parent`.
|
||||
*
|
||||
* - When `posterUrl` is non-empty, creates the `<img>` if needed and sets src.
|
||||
* - When `posterUrl` is null/empty, removes the existing element (if any).
|
||||
*
|
||||
* Returns the current poster element (possibly newly created) or `null`.
|
||||
*/
|
||||
export function setupPoster(
|
||||
parent: ShadowRoot,
|
||||
posterUrl: string | null,
|
||||
existing: HTMLImageElement | null,
|
||||
): HTMLImageElement | null {
|
||||
if (!posterUrl) {
|
||||
existing?.remove();
|
||||
return null;
|
||||
}
|
||||
if (!existing) {
|
||||
existing = document.createElement("img");
|
||||
existing.className = "hfp-poster";
|
||||
parent.appendChild(existing);
|
||||
}
|
||||
existing.src = posterUrl;
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when `event` originated inside an `hfp-controls` element.
|
||||
* Used to prevent the bare-player-surface click handler from double-firing
|
||||
* when the user clicks an overlay control button.
|
||||
*/
|
||||
export function isControlsClick(event: Event): boolean {
|
||||
return event
|
||||
.composedPath()
|
||||
.some((t) => t instanceof HTMLElement && t.classList.contains("hfp-controls"));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { type ControlsCallbacks, createControls } from "./controls";
|
||||
|
||||
function noopCallbacks(): ControlsCallbacks {
|
||||
return {
|
||||
onPlay: () => {},
|
||||
onPause: () => {},
|
||||
onSeek: () => {},
|
||||
onSpeedChange: () => {},
|
||||
onMuteToggle: () => {},
|
||||
onVolumeChange: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createControls host listeners", () => {
|
||||
it("removes every host listener it added on destroy", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const addSpy = vi.spyOn(host, "addEventListener");
|
||||
const removeSpy = vi.spyOn(host, "removeEventListener");
|
||||
|
||||
const api = createControls(host, noopCallbacks());
|
||||
|
||||
// Capture the exact handler references registered on the host element.
|
||||
const added = new Map<string, EventListenerOrEventListenerObject>();
|
||||
for (const [type, handler] of addSpy.mock.calls) {
|
||||
added.set(type, handler as EventListenerOrEventListenerObject);
|
||||
}
|
||||
expect(added.has("mousemove")).toBe(true);
|
||||
expect(added.has("mouseleave")).toBe(true);
|
||||
|
||||
api.destroy();
|
||||
|
||||
// Each host listener must be torn down with the same reference; anonymous
|
||||
// handlers (the previous bug) could never be removed, so toggling the
|
||||
// `controls` attribute leaked a duplicate pair on every cycle.
|
||||
for (const [type, handler] of added) {
|
||||
expect(removeSpy).toHaveBeenCalledWith(type, handler);
|
||||
}
|
||||
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("stops reacting to host mousemove after destroy", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const api = createControls(host, noopCallbacks());
|
||||
const controls = host.querySelector<HTMLElement>(".hfp-controls");
|
||||
expect(controls).not.toBeNull();
|
||||
|
||||
api.destroy();
|
||||
|
||||
// A mousemove after destroy must not revive the controls overlay.
|
||||
controls!.classList.add("hfp-hidden");
|
||||
host.dispatchEvent(new Event("mousemove"));
|
||||
expect(controls!.classList.contains("hfp-hidden")).toBe(true);
|
||||
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("removes its controls element from the host on destroy", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const api = createControls(host, noopCallbacks());
|
||||
const controls = host.querySelector<HTMLElement>(".hfp-controls");
|
||||
expect(controls).not.toBeNull();
|
||||
|
||||
api.destroy();
|
||||
|
||||
expect(host.querySelector(".hfp-controls")).toBeNull();
|
||||
expect(controls!.isConnected).toBe(false);
|
||||
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
PLAY_ICON,
|
||||
PAUSE_ICON,
|
||||
VOLUME_HIGH_ICON,
|
||||
VOLUME_LOW_ICON,
|
||||
VOLUME_MUTED_ICON,
|
||||
} from "./styles.js";
|
||||
|
||||
export interface ControlsCallbacks {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSeek: (fraction: number) => void;
|
||||
/** Scrub drag started (mousedown/touchstart on the scrubber). */
|
||||
onScrubStart?: () => void;
|
||||
/** Scrub drag ended (mouseup/touchend). */
|
||||
onScrubEnd?: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onMuteToggle: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
}
|
||||
|
||||
/** Default logarithmic speed presets — each step roughly doubles/halves. */
|
||||
export const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
|
||||
|
||||
export interface ControlsOptions {
|
||||
/** Speed presets shown in the menu. Defaults to SPEED_PRESETS. */
|
||||
speedPresets?: readonly number[];
|
||||
/**
|
||||
* When true, the volume controls (mute button + volume slider) are hidden so
|
||||
* the viewer cannot change the audio state. Backs the `audio-locked`
|
||||
* attribute on `<hyperframes-player>`, which enforces host-mandated silent
|
||||
* playback (e.g. a HyperFrames project embedded in a chat host). Toggleable
|
||||
* at runtime via the returned `setVolumeControlsHidden`.
|
||||
*/
|
||||
audioLocked?: boolean;
|
||||
}
|
||||
|
||||
export function formatSpeed(speed: number): string {
|
||||
return Number.isInteger(speed) ? `${speed}x` : `${speed}x`;
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
// Handle non-finite values gracefully
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return "0:00";
|
||||
}
|
||||
const s = Math.floor(seconds);
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function createControls(
|
||||
parent: ShadowRoot | HTMLElement,
|
||||
callbacks: ControlsCallbacks,
|
||||
options: ControlsOptions = {},
|
||||
): {
|
||||
updateTime: (current: number, duration: number) => void;
|
||||
updatePlaying: (playing: boolean) => void;
|
||||
updateSpeed: (speed: number) => void;
|
||||
updateMuted: (muted: boolean) => void;
|
||||
updateVolume: (volume: number) => void;
|
||||
setVolumeControlsHidden: (hidden: boolean) => void;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
destroy: () => void;
|
||||
} {
|
||||
const presets = options.speedPresets ?? SPEED_PRESETS;
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "hfp-controls";
|
||||
// Keep overlay interactions from falling through to the host-level click toggle.
|
||||
controls.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
const playBtn = document.createElement("button");
|
||||
playBtn.className = "hfp-play-btn";
|
||||
playBtn.type = "button";
|
||||
// Both glyphs stay in the DOM, stacked; toggling `hfp-playing` crossfade-morphs
|
||||
// between them (see styles.ts) instead of swapping innerHTML, which would kill
|
||||
// the transition.
|
||||
playBtn.innerHTML = `<span class="hfp-ico hfp-ico-play">${PLAY_ICON}</span><span class="hfp-ico hfp-ico-pause">${PAUSE_ICON}</span>`;
|
||||
playBtn.setAttribute("aria-label", "Play");
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
scrubber.className = "hfp-scrubber";
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "hfp-progress";
|
||||
progress.style.width = "0%";
|
||||
scrubber.appendChild(progress);
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "hfp-time";
|
||||
time.textContent = "0:00 / 0:00";
|
||||
|
||||
const speedWrap = document.createElement("div");
|
||||
speedWrap.className = "hfp-speed-wrap";
|
||||
|
||||
const speedBtn = document.createElement("button");
|
||||
speedBtn.className = "hfp-speed-btn";
|
||||
speedBtn.type = "button";
|
||||
speedBtn.textContent = "1x";
|
||||
speedBtn.setAttribute("aria-label", "Playback speed");
|
||||
|
||||
const speedMenu = document.createElement("div");
|
||||
speedMenu.className = "hfp-speed-menu";
|
||||
speedMenu.setAttribute("role", "menu");
|
||||
for (const preset of presets) {
|
||||
const item = document.createElement("button");
|
||||
item.className = "hfp-speed-option";
|
||||
item.type = "button";
|
||||
item.setAttribute("role", "menuitem");
|
||||
item.dataset.speed = String(preset);
|
||||
item.textContent = formatSpeed(preset);
|
||||
if (preset === 1) item.classList.add("hfp-active");
|
||||
speedMenu.appendChild(item);
|
||||
}
|
||||
|
||||
speedWrap.appendChild(speedMenu);
|
||||
speedWrap.appendChild(speedBtn);
|
||||
|
||||
const volumeWrap = document.createElement("div");
|
||||
volumeWrap.className = "hfp-volume-wrap";
|
||||
|
||||
const muteBtn = document.createElement("button");
|
||||
muteBtn.className = "hfp-mute-btn";
|
||||
muteBtn.type = "button";
|
||||
muteBtn.innerHTML = VOLUME_HIGH_ICON;
|
||||
muteBtn.setAttribute("aria-label", "Mute");
|
||||
|
||||
const volumeSliderWrap = document.createElement("div");
|
||||
volumeSliderWrap.className = "hfp-volume-slider-wrap";
|
||||
|
||||
const volumeSlider = document.createElement("div");
|
||||
volumeSlider.className = "hfp-volume-slider";
|
||||
volumeSlider.setAttribute("role", "slider");
|
||||
volumeSlider.setAttribute("aria-label", "Volume");
|
||||
volumeSlider.setAttribute("aria-valuemin", "0");
|
||||
volumeSlider.setAttribute("aria-valuemax", "100");
|
||||
volumeSlider.setAttribute("aria-valuenow", "100");
|
||||
volumeSlider.tabIndex = 0;
|
||||
const volumeFill = document.createElement("div");
|
||||
volumeFill.className = "hfp-volume-fill";
|
||||
volumeFill.style.width = "100%";
|
||||
volumeSlider.appendChild(volumeFill);
|
||||
volumeSliderWrap.appendChild(volumeSlider);
|
||||
|
||||
volumeWrap.appendChild(volumeSliderWrap);
|
||||
volumeWrap.appendChild(muteBtn);
|
||||
|
||||
// Audio-locked: hide the whole volume control (mute toggle + slider) so the
|
||||
// viewer has no UI path to turn sound on. The player still mutes the media
|
||||
// independently via the `muted` attribute; this only removes the controls.
|
||||
if (options.audioLocked) volumeWrap.style.display = "none";
|
||||
|
||||
controls.appendChild(playBtn);
|
||||
controls.appendChild(scrubber);
|
||||
controls.appendChild(time);
|
||||
controls.appendChild(volumeWrap);
|
||||
controls.appendChild(speedWrap);
|
||||
parent.appendChild(controls);
|
||||
|
||||
let isPlaying = false;
|
||||
let isMuted = false;
|
||||
let currentVolume = 1;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let speedIndex = presets.indexOf(1); // start at 1x
|
||||
if (speedIndex === -1) speedIndex = 0;
|
||||
|
||||
const getVolumeIcon = (muted: boolean, volume: number): string => {
|
||||
if (muted) return VOLUME_MUTED_ICON;
|
||||
if (volume === 0) return VOLUME_LOW_ICON;
|
||||
if (volume < 0.5) return VOLUME_LOW_ICON;
|
||||
return VOLUME_HIGH_ICON;
|
||||
};
|
||||
|
||||
playBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (isPlaying) callbacks.onPause();
|
||||
else callbacks.onPlay();
|
||||
});
|
||||
|
||||
muteBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
callbacks.onMuteToggle();
|
||||
});
|
||||
|
||||
let volumeScrubbing = false;
|
||||
|
||||
const handleVolumeAt = (clientX: number) => {
|
||||
const rect = volumeSlider.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
currentVolume = fraction;
|
||||
volumeFill.style.width = `${fraction * 100}%`;
|
||||
volumeSlider.setAttribute("aria-valuenow", String(Math.round(fraction * 100)));
|
||||
if (isMuted && fraction > 0) callbacks.onMuteToggle();
|
||||
muteBtn.innerHTML = getVolumeIcon(isMuted, fraction);
|
||||
callbacks.onVolumeChange(fraction);
|
||||
};
|
||||
|
||||
volumeSlider.addEventListener("mousedown", (e) => {
|
||||
e.stopPropagation();
|
||||
volumeScrubbing = true;
|
||||
handleVolumeAt(e.clientX);
|
||||
});
|
||||
const onVolumeMouseMove = (e: MouseEvent) => {
|
||||
if (volumeScrubbing) handleVolumeAt(e.clientX);
|
||||
};
|
||||
const onVolumeMouseUp = () => {
|
||||
volumeScrubbing = false;
|
||||
};
|
||||
document.addEventListener("mousemove", onVolumeMouseMove);
|
||||
document.addEventListener("mouseup", onVolumeMouseUp);
|
||||
|
||||
volumeSlider.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
volumeScrubbing = true;
|
||||
const touch = e.touches[0];
|
||||
if (touch) handleVolumeAt(touch.clientX);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
const onVolumeTouchMove = (e: TouchEvent) => {
|
||||
if (volumeScrubbing) {
|
||||
const touch = e.touches[0];
|
||||
if (touch) handleVolumeAt(touch.clientX);
|
||||
}
|
||||
};
|
||||
const onVolumeTouchEnd = () => {
|
||||
volumeScrubbing = false;
|
||||
};
|
||||
document.addEventListener("touchmove", onVolumeTouchMove, { passive: true });
|
||||
document.addEventListener("touchend", onVolumeTouchEnd);
|
||||
|
||||
const VOLUME_STEP = 0.05;
|
||||
volumeSlider.addEventListener("keydown", (e) => {
|
||||
let newVol = currentVolume;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
|
||||
newVol = Math.min(1, currentVolume + VOLUME_STEP);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
|
||||
newVol = Math.max(0, currentVolume - VOLUME_STEP);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
currentVolume = newVol;
|
||||
volumeFill.style.width = `${newVol * 100}%`;
|
||||
volumeSlider.setAttribute("aria-valuenow", String(Math.round(newVol * 100)));
|
||||
if (isMuted && newVol > 0) callbacks.onMuteToggle();
|
||||
muteBtn.innerHTML = getVolumeIcon(isMuted, newVol);
|
||||
callbacks.onVolumeChange(newVol);
|
||||
});
|
||||
|
||||
const setActiveOption = (speed: number) => {
|
||||
for (const opt of speedMenu.querySelectorAll(".hfp-speed-option")) {
|
||||
opt.classList.toggle("hfp-active", (opt as HTMLElement).dataset.speed === String(speed));
|
||||
}
|
||||
};
|
||||
|
||||
speedBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = speedMenu.classList.toggle("hfp-open");
|
||||
speedBtn.setAttribute("aria-expanded", String(isOpen));
|
||||
});
|
||||
|
||||
speedMenu.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const target = (e.target as HTMLElement).closest(".hfp-speed-option") as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const newSpeed = parseFloat(target.dataset.speed!);
|
||||
speedIndex = presets.indexOf(newSpeed);
|
||||
speedBtn.textContent = formatSpeed(newSpeed);
|
||||
setActiveOption(newSpeed);
|
||||
speedMenu.classList.remove("hfp-open");
|
||||
speedBtn.setAttribute("aria-expanded", "false");
|
||||
callbacks.onSpeedChange(newSpeed);
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
const onDocClick = () => {
|
||||
speedMenu.classList.remove("hfp-open");
|
||||
speedBtn.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
document.addEventListener("click", onDocClick);
|
||||
|
||||
const handleScrubAt = (clientX: number) => {
|
||||
const rect = scrubber.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
callbacks.onSeek(fraction);
|
||||
};
|
||||
|
||||
let scrubbing = false;
|
||||
|
||||
scrubber.addEventListener("mousedown", (e) => {
|
||||
e.stopPropagation();
|
||||
scrubbing = true;
|
||||
callbacks.onScrubStart?.();
|
||||
handleScrubAt(e.clientX);
|
||||
});
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (scrubbing) handleScrubAt(e.clientX);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
if (scrubbing) {
|
||||
scrubbing = false;
|
||||
callbacks.onScrubEnd?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
scrubber.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
scrubbing = true;
|
||||
callbacks.onScrubStart?.();
|
||||
const touch = e.touches[0];
|
||||
if (touch) handleScrubAt(touch.clientX);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (scrubbing) {
|
||||
const touch = e.touches[0];
|
||||
if (touch) handleScrubAt(touch.clientX);
|
||||
}
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
if (scrubbing) {
|
||||
scrubbing = false;
|
||||
callbacks.onScrubEnd?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||
document.addEventListener("touchend", onTouchEnd);
|
||||
|
||||
const startHideTimer = () => {
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
hideTimeout = setTimeout(() => {
|
||||
if (isPlaying) controls.classList.add("hfp-hidden");
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent;
|
||||
const onHostMouseMove = () => {
|
||||
controls.classList.remove("hfp-hidden");
|
||||
startHideTimer();
|
||||
};
|
||||
const onHostMouseLeave = () => {
|
||||
if (isPlaying) controls.classList.add("hfp-hidden");
|
||||
};
|
||||
host.addEventListener("mousemove", onHostMouseMove);
|
||||
host.addEventListener("mouseleave", onHostMouseLeave);
|
||||
|
||||
return {
|
||||
updateTime(current: number, duration: number) {
|
||||
// Defensive: source should already clamp, but guard here so the UI never overflows.
|
||||
const clampedCurrent = duration > 0 ? Math.min(current, duration) : current;
|
||||
const pct = duration > 0 ? (clampedCurrent / duration) * 100 : 0;
|
||||
progress.style.width = `${pct}%`;
|
||||
time.textContent = `${formatTime(clampedCurrent)} / ${formatTime(duration)}`;
|
||||
},
|
||||
updatePlaying(playing: boolean) {
|
||||
isPlaying = playing;
|
||||
playBtn.classList.toggle("hfp-playing", playing);
|
||||
playBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
|
||||
if (playing) startHideTimer();
|
||||
else controls.classList.remove("hfp-hidden");
|
||||
},
|
||||
updateSpeed(speed: number) {
|
||||
const idx = presets.indexOf(speed);
|
||||
if (idx !== -1) speedIndex = idx;
|
||||
speedBtn.textContent = formatSpeed(speed);
|
||||
setActiveOption(speed);
|
||||
},
|
||||
updateMuted(muted: boolean) {
|
||||
isMuted = muted;
|
||||
muteBtn.innerHTML = getVolumeIcon(muted, currentVolume);
|
||||
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
|
||||
},
|
||||
updateVolume(volume: number) {
|
||||
currentVolume = volume;
|
||||
volumeFill.style.width = `${volume * 100}%`;
|
||||
volumeSlider.setAttribute("aria-valuenow", String(Math.round(volume * 100)));
|
||||
muteBtn.innerHTML = getVolumeIcon(isMuted, volume);
|
||||
},
|
||||
setVolumeControlsHidden(hidden: boolean) {
|
||||
volumeWrap.style.display = hidden ? "none" : "";
|
||||
},
|
||||
show() {
|
||||
controls.style.display = "";
|
||||
},
|
||||
hide() {
|
||||
controls.style.display = "none";
|
||||
},
|
||||
destroy() {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
document.removeEventListener("touchend", onTouchEnd);
|
||||
document.removeEventListener("mousemove", onVolumeMouseMove);
|
||||
document.removeEventListener("mouseup", onVolumeMouseUp);
|
||||
document.removeEventListener("touchmove", onVolumeTouchMove);
|
||||
document.removeEventListener("touchend", onVolumeTouchEnd);
|
||||
document.removeEventListener("click", onDocClick);
|
||||
host.removeEventListener("mousemove", onHostMouseMove);
|
||||
host.removeEventListener("mouseleave", onHostMouseLeave);
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
controls.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* rAF-based clock that polls a `DirectTimelineAdapter` for current time and
|
||||
* drives the player's time/playback-ended callbacks.
|
||||
*
|
||||
* Used for same-origin standalone GSAP compositions that expose
|
||||
* `window.__timelines` but have no runtime bridge — the player drives them
|
||||
* directly through the adapter instead of going through postMessage.
|
||||
*/
|
||||
|
||||
import type { DirectTimelineAdapter } from "./timeline-adapters.js";
|
||||
|
||||
const UI_UPDATE_INTERVAL_MS = 100;
|
||||
|
||||
export interface ClockCallbacks {
|
||||
/** Called every ~100ms and on completion with the current time. */
|
||||
onTimeUpdate: (currentTime: number, duration: number) => void;
|
||||
/** Called when playback reaches the end. Return true to loop (seek+play). */
|
||||
onEnded: () => boolean;
|
||||
/** Get the current loop flag. */
|
||||
getLoop: () => boolean;
|
||||
/** Trigger a seek-then-play loop restart. */
|
||||
restart: () => void;
|
||||
/** Notify that playback has paused (from the timeline side). */
|
||||
onPaused: () => void;
|
||||
}
|
||||
|
||||
export class DirectTimelineClock {
|
||||
private _raf: number | null = null;
|
||||
private _lastUpdateMs = 0;
|
||||
|
||||
constructor(private readonly _callbacks: ClockCallbacks) {}
|
||||
|
||||
start(
|
||||
timeline: DirectTimelineAdapter,
|
||||
getCurrentTime: () => number,
|
||||
getDuration: () => number,
|
||||
isPaused: () => boolean,
|
||||
): void {
|
||||
this.stop();
|
||||
|
||||
const tick = () => {
|
||||
if (isPaused()) {
|
||||
this._raf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentTime: number;
|
||||
try {
|
||||
currentTime = timeline.time();
|
||||
} catch {
|
||||
this._raf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = getDuration();
|
||||
if (duration > 0) currentTime = Math.min(currentTime, duration);
|
||||
|
||||
const completedPlayback = duration > 0 && currentTime >= duration;
|
||||
const now = performance.now();
|
||||
|
||||
if (now - this._lastUpdateMs > UI_UPDATE_INTERVAL_MS || completedPlayback) {
|
||||
this._lastUpdateMs = now;
|
||||
this._callbacks.onTimeUpdate(currentTime, duration);
|
||||
}
|
||||
|
||||
if (completedPlayback) {
|
||||
if (this._callbacks.getLoop()) {
|
||||
this._callbacks.restart();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
timeline.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this._callbacks.onPaused();
|
||||
this._raf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this._raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
this._raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this._raf === null) return;
|
||||
cancelAnimationFrame(this._raf);
|
||||
this._raf = null;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this._raf !== null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,809 @@
|
||||
import { CompositionProbe, type ProbeResult, readPositiveDimension } from "./composition-probe.js";
|
||||
import { isControlsClick, setupControls, setupPoster } from "./controls-setup.js";
|
||||
import { adoptShadowStyles, createCompositionIframe, scaleIframeToFit } from "./iframe-dom.js";
|
||||
import { DirectTimelineClock } from "./direct-timeline-clock.js";
|
||||
import { ParentMediaManager } from "./parent-media.js";
|
||||
import { isRealmHtmlMediaElement } from "./media-element-guards.js";
|
||||
import { handleRuntimeMessage } from "./runtime-message-handler.js";
|
||||
import {
|
||||
SHADER_CAPTURE_SCALE_ATTR,
|
||||
SHADER_LOADING_ATTR,
|
||||
type ShaderLoadingMode,
|
||||
getShaderCaptureScaleFromElement,
|
||||
getShaderModeFromElement,
|
||||
prepareSrcForElement,
|
||||
prepareSrcdocForElement,
|
||||
} from "./shader-options.js";
|
||||
import { createShaderLoader } from "./shader-loader-element.js";
|
||||
import { ShaderLoaderState } from "./shader-loader-state.js";
|
||||
import { PLAYER_STYLES } from "./styles.js";
|
||||
import { type DirectTimelineAdapter } from "./timeline-adapters.js";
|
||||
|
||||
// Playback-rate bounds mirror the runtime clamp in
|
||||
// packages/core/src/runtime/init.ts (applyPlaybackRate) and media.ts so the
|
||||
// player accepts the same range as the in-iframe runtime: an out-of-range rate
|
||||
// would otherwise drive the parent-proxied <audio> outside the bounds the
|
||||
// timeline itself respects. Clamping here also shields the native
|
||||
// HTMLMediaElement.playbackRate setter, which throws for extreme values in
|
||||
// production browsers.
|
||||
const MIN_PLAYBACK_RATE = 0.1;
|
||||
const MAX_PLAYBACK_RATE = 5;
|
||||
|
||||
export type ColorGradingTarget =
|
||||
| string
|
||||
| {
|
||||
id?: string | null;
|
||||
hfId?: string | null;
|
||||
selector?: string | null;
|
||||
selectorIndex?: number | null;
|
||||
};
|
||||
|
||||
export type ColorGradingCompareState = {
|
||||
enabled: boolean;
|
||||
position?: number;
|
||||
softness?: number;
|
||||
lineWidth?: number;
|
||||
};
|
||||
|
||||
function clampPlaybackRate(rate: number): number {
|
||||
if (!Number.isFinite(rate) || rate <= 0) return 1;
|
||||
return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
|
||||
}
|
||||
|
||||
class HyperframesPlayer extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return [
|
||||
"src",
|
||||
"srcdoc",
|
||||
"width",
|
||||
"height",
|
||||
"controls",
|
||||
"muted",
|
||||
"audio-locked",
|
||||
"volume",
|
||||
"poster",
|
||||
"playback-rate",
|
||||
"audio-src",
|
||||
SHADER_CAPTURE_SCALE_ATTR,
|
||||
SHADER_LOADING_ATTR,
|
||||
];
|
||||
}
|
||||
|
||||
private shadow: ShadowRoot;
|
||||
private container: HTMLDivElement;
|
||||
private iframe: HTMLIFrameElement;
|
||||
private posterEl: HTMLImageElement | null = null;
|
||||
private controlsApi: ReturnType<typeof setupControls> | null = null;
|
||||
private resizeObserver: ResizeObserver;
|
||||
private shaderLoader: ShaderLoaderState;
|
||||
private probe: CompositionProbe;
|
||||
|
||||
private _ready = false;
|
||||
private _currentTime = 0;
|
||||
private _duration = 0;
|
||||
private _paused = true;
|
||||
/** True while the user is dragging the scrubber — makes seek() play audio at the
|
||||
* playhead (audible scrub) instead of positioning it silently. */
|
||||
private _scrubbing = false;
|
||||
private _lastUpdateMs = 0;
|
||||
private _volume = 1;
|
||||
private _compositionWidth = 1920;
|
||||
private _compositionHeight = 1080;
|
||||
private _rescaleWarned = false;
|
||||
private _directTimelineAdapter: DirectTimelineAdapter | null = null;
|
||||
private _directTimelineClock: DirectTimelineClock;
|
||||
private _parentTickRaf: number | null = null;
|
||||
private _media: ParentMediaManager;
|
||||
private _scenes: { id: string; start: number; duration: number }[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
|
||||
adoptShadowStyles(this.shadow, PLAYER_STYLES);
|
||||
({ container: this.container, iframe: this.iframe } = createCompositionIframe());
|
||||
this.shadow.appendChild(this.container);
|
||||
|
||||
const loaderElements = createShaderLoader();
|
||||
this.shadow.appendChild(loaderElements.root);
|
||||
this.shaderLoader = new ShaderLoaderState(loaderElements);
|
||||
|
||||
this._media = new ParentMediaManager({
|
||||
dispatchEvent: (e) => this.dispatchEvent(e),
|
||||
getMuted: () => this.muted,
|
||||
getVolume: () => this._volume,
|
||||
getPlaybackRate: () => this.playbackRate,
|
||||
getCurrentTime: () => this._currentTime,
|
||||
isPaused: () => this._paused,
|
||||
});
|
||||
|
||||
this._directTimelineClock = new DirectTimelineClock({
|
||||
onTimeUpdate: (currentTime, duration) => {
|
||||
this._currentTime = currentTime;
|
||||
this.controlsApi?.updateTime(currentTime, duration);
|
||||
this.dispatchEvent(new CustomEvent("timeupdate", { detail: { currentTime } }));
|
||||
},
|
||||
getLoop: () => this.loop,
|
||||
restart: () => {
|
||||
this.seek(0);
|
||||
this.play();
|
||||
},
|
||||
onPaused: () => {
|
||||
if (this._media.audioOwner === "parent") this._media.pauseAll();
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
this.dispatchEvent(new Event("ended"));
|
||||
},
|
||||
onEnded: () => this.loop,
|
||||
});
|
||||
|
||||
this.probe = new CompositionProbe(this.iframe, {
|
||||
onReady: (result) => this._onProbeReady(result),
|
||||
onError: (message) => this.dispatchEvent(new CustomEvent("error", { detail: { message } })),
|
||||
});
|
||||
|
||||
this.addEventListener("click", (event) => {
|
||||
if (isControlsClick(event)) return;
|
||||
if (this._paused) this.play();
|
||||
else this.pause();
|
||||
});
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => this._rescale());
|
||||
this._onMessage = this._onMessage.bind(this);
|
||||
this._onIframeLoad = this._onIframeLoad.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.resizeObserver.observe(this);
|
||||
window.addEventListener("message", this._onMessage);
|
||||
this.iframe.addEventListener("load", this._onIframeLoad);
|
||||
if (this.hasAttribute("controls")) this._setupControls();
|
||||
if (this.hasAttribute("poster"))
|
||||
this.posterEl = setupPoster(this.shadow, this.getAttribute("poster"), this.posterEl);
|
||||
if (this.hasAttribute("audio-src")) this._media.setupFromUrl(this.getAttribute("audio-src")!);
|
||||
if (this.hasAttribute("srcdoc"))
|
||||
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc")!);
|
||||
if (this.hasAttribute("src"))
|
||||
this.iframe.src = prepareSrcForElement(this, this.getAttribute("src")!);
|
||||
|
||||
// Host-environment audio lock: when the embedding host (e.g. Claude
|
||||
// desktop) drops the `audio-locked` attribute, attributeChangedCallback
|
||||
// never fires for it, so apply the lock here based on UA detection.
|
||||
if (!this.hasAttribute("audio-locked") && this._isLockedHostEnvironment()) {
|
||||
this._applyAudioLock(true);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.resizeObserver.disconnect();
|
||||
window.removeEventListener("message", this._onMessage);
|
||||
this.iframe.removeEventListener("load", this._onIframeLoad);
|
||||
this.probe.stop();
|
||||
this._directTimelineClock.stop();
|
||||
this._stopParentTickClock();
|
||||
this._directTimelineAdapter = null;
|
||||
this.shaderLoader.destroy();
|
||||
this._media.destroy();
|
||||
this.controlsApi?.destroy();
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
switch (name) {
|
||||
case "src":
|
||||
if (val) {
|
||||
this._ready = false;
|
||||
this.iframe.src = prepareSrcForElement(this, val);
|
||||
}
|
||||
break;
|
||||
case "srcdoc":
|
||||
this._ready = false;
|
||||
if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val);
|
||||
else this.iframe.removeAttribute("srcdoc");
|
||||
break;
|
||||
// Reject NaN/zero/negative dimensions the same way the composition
|
||||
// probe does (a typo like width="abc" or width="0" would otherwise
|
||||
// reach scaleIframeToFit as scale(NaN) or a division by zero and
|
||||
// blank the player); fall back to the defaults instead.
|
||||
case "width":
|
||||
this._compositionWidth = readPositiveDimension(val) ?? 1920;
|
||||
this._rescale();
|
||||
break;
|
||||
case "height":
|
||||
this._compositionHeight = readPositiveDimension(val) ?? 1080;
|
||||
this._rescale();
|
||||
break;
|
||||
case "controls":
|
||||
if (val !== null) this._setupControls();
|
||||
else {
|
||||
this.controlsApi?.destroy();
|
||||
this.controlsApi = null;
|
||||
}
|
||||
break;
|
||||
case "poster":
|
||||
this.posterEl = setupPoster(this.shadow, val, this.posterEl);
|
||||
break;
|
||||
case "playback-rate": {
|
||||
const rate = clampPlaybackRate(parseFloat(val || "1"));
|
||||
this._media.updatePlaybackRate(rate);
|
||||
this._sendControl("set-playback-rate", { playbackRate: rate });
|
||||
this._directTimelineAdapter?.timeScale?.(rate);
|
||||
this.controlsApi?.updateSpeed(rate);
|
||||
this.dispatchEvent(new Event("ratechange"));
|
||||
break;
|
||||
}
|
||||
case "muted":
|
||||
this._handleMutedChange(val);
|
||||
break;
|
||||
case "audio-locked":
|
||||
this._applyAudioLock(val !== null);
|
||||
break;
|
||||
case "volume": {
|
||||
const v = Math.max(0, Math.min(1, parseFloat(val || "1")));
|
||||
this._volume = v;
|
||||
this._media.updateVolume(v);
|
||||
this._sendControl("set-volume", { volume: v });
|
||||
this.controlsApi?.updateVolume(v);
|
||||
this.dispatchEvent(new Event("volumechange"));
|
||||
break;
|
||||
}
|
||||
case "audio-src":
|
||||
if (val) this._media.setupFromUrl(val);
|
||||
else this._media.teardownUrlAudio();
|
||||
break;
|
||||
case SHADER_CAPTURE_SCALE_ATTR:
|
||||
case SHADER_LOADING_ATTR:
|
||||
this._reloadShaderOptions();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The inner `<iframe>` rendering the composition. Use this when integrating
|
||||
* with tools that need `contentWindow` — `.contentWindow` on the
|
||||
* `<hyperframes-player>` element itself returns `null` (Shadow DOM).
|
||||
*/
|
||||
get iframeElement(): HTMLIFrameElement {
|
||||
return this.iframe;
|
||||
}
|
||||
|
||||
/** Scene list from the last-received runtime timeline message. Empty until
|
||||
* the composition runtime fires its first "timeline" postMessage. */
|
||||
get scenes(): { id: string; start: number; duration: number }[] {
|
||||
return this._scenes;
|
||||
}
|
||||
|
||||
play() {
|
||||
this.posterEl?.remove();
|
||||
this.posterEl = null;
|
||||
if (this._duration > 0 && this._currentTime >= this._duration) this.seek(0);
|
||||
// Must be set before _startParentTickClock so the RAF loop's `_paused`
|
||||
// check doesn't immediately self-terminate on the first callback.
|
||||
this._paused = false;
|
||||
const directTimelineStarted = this._tryDirectTimelinePlay();
|
||||
if (!directTimelineStarted) {
|
||||
this._sendControl("play");
|
||||
// Only start the parent tick clock once the composition is ready and
|
||||
// confirmed on the runtime bridge path (not the direct-timeline path).
|
||||
// Guards against firing ticks into an uninitialized iframe when play()
|
||||
// is called before the probe has resolved.
|
||||
if (this._ready && !this._directTimelineAdapter) {
|
||||
this._startParentTickClock();
|
||||
}
|
||||
}
|
||||
if (this._media.audioOwner === "parent") this._media.playAll();
|
||||
this.controlsApi?.updatePlaying(true);
|
||||
this.dispatchEvent(new Event("play"));
|
||||
if (directTimelineStarted && this._directTimelineAdapter) {
|
||||
this._directTimelineClock.start(
|
||||
this._directTimelineAdapter,
|
||||
() => this._currentTime,
|
||||
() => this._duration,
|
||||
() => this._paused,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (!this._tryDirectTimelinePause()) this._sendControl("pause");
|
||||
this._directTimelineClock.stop();
|
||||
this._stopParentTickClock();
|
||||
if (this._media.audioOwner === "parent") this._media.pauseAll();
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
this.dispatchEvent(new Event("pause"));
|
||||
}
|
||||
|
||||
stopMedia() {
|
||||
this._sendControl("stop-media");
|
||||
this._stopIframeMedia();
|
||||
this._media.stopAdoptedMedia();
|
||||
}
|
||||
|
||||
seek(timeInSeconds: number) {
|
||||
if (!this._trySyncSeek(timeInSeconds) && !this._tryDirectTimelineSeek(timeInSeconds)) {
|
||||
this._sendControl("seek", { frame: Math.round(timeInSeconds * 30) });
|
||||
}
|
||||
this._directTimelineClock.stop();
|
||||
this._stopParentTickClock();
|
||||
this._currentTime = timeInSeconds;
|
||||
if (this._media.audioOwner === "parent") {
|
||||
if (this._scrubbing) {
|
||||
// Audible scrub: play the proxy audio at the playhead so the viewer hears
|
||||
// the track as they drag. Each move re-seeks, restarting playback from the
|
||||
// new position. onScrubEnd settles back to silence via a normal seek.
|
||||
this._media.scrubAll(timeInSeconds);
|
||||
} else {
|
||||
// Pause BEFORE seek: leaving the proxy playing turns the next
|
||||
// `mirrorTime` drift-correction tick into a perpetual seek→play→drift→seek
|
||||
// stutter loop, where ~80ms of audio plays past the (now frozen) timeline,
|
||||
// then mirrorTime yanks `currentTime` back to match it. Symmetric with
|
||||
// `pause()` below.
|
||||
this._media.pauseAll();
|
||||
this._media.seekAll(timeInSeconds);
|
||||
}
|
||||
}
|
||||
this._paused = true;
|
||||
this.controlsApi?.updatePlaying(false);
|
||||
this.controlsApi?.updateTime(this._currentTime, this._duration);
|
||||
}
|
||||
|
||||
setColorGrading(target: ColorGradingTarget, grading: unknown) {
|
||||
this._sendControl("set-color-grading", { target, grading });
|
||||
}
|
||||
|
||||
clearColorGrading(target: ColorGradingTarget) {
|
||||
this._sendControl("set-color-grading", { target, grading: null });
|
||||
}
|
||||
|
||||
setColorGradingCompare(target: ColorGradingTarget, compare: ColorGradingCompareState) {
|
||||
this._sendControl("set-color-grading-compare", { target, compare });
|
||||
}
|
||||
|
||||
clearColorGradingCompare(target: ColorGradingTarget) {
|
||||
this._sendControl("set-color-grading-compare", {
|
||||
target,
|
||||
compare: { enabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
get currentTime() {
|
||||
return this._currentTime;
|
||||
}
|
||||
set currentTime(t: number) {
|
||||
this.seek(t);
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this._duration;
|
||||
}
|
||||
get paused() {
|
||||
return this._paused;
|
||||
}
|
||||
get ready() {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
get playbackRate() {
|
||||
return clampPlaybackRate(parseFloat(this.getAttribute("playback-rate") || "1"));
|
||||
}
|
||||
set playbackRate(r: number) {
|
||||
this.setAttribute("playback-rate", String(clampPlaybackRate(r)));
|
||||
}
|
||||
|
||||
get shaderCaptureScale() {
|
||||
return getShaderCaptureScaleFromElement(this);
|
||||
}
|
||||
set shaderCaptureScale(scale: number) {
|
||||
this.setAttribute(SHADER_CAPTURE_SCALE_ATTR, String(scale));
|
||||
}
|
||||
|
||||
get shaderLoading() {
|
||||
return getShaderModeFromElement(this);
|
||||
}
|
||||
set shaderLoading(mode: ShaderLoadingMode) {
|
||||
if (mode === "composition") this.removeAttribute(SHADER_LOADING_ATTR);
|
||||
else this.setAttribute(SHADER_LOADING_ATTR, mode);
|
||||
}
|
||||
|
||||
get muted() {
|
||||
return this.hasAttribute("muted");
|
||||
}
|
||||
set muted(m: boolean) {
|
||||
if (m) this.setAttribute("muted", "");
|
||||
else this.removeAttribute("muted");
|
||||
}
|
||||
|
||||
get audioLocked() {
|
||||
return this.hasAttribute("audio-locked");
|
||||
}
|
||||
set audioLocked(locked: boolean) {
|
||||
if (locked) this.setAttribute("audio-locked", "");
|
||||
else this.removeAttribute("audio-locked");
|
||||
}
|
||||
|
||||
/**
|
||||
* Host renderers that strip unknown custom-element attributes before they
|
||||
* reach the DOM (observed on the Claude desktop Electron client) can defeat
|
||||
* `audio-locked` even when the host *intends* to lock audio. When we detect
|
||||
* such an environment, self-impose the same restriction the attribute would
|
||||
* apply. Web (browser) hosts preserve the attribute and don't need this.
|
||||
*/
|
||||
private _isLockedHostEnvironment(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent || "";
|
||||
// Claude desktop ships as an Electron app with a "Claude/<version>" UA token.
|
||||
return /\bClaude\/\d/.test(ua) && /\bElectron\b/.test(ua);
|
||||
}
|
||||
|
||||
/** True when audio playback must be locked: attribute OR host fallback. */
|
||||
private _isAudioLocked(): boolean {
|
||||
return this.hasAttribute("audio-locked") || this._isLockedHostEnvironment();
|
||||
}
|
||||
|
||||
private _isSlideshowPlayer(): boolean {
|
||||
return this.closest("hyperframes-slideshow") !== null;
|
||||
}
|
||||
|
||||
/** Apply a change to the `muted` attribute: re-assert under an audio lock,
|
||||
* else mute/unmute the media, sync the controls, and fire `volumechange`. */
|
||||
private _handleMutedChange(val: string | null): void {
|
||||
// While audio is locked, ignore any attempt to clear `muted` (host control,
|
||||
// stray script, raw `removeAttribute`) and re-assert it. The re-set fires
|
||||
// this callback again with val="" (not null) so it mutes normally — no loop.
|
||||
if (val === null && this._isAudioLocked()) {
|
||||
this.setAttribute("muted", "");
|
||||
return;
|
||||
}
|
||||
this._media.updateMuted(val !== null);
|
||||
this._setIframeMediaMuted(val !== null);
|
||||
this._sendControl("set-muted", { muted: val !== null });
|
||||
this.controlsApi?.updateMuted(val !== null);
|
||||
this.dispatchEvent(new Event("volumechange"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-mandated silent playback (e.g. embedded in a chat host): force mute
|
||||
* and hide the volume controls so the viewer cannot turn sound on. Unlocking
|
||||
* only unhides the controls — it does not auto-unmute; callers manage `muted`
|
||||
* explicitly after unlocking.
|
||||
*/
|
||||
private _applyAudioLock(locked: boolean): void {
|
||||
if (locked) this.muted = true;
|
||||
this.controlsApi?.setVolumeControlsHidden(locked);
|
||||
}
|
||||
|
||||
get volume() {
|
||||
return this._volume;
|
||||
}
|
||||
set volume(v: number) {
|
||||
this.setAttribute("volume", String(Math.max(0, Math.min(1, v))));
|
||||
}
|
||||
|
||||
get loop() {
|
||||
return this.hasAttribute("loop");
|
||||
}
|
||||
set loop(l: boolean) {
|
||||
if (l) this.setAttribute("loop", "");
|
||||
else this.removeAttribute("loop");
|
||||
}
|
||||
|
||||
private _sendControl(action: string, extra: Record<string, unknown> = {}) {
|
||||
try {
|
||||
this.iframe.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action, ...extra },
|
||||
"*",
|
||||
);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the iframe's contentDocument if same-origin and reachable,
|
||||
* otherwise null. Accessing contentDocument can throw on cross-origin
|
||||
* iframes — this swallows that as a clean null sentinel.
|
||||
*/
|
||||
private _getSameOriginIframeDocument(): Document | null {
|
||||
try {
|
||||
return this.iframe.contentDocument;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private _setIframeMediaMuted(muted: boolean): void {
|
||||
const iframeDoc = this._getSameOriginIframeDocument();
|
||||
if (!iframeDoc) return;
|
||||
for (const el of iframeDoc.querySelectorAll("video, audio")) {
|
||||
if (isRealmHtmlMediaElement(el)) el.muted = muted || el.defaultMuted;
|
||||
}
|
||||
}
|
||||
|
||||
private _stopIframeMedia(): void {
|
||||
const iframeDoc = this._getSameOriginIframeDocument();
|
||||
if (!iframeDoc) return;
|
||||
for (const el of iframeDoc.querySelectorAll("video, audio")) {
|
||||
if (isRealmHtmlMediaElement(el)) el.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay current bridge state to the iframe runtime. Triggered when the
|
||||
* runtime announces `{type: "ready"}` — repairs the race where the parent
|
||||
* posts control messages before the iframe's bridge listener is installed
|
||||
* (warm-cache reloads, the Claude desktop Electron client, anywhere the
|
||||
* iframe finishes loading after we've already called `set-muted` etc).
|
||||
* Re-sending current state is idempotent — even at default values it just
|
||||
* confirms what the runtime would have done anyway.
|
||||
*/
|
||||
private _replayBridgeState(): void {
|
||||
this._sendControl("set-muted", { muted: this.muted });
|
||||
this._sendControl("set-volume", { volume: this._volume });
|
||||
this._sendControl("set-playback-rate", { playbackRate: this.playbackRate });
|
||||
this._sendControl("set-native-media-sync-disabled", {
|
||||
disabled: this._isSlideshowPlayer(),
|
||||
});
|
||||
this._sendControl("set-web-audio-media-disabled", {
|
||||
disabled: this._isSlideshowPlayer(),
|
||||
});
|
||||
}
|
||||
|
||||
private _reloadShaderOptions(): void {
|
||||
if (getShaderModeFromElement(this) !== "player") this.shaderLoader.reset();
|
||||
if (this.hasAttribute("srcdoc")) {
|
||||
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc") || "");
|
||||
return;
|
||||
}
|
||||
if (this.hasAttribute("src")) {
|
||||
this.iframe.src = prepareSrcForElement(this, this.getAttribute("src") || "");
|
||||
}
|
||||
}
|
||||
|
||||
private _trySyncSeek(timeInSeconds: number): boolean {
|
||||
try {
|
||||
const win = this.iframe.contentWindow as
|
||||
| (Window & { __player?: { seek?: (t: number) => void } })
|
||||
| null;
|
||||
const player = win?.__player;
|
||||
if (typeof player?.seek !== "function") return false;
|
||||
player.seek.call(player, timeInSeconds);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _withDirectTimeline(fn: (tl: DirectTimelineAdapter) => void): boolean {
|
||||
const tl = this._directTimelineAdapter || this.probe.resolveDirectTimelineAdapter();
|
||||
if (!tl) return false;
|
||||
try {
|
||||
fn(tl);
|
||||
this._directTimelineAdapter = tl;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// GSAP seek() preserves play state; player seek() contract lands paused.
|
||||
private _tryDirectTimelineSeek(t: number): boolean {
|
||||
return this._withDirectTimeline((tl) => {
|
||||
// suppressEvents=false: fire the timeline's onUpdate so compositions that
|
||||
// drive scene visibility imperatively (via the root timeline's onUpdate,
|
||||
// e.g. slideshow decks) repaint on a paused seek — not only while playing.
|
||||
tl.seek(t, false);
|
||||
tl.pause();
|
||||
});
|
||||
}
|
||||
private _tryDirectTimelinePlay(): boolean {
|
||||
return this._withDirectTimeline((tl) => void tl.play());
|
||||
}
|
||||
private _tryDirectTimelinePause(): boolean {
|
||||
return this._withDirectTimeline((tl) => void tl.pause());
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget-frame RAF loop that sends "tick" postMessages to the composition
|
||||
* iframe on every frame. Used for the runtime bridge path so that animation
|
||||
* advances even when the composition iframe's own rAF is throttled by
|
||||
* Chromium (e.g. deeply nested cross-origin iframes in Electron / Claude desktop).
|
||||
* The runtime's own rAF loop still runs — ticking GSAP twice per frame is
|
||||
* harmless because seekTimelineAndAdapters is idempotent.
|
||||
*/
|
||||
private _startParentTickClock(): void {
|
||||
this._stopParentTickClock();
|
||||
const tick = () => {
|
||||
if (this._paused) {
|
||||
this._parentTickRaf = null;
|
||||
return;
|
||||
}
|
||||
this._sendControl("tick");
|
||||
this._parentTickRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
this._parentTickRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
private _stopParentTickClock(): void {
|
||||
if (this._parentTickRaf === null) return;
|
||||
cancelAnimationFrame(this._parentTickRaf);
|
||||
this._parentTickRaf = null;
|
||||
}
|
||||
|
||||
private _onMessage(e: MessageEvent) {
|
||||
handleRuntimeMessage(e, this.iframe.contentWindow, {
|
||||
getPlaybackState: () => ({
|
||||
currentTime: this._currentTime,
|
||||
duration: this._duration,
|
||||
paused: this._paused,
|
||||
lastUpdateMs: this._lastUpdateMs,
|
||||
}),
|
||||
setPlaybackState: ({ currentTime, duration, paused, lastUpdateMs }) => {
|
||||
this._currentTime = currentTime;
|
||||
this._duration = duration;
|
||||
this._paused = paused;
|
||||
this._lastUpdateMs = lastUpdateMs;
|
||||
},
|
||||
getShaderLoadingMode: () => getShaderModeFromElement(this),
|
||||
shaderLoader: this.shaderLoader,
|
||||
setCompositionSize: (w, h) => {
|
||||
this._compositionWidth = w;
|
||||
this._compositionHeight = h;
|
||||
this._rescale();
|
||||
},
|
||||
sendControl: (action, extra) => this._sendControl(action, extra),
|
||||
getIframeDoc: () => this.iframe.contentDocument,
|
||||
onRuntimeReady: () => this._replayBridgeState(),
|
||||
onRuntimeTimelineReady: (duration) => this._onRuntimeTimelineReady(duration),
|
||||
shouldPromoteMediaAutoplayFallback: () => !this._isSlideshowPlayer(),
|
||||
setScenes: (scenes) => {
|
||||
this._scenes = scenes;
|
||||
this.dispatchEvent(new CustomEvent("scenes", { detail: { scenes } }));
|
||||
},
|
||||
updateControlsTime: (t, d) => this.controlsApi?.updateTime(t, d),
|
||||
updateControlsPlaying: (p) => this.controlsApi?.updatePlaying(p),
|
||||
dispatchEvent: (ev) => this.dispatchEvent(ev),
|
||||
seek: (t) => this.seek(t),
|
||||
play: () => this.play(),
|
||||
getLoop: () => this.loop,
|
||||
media: this._media,
|
||||
});
|
||||
}
|
||||
|
||||
private _onRuntimeTimelineReady(duration: number) {
|
||||
if (this._ready) return;
|
||||
this.probe.stop();
|
||||
this._duration = duration;
|
||||
this._directTimelineAdapter = null;
|
||||
this._ready = true;
|
||||
this.controlsApi?.updateTime(this._currentTime, duration);
|
||||
this.dispatchEvent(new CustomEvent("ready", { detail: { duration } }));
|
||||
// stage-size may not have arrived yet (race in the runtime's postTimeline
|
||||
// resolving the root's data-width/data-height on first paint) — rescale
|
||||
// here too so cross-origin compositions never stay unscaled/untransformed.
|
||||
this._rescale();
|
||||
|
||||
const doc = this._getSameOriginIframeDocument();
|
||||
if (doc) this._media.setupFromIframe(doc);
|
||||
|
||||
this._replayBridgeState();
|
||||
this._setIframeMediaMuted(this.muted);
|
||||
if (this.hasAttribute("autoplay")) this.play();
|
||||
}
|
||||
|
||||
private _onProbeReady({ duration, adapter, compositionSize }: ProbeResult) {
|
||||
this._duration = duration;
|
||||
this._directTimelineAdapter = adapter.kind === "direct-timeline" ? adapter.timeline : null;
|
||||
this._ready = true;
|
||||
this.controlsApi?.updateTime(0, duration);
|
||||
this.dispatchEvent(new CustomEvent("ready", { detail: { duration } }));
|
||||
if (compositionSize) {
|
||||
this._compositionWidth = compositionSize.width;
|
||||
this._compositionHeight = compositionSize.height;
|
||||
this._rescale();
|
||||
}
|
||||
try {
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (doc) this._media.setupFromIframe(doc);
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
this._setIframeMediaMuted(this.muted);
|
||||
if (this.hasAttribute("autoplay")) this.play();
|
||||
}
|
||||
|
||||
private _rescale() {
|
||||
const applied = scaleIframeToFit(
|
||||
this,
|
||||
this.iframe,
|
||||
this._compositionWidth,
|
||||
this._compositionHeight,
|
||||
);
|
||||
// A no-op before "ready" is expected (element not painted yet). A no-op
|
||||
// once ready means the composition is stuck unscaled/untransformed —
|
||||
// pinned to the iframe's default top-left position — with no evidence of
|
||||
// why in the field. Surface it once (not on every ResizeObserver tick —
|
||||
// a legitimately hidden/zero-sized player, e.g. a collapsed tab or
|
||||
// off-screen carousel card, would otherwise spam the console forever).
|
||||
if (!applied && this._ready && !this._rescaleWarned) {
|
||||
this._rescaleWarned = true;
|
||||
console.warn("[hyperframes-player] rescale no-op after ready — zero-size player element", {
|
||||
src: this.getAttribute("src"),
|
||||
offsetWidth: this.offsetWidth,
|
||||
offsetHeight: this.offsetHeight,
|
||||
compositionWidth: this._compositionWidth,
|
||||
compositionHeight: this._compositionHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _onIframeLoad() {
|
||||
this._directTimelineAdapter = null;
|
||||
this._directTimelineClock.stop();
|
||||
this._stopParentTickClock();
|
||||
this.shaderLoader.reset();
|
||||
this._media.resetForIframeLoad();
|
||||
this.probe.start();
|
||||
}
|
||||
|
||||
private _setupControls() {
|
||||
if (this.controlsApi) return;
|
||||
this.controlsApi = setupControls(
|
||||
this.shadow,
|
||||
this.muted,
|
||||
this._volume,
|
||||
this.getAttribute("speed-presets"),
|
||||
{
|
||||
onPlay: () => this.play(),
|
||||
onPause: () => this.pause(),
|
||||
onSeek: (f) => this.seek(f * this._duration),
|
||||
onScrubStart: () => {
|
||||
this._scrubbing = true;
|
||||
},
|
||||
onScrubEnd: () => {
|
||||
this._scrubbing = false;
|
||||
// Settle: a normal (silent) seek pauses the proxy audio at the final
|
||||
// scrub position, matching the paused playhead.
|
||||
this.seek(this._currentTime);
|
||||
},
|
||||
onSpeedChange: (s) => void (this.playbackRate = s),
|
||||
onMuteToggle: () => void (this.muted = !this.muted),
|
||||
onVolumeChange: (v) => void (this.volume = v),
|
||||
},
|
||||
this._isAudioLocked(),
|
||||
);
|
||||
}
|
||||
|
||||
// Test-instrumentation pass-throughs (match original field names).
|
||||
get _audioOwner() {
|
||||
return this._media.audioOwner;
|
||||
}
|
||||
get _parentMedia() {
|
||||
return this._media.entries;
|
||||
}
|
||||
_mirrorParentMediaTime(t: number, opts?: { force?: boolean }) {
|
||||
this._media.mirrorTime(t, opts);
|
||||
}
|
||||
_promoteToParentProxy() {
|
||||
let d: Document | null = null;
|
||||
try {
|
||||
d = this.iframe.contentDocument;
|
||||
} catch {
|
||||
/* x-origin */
|
||||
}
|
||||
this._media.promoteToParentProxy(d, (t, o) => this._mirrorParentMediaTime(t, o));
|
||||
this._sendControl("set-media-output-muted", { muted: true });
|
||||
}
|
||||
_observeDynamicMedia(doc: Document) {
|
||||
this._media.setupFromIframe(doc);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("hyperframes-player")) {
|
||||
customElements.define("hyperframes-player", HyperframesPlayer);
|
||||
}
|
||||
|
||||
export { HyperframesPlayer };
|
||||
export { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js";
|
||||
export type { ControlsCallbacks, ControlsOptions } from "./controls.js";
|
||||
export type { ShaderLoadingMode } from "./shader-options.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* DOM setup helpers for the player's shadow root.
|
||||
*
|
||||
* Keeps constructor boilerplate out of the web component class body.
|
||||
*/
|
||||
|
||||
// Cached Constructable Stylesheet shared across all player instances.
|
||||
let _sharedSheet: CSSStyleSheet | null = null;
|
||||
|
||||
/**
|
||||
* Adopt `cssText` into `shadow` via a shared Constructable Stylesheet when the
|
||||
* browser supports it, falling back to a `<style>` element injection. The sheet
|
||||
* is cached on first creation and reused across all player instances.
|
||||
*/
|
||||
export function adoptShadowStyles(shadow: ShadowRoot, cssText: string): void {
|
||||
if (typeof CSSStyleSheet !== "undefined") {
|
||||
try {
|
||||
if (!_sharedSheet) {
|
||||
_sharedSheet = new CSSStyleSheet();
|
||||
_sharedSheet.replaceSync(cssText);
|
||||
}
|
||||
shadow.adoptedStyleSheets = [_sharedSheet];
|
||||
return;
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssText;
|
||||
shadow.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures the iframe element that hosts the composition, plus
|
||||
* the wrapper container div. Returns handles to both so the constructor can
|
||||
* attach them to the shadow root and track references without inlining the
|
||||
* boilerplate.
|
||||
*/
|
||||
export function createCompositionIframe(): {
|
||||
container: HTMLDivElement;
|
||||
iframe: HTMLIFrameElement;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
container.className = "hfp-container";
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.className = "hfp-iframe";
|
||||
iframe.sandbox.add("allow-scripts", "allow-same-origin");
|
||||
iframe.allow = "autoplay; fullscreen";
|
||||
iframe.referrerPolicy = "no-referrer";
|
||||
iframe.title = "HyperFrames Composition";
|
||||
|
||||
container.appendChild(iframe);
|
||||
return { container, iframe };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the iframe so the composition fits inside the player element while
|
||||
* preserving aspect ratio. No-ops when the player has no painted size yet.
|
||||
* Returns whether the transform was actually applied, so callers can tell a
|
||||
* real no-op (still 0×0) apart from a successful rescale.
|
||||
*/
|
||||
export function scaleIframeToFit(
|
||||
playerElement: HTMLElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
compositionWidth: number,
|
||||
compositionHeight: number,
|
||||
): boolean {
|
||||
const w = playerElement.offsetWidth;
|
||||
const h = playerElement.offsetHeight;
|
||||
if (w === 0 || h === 0) return false;
|
||||
const scale = Math.min(w / compositionWidth, h / compositionHeight);
|
||||
iframe.style.width = `${compositionWidth}px`;
|
||||
iframe.style.height = `${compositionHeight}px`;
|
||||
iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function isRealmElement(node: Node): node is Element {
|
||||
const view = node.ownerDocument?.defaultView;
|
||||
if (view && node instanceof view.Element) return true;
|
||||
return node instanceof Element;
|
||||
}
|
||||
|
||||
export function isRealmHtmlMediaElement(node: Node): node is HTMLMediaElement {
|
||||
if (!isRealmElement(node)) return false;
|
||||
if (node.tagName !== "AUDIO" && node.tagName !== "VIDEO") return false;
|
||||
|
||||
const view = node.ownerDocument?.defaultView;
|
||||
if (view && node instanceof view.HTMLMediaElement) return true;
|
||||
return node instanceof HTMLMediaElement;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { selectMediaObserverTargets } from "./mediaObserverScope.js";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeDoc(html: string): Document {
|
||||
// happy-dom doesn't ship a usable XMLHttpRequest path for parser-driven
|
||||
// doc creation, so we build a fresh document by hand and inject markup
|
||||
// through the body — same DOM shape the iframe document will have when
|
||||
// the runtime finishes mounting compositions.
|
||||
const doc = document.implementation.createHTMLDocument("test");
|
||||
doc.body.innerHTML = html;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe("selectMediaObserverTargets", () => {
|
||||
it("returns the single root composition host", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root"></div>
|
||||
`);
|
||||
|
||||
const targets = selectMediaObserverTargets(doc);
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.getAttribute("data-composition-id")).toBe("root");
|
||||
});
|
||||
|
||||
it("returns only top-level hosts when sub-composition hosts are nested", () => {
|
||||
// Mirrors the runtime structure: root host with a sub-composition host
|
||||
// mounted inside it. The nested host is already covered by the root
|
||||
// host's subtree observation.
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div data-composition-id="sub-1"></div>
|
||||
<div>
|
||||
<div data-composition-id="sub-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const targets = selectMediaObserverTargets(doc);
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.getAttribute("data-composition-id")).toBe("root");
|
||||
});
|
||||
|
||||
it("returns multiple hosts when they are siblings (no shared ancestor host)", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="comp-a"></div>
|
||||
<div data-composition-id="comp-b"></div>
|
||||
`);
|
||||
|
||||
const targets = selectMediaObserverTargets(doc);
|
||||
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets.map((t) => t.getAttribute("data-composition-id"))).toEqual(["comp-a", "comp-b"]);
|
||||
});
|
||||
|
||||
it("ignores attribute presence on intermediate non-host elements", () => {
|
||||
// Only `data-composition-id` is meaningful; an unrelated `data-composition`
|
||||
// attribute on a wrapper must not promote a nested host to top-level.
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div data-composition="not-a-host">
|
||||
<div data-composition-id="sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const targets = selectMediaObserverTargets(doc);
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.getAttribute("data-composition-id")).toBe("root");
|
||||
});
|
||||
|
||||
it("falls back to the document body when no composition hosts exist", () => {
|
||||
// Documents that haven't been bootstrapped (or never will be) keep the
|
||||
// legacy behavior so adoption logic still runs against late additions.
|
||||
const doc = makeDoc(`<div class="not-a-composition"></div>`);
|
||||
|
||||
const targets = selectMediaObserverTargets(doc);
|
||||
|
||||
expect(targets).toEqual([doc.body]);
|
||||
});
|
||||
|
||||
it("returns an empty array when neither hosts nor body are available", () => {
|
||||
// Synthetic edge case — guards the caller against attaching an observer
|
||||
// to `undefined` if the document is missing both signals. happy-dom
|
||||
// auto-fills `<body>`, so we hand-roll a minimal Document shape rather
|
||||
// than fight the runtime.
|
||||
const doc = {
|
||||
body: null,
|
||||
querySelectorAll: () => [],
|
||||
} as unknown as Document;
|
||||
|
||||
const targets = selectMediaObserverTargets(doc);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
|
||||
describe("body-fallback collision warning", () => {
|
||||
it("warns when scoped observation skips body-level timed media", () => {
|
||||
// Composition host present → scoped path. The body-level <audio data-start>
|
||||
// is outside every host subtree, so the observer would never see it.
|
||||
// This is precisely the silent-miss the warning is designed to surface.
|
||||
const doc = makeDoc(`
|
||||
<audio data-start="0" src="theme.mp3"></audio>
|
||||
<div data-composition-id="root">
|
||||
<video data-start="1" src="hero.mp4"></video>
|
||||
</div>
|
||||
`);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
selectMediaObserverTargets(doc);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const [message, orphans] = warn.mock.calls[0] ?? [];
|
||||
expect(typeof message).toBe("string");
|
||||
expect(message).toContain("body-level timed media");
|
||||
expect(Array.isArray(orphans)).toBe(true);
|
||||
expect((orphans as Element[]).map((el) => el.tagName)).toEqual(["AUDIO"]);
|
||||
});
|
||||
|
||||
it("does not warn when every body-level timed media element lives inside a host", () => {
|
||||
// Same body-level audio as above, but now nested under a composition
|
||||
// host — the scoped observer will pick it up via the host subtree, so
|
||||
// there's no silent-miss to flag.
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<audio data-start="0" src="theme.mp3"></audio>
|
||||
<video data-start="1" src="hero.mp4"></video>
|
||||
</div>
|
||||
`);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
selectMediaObserverTargets(doc);
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not warn on the body-fallback path even with orphan timed media", () => {
|
||||
// No composition hosts → fallback observer attaches to `doc.body`, which
|
||||
// already covers any body-level media. Emitting the warning here would
|
||||
// be noise on every legacy / pre-bootstrap document.
|
||||
const doc = makeDoc(`
|
||||
<audio data-start="0" src="theme.mp3"></audio>
|
||||
`);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
selectMediaObserverTargets(doc);
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores body-level audio/video that are not timed (no data-start)", () => {
|
||||
// Untimed media isn't part of the time-sync pipeline, so it doesn't
|
||||
// matter whether the observer sees it. Only `[data-start]` orphans
|
||||
// qualify as a silent miss worth surfacing.
|
||||
const doc = makeDoc(`
|
||||
<audio src="ambient.mp3"></audio>
|
||||
<div data-composition-id="root"></div>
|
||||
`);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
selectMediaObserverTargets(doc);
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits a single warn for multiple orphaned timed media elements", () => {
|
||||
// The whole point of the forensic guard is to give a single, batched
|
||||
// signal. Spamming one warn per orphan would drown out the diagnostic
|
||||
// value on documents with many late-bound clips.
|
||||
const doc = makeDoc(`
|
||||
<audio data-start="0" src="a.mp3"></audio>
|
||||
<video data-start="1" src="b.mp4"></video>
|
||||
<audio data-start="2" src="c.mp3"></audio>
|
||||
<div data-composition-id="root"></div>
|
||||
`);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
selectMediaObserverTargets(doc);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const [, orphans] = warn.mock.calls[0] ?? [];
|
||||
expect((orphans as Element[]).map((el) => el.tagName)).toEqual(["AUDIO", "VIDEO", "AUDIO"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Internal helper for scoping the player's media MutationObserver to the
|
||||
* composition tree inside the iframe.
|
||||
*
|
||||
* Not part of the package's public API — kept in its own module so the
|
||||
* decision logic can be exercised by unit tests without exposing it through
|
||||
* the player entry point.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pick the elements inside `doc` that the media MutationObserver should
|
||||
* attach to.
|
||||
*
|
||||
* Compositions mount inside `[data-composition-id]` host elements — the
|
||||
* runtime root and any sub-composition hosts that `compositionLoader` writes
|
||||
* into them. Watching only those hosts (with `subtree: true`) catches every
|
||||
* late-arriving timed media element from sub-composition activation, while
|
||||
* filtering out churn from analytics tags, runtime telemetry markers, and
|
||||
* other out-of-host nodes that the runtime appends straight to `<body>`
|
||||
* during bootstrap.
|
||||
*
|
||||
* Nested hosts are filtered out — they're already covered by their nearest
|
||||
* host ancestor's subtree observation, so observing them too would deliver
|
||||
* each callback twice and double-count adoption work.
|
||||
*
|
||||
* Falls back to `[doc.body]` when no composition hosts are present, which
|
||||
* preserves the previous behavior for documents that aren't yet (or never
|
||||
* will be) composition-structured. Returns an empty array when neither a
|
||||
* host nor a body is available — the caller should treat that as "nothing
|
||||
* to observe".
|
||||
*
|
||||
* When the scoped path is taken but the body still carries timed media
|
||||
* outside every host, a `console.warn` fires once per call as a forensic
|
||||
* signal: the new scope skips that media, so any `<audio data-start>` /
|
||||
* `<video data-start>` injected at body level will silently never get a
|
||||
* parent-frame proxy. Today every runtime path appends inside a host so
|
||||
* this branch shouldn't trip; if it does, the warn surfaces the drift
|
||||
* immediately rather than presenting as a missing-audio bug downstream.
|
||||
*/
|
||||
export function selectMediaObserverTargets(doc: Document): Element[] {
|
||||
const all = Array.from(doc.querySelectorAll<Element>("[data-composition-id]"));
|
||||
if (all.length === 0) {
|
||||
return doc.body ? [doc.body] : [];
|
||||
}
|
||||
|
||||
const topLevel: Element[] = [];
|
||||
for (const el of all) {
|
||||
if (!hasCompositionAncestor(el)) {
|
||||
topLevel.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
warnOnUnscopedTimedMedia(doc);
|
||||
return topLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forensic guard: with composition hosts present the observer attaches only
|
||||
* to those subtrees, so any timed media sitting at body level (or under a
|
||||
* non-host wrapper) is invisible to the adoption pipeline. Walk the body for
|
||||
* `[data-start]` audio/video that has no `[data-composition-id]` ancestor
|
||||
* and emit a single `console.warn` listing the orphans. The walk is cheap
|
||||
* (one `querySelectorAll` over a typed selector + a `closest` per match)
|
||||
* and only runs on the scoped path, so the no-host fallback retains its
|
||||
* legacy behavior with zero extra work.
|
||||
*/
|
||||
function warnOnUnscopedTimedMedia(doc: Document): void {
|
||||
const body = doc.body;
|
||||
if (!body) return;
|
||||
if (typeof console === "undefined" || typeof console.warn !== "function") return;
|
||||
|
||||
const candidates = body.querySelectorAll<HTMLMediaElement>(
|
||||
"audio[data-start], video[data-start]",
|
||||
);
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const orphans: HTMLMediaElement[] = [];
|
||||
for (const el of candidates) {
|
||||
if (!el.closest("[data-composition-id]")) orphans.push(el);
|
||||
}
|
||||
if (orphans.length === 0) return;
|
||||
|
||||
console.warn(
|
||||
"[hyperframes-player] selectMediaObserverTargets: composition hosts are present, " +
|
||||
`but ${orphans.length} body-level timed media element(s) sit outside every ` +
|
||||
"[data-composition-id] subtree and will not be observed. Move them inside a " +
|
||||
"composition host or the parent-frame proxy will never adopt them.",
|
||||
orphans,
|
||||
);
|
||||
}
|
||||
|
||||
function hasCompositionAncestor(el: Element): boolean {
|
||||
let cursor = el.parentElement;
|
||||
while (cursor) {
|
||||
if (cursor.hasAttribute("data-composition-id")) return true;
|
||||
cursor = cursor.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ParentMediaManager, type ProxyEntry } from "./parent-media";
|
||||
|
||||
// A fake media element whose paused state is driven by play()/pause() stubs.
|
||||
function makeFakeAudio(initiallyPaused: boolean): HTMLMediaElement {
|
||||
const el = new Audio();
|
||||
let paused = initiallyPaused;
|
||||
Object.defineProperty(el, "paused", { get: () => paused });
|
||||
el.pause = () => {
|
||||
paused = true;
|
||||
};
|
||||
el.play = () => {
|
||||
paused = false;
|
||||
return Promise.resolve();
|
||||
};
|
||||
el.src = "https://example.test/music.mp3";
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeManager(overrides: Partial<{ isPaused: boolean; owner: "runtime" | "parent" }> = {}) {
|
||||
const mgr = new ParentMediaManager({
|
||||
dispatchEvent: () => {},
|
||||
getMuted: () => false,
|
||||
getVolume: () => 1,
|
||||
getPlaybackRate: () => 1,
|
||||
getCurrentTime: () => 0,
|
||||
isPaused: () => overrides.isPaused ?? true,
|
||||
});
|
||||
return mgr;
|
||||
}
|
||||
|
||||
describe("ParentMediaManager audio-src proxy lifecycle", () => {
|
||||
it("replaces the audio-src proxy instead of stacking a second one", () => {
|
||||
const mgr = makeManager();
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
|
||||
mgr.setupFromUrl("https://example.test/b.mp3");
|
||||
// The old proxy must be gone, not accumulated alongside the new one.
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0].el.src).toBe("https://example.test/b.mp3");
|
||||
});
|
||||
|
||||
it("is a no-op when the same audio-src URL is set again", () => {
|
||||
const mgr = makeManager();
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
const first = mgr.entries[0];
|
||||
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
// Same element reference — not torn down and rebuilt.
|
||||
expect(mgr.entries[0]).toBe(first);
|
||||
});
|
||||
|
||||
it("clears the audio-src proxy on teardownUrlAudio", () => {
|
||||
const mgr = makeManager();
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
const el = mgr.entries[0].el;
|
||||
|
||||
mgr.teardownUrlAudio();
|
||||
expect(mgr.entries).toHaveLength(0);
|
||||
// The proxy's source is reset so it stops preloading.
|
||||
expect(el.src).not.toBe("https://example.test/a.mp3");
|
||||
});
|
||||
|
||||
it("teardownUrlAudio removes only the url proxy, leaving other entries", () => {
|
||||
const mgr = makeManager();
|
||||
// Simulate an iframe-adopted entry already in the pool.
|
||||
const adopted: ProxyEntry = {
|
||||
el: new Audio(),
|
||||
start: 0,
|
||||
duration: Infinity,
|
||||
driftSamples: 0,
|
||||
};
|
||||
adopted.el.src = "https://example.test/iframe-clip.mp4";
|
||||
mgr.entries.push(adopted);
|
||||
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
expect(mgr.entries).toHaveLength(2);
|
||||
|
||||
mgr.teardownUrlAudio();
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0]).toBe(adopted);
|
||||
});
|
||||
|
||||
it("teardownUrlAudio is safe to call with no audio-src set", () => {
|
||||
const mgr = makeManager();
|
||||
expect(() => mgr.teardownUrlAudio()).not.toThrow();
|
||||
expect(mgr.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("pauses a proxy once the playhead passes the clip end (trimmed clip)", () => {
|
||||
const mgr = makeManager({ owner: "parent", isPaused: false });
|
||||
const el = makeFakeAudio(false); // already playing within the clip
|
||||
mgr.entries.push({ el, start: 0, duration: 5, driftSamples: 0 });
|
||||
|
||||
mgr.mirrorTime(3); // inside [0, 5) — stays playing
|
||||
expect(el.paused).toBe(false);
|
||||
|
||||
mgr.mirrorTime(6); // past the trimmed end — must pause
|
||||
expect(el.paused).toBe(true);
|
||||
});
|
||||
|
||||
it("re-reads the source element's live data-duration so trims bound the proxy", () => {
|
||||
const mgr = makeManager({ owner: "parent", isPaused: false });
|
||||
const source = new Audio();
|
||||
source.setAttribute("data-start", "0");
|
||||
source.setAttribute("data-duration", "30");
|
||||
// jsdom reports isConnected=false unless attached; attach it.
|
||||
document.body.appendChild(source);
|
||||
|
||||
const el = makeFakeAudio(false);
|
||||
mgr.entries.push({ el, start: 0, duration: 30, driftSamples: 0, source });
|
||||
|
||||
mgr.mirrorTime(20); // within 30 → playing
|
||||
expect(el.paused).toBe(false);
|
||||
|
||||
// User trims the clip to 10s; the proxy must pick it up and pause at 20s.
|
||||
source.setAttribute("data-duration", "10");
|
||||
mgr.mirrorTime(20);
|
||||
expect(el.paused).toBe(true);
|
||||
source.remove();
|
||||
});
|
||||
|
||||
it("scrubAll plays in-window proxies at the playhead and pauses out-of-window ones", () => {
|
||||
const mgr = makeManager({ owner: "parent" });
|
||||
const inWin = makeFakeAudio(true); // currently paused — scrub should start it
|
||||
const outWin = makeFakeAudio(false); // currently playing, but outside its window
|
||||
mgr.entries.push({ el: inWin, start: 0, duration: 5, driftSamples: 0 });
|
||||
mgr.entries.push({ el: outWin, start: 10, duration: 5, driftSamples: 0 });
|
||||
|
||||
mgr.scrubAll(2); // playhead at 2s
|
||||
|
||||
// in-window proxy: positioned at rel time and AUDIBLE (the point of scrub-audio)
|
||||
expect(inWin.currentTime).toBe(2);
|
||||
expect(inWin.paused).toBe(false);
|
||||
// out-of-window proxy: paused, not blipped
|
||||
expect(outWin.paused).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate or hijack a clip the composition already owns", () => {
|
||||
const mgr = makeManager();
|
||||
// The composition already adopted a clip with this URL.
|
||||
const adopted: ProxyEntry = {
|
||||
el: new Audio(),
|
||||
start: 0,
|
||||
duration: Infinity,
|
||||
driftSamples: 0,
|
||||
};
|
||||
adopted.el.src = "https://example.test/shared.mp3";
|
||||
mgr.entries.push(adopted);
|
||||
|
||||
// Pointing audio-src at the same URL must not create a second proxy...
|
||||
mgr.setupFromUrl("https://example.test/shared.mp3");
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0]).toBe(adopted);
|
||||
|
||||
// ...and removing audio-src must not tear down the composition's own clip
|
||||
// (teardown targets the tracked proxy by reference, not by URL match).
|
||||
mgr.teardownUrlAudio();
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0]).toBe(adopted);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Parent-frame media proxy subsystem.
|
||||
*
|
||||
* Maintains mirror copies of the iframe's timed `<audio>`/`<video>` elements
|
||||
* in the parent frame so that mobile browsers — which gate `el.play()` on user
|
||||
* activation in the *same* frame — can still produce audible output via proxies
|
||||
* the parent controls directly.
|
||||
*
|
||||
* See the class-level JSDoc on `HyperframesPlayer` for the full ownership model.
|
||||
*/
|
||||
|
||||
import { selectMediaObserverTargets } from "./mediaObserverScope.js";
|
||||
import { isRealmElement, isRealmHtmlMediaElement } from "./media-element-guards.js";
|
||||
|
||||
/** Minimum absolute drift before a currentTime correction is attempted. */
|
||||
const MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05;
|
||||
|
||||
/**
|
||||
* How many *consecutive* over-threshold samples are required before issuing a
|
||||
* `currentTime` write. Absorbs single-sample jitter (GC pause, slow bridge
|
||||
* tick) without thrashing. Forced calls bypass this gate.
|
||||
*
|
||||
* Worst-case correction latency ≈ this × bridgeMaxPostIntervalMs (80 ms in
|
||||
* core/runtime/state.ts) = 160 ms — well under human A/V re-sync tolerance.
|
||||
*/
|
||||
const MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES = 2;
|
||||
|
||||
export interface ProxyEntry {
|
||||
el: HTMLMediaElement;
|
||||
start: number;
|
||||
duration: number;
|
||||
/**
|
||||
* The iframe media element this proxy mirrors, when adopted from the DOM.
|
||||
* Its `data-start`/`data-duration` are re-read each tick so live timeline
|
||||
* edits (trim/move) bound the proxy correctly. Null for URL-driven proxies.
|
||||
*/
|
||||
source?: HTMLMediaElement | null;
|
||||
/**
|
||||
* Count of consecutive steady-state samples in which the proxy's
|
||||
* `currentTime` was found drifted beyond `MIRROR_DRIFT_THRESHOLD_SECONDS`.
|
||||
* Reset on every in-threshold sample. A write is only issued once this
|
||||
* reaches `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES`, absorbing
|
||||
* single-sample jitter without thrashing.
|
||||
*/
|
||||
driftSamples: number;
|
||||
}
|
||||
|
||||
export class ParentMediaManager {
|
||||
private _entries: ProxyEntry[] = [];
|
||||
private _mediaObserver?: MutationObserver;
|
||||
private _playbackErrorPosted = false;
|
||||
private _audioOwner: "runtime" | "parent" = "runtime";
|
||||
/** The proxy created from the `audio-src` attribute, tracked so it can be
|
||||
* replaced or cleared instead of accumulating on every attribute change. */
|
||||
private _urlAudioEntry: ProxyEntry | null = null;
|
||||
private _urlAudioSrc: string | null = null;
|
||||
|
||||
private readonly _dispatchEvent: (event: Event) => void;
|
||||
private readonly _getMuted: () => boolean;
|
||||
private readonly _getVolume: () => number;
|
||||
private readonly _getPlaybackRate: () => number;
|
||||
private readonly _getCurrentTime: () => number;
|
||||
private readonly _isPaused: () => boolean;
|
||||
|
||||
constructor(opts: {
|
||||
dispatchEvent: (event: Event) => void;
|
||||
getMuted: () => boolean;
|
||||
getVolume: () => number;
|
||||
getPlaybackRate: () => number;
|
||||
getCurrentTime: () => number;
|
||||
isPaused: () => boolean;
|
||||
}) {
|
||||
this._dispatchEvent = opts.dispatchEvent;
|
||||
this._getMuted = opts.getMuted;
|
||||
this._getVolume = opts.getVolume;
|
||||
this._getPlaybackRate = opts.getPlaybackRate;
|
||||
this._getCurrentTime = opts.getCurrentTime;
|
||||
this._isPaused = opts.isPaused;
|
||||
}
|
||||
|
||||
get audioOwner(): "runtime" | "parent" {
|
||||
return this._audioOwner;
|
||||
}
|
||||
|
||||
/** Exposed for test instrumentation only — do not use in production code. */
|
||||
get entries(): ProxyEntry[] {
|
||||
return this._entries;
|
||||
}
|
||||
|
||||
resetForIframeLoad(): void {
|
||||
this._playbackErrorPosted = false;
|
||||
const wasPromoted = this._audioOwner === "parent";
|
||||
this._audioOwner = "runtime";
|
||||
this.pauseAll();
|
||||
this.teardownObserver();
|
||||
if (wasPromoted) {
|
||||
this._dispatchEvent(
|
||||
new CustomEvent("audioownershipchange", {
|
||||
detail: { owner: "runtime", reason: "iframe-reload" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.teardownObserver();
|
||||
for (const m of this._entries) {
|
||||
m.el.pause();
|
||||
m.el.src = "";
|
||||
}
|
||||
this._entries = [];
|
||||
this._urlAudioEntry = null;
|
||||
this._urlAudioSrc = null;
|
||||
}
|
||||
|
||||
updateMuted(muted: boolean): void {
|
||||
for (const m of this._entries) m.el.muted = muted;
|
||||
}
|
||||
|
||||
updateVolume(volume: number): void {
|
||||
for (const m of this._entries) m.el.volume = volume;
|
||||
}
|
||||
|
||||
updatePlaybackRate(rate: number): void {
|
||||
for (const m of this._entries) m.el.playbackRate = rate;
|
||||
}
|
||||
|
||||
private _playEntry(m: ProxyEntry): void {
|
||||
if (!m.el.src) return;
|
||||
m.el.play().catch((err: unknown) => this._reportPlaybackError(err));
|
||||
}
|
||||
|
||||
// Play only if the current playhead is inside the clip's (live) window, so
|
||||
// bulk starts (playAll / adopt) don't blip audio for clips outside their
|
||||
// window until the next mirrorTime tick gates them off.
|
||||
private _playEntryIfActive(m: ProxyEntry): void {
|
||||
this._refreshEntryBounds(m);
|
||||
const relTime = this._getCurrentTime() - m.start;
|
||||
if (relTime < 0 || relTime >= m.duration) return;
|
||||
this._playEntry(m);
|
||||
}
|
||||
|
||||
// Re-read the source clip's live timing so trims/moves bound the proxy
|
||||
// (adopt-time values go stale when the timeline is edited).
|
||||
private _refreshEntryBounds(m: ProxyEntry): void {
|
||||
if (!m.source?.isConnected) return;
|
||||
// Guard against a malformed (non-numeric) attribute parsing to NaN: an NaN
|
||||
// duration makes every `relTime >= m.duration` window check false, so the
|
||||
// gate never closes and the proxy plays past its clip end.
|
||||
const start = parseFloat(m.source.getAttribute("data-start") || "0");
|
||||
m.start = Number.isFinite(start) ? start : 0;
|
||||
const duration = parseFloat(m.source.getAttribute("data-duration") || "");
|
||||
m.duration = Number.isFinite(duration) && duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
// Pause the proxy outside its clip window; resume it on re-entry during
|
||||
// parent-owned playback. Returns whether the proxy is within the window.
|
||||
private _gateEntryPlayback(m: ProxyEntry, relTime: number): boolean {
|
||||
if (relTime < 0 || relTime >= m.duration) {
|
||||
if (!m.el.paused) m.el.pause();
|
||||
m.driftSamples = 0;
|
||||
return false;
|
||||
}
|
||||
if (this._audioOwner === "parent" && !this._isPaused() && m.el.paused) this._playEntry(m);
|
||||
return true;
|
||||
}
|
||||
|
||||
playAll(): void {
|
||||
for (const m of this._entries) this._playEntryIfActive(m);
|
||||
}
|
||||
|
||||
pauseAll(): void {
|
||||
for (const m of this._entries) m.el.pause();
|
||||
}
|
||||
|
||||
stopAdoptedMedia(): void {
|
||||
for (const m of this._entries) {
|
||||
if (m.source) m.el.pause();
|
||||
}
|
||||
}
|
||||
|
||||
seekAll(timeInSeconds: number): void {
|
||||
for (const m of this._entries) {
|
||||
// Re-read live bounds so a trim/move just before a paused scrub gates and
|
||||
// positions against the current clip window, not the adopt-time one.
|
||||
this._refreshEntryBounds(m);
|
||||
const relTime = timeInSeconds - m.start;
|
||||
if (relTime >= 0 && relTime < m.duration) m.el.currentTime = relTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Audible scrub: position every proxy at `timeInSeconds` AND play the ones whose
|
||||
// clip window covers it, so the viewer hears the track under the playhead while
|
||||
// dragging the scrubber (vs seekAll, which positions silently). Each drag move
|
||||
// re-seeks to the new position, so playback restarts from the playhead and you
|
||||
// hear the audio you're scrubbing over. The caller settles back to silence on
|
||||
// scrub end (a normal pause+seekAll). Muted proxies stay silent (play() is a no-op
|
||||
// for output). Out-of-window proxies are paused.
|
||||
scrubAll(timeInSeconds: number): void {
|
||||
for (const m of this._entries) {
|
||||
this._refreshEntryBounds(m);
|
||||
const relTime = timeInSeconds - m.start;
|
||||
if (relTime >= 0 && relTime < m.duration) {
|
||||
m.el.currentTime = relTime;
|
||||
this._playEntry(m);
|
||||
} else if (!m.el.paused) {
|
||||
m.el.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror parent-proxy `currentTime` to the iframe timeline, with optional
|
||||
* jitter-coalescing. Pass `{ force: true }` for alignment moments (ownership
|
||||
* promotion, new proxy initialization) where drift must be corrected
|
||||
* immediately.
|
||||
*/
|
||||
mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void {
|
||||
const force = options?.force === true;
|
||||
for (const m of this._entries) {
|
||||
this._refreshEntryBounds(m);
|
||||
const relTime = timelineSeconds - m.start;
|
||||
if (!this._gateEntryPlayback(m, relTime)) continue;
|
||||
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
|
||||
m.driftSamples += 1;
|
||||
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {
|
||||
m.el.currentTime = relTime;
|
||||
m.driftSamples = 0;
|
||||
}
|
||||
} else {
|
||||
m.driftSamples = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of audible playback in response to the runtime's
|
||||
* `media-autoplay-blocked` signal. Idempotent.
|
||||
*
|
||||
* The caller is responsible for muting the iframe's own media output via the
|
||||
* postMessage bridge (`set-media-output-muted`) after calling this.
|
||||
*/
|
||||
/**
|
||||
* Take ownership of audible playback. Idempotent. The `onMirror` callback
|
||||
* is called with the current timeline time and `{ force: true }` so the
|
||||
* caller's mirror implementation runs (enabling test spies on the player
|
||||
* to fire). If omitted, `mirrorTime` is called directly.
|
||||
*/
|
||||
promoteToParentProxy(
|
||||
iframeDoc: Document | null,
|
||||
onMirror?: (t: number, opts: { force: boolean }) => void,
|
||||
): void {
|
||||
if (this._audioOwner === "parent") return;
|
||||
this._audioOwner = "parent";
|
||||
|
||||
// Synchronously mute iframe media to close the race window.
|
||||
if (iframeDoc) {
|
||||
for (const el of iframeDoc.querySelectorAll("video, audio")) {
|
||||
if (isRealmHtmlMediaElement(el)) el.muted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot alignment — bypass jitter-coalescing gate.
|
||||
const t = this._getCurrentTime();
|
||||
if (onMirror) onMirror(t, { force: true });
|
||||
else this.mirrorTime(t, { force: true });
|
||||
if (!this._isPaused()) this.playAll();
|
||||
|
||||
this._dispatchEvent(
|
||||
new CustomEvent("audioownershipchange", {
|
||||
detail: { owner: "parent", reason: "autoplay-blocked" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up proxies for all timed media currently in the iframe document, then
|
||||
* install a MutationObserver for media added later (sub-composition activation).
|
||||
*/
|
||||
setupFromIframe(iframeDoc: Document): void {
|
||||
const mediaEls = iframeDoc.querySelectorAll("audio[data-start], video[data-start]");
|
||||
for (const iframeEl of mediaEls) {
|
||||
if (isRealmHtmlMediaElement(iframeEl)) this._adoptIframeMedia(iframeEl);
|
||||
}
|
||||
this._observeDynamicMedia(iframeDoc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or replace) the parent-frame audio proxy driven by the `audio-src`
|
||||
* attribute. Re-setting with a different URL tears down the previous proxy
|
||||
* first, so changing `audio-src` swaps the track instead of stacking a
|
||||
* second one that keeps preloading and plays in parallel.
|
||||
*/
|
||||
setupFromUrl(audioSrc: string): void {
|
||||
if (this._urlAudioSrc === audioSrc && this._urlAudioEntry) return;
|
||||
this.teardownUrlAudio();
|
||||
const entry = this._createEntry(audioSrc, "audio", 0, Infinity);
|
||||
// `_createEntry` returns null when a proxy for this URL already exists
|
||||
// (e.g. the composition already adopted the same media). In that case we do
|
||||
// not own a proxy, so leave the tracking cleared rather than recording a
|
||||
// src with no entry — otherwise teardown would target nothing and the
|
||||
// no-op guard would never engage.
|
||||
this._urlAudioEntry = entry;
|
||||
this._urlAudioSrc = entry ? audioSrc : null;
|
||||
// If the parent already owns playback, bring the fresh proxy online so a
|
||||
// mid-playback swap is not silent until the next play tick.
|
||||
if (entry && this._audioOwner === "parent" && !this._isPaused()) {
|
||||
this.mirrorTime(this._getCurrentTime(), { force: true });
|
||||
this.playAll();
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the `audio-src` proxy (used when the attribute is removed). */
|
||||
teardownUrlAudio(): void {
|
||||
const entry = this._urlAudioEntry;
|
||||
this._urlAudioEntry = null;
|
||||
this._urlAudioSrc = null;
|
||||
if (!entry) return;
|
||||
entry.el.pause();
|
||||
entry.el.src = "";
|
||||
const idx = this._entries.indexOf(entry);
|
||||
if (idx !== -1) this._entries.splice(idx, 1);
|
||||
}
|
||||
|
||||
teardownObserver(): void {
|
||||
this._mediaObserver?.disconnect();
|
||||
this._mediaObserver = undefined;
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private _reportPlaybackError(err: unknown): void {
|
||||
if (this._playbackErrorPosted) return;
|
||||
this._playbackErrorPosted = true;
|
||||
this._dispatchEvent(
|
||||
new CustomEvent("playbackerror", { detail: { source: "parent-proxy", error: err } }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a parent-frame media element and start preloading it. Returns the
|
||||
* new entry, or `null` if a proxy for this src already exists (dedup).
|
||||
*/
|
||||
private _createEntry(
|
||||
src: string,
|
||||
tag: "audio" | "video",
|
||||
start: number,
|
||||
duration: number,
|
||||
source?: HTMLMediaElement | null,
|
||||
): ProxyEntry | null {
|
||||
if (this._entries.some((m) => m.el.src === src)) return null;
|
||||
|
||||
const el = tag === "video" ? document.createElement("video") : new Audio();
|
||||
el.preload = "auto";
|
||||
el.src = src;
|
||||
el.load();
|
||||
el.muted = this._getMuted();
|
||||
el.volume = this._getVolume();
|
||||
const rate = this._getPlaybackRate();
|
||||
if (rate !== 1) el.playbackRate = rate;
|
||||
|
||||
const entry: ProxyEntry = { el, start, duration, driftSamples: 0, source };
|
||||
this._entries.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Resolve an iframe media element's source to an absolute URL, or null. */
|
||||
private _resolveIframeMediaSrc(iframeEl: HTMLMediaElement): string | null {
|
||||
const rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
return rawSrc ? new URL(rawSrc, iframeEl.ownerDocument.baseURI).href : null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
|
||||
// Skip elements the preloader has demoted — the observer will re-trigger
|
||||
// when the preload attribute is promoted to "auto".
|
||||
if (iframeEl.preload === "metadata" || iframeEl.preload === "none") return;
|
||||
|
||||
const src = this._resolveIframeMediaSrc(iframeEl);
|
||||
if (!src) return;
|
||||
|
||||
const start = parseFloat(iframeEl.getAttribute("data-start") || "0");
|
||||
const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity");
|
||||
const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const);
|
||||
|
||||
const created = this._createEntry(src, tag, start, duration, iframeEl);
|
||||
|
||||
// If already under parent ownership and playing, the new proxy must catch
|
||||
// up immediately — bypass the jitter-coalescing gate.
|
||||
if (created && this._audioOwner === "parent") {
|
||||
this.mirrorTime(this._getCurrentTime(), { force: true });
|
||||
if (!this._isPaused()) this._playEntryIfActive(created);
|
||||
}
|
||||
}
|
||||
|
||||
private _detachIframeMedia(iframeEl: HTMLMediaElement): void {
|
||||
const src = this._resolveIframeMediaSrc(iframeEl);
|
||||
if (!src) return;
|
||||
const idx = this._entries.findIndex((m) => m.el.src === src);
|
||||
if (idx === -1) return;
|
||||
const entry = this._entries[idx];
|
||||
entry.el.pause();
|
||||
entry.el.src = "";
|
||||
this._entries.splice(idx, 1);
|
||||
}
|
||||
|
||||
private _observeDynamicMedia(doc: Document): void {
|
||||
this.teardownObserver();
|
||||
if (typeof MutationObserver === "undefined" || !doc.body) return;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.type === "attributes" && m.attributeName === "preload") {
|
||||
const target = m.target;
|
||||
if (
|
||||
isRealmHtmlMediaElement(target) &&
|
||||
target.matches("audio[data-start], video[data-start]") &&
|
||||
target.preload === "auto"
|
||||
) {
|
||||
this._adoptIframeMedia(target);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const added of m.addedNodes) {
|
||||
if (!isRealmElement(added)) continue;
|
||||
const candidates: HTMLMediaElement[] = [];
|
||||
if (
|
||||
isRealmHtmlMediaElement(added) &&
|
||||
added.matches("audio[data-start], video[data-start]")
|
||||
) {
|
||||
candidates.push(added);
|
||||
}
|
||||
const inside = added.querySelectorAll("audio[data-start], video[data-start]");
|
||||
for (const el of inside) {
|
||||
if (isRealmHtmlMediaElement(el)) candidates.push(el);
|
||||
}
|
||||
for (const el of candidates) this._adoptIframeMedia(el);
|
||||
}
|
||||
|
||||
for (const removed of m.removedNodes) {
|
||||
if (!isRealmElement(removed)) continue;
|
||||
const dropped: HTMLMediaElement[] = [];
|
||||
if (
|
||||
isRealmHtmlMediaElement(removed) &&
|
||||
removed.matches("audio[data-start], video[data-start]")
|
||||
) {
|
||||
dropped.push(removed);
|
||||
}
|
||||
const inside = removed.querySelectorAll("audio[data-start], video[data-start]");
|
||||
for (const el of inside) {
|
||||
if (isRealmHtmlMediaElement(el)) dropped.push(el);
|
||||
}
|
||||
for (const el of dropped) this._detachIframeMedia(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const observeOpts: MutationObserverInit = {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["preload"],
|
||||
};
|
||||
|
||||
const targets = selectMediaObserverTargets(doc);
|
||||
for (const target of targets) {
|
||||
obs.observe(target, observeOpts);
|
||||
}
|
||||
this._mediaObserver = obs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Pure playback-state update logic for the `state` message from the runtime.
|
||||
*
|
||||
* Extracted from the web component so the state-transition rules — loop
|
||||
* handling, play/pause mirroring, completion detection — can be read and
|
||||
* exercised independently.
|
||||
*/
|
||||
|
||||
import type { ParentMediaManager } from "./parent-media.js";
|
||||
|
||||
const UI_UPDATE_INTERVAL_MS = 100;
|
||||
|
||||
export interface PlaybackState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
paused: boolean;
|
||||
lastUpdateMs: number;
|
||||
}
|
||||
|
||||
export interface PlaybackStateCallbacks {
|
||||
updateControlsTime: (current: number, duration: number) => void;
|
||||
updateControlsPlaying: (playing: boolean) => void;
|
||||
dispatchEvent: (event: Event) => void;
|
||||
seek: (t: number) => void;
|
||||
play: () => void;
|
||||
getLoop: () => boolean;
|
||||
media: ParentMediaManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a `state` message from the runtime and return the next state.
|
||||
* Side effects (controls updates, events, media mirroring) are fired through
|
||||
* `callbacks`. The caller must commit the returned state object.
|
||||
*/
|
||||
export function applyRuntimeStateMessage(
|
||||
data: { frame: number; isPlaying: boolean },
|
||||
fps: number,
|
||||
current: PlaybackState,
|
||||
callbacks: PlaybackStateCallbacks,
|
||||
): PlaybackState {
|
||||
const rawTime = (data.frame ?? 0) / fps;
|
||||
const currentTime = current.duration > 0 ? Math.min(rawTime, current.duration) : rawTime;
|
||||
const wasPlaying = !current.paused;
|
||||
const nextPaused = !data.isPlaying;
|
||||
const completedPlayback =
|
||||
current.duration > 0 && currentTime >= current.duration && (wasPlaying || data.isPlaying);
|
||||
|
||||
if (completedPlayback && callbacks.getLoop()) {
|
||||
if (callbacks.media.audioOwner === "parent") callbacks.media.pauseAll();
|
||||
callbacks.seek(0);
|
||||
callbacks.play();
|
||||
// play() sets paused=false; reflect that in the returned state so the
|
||||
// caller's destructure doesn't overwrite it with the stale nextPaused value.
|
||||
return { ...current, currentTime, paused: false };
|
||||
}
|
||||
|
||||
const next: PlaybackState = { ...current, currentTime, paused: nextPaused };
|
||||
|
||||
if (callbacks.media.audioOwner === "parent") {
|
||||
if (wasPlaying && nextPaused) {
|
||||
callbacks.media.pauseAll();
|
||||
} else if (!wasPlaying && !nextPaused) {
|
||||
callbacks.media.playAll();
|
||||
}
|
||||
callbacks.media.mirrorTime(currentTime);
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const playStateChanged = nextPaused !== current.paused;
|
||||
if (now - current.lastUpdateMs > UI_UPDATE_INTERVAL_MS || playStateChanged) {
|
||||
next.lastUpdateMs = now;
|
||||
callbacks.updateControlsTime(currentTime, current.duration);
|
||||
callbacks.updateControlsPlaying(!nextPaused);
|
||||
callbacks.dispatchEvent(new CustomEvent("timeupdate", { detail: { currentTime } }));
|
||||
}
|
||||
|
||||
if (completedPlayback) {
|
||||
if (callbacks.media.audioOwner === "parent") callbacks.media.pauseAll();
|
||||
next.paused = true;
|
||||
callbacks.updateControlsPlaying(false);
|
||||
callbacks.dispatchEvent(new Event("ended"));
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { handleRuntimeMessage, type MessageHandlerCallbacks } from "./runtime-message-handler.js";
|
||||
import type { ParentMediaManager } from "./parent-media.js";
|
||||
import type { ShaderLoaderState } from "./shader-loader-state.js";
|
||||
|
||||
// Only the stage-size branch is exercised here; the rest of the callback
|
||||
// surface is satisfied with inert spies so the handler's type contract
|
||||
// stays honest without pulling in the real player.
|
||||
const makeCallbacks = (): MessageHandlerCallbacks => ({
|
||||
updateControlsTime: vi.fn(),
|
||||
updateControlsPlaying: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
onRuntimeReady: vi.fn(),
|
||||
onRuntimeTimelineReady: vi.fn(),
|
||||
seek: vi.fn(),
|
||||
play: vi.fn(),
|
||||
getLoop: vi.fn(() => false),
|
||||
media: { mirrorTime: vi.fn(), promoteToParentProxy: vi.fn() } as unknown as ParentMediaManager,
|
||||
getPlaybackState: vi.fn(() => ({ currentTime: 0, duration: 0, paused: true, lastUpdateMs: 0 })),
|
||||
setPlaybackState: vi.fn(),
|
||||
setScenes: vi.fn(),
|
||||
getShaderLoadingMode: vi.fn(() => "auto"),
|
||||
shaderLoader: { update: vi.fn() } as unknown as ShaderLoaderState,
|
||||
setCompositionSize: vi.fn(),
|
||||
sendControl: vi.fn(),
|
||||
getIframeDoc: vi.fn(() => null),
|
||||
});
|
||||
|
||||
const stageSizeEvent = (width: unknown, height: unknown, source: object): MessageEvent =>
|
||||
({
|
||||
source,
|
||||
data: { source: "hf-preview", type: "stage-size", width, height },
|
||||
}) as unknown as MessageEvent;
|
||||
|
||||
describe("handleRuntimeMessage stage-size", () => {
|
||||
it("applies a finite positive stage size", () => {
|
||||
const frameWindow = {} as Window;
|
||||
const callbacks = makeCallbacks();
|
||||
|
||||
handleRuntimeMessage(stageSizeEvent(1280, 720, frameWindow), frameWindow, callbacks);
|
||||
|
||||
expect(callbacks.setCompositionSize).toHaveBeenCalledWith(1280, 720);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Infinity width", Infinity, 720],
|
||||
["Infinity height", 1280, Infinity],
|
||||
["NaN width", NaN, 720],
|
||||
["zero width", 0, 720],
|
||||
["negative height", 1280, -720],
|
||||
["string width", "1280", 720],
|
||||
])("ignores stage-size with %s", (_label, width, height) => {
|
||||
const frameWindow = {} as Window;
|
||||
const callbacks = makeCallbacks();
|
||||
|
||||
handleRuntimeMessage(stageSizeEvent(width, height, frameWindow), frameWindow, callbacks);
|
||||
|
||||
expect(callbacks.setCompositionSize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores messages from a different source window", () => {
|
||||
const frameWindow = {} as Window;
|
||||
const callbacks = makeCallbacks();
|
||||
|
||||
handleRuntimeMessage(stageSizeEvent(1280, 720, {}), frameWindow, callbacks);
|
||||
|
||||
expect(callbacks.setCompositionSize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleRuntimeMessage media autoplay fallback", () => {
|
||||
const autoplayBlockedEvent = (source: object): MessageEvent =>
|
||||
({
|
||||
source,
|
||||
data: { source: "hf-preview", type: "media-autoplay-blocked" },
|
||||
}) as unknown as MessageEvent;
|
||||
|
||||
it("promotes and mutes iframe output by default", () => {
|
||||
const frameWindow = {} as Window;
|
||||
const callbacks = makeCallbacks();
|
||||
|
||||
handleRuntimeMessage(autoplayBlockedEvent(frameWindow), frameWindow, callbacks);
|
||||
|
||||
expect(callbacks.media.promoteToParentProxy).toHaveBeenCalled();
|
||||
expect(callbacks.sendControl).toHaveBeenCalledWith("set-media-output-muted", {
|
||||
muted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not promote or mute iframe output when the host vetoes the fallback", () => {
|
||||
const frameWindow = {} as Window;
|
||||
const callbacks = {
|
||||
...makeCallbacks(),
|
||||
shouldPromoteMediaAutoplayFallback: vi.fn(() => false),
|
||||
};
|
||||
|
||||
handleRuntimeMessage(autoplayBlockedEvent(frameWindow), frameWindow, callbacks);
|
||||
|
||||
expect(callbacks.shouldPromoteMediaAutoplayFallback).toHaveBeenCalled();
|
||||
expect(callbacks.media.promoteToParentProxy).not.toHaveBeenCalled();
|
||||
expect(callbacks.sendControl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleRuntimeMessage timeline ready", () => {
|
||||
const timelineEvent = (durationInFrames: unknown, source: object): MessageEvent =>
|
||||
({
|
||||
source,
|
||||
data: { source: "hf-preview", type: "timeline", durationInFrames, scenes: [] },
|
||||
}) as unknown as MessageEvent;
|
||||
|
||||
it("reports a finite positive timeline duration in seconds", () => {
|
||||
const frameWindow = {} as Window;
|
||||
const callbacks = makeCallbacks();
|
||||
|
||||
handleRuntimeMessage(timelineEvent(120, frameWindow), frameWindow, callbacks);
|
||||
|
||||
expect(callbacks.onRuntimeTimelineReady).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it("does not report invalid timeline durations as ready", () => {
|
||||
const frameWindow = {} as Window;
|
||||
const callbacks = makeCallbacks();
|
||||
|
||||
handleRuntimeMessage(timelineEvent(Infinity, frameWindow), frameWindow, callbacks);
|
||||
|
||||
expect(callbacks.onRuntimeTimelineReady).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Routes postMessages from the composition iframe to the appropriate handlers.
|
||||
*
|
||||
* Accepts the raw MessageEvent and delegates through typed callbacks so the
|
||||
* web component keeps its state fields private and this module stays stateless.
|
||||
*/
|
||||
|
||||
import {
|
||||
applyRuntimeStateMessage,
|
||||
type PlaybackState,
|
||||
type PlaybackStateCallbacks,
|
||||
} from "./playback-state.js";
|
||||
import type { ShaderLoaderState } from "./shader-loader-state.js";
|
||||
import type { ShaderTransitionState } from "./shader-options.js";
|
||||
|
||||
const FPS = 30;
|
||||
|
||||
type SceneRecord = { id: string; start: number; duration: number };
|
||||
|
||||
function extractScenes(raw: unknown): SceneRecord[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(
|
||||
(s): s is SceneRecord =>
|
||||
typeof s === "object" &&
|
||||
s !== null &&
|
||||
typeof (s as Record<string, unknown>)["id"] === "string" &&
|
||||
typeof (s as Record<string, unknown>)["start"] === "number" &&
|
||||
typeof (s as Record<string, unknown>)["duration"] === "number",
|
||||
);
|
||||
}
|
||||
|
||||
export interface MessageHandlerCallbacks extends PlaybackStateCallbacks {
|
||||
getPlaybackState: () => PlaybackState;
|
||||
setPlaybackState: (next: PlaybackState) => void;
|
||||
getShaderLoadingMode: () => string;
|
||||
shaderLoader: ShaderLoaderState;
|
||||
setCompositionSize: (width: number, height: number) => void;
|
||||
sendControl: (action: string, extra?: Record<string, unknown>) => void;
|
||||
getIframeDoc: () => Document | null;
|
||||
/** Invoked when the iframe runtime posts `{type: "ready"}` — the player
|
||||
* uses it to replay current bridge state (mute, volume, playback rate) so
|
||||
* control messages sent before the iframe's listener registered aren't lost. */
|
||||
onRuntimeReady: () => void;
|
||||
/** Invoked when the runtime posts a finite positive timeline duration. The
|
||||
* player uses this as the cross-origin readiness signal because the
|
||||
* same-origin composition probe cannot inspect CDN iframes. */
|
||||
onRuntimeTimelineReady: (duration: number) => void;
|
||||
/** Called with the scene list whenever a "timeline" message is received. */
|
||||
setScenes: (scenes: SceneRecord[]) => void;
|
||||
/** Return false to ignore the iframe runtime's audible-media autoplay fallback.
|
||||
* Slideshow embeds keep iframe media under native element ownership because
|
||||
* presenter/audience sync mirrors those media events directly. */
|
||||
shouldPromoteMediaAutoplayFallback?: () => boolean;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function handleRuntimeMessage(
|
||||
event: MessageEvent,
|
||||
frameWindow: Window | null,
|
||||
callbacks: MessageHandlerCallbacks,
|
||||
): void {
|
||||
if (event.source !== frameWindow) return;
|
||||
const data = event.data as Record<string, unknown> | undefined;
|
||||
if (!data || data["source"] !== "hf-preview") return;
|
||||
|
||||
if (data["type"] === "shader-transition-state") {
|
||||
const state: ShaderTransitionState =
|
||||
data["state"] && typeof data["state"] === "object"
|
||||
? (data["state"] as ShaderTransitionState)
|
||||
: {};
|
||||
callbacks.shaderLoader.update(state, callbacks.getShaderLoadingMode());
|
||||
callbacks.dispatchEvent(
|
||||
new CustomEvent("shadertransitionstate", {
|
||||
detail: { compositionId: data["compositionId"], state },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data["type"] === "ready") {
|
||||
callbacks.onRuntimeReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data["type"] === "state") {
|
||||
callbacks.setPlaybackState(
|
||||
applyRuntimeStateMessage(
|
||||
{ frame: (data["frame"] as number) ?? 0, isPlaying: !!data["isPlaying"] },
|
||||
FPS,
|
||||
callbacks.getPlaybackState(),
|
||||
callbacks,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data["type"] === "media-autoplay-blocked") {
|
||||
if (callbacks.shouldPromoteMediaAutoplayFallback?.() === false) return;
|
||||
let iframeDoc: Document | null = null;
|
||||
try {
|
||||
iframeDoc = callbacks.getIframeDoc();
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
callbacks.media.promoteToParentProxy(iframeDoc, (t, opts) =>
|
||||
callbacks.media.mirrorTime(t, opts),
|
||||
);
|
||||
callbacks.sendControl("set-media-output-muted", { muted: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data["type"] === "timeline" && (data["durationInFrames"] as number) > 0) {
|
||||
if (Number.isFinite(data["durationInFrames"])) {
|
||||
const pb = callbacks.getPlaybackState();
|
||||
const duration = (data["durationInFrames"] as number) / FPS;
|
||||
callbacks.setPlaybackState({ ...pb, duration });
|
||||
callbacks.updateControlsTime(pb.currentTime, duration);
|
||||
callbacks.onRuntimeTimelineReady(duration);
|
||||
}
|
||||
callbacks.setScenes(extractScenes(data["scenes"]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
data["type"] === "stage-size" &&
|
||||
// Finite-check like the timeline branch above: `> 0` alone lets
|
||||
// Infinity through, which scales the iframe to 0 and blanks it.
|
||||
Number.isFinite(data["width"]) &&
|
||||
(data["width"] as number) > 0 &&
|
||||
Number.isFinite(data["height"]) &&
|
||||
(data["height"] as number) > 0
|
||||
) {
|
||||
callbacks.setCompositionSize(data["width"] as number, data["height"] as number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Factory for the shader-transition loading overlay DOM tree.
|
||||
*
|
||||
* Kept in its own module so the ~100-line DOM construction stays out of the
|
||||
* web component class body. The returned `ShaderLoaderElements` bag gives the
|
||||
* component direct handles to the nodes it needs to update without querying
|
||||
* the shadow DOM on every state change.
|
||||
*/
|
||||
|
||||
import { SHADER_LOADING_PHRASES } from "./shader-options.js";
|
||||
|
||||
export interface ShaderLoaderElements {
|
||||
root: HTMLDivElement;
|
||||
fill: HTMLDivElement;
|
||||
title: HTMLSpanElement;
|
||||
detail: HTMLDivElement;
|
||||
transitionValue: HTMLSpanElement;
|
||||
frameLabel: HTMLSpanElement;
|
||||
frameValue: HTMLSpanElement;
|
||||
frameRow: HTMLDivElement;
|
||||
}
|
||||
|
||||
export function createShaderLoader(): ShaderLoaderElements {
|
||||
const root = document.createElement("div");
|
||||
root.className = "hfp-shader-loader";
|
||||
root.setAttribute("role", "status");
|
||||
root.setAttribute("aria-live", "polite");
|
||||
root.setAttribute("aria-label", "Preparing scene transitions");
|
||||
root.setAttribute("data-hyperframes-ignore", "");
|
||||
root.draggable = false;
|
||||
|
||||
const blockOverlayInteraction = (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
for (const eventName of [
|
||||
"selectstart",
|
||||
"dragstart",
|
||||
"pointerdown",
|
||||
"mousedown",
|
||||
"click",
|
||||
"dblclick",
|
||||
"contextmenu",
|
||||
"touchstart",
|
||||
]) {
|
||||
root.addEventListener(eventName, blockOverlayInteraction, { capture: true });
|
||||
}
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "hfp-shader-loader-panel";
|
||||
panel.draggable = false;
|
||||
|
||||
const markFrame = document.createElement("div");
|
||||
markFrame.className = "hfp-shader-loader-mark";
|
||||
markFrame.draggable = false;
|
||||
markFrame.innerHTML = [
|
||||
'<svg width="78" height="78" viewBox="0 0 100 100" fill="none" aria-hidden="true" draggable="false">',
|
||||
'<path d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z" fill="url(#hfp-shader-loader-grad-left)"/>',
|
||||
'<path d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z" fill="url(#hfp-shader-loader-grad-right)"/>',
|
||||
"<defs>",
|
||||
'<linearGradient id="hfp-shader-loader-grad-left" x1="48.5676" y1="25" x2="44.7804" y2="71.9384" gradientUnits="userSpaceOnUse">',
|
||||
'<stop stop-color="#06E3FA"/>',
|
||||
'<stop offset="1" stop-color="#4FDB5E"/>',
|
||||
"</linearGradient>",
|
||||
'<linearGradient id="hfp-shader-loader-grad-right" x1="54.8282" y1="73.8392" x2="72.0989" y2="32.8932" gradientUnits="userSpaceOnUse">',
|
||||
'<stop stop-color="#06E3FA"/>',
|
||||
'<stop offset="1" stop-color="#4FDB5E"/>',
|
||||
"</linearGradient>",
|
||||
"</defs>",
|
||||
"</svg>",
|
||||
].join("");
|
||||
|
||||
const titleContainer = document.createElement("div");
|
||||
titleContainer.className = "hfp-shader-loader-title";
|
||||
const titleText = document.createElement("span");
|
||||
titleText.className = "hfp-shader-loader-title-text";
|
||||
titleText.textContent = SHADER_LOADING_PHRASES[0] || "Preparing scene transitions";
|
||||
titleContainer.appendChild(titleText);
|
||||
|
||||
const detail = document.createElement("div");
|
||||
detail.className = "hfp-shader-loader-detail";
|
||||
detail.textContent = "Rendering animated scene samples for shader transitions.";
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "hfp-shader-loader-track";
|
||||
track.setAttribute("aria-hidden", "true");
|
||||
const fill = document.createElement("div");
|
||||
fill.className = "hfp-shader-loader-fill";
|
||||
track.appendChild(fill);
|
||||
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "hfp-shader-loader-progress";
|
||||
const createProgressRow = (labelText: string) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "hfp-shader-loader-row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "hfp-shader-loader-label";
|
||||
label.textContent = labelText;
|
||||
const value = document.createElement("span");
|
||||
value.className = "hfp-shader-loader-value";
|
||||
row.appendChild(label);
|
||||
row.appendChild(value);
|
||||
progress.appendChild(row);
|
||||
return { row, label, value };
|
||||
};
|
||||
const transitionStatus = createProgressRow("transition");
|
||||
const frameStatus = createProgressRow("transition frame");
|
||||
|
||||
panel.appendChild(markFrame);
|
||||
panel.appendChild(titleContainer);
|
||||
panel.appendChild(detail);
|
||||
panel.appendChild(track);
|
||||
panel.appendChild(progress);
|
||||
root.appendChild(panel);
|
||||
|
||||
return {
|
||||
root,
|
||||
fill,
|
||||
title: titleText,
|
||||
detail,
|
||||
transitionValue: transitionStatus.value,
|
||||
frameLabel: frameStatus.label,
|
||||
frameValue: frameStatus.value,
|
||||
frameRow: frameStatus.row,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Runtime state controller for the shader-transition loading overlay.
|
||||
*
|
||||
* Manages show/hide transitions (with a CSS fade-out delay) and updates
|
||||
* the progress bar, phrase text, and detail rows from `ShaderTransitionState`
|
||||
* messages received from the iframe.
|
||||
*
|
||||
* Holds direct references to the DOM nodes created by `createShaderLoader`
|
||||
* so state updates never touch the shadow-DOM query API at runtime.
|
||||
*/
|
||||
|
||||
import { SHADER_LOADING_PHRASES, type ShaderTransitionState } from "./shader-options.js";
|
||||
import type { ShaderLoaderElements } from "./shader-loader-element.js";
|
||||
|
||||
const HIDE_TRANSITION_MS = 420;
|
||||
|
||||
export class ShaderLoaderState {
|
||||
private readonly _el: ShaderLoaderElements;
|
||||
private _hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(elements: ShaderLoaderElements) {
|
||||
this._el = elements;
|
||||
}
|
||||
|
||||
show(): void {
|
||||
if (this._hideTimeout) {
|
||||
clearTimeout(this._hideTimeout);
|
||||
this._hideTimeout = null;
|
||||
}
|
||||
this._el.root.classList.remove("hfp-hiding");
|
||||
this._el.root.classList.add("hfp-visible");
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (this._el.root.classList.contains("hfp-hiding")) {
|
||||
if (!this._hideTimeout) this._scheduleCleanup();
|
||||
return;
|
||||
}
|
||||
if (!this._el.root.classList.contains("hfp-visible")) return;
|
||||
this._el.root.classList.add("hfp-hiding");
|
||||
this._el.root.classList.remove("hfp-visible");
|
||||
this._scheduleCleanup();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (this._hideTimeout) {
|
||||
clearTimeout(this._hideTimeout);
|
||||
this._hideTimeout = null;
|
||||
}
|
||||
this._el.root.classList.remove("hfp-visible", "hfp-hiding");
|
||||
this._el.fill.style.transform = "scaleX(0)";
|
||||
this._el.transitionValue.textContent = "";
|
||||
this._el.frameValue.textContent = "";
|
||||
this._el.frameRow.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
update(status: ShaderTransitionState, loadingMode: string): void {
|
||||
if (loadingMode !== "player") {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
if (status.ready || !status.loading) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const progress =
|
||||
typeof status.progress === "number" && Number.isFinite(status.progress) ? status.progress : 0;
|
||||
const total =
|
||||
typeof status.total === "number" && Number.isFinite(status.total) ? status.total : 0;
|
||||
const ratio = total > 0 ? Math.min(1, Math.max(0, progress / total)) : 0;
|
||||
|
||||
const phraseIndex = Math.min(
|
||||
SHADER_LOADING_PHRASES.length - 1,
|
||||
Math.floor(ratio * SHADER_LOADING_PHRASES.length),
|
||||
);
|
||||
this._el.title.textContent =
|
||||
SHADER_LOADING_PHRASES[phraseIndex] || "Preparing scene transitions";
|
||||
|
||||
this._el.detail.textContent =
|
||||
status.phase === "cached"
|
||||
? "Loading cached transition frames before playback."
|
||||
: status.phase === "finalizing"
|
||||
? "Uploading transition textures for smooth playback."
|
||||
: "Rendering animated scene samples for shader transitions.";
|
||||
|
||||
this._el.fill.style.transform = `scaleX(${ratio})`;
|
||||
|
||||
this._el.transitionValue.textContent =
|
||||
status.currentTransition !== undefined && status.transitionTotal !== undefined
|
||||
? `${status.currentTransition}/${status.transitionTotal}`
|
||||
: total > 0
|
||||
? `${progress}/${total}`
|
||||
: "";
|
||||
|
||||
const frameValue =
|
||||
status.transitionFrame !== undefined && status.transitionFrames !== undefined
|
||||
? `${status.transitionFrame}/${status.transitionFrames}`
|
||||
: "";
|
||||
|
||||
this._el.frameLabel.textContent =
|
||||
status.phase === "cached"
|
||||
? "cached transition frames"
|
||||
: status.phase === "finalizing"
|
||||
? "finalizing transition frames"
|
||||
: "rendering transition frames";
|
||||
|
||||
this._el.frameValue.textContent = frameValue;
|
||||
this._el.frameRow.style.visibility = frameValue ? "visible" : "hidden";
|
||||
this._el.root.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
|
||||
this.show();
|
||||
}
|
||||
|
||||
get hideTimeout(): ReturnType<typeof setTimeout> | null {
|
||||
return this._hideTimeout;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this._hideTimeout) {
|
||||
clearTimeout(this._hideTimeout);
|
||||
this._hideTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleCleanup(): void {
|
||||
if (this._hideTimeout) clearTimeout(this._hideTimeout);
|
||||
this._hideTimeout = setTimeout(() => {
|
||||
this._el.root.classList.remove("hfp-hiding");
|
||||
this._hideTimeout = null;
|
||||
}, HIDE_TRANSITION_MS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Shader transition option types, constants, and pure helper functions for
|
||||
* injecting shader capture scale and loading mode parameters into composition
|
||||
* URLs and srcdoc HTML.
|
||||
*/
|
||||
|
||||
export const SHADER_CAPTURE_SCALE_ATTR = "shader-capture-scale";
|
||||
export const SHADER_LOADING_ATTR = "shader-loading";
|
||||
const SHADER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale";
|
||||
const SHADER_LOADING_PARAM = "__hf_shader_loading";
|
||||
|
||||
export const SHADER_LOADING_PHRASES = [
|
||||
"Preparing scene transitions",
|
||||
"Sampling outgoing scene motion",
|
||||
"Sampling incoming scene motion",
|
||||
"Caching transition frames",
|
||||
"Finalizing transition preview",
|
||||
];
|
||||
|
||||
export type ShaderLoadingMode = "composition" | "player" | "none";
|
||||
|
||||
export interface ShaderTransitionState {
|
||||
ready?: boolean;
|
||||
progress?: number;
|
||||
total?: number;
|
||||
currentTransition?: number;
|
||||
transitionTotal?: number;
|
||||
transitionFrame?: number;
|
||||
transitionFrames?: number;
|
||||
phase?: "cached" | "capturing" | "finalizing";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function normalizeShaderCaptureScale(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return String(Math.min(1, Math.max(0.25, parsed)));
|
||||
}
|
||||
|
||||
function normalizeShaderLoadingMode(value: string | null): ShaderLoadingMode {
|
||||
if (value === null || value.trim() === "") return "composition";
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "none" ||
|
||||
normalized === "false" ||
|
||||
normalized === "0" ||
|
||||
normalized === "off"
|
||||
) {
|
||||
return "none";
|
||||
}
|
||||
if (
|
||||
normalized === "player" ||
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "on"
|
||||
) {
|
||||
return "player";
|
||||
}
|
||||
return "composition";
|
||||
}
|
||||
|
||||
function setQueryParam(params: URLSearchParams, key: string, value: string | null): void {
|
||||
if (value === null) params.delete(key);
|
||||
else params.set(key, value);
|
||||
}
|
||||
|
||||
function withShaderQueryParams(
|
||||
src: string,
|
||||
scale: string | null,
|
||||
loadingMode: ShaderLoadingMode,
|
||||
): string {
|
||||
const hashIndex = src.indexOf("#");
|
||||
const beforeHash = hashIndex >= 0 ? src.slice(0, hashIndex) : src;
|
||||
const hash = hashIndex >= 0 ? src.slice(hashIndex) : "";
|
||||
const queryIndex = beforeHash.indexOf("?");
|
||||
const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
|
||||
const query = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : "";
|
||||
const params = new URLSearchParams(query);
|
||||
setQueryParam(params, SHADER_CAPTURE_SCALE_PARAM, scale);
|
||||
setQueryParam(params, SHADER_LOADING_PARAM, loadingMode === "composition" ? null : loadingMode);
|
||||
const nextQuery = params.toString();
|
||||
return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`;
|
||||
}
|
||||
|
||||
function injectShaderOptionsIntoSrcdoc(
|
||||
html: string,
|
||||
scale: string | null,
|
||||
loadingMode: ShaderLoadingMode,
|
||||
): string {
|
||||
if (scale === null && loadingMode === "composition") return html;
|
||||
const lines: string[] = [];
|
||||
if (scale !== null) lines.push(`window.__HF_SHADER_CAPTURE_SCALE=${JSON.stringify(scale)};`);
|
||||
if (loadingMode !== "composition") {
|
||||
lines.push(`window.__HF_SHADER_LOADING=${JSON.stringify(loadingMode)};`);
|
||||
}
|
||||
const script = `<script data-hyperframes-player-shader-options>${lines.join("")}</script>`;
|
||||
if (/<head\b[^>]*>/i.test(html))
|
||||
return html.replace(/<head\b[^>]*>/i, (match) => `${match}${script}`);
|
||||
if (/<html\b[^>]*>/i.test(html))
|
||||
return html.replace(/<html\b[^>]*>/i, (match) => `${match}${script}`);
|
||||
return `${script}${html}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrappers that read shader attributes directly from an element,
|
||||
* avoiding boilerplate in the web component class body.
|
||||
*/
|
||||
|
||||
export function getShaderModeFromElement(el: Element): ShaderLoadingMode {
|
||||
return normalizeShaderLoadingMode(el.getAttribute(SHADER_LOADING_ATTR));
|
||||
}
|
||||
|
||||
export function getShaderCaptureScaleFromElement(el: Element): number {
|
||||
return Number(normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)) ?? "1");
|
||||
}
|
||||
|
||||
export function prepareSrcForElement(el: Element, src: string): string {
|
||||
return withShaderQueryParams(
|
||||
src,
|
||||
normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)),
|
||||
getShaderModeFromElement(el),
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareSrcdocForElement(el: Element, srcdoc: string): string {
|
||||
return injectShaderOptionsIntoSrcdoc(
|
||||
srcdoc,
|
||||
normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)),
|
||||
getShaderModeFromElement(el),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldInjectRuntime, type ProbeState } from "./shouldInjectRuntime.js";
|
||||
|
||||
const baseState: ProbeState = {
|
||||
hasRuntime: false,
|
||||
hasTimelines: false,
|
||||
hasNestedCompositions: false,
|
||||
runtimeInjected: false,
|
||||
attempts: 1,
|
||||
};
|
||||
|
||||
describe("shouldInjectRuntime", () => {
|
||||
it("never injects when the runtime bridge is already present", () => {
|
||||
for (let attempts = 0; attempts <= 40; attempts++) {
|
||||
expect(
|
||||
shouldInjectRuntime({
|
||||
...baseState,
|
||||
hasRuntime: true,
|
||||
hasTimelines: true,
|
||||
hasNestedCompositions: true,
|
||||
attempts,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("never injects twice — runtimeInjected short-circuits", () => {
|
||||
expect(
|
||||
shouldInjectRuntime({
|
||||
...baseState,
|
||||
hasTimelines: true,
|
||||
hasNestedCompositions: true,
|
||||
runtimeInjected: true,
|
||||
attempts: 10,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe("nested compositions (data-composition-src children)", () => {
|
||||
it("injects on the first tick — no attempts gate", () => {
|
||||
expect(
|
||||
shouldInjectRuntime({
|
||||
...baseState,
|
||||
hasNestedCompositions: true,
|
||||
attempts: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Regression: product-promo and other registry examples register inline
|
||||
// pre-runtime timelines (`window.__timelines["main"]`) with only partial
|
||||
// durations during iframe load. Without this, the adapter path would
|
||||
// resolve against that partial timeline and lock the player into a
|
||||
// broken "ready" state before the 5-tick fallback ever fires.
|
||||
it("injects even when pre-runtime timelines are already registered", () => {
|
||||
expect(
|
||||
shouldInjectRuntime({
|
||||
...baseState,
|
||||
hasTimelines: true,
|
||||
hasNestedCompositions: true,
|
||||
attempts: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not re-inject once runtimeInjected flips", () => {
|
||||
expect(
|
||||
shouldInjectRuntime({
|
||||
...baseState,
|
||||
hasNestedCompositions: true,
|
||||
runtimeInjected: true,
|
||||
attempts: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("self-contained compositions (GSAP-only, no nested children)", () => {
|
||||
it("waits during the grace period (attempts < 5) even with timelines", () => {
|
||||
for (let attempts = 0; attempts < 5; attempts++) {
|
||||
expect(
|
||||
shouldInjectRuntime({
|
||||
...baseState,
|
||||
hasTimelines: true,
|
||||
attempts,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("injects as a fallback at attempt 5", () => {
|
||||
expect(
|
||||
shouldInjectRuntime({
|
||||
...baseState,
|
||||
hasTimelines: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not inject when there are neither timelines nor nested scenes", () => {
|
||||
for (let attempts = 0; attempts <= 40; attempts++) {
|
||||
expect(
|
||||
shouldInjectRuntime({
|
||||
...baseState,
|
||||
attempts,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Decide whether the player should inject the HyperFrames runtime on the
|
||||
* current probe tick.
|
||||
*
|
||||
* The player polls the loaded iframe every 200ms to discover either:
|
||||
* - a runtime bridge already installed (`window.__hf` / `window.__player`), or
|
||||
* - GSAP timelines registered at `window.__timelines`.
|
||||
*
|
||||
* Two classes of composition require different injection timing:
|
||||
*
|
||||
* Nested — the composition uses `data-composition-src` on child elements to
|
||||
* lazy-load sub-scenes. The runtime is what loads those children, so the
|
||||
* composition cannot possibly render on its own. We inject immediately; if
|
||||
* we waited, an inline pre-runtime `gsap.timeline` (common for authoring a
|
||||
* preview before the runtime rebuilds the master timeline) would register
|
||||
* at `__timelines["main"]` with a partial duration, and the adapter path
|
||||
* would then lock the player into `ready` against that incomplete timeline.
|
||||
*
|
||||
* Self-contained — the composition has no nested scenes and ships all of
|
||||
* its animation inline (timelines registered under `__timelines`). These
|
||||
* don't strictly need the runtime; the adapter can drive them directly.
|
||||
* We give the adapter path first shot (a 5-tick grace period) and only
|
||||
* inject the runtime as a fallback if no adapter emerges.
|
||||
*/
|
||||
export interface ProbeState {
|
||||
hasRuntime: boolean;
|
||||
hasTimelines: boolean;
|
||||
hasNestedCompositions: boolean;
|
||||
runtimeInjected: boolean;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export function shouldInjectRuntime(state: ProbeState): boolean {
|
||||
if (state.hasRuntime || state.runtimeInjected) return false;
|
||||
if (state.hasNestedCompositions) return true;
|
||||
if (state.hasTimelines && state.attempts >= 5) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SlideshowController } from "./SlideshowController";
|
||||
import type { ResolvedSlideshow } from "@hyperframes/core/slideshow";
|
||||
|
||||
function fakePlayer() {
|
||||
let cb: ((t: number) => void) | null = null;
|
||||
const player = {
|
||||
currentTime: 0,
|
||||
seek: vi.fn((t: number) => {
|
||||
player.currentTime = t;
|
||||
}),
|
||||
play: vi.fn(() => {}),
|
||||
pause: vi.fn(() => {}),
|
||||
stopMedia: vi.fn(() => {}),
|
||||
playSceneMedia: vi.fn((_sceneId: string) => {}),
|
||||
onTimeUpdate: (fn: (t: number) => void) => {
|
||||
cb = fn;
|
||||
return () => {
|
||||
cb = null;
|
||||
};
|
||||
},
|
||||
emit: (t: number) => {
|
||||
player.currentTime = t;
|
||||
cb?.(t);
|
||||
},
|
||||
};
|
||||
return player;
|
||||
}
|
||||
|
||||
const SHOW: ResolvedSlideshow = {
|
||||
slides: [
|
||||
{ sceneId: "a", start: 0, end: 5, fragments: [2, 4], hotspots: [] },
|
||||
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
||||
],
|
||||
sequences: {
|
||||
deep: {
|
||||
id: "deep",
|
||||
label: "Deep dive",
|
||||
slides: [{ sceneId: "c", start: 10, end: 13, fragments: [], hotspots: [] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory: controller on SHOW, advanced to fragmentIndex=1. Construction enters
|
||||
* slide a at fragmentIndex 0 (its first fragment); one next() reveals fragment 1.
|
||||
* Navigation is synchronous (seek-driven) — no playback emit needed.
|
||||
*/
|
||||
function showAtFrag1() {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.next(); // fragmentIndex 0 → 1
|
||||
return { p, c };
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: controller on SHOW, at slide 1, inside the "deep" branch.
|
||||
* Used across branching + backToMain tests that share goToSlide(1)+enterBranch setup.
|
||||
*/
|
||||
function showAtSlide1InDeep() {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.goToSlide(1);
|
||||
c.enterBranch("deep");
|
||||
return { p, c };
|
||||
}
|
||||
|
||||
describe("SlideshowController linear nav", () => {
|
||||
it("enters the first slide on construction: seeks to the first fragment (no auto-play)", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
// Synchronous seek-only hold: jump to fragments[0]=2, fragmentIndex 0, never play.
|
||||
expect(p.seek).toHaveBeenCalledWith(2);
|
||||
expect(p.play).not.toHaveBeenCalled();
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.position.fragmentIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("never auto-plays — a single seek both repaints and holds", () => {
|
||||
const p = fakePlayer();
|
||||
new SlideshowController(p, SHOW);
|
||||
// Determinism: navigation is a pure seek; the player is never put into a
|
||||
// playing state that could run on into the next fragment/scene.
|
||||
expect(p.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not stop media on construction or same-slide fragment navigation", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
expect(p.stopMedia).not.toHaveBeenCalled();
|
||||
|
||||
c.next(); // slide a fragment 0 -> fragment 1, same slide
|
||||
expect(p.stopMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops media before changing to another slide", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
|
||||
c.next(); // fragment 0 -> fragment 1, same slide
|
||||
c.next(); // slide a -> slide b
|
||||
|
||||
expect(p.stopMedia).toHaveBeenCalledOnce();
|
||||
expect(p.seek).toHaveBeenLastCalledWith(7.5);
|
||||
});
|
||||
|
||||
it("next stops at the first fragment, not the next slide", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
// Construction already lands on fragment 0 of slide a.
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.position.fragmentIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("next past the last fragment advances to the next slide immediately", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
// construction → fragment 0; next → fragment 1; next → slide b (no fragments)
|
||||
c.next(); // fragmentIndex 0 → 1 (seek 4)
|
||||
c.next(); // no more fragments — advance to slide b immediately
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint
|
||||
});
|
||||
|
||||
it("next() on a slide with NO fragments advances to the next slide immediately", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
// Go to slide b (index 1, no fragments, not at end yet)
|
||||
c.goToSlide(1); // slide b: start=5, end=10, fragments=[]
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
// The demo SHOW only has 2 slides, so next on slide 1 is a no-op.
|
||||
// Use a show with a third slide to verify advancement.
|
||||
const show3: ResolvedSlideshow = {
|
||||
slides: [
|
||||
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
|
||||
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
||||
{ sceneId: "c", start: 10, end: 15, fragments: [], hotspots: [] },
|
||||
],
|
||||
sequences: {},
|
||||
};
|
||||
const p2 = fakePlayer();
|
||||
const c2 = new SlideshowController(p2, show3);
|
||||
// slide 0 has no fragments; one next() should advance immediately to slide 1
|
||||
c2.next();
|
||||
expect(c2.position.slideIndex).toBe(1);
|
||||
expect(p2.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint
|
||||
});
|
||||
|
||||
it("next() on the last slide is a no-op", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.goToSlide(1); // slide b is last
|
||||
c.next();
|
||||
expect(c.position.slideIndex).toBe(1); // no change
|
||||
});
|
||||
|
||||
it("prev returns to the previous slide start", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.goToSlide(1);
|
||||
c.prev();
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("at a fragment, next advances to the FOLLOWING fragment (not the end)", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
expect(c.position.fragmentIndex).toBe(0); // construction → fragment 0
|
||||
c.next(); // should target fragments[1]=4, NOT slide.end=5
|
||||
expect(c.position.fragmentIndex).toBe(1);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlideshowController nextSlide", () => {
|
||||
it("returns the next slide when not at the end", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
// At slide 0, next should be slide 1 (sceneId "b")
|
||||
expect(c.nextSlide).not.toBeNull();
|
||||
expect(c.nextSlide?.sceneId).toBe("b");
|
||||
});
|
||||
|
||||
it("returns null when at the last slide in the sequence", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.goToSlide(1); // slide "b" is the last in main
|
||||
expect(c.nextSlide).toBeNull();
|
||||
});
|
||||
|
||||
it("nextSlide is scoped to the current sequence", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.enterBranch("deep"); // "deep" has only one slide
|
||||
expect(c.nextSlide).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlideshowController branching", () => {
|
||||
it("enterBranch pushes onto the stack and enters the branch's first slide", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.enterBranch("deep");
|
||||
expect(c.position.sequenceId).toBe("deep");
|
||||
expect(c.currentSlide?.sceneId).toBe("c");
|
||||
expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint
|
||||
});
|
||||
|
||||
it("stops media when entering and leaving a branch", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
|
||||
c.enterBranch("deep");
|
||||
expect(p.stopMedia).toHaveBeenCalledTimes(1);
|
||||
|
||||
c.back();
|
||||
expect(p.stopMedia).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("counter is scoped to the current sequence", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.enterBranch("deep");
|
||||
expect(c.counter).toEqual({ index: 1, total: 1 });
|
||||
});
|
||||
|
||||
it("breadcrumb reflects the stack", () => {
|
||||
const { c } = showAtSlide1InDeep();
|
||||
expect(c.breadcrumb.map((b) => b.label)).toEqual(["Main deck", "Deep dive"]);
|
||||
});
|
||||
|
||||
it("back returns to the exact parent slide", () => {
|
||||
const { c } = showAtSlide1InDeep();
|
||||
c.back();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("backToMain clears nested branches to the root", () => {
|
||||
const { c } = showAtSlide1InDeep();
|
||||
c.backToMain();
|
||||
expect(c.breadcrumb.length).toBe(1);
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlideshowController — fragmentIndex advances synchronously on next()", () => {
|
||||
it("construction lands on fragment 0; next() reveals fragment 1 immediately", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
// Seek-only model: entering a fragmented slide shows its first fragment.
|
||||
expect(c.position.fragmentIndex).toBe(0);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(2); // fragments[0]
|
||||
c.next();
|
||||
expect(c.position.fragmentIndex).toBe(1); // synchronous, no played tick
|
||||
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1]
|
||||
});
|
||||
|
||||
it("next() targets the FOLLOWING fragment (not slide end) while fragments remain", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.next(); // fragment 0 → 1 (fragments[1]=4, NOT slide.end=5)
|
||||
expect(c.position.fragmentIndex).toBe(1);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlideshowController Fix 8b — back() restores parent fragmentIndex", () => {
|
||||
it("back() restores the saved fragmentIndex and seeks to the fragment time", () => {
|
||||
const { p, c } = showAtFrag1();
|
||||
expect(c.position.fragmentIndex).toBe(1);
|
||||
// Enter branch — saves frame {main, slideIndex:0, fragmentIndex:1}
|
||||
c.enterBranch("deep");
|
||||
expect(c.position.sequenceId).toBe("deep");
|
||||
// Back should restore main, slideIndex=0, fragmentIndex=1, seek to fragments[1]=4
|
||||
c.back();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.position.fragmentIndex).toBe(1);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4
|
||||
});
|
||||
|
||||
it("resuming a fragmented slide at fragmentIndex -1 seeks to slide start", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
// fragmentIndex -1 on a fragmented slide = before the first reveal. This state
|
||||
// is reachable via syncTo (audience mirror); resume should seek to slide.start.
|
||||
c.syncTo("main", 0, -1);
|
||||
expect(c.position.fragmentIndex).toBe(-1);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(0); // slide a start
|
||||
});
|
||||
|
||||
it("back() to a NO-fragment parent slide resumes at its midpoint, not frame 0", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.goToSlide(1); // slide b: [5,10], no fragments
|
||||
c.enterBranch("deep");
|
||||
c.back();
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
// Mirrors enterSlide's no-fragment rest frame (midpoint) so the slide is
|
||||
// visible at rest instead of frozen at its pre-entrance frame-0.
|
||||
expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint (5 + 5*0.5)
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlideshowController unknown-sequence degradation", () => {
|
||||
it("enterBranch with an unknown id does not throw and leaves nav state unchanged", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.goToSlide(1);
|
||||
expect(() => c.enterBranch("no-such-seq")).not.toThrow();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("counter and currentSlide degrade gracefully when sequence is missing from show", () => {
|
||||
// Construct a show where sequences has no entries, then verify slidesOf([missing])
|
||||
// returns [] and counter/currentSlide do not throw.
|
||||
const showNoSeq: ResolvedSlideshow = {
|
||||
slides: [{ sceneId: "x", start: 0, end: 5, fragments: [], hotspots: [] }],
|
||||
sequences: {},
|
||||
};
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, showNoSeq);
|
||||
// enterBranch guards — no bogus frame gets pushed. counter on main is safe.
|
||||
expect(() => c.counter).not.toThrow();
|
||||
expect(c.counter).toEqual({ index: 1, total: 1 });
|
||||
// enterBranch with an unknown id: guard fires, state stays on main
|
||||
expect(() => c.enterBranch("ghost")).not.toThrow();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
// breadcrumb does not throw for unknown sequence in stack (regression guard)
|
||||
expect(() => c.breadcrumb).not.toThrow();
|
||||
expect(c.breadcrumb[0]?.id).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug fix tests: #5-ctrl — enterSlide clears holdAt on empty-slide early return
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("SlideshowController Fix #5-ctrl — enterBranch ignores empty branch", () => {
|
||||
it("enterBranch into an empty sequence is a no-op (does not enter the branch)", () => {
|
||||
// Build a show where "empty" sequence has no slides
|
||||
const show: ResolvedSlideshow = {
|
||||
slides: [{ sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] }],
|
||||
sequences: {
|
||||
empty: { id: "empty", label: "Empty", slides: [] },
|
||||
},
|
||||
};
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, show);
|
||||
|
||||
// Advance to a holdAt state by calling next() (sets holdAt to fragment 2)
|
||||
c.next();
|
||||
// Entering a branch that has no slides must be ignored — nav state unchanged.
|
||||
c.enterBranch("empty");
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("enterSlide(0) on an empty main sequence does not throw", () => {
|
||||
// This verifies the early-return path doesn't leave holdAt dirty
|
||||
const show: ResolvedSlideshow = {
|
||||
slides: [],
|
||||
sequences: {},
|
||||
};
|
||||
const p = fakePlayer();
|
||||
// Constructor calls enterSlide(0) — must not throw with empty slides
|
||||
expect(() => new SlideshowController(p, show)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug fix tests: #backToMain — uses resumeSlide to preserve fragment position
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("SlideshowController Fix #backToMain — restores fragment position like back()", () => {
|
||||
it("backToMain restores the root frame's fragmentIndex (not reset to -1)", () => {
|
||||
const { p, c } = showAtFrag1();
|
||||
expect(c.position.fragmentIndex).toBe(1);
|
||||
|
||||
// Enter branch — saves root frame with fragmentIndex=1
|
||||
c.enterBranch("deep");
|
||||
expect(c.position.sequenceId).toBe("deep");
|
||||
|
||||
// backToMain should restore to main slideIndex=0, fragmentIndex=1 (not -1)
|
||||
c.backToMain();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.position.fragmentIndex).toBe(1);
|
||||
// resumeSlide seeks to the fragment time (fragments[1]=4)
|
||||
expect(p.seek).toHaveBeenLastCalledWith(4);
|
||||
});
|
||||
|
||||
it("backToMain restores the root fragment the branch was entered from", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
|
||||
// Construction lands on slide a fragment 0; enter a branch, then return.
|
||||
c.enterBranch("deep");
|
||||
c.backToMain();
|
||||
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.position.fragmentIndex).toBe(0);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(2); // fragments[0]
|
||||
});
|
||||
|
||||
it("backToMain with multiple nested branches restores root slide position", () => {
|
||||
const show: ResolvedSlideshow = {
|
||||
slides: [
|
||||
{ sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] },
|
||||
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
||||
],
|
||||
sequences: {
|
||||
lvl1: {
|
||||
id: "lvl1",
|
||||
label: "Level 1",
|
||||
slides: [{ sceneId: "c", start: 10, end: 13, fragments: [], hotspots: [] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, show);
|
||||
c.goToSlide(1); // root at slide 1
|
||||
c.enterBranch("lvl1");
|
||||
|
||||
// backToMain must pop all frames back to root
|
||||
c.backToMain();
|
||||
expect(c.breadcrumb.length).toBe(1);
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch-edge navigation: prev/next at branch boundaries return to parent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Show used only for branch-edge tests: 2 main slides + single- and multi-slide branches.
|
||||
const SHOW_BRANCH_EDGE: ResolvedSlideshow = {
|
||||
slides: [
|
||||
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
|
||||
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
||||
],
|
||||
sequences: {
|
||||
single: {
|
||||
id: "single",
|
||||
label: "Single slide branch",
|
||||
slides: [{ sceneId: "x", start: 10, end: 13, fragments: [], hotspots: [] }],
|
||||
},
|
||||
multi: {
|
||||
id: "multi",
|
||||
label: "Multi slide branch",
|
||||
slides: [
|
||||
{ sceneId: "y", start: 13, end: 16, fragments: [], hotspots: [] },
|
||||
{ sceneId: "z", start: 16, end: 20, fragments: [], hotspots: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Factory: controller on SHOW_BRANCH_EDGE, already inside the given branch. */
|
||||
function inBranch(branchId: string): { c: SlideshowController } {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
||||
c.enterBranch(branchId);
|
||||
return { c };
|
||||
}
|
||||
|
||||
describe("SlideshowController branch-edge nav — prev/next return to parent", () => {
|
||||
it("single-slide branch: prev() returns to parent", () => {
|
||||
const { c } = inBranch("single");
|
||||
expect(c.breadcrumb.length).toBe(2);
|
||||
c.prev();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.breadcrumb.length).toBe(1);
|
||||
});
|
||||
|
||||
it("single-slide branch: next() (no fragments, last slide) returns to parent", () => {
|
||||
const { c } = inBranch("single");
|
||||
expect(c.breadcrumb.length).toBe(2);
|
||||
c.next();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.breadcrumb.length).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-slide branch: prev() from slide 1 → slide 0, NOT popped", () => {
|
||||
const { c } = inBranch("multi");
|
||||
c.goToSlide(1);
|
||||
c.prev();
|
||||
expect(c.position.sequenceId).toBe("multi");
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.breadcrumb.length).toBe(2);
|
||||
});
|
||||
|
||||
it("multi-slide branch: prev() from slide 0 → parent", () => {
|
||||
const { c } = inBranch("multi");
|
||||
c.prev();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.breadcrumb.length).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-slide branch: next() from slide 0 → slide 1, NOT popped", () => {
|
||||
const { c } = inBranch("multi");
|
||||
c.next();
|
||||
expect(c.position.sequenceId).toBe("multi");
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
expect(c.breadcrumb.length).toBe(2);
|
||||
});
|
||||
|
||||
it("multi-slide branch: next() from slide 1 (last) → parent", () => {
|
||||
const { c } = inBranch("multi");
|
||||
c.goToSlide(1);
|
||||
c.next();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.breadcrumb.length).toBe(1);
|
||||
});
|
||||
|
||||
it("main line: prev() at slide 0 is a no-op (stack.length === 1)", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
||||
c.prev();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.breadcrumb.length).toBe(1);
|
||||
});
|
||||
|
||||
it("main line: next() at last slide is a no-op (does NOT call back)", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
||||
c.goToSlide(1);
|
||||
c.next();
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
expect(c.breadcrumb.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canPrev / canNext getters
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("SlideshowController canPrev / canNext", () => {
|
||||
it("main first slide: canPrev=false, canNext=true", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
||||
expect(c.canPrev).toBe(false);
|
||||
expect(c.canNext).toBe(true);
|
||||
});
|
||||
|
||||
it("main last slide: canPrev=true, canNext=false", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW_BRANCH_EDGE);
|
||||
c.goToSlide(1); // last slide (total=2)
|
||||
expect(c.canPrev).toBe(true);
|
||||
expect(c.canNext).toBe(false);
|
||||
});
|
||||
|
||||
it("main middle slide: canPrev=true, canNext=true", () => {
|
||||
// Use SHOW (3+ slides via SHOW_BRANCH_EDGE is only 2; use a 3-slide show)
|
||||
const threeSlideShow: ResolvedSlideshow = {
|
||||
slides: [
|
||||
{ sceneId: "a", start: 0, end: 5, fragments: [], hotspots: [] },
|
||||
{ sceneId: "b", start: 5, end: 10, fragments: [], hotspots: [] },
|
||||
{ sceneId: "c", start: 10, end: 15, fragments: [], hotspots: [] },
|
||||
],
|
||||
sequences: {},
|
||||
};
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, threeSlideShow);
|
||||
c.goToSlide(1);
|
||||
expect(c.canPrev).toBe(true);
|
||||
expect(c.canNext).toBe(true);
|
||||
});
|
||||
|
||||
it("single-slide main: canPrev=false, canNext=false", () => {
|
||||
const oneSlide: ResolvedSlideshow = {
|
||||
slides: [{ sceneId: "only", start: 0, end: 5, fragments: [], hotspots: [] }],
|
||||
sequences: {},
|
||||
};
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, oneSlide);
|
||||
expect(c.canPrev).toBe(false);
|
||||
expect(c.canNext).toBe(false);
|
||||
});
|
||||
|
||||
it("inside a branch (first slide): canPrev=true (parent is prev), canNext=true (next-within or parent)", () => {
|
||||
const { c } = inBranch("single");
|
||||
// single-slide branch, slideIndex=0, stack.length=2
|
||||
expect(c.canPrev).toBe(true);
|
||||
expect(c.canNext).toBe(true);
|
||||
});
|
||||
|
||||
it("inside a multi-slide branch (first slide): canPrev=true, canNext=true", () => {
|
||||
const { c } = inBranch("multi");
|
||||
// slideIndex=0, next slide exists within branch
|
||||
expect(c.canPrev).toBe(true);
|
||||
expect(c.canNext).toBe(true);
|
||||
});
|
||||
|
||||
it("inside a multi-slide branch (last slide): canPrev=true, canNext=true (parent is next)", () => {
|
||||
const { c } = inBranch("multi");
|
||||
c.goToSlide(1); // last slide in branch
|
||||
expect(c.canPrev).toBe(true);
|
||||
expect(c.canNext).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// next() reveals remaining fragments even when playback is already at slide end
|
||||
// (the atEnd gate was removed so a no-animation jump to slide end still steps
|
||||
// through pending fragments rather than skipping straight to the next slide).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("SlideshowController next() — reveals remaining fragments at slide end", () => {
|
||||
it("reveals the next fragment even when currentTime is already at slide end", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
// Static jump to slide end; pending fragments should still be revealed in order
|
||||
// (the playhead position doesn't gate fragment stepping).
|
||||
p.currentTime = 5; // slide a end
|
||||
c.next(); // fragment 0 → 1, stays on slide a
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.position.fragmentIndex).toBe(1);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1], not slide b
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// syncTo — absolute, animation-free position mirroring for the audience window.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("SlideshowController syncTo", () => {
|
||||
it("re-roots to a branch sequence and restores slide+fragment statically", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.syncTo("deep", 0, -1);
|
||||
expect(c.position.sequenceId).toBe("deep");
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
// Slide c has no fragments, so resumeSlide lands at its midpoint (restFrame) —
|
||||
// the same visible-at-rest position enterSlide uses — not slide start. A single
|
||||
// seek both repaints and holds (no sustained playback).
|
||||
expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint (10 + 3*0.5)
|
||||
expect(p.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs a main-line slide+fragment position without animating", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.syncTo("main", 0, 1); // slide a, fragmentIndex 1 → fragments[1]=4
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
expect(c.position.fragmentIndex).toBe(1);
|
||||
expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4
|
||||
});
|
||||
|
||||
it("does not stop media when syncing only the fragment within the same slide", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
|
||||
c.syncTo("main", 0, 1);
|
||||
|
||||
expect(p.stopMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops media when audience sync moves to a different slide or sequence", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
|
||||
c.syncTo("main", 1, -1);
|
||||
expect(p.stopMedia).toHaveBeenCalledTimes(1);
|
||||
|
||||
c.syncTo("deep", 0, -1);
|
||||
expect(p.stopMedia).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores an unknown sequence target", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.syncTo("nope", 0, -1);
|
||||
expect(c.position.sequenceId).toBe("main");
|
||||
});
|
||||
|
||||
it("ignores an out-of-range slide index", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, SHOW);
|
||||
c.syncTo("main", 99, -1);
|
||||
expect(c.position.slideIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlideshowController autoplay", () => {
|
||||
// "v" autoplays; "w" does not. Both sit on the main line.
|
||||
const AUTOPLAY_SHOW: ResolvedSlideshow = {
|
||||
slides: [
|
||||
{ sceneId: "v", start: 0, end: 5, fragments: [], hotspots: [], autoplay: true },
|
||||
{ sceneId: "w", start: 5, end: 10, fragments: [], hotspots: [] },
|
||||
],
|
||||
sequences: {},
|
||||
};
|
||||
|
||||
it("plays the slide's media on enter when autoplay is set", () => {
|
||||
const p = fakePlayer();
|
||||
new SlideshowController(p, AUTOPLAY_SHOW); // constructs on slide "v"
|
||||
expect(p.playSceneMedia).toHaveBeenCalledWith("v");
|
||||
expect(p.playSceneMedia).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not play media when entering a non-autoplay slide", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, AUTOPLAY_SHOW);
|
||||
p.playSceneMedia.mockClear();
|
||||
c.next(); // v → w (w is not autoplay)
|
||||
expect(c.position.slideIndex).toBe(1);
|
||||
expect(p.playSceneMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops prior media and plays again when navigating back into an autoplay slide", () => {
|
||||
const p = fakePlayer();
|
||||
const c = new SlideshowController(p, AUTOPLAY_SHOW);
|
||||
p.playSceneMedia.mockClear();
|
||||
c.next(); // → w
|
||||
expect(p.stopMedia).toHaveBeenCalled(); // leaving v stops its clip
|
||||
p.playSceneMedia.mockClear();
|
||||
c.prev(); // back into v (enterSlide) → replays
|
||||
expect(p.playSceneMedia).toHaveBeenCalledWith("v");
|
||||
});
|
||||
|
||||
it("does not require autoplay support on the port (optional hook)", () => {
|
||||
// A port without playSceneMedia must not throw when entering an autoplay slide.
|
||||
const { playSceneMedia: _omitted, ...port } = fakePlayer();
|
||||
expect(() => new SlideshowController(port, AUTOPLAY_SHOW)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { ResolvedSlideshow, ResolvedSlide } from "@hyperframes/core/slideshow";
|
||||
|
||||
export interface PlayerPort {
|
||||
seek(t: number): void;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
stopMedia?(): void;
|
||||
/** Play the `<video>` inside the given scene (for `autoplay` slides). */
|
||||
playSceneMedia?(sceneId: string): void;
|
||||
readonly currentTime: number;
|
||||
onTimeUpdate(cb: (t: number) => void): () => void;
|
||||
}
|
||||
|
||||
interface StackFrame {
|
||||
sequenceId: string;
|
||||
slideIndex: number;
|
||||
fragmentIndex: number; // -1 = before first fragment / at slide start
|
||||
}
|
||||
|
||||
const MAIN = "main";
|
||||
|
||||
export class SlideshowController {
|
||||
private stack: StackFrame[] = [{ sequenceId: MAIN, slideIndex: 0, fragmentIndex: -1 }];
|
||||
private changeCbs = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
private player: PlayerPort,
|
||||
private show: ResolvedSlideshow,
|
||||
) {
|
||||
this.enterSlide(0);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
dispose(): void {
|
||||
// No subscriptions to tear down — navigation is seek-driven (see playTo).
|
||||
}
|
||||
|
||||
private slidesOf(sequenceId: string): ResolvedSlide[] {
|
||||
if (sequenceId === MAIN) return this.show.slides;
|
||||
return this.show.sequences[sequenceId]?.slides ?? [];
|
||||
}
|
||||
|
||||
private get frame(): StackFrame {
|
||||
return this.stack[this.stack.length - 1];
|
||||
}
|
||||
|
||||
get currentSlide(): ResolvedSlide | undefined {
|
||||
return this.slidesOf(this.frame.sequenceId)[this.frame.slideIndex];
|
||||
}
|
||||
|
||||
get nextSlide(): ResolvedSlide | null {
|
||||
const slides = this.slidesOf(this.frame.sequenceId);
|
||||
const next = slides[this.frame.slideIndex + 1];
|
||||
return next ?? null;
|
||||
}
|
||||
|
||||
get position(): { sequenceId: string; slideIndex: number; fragmentIndex: number } {
|
||||
return { ...this.frame };
|
||||
}
|
||||
|
||||
get counter(): { index: number; total: number } {
|
||||
return {
|
||||
index: this.frame.slideIndex + 1,
|
||||
total: this.slidesOf(this.frame.sequenceId).length,
|
||||
};
|
||||
}
|
||||
|
||||
get canPrev(): boolean {
|
||||
// prev has a destination: an earlier slide in this sequence, OR (in a branch) the parent.
|
||||
return this.frame.slideIndex > 0 || this.stack.length > 1;
|
||||
}
|
||||
|
||||
get canNext(): boolean {
|
||||
// next has a destination: a later slide in this sequence, OR (in a branch) the parent.
|
||||
const slides = this.slidesOf(this.frame.sequenceId);
|
||||
return this.frame.slideIndex + 1 < slides.length || this.stack.length > 1;
|
||||
}
|
||||
|
||||
get breadcrumb(): { id: string; label: string }[] {
|
||||
return this.stack.map((f) =>
|
||||
f.sequenceId === MAIN
|
||||
? { id: MAIN, label: "Main deck" }
|
||||
: { id: f.sequenceId, label: this.show.sequences[f.sequenceId]?.label ?? f.sequenceId },
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
onChange(cb: () => void): () => void {
|
||||
this.changeCbs.add(cb);
|
||||
return () => this.changeCbs.delete(cb);
|
||||
}
|
||||
|
||||
private emitChange(): void {
|
||||
for (const cb of this.changeCbs) cb();
|
||||
}
|
||||
|
||||
private stopSlideMedia(): void {
|
||||
this.player.stopMedia?.();
|
||||
}
|
||||
|
||||
private enterSlide(index: number): void {
|
||||
if (index !== this.frame.slideIndex) this.stopSlideMedia();
|
||||
this.frame.slideIndex = index;
|
||||
const slide = this.currentSlide;
|
||||
if (!slide) {
|
||||
this.frame.fragmentIndex = -1;
|
||||
return;
|
||||
}
|
||||
// Jump to the slide's first hold and stay there (no auto-progress). With
|
||||
// fragments that's the first fragment (fragmentIndex 0); without, a settled
|
||||
// frame INSIDE the slide (its midpoint) — NOT slide.end, which is the boundary
|
||||
// where the next scene begins (else slide 1 would render slide 2's content).
|
||||
if (slide.fragments.length > 0) {
|
||||
this.frame.fragmentIndex = 0;
|
||||
this.playTo(slide.fragments[0] ?? slide.end);
|
||||
} else {
|
||||
this.frame.fragmentIndex = -1;
|
||||
this.playTo(this.restFrame(slide));
|
||||
}
|
||||
// Opt-in: play the slide's own clip on enter (saves a click into the
|
||||
// pointer-events:none composition). We never auto-advance — the presenter
|
||||
// still clicks Next. Fires from enterSlide (next / prev / goToSlide), NOT
|
||||
// from resumeSlide (back / backToMain / syncTo), which restores a saved
|
||||
// position; the component also skips it on the audience, which mirrors the
|
||||
// presenter's media events rather than driving its own.
|
||||
if (slide.autoplay) this.player.playSceneMedia?.(slide.sceneId);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
/** A representative, non-boundary frame for a slide with no fragments. */
|
||||
private restFrame(slide: ResolvedSlide): number {
|
||||
return slide.start + (slide.end - slide.start) * 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a slide at a saved fragmentIndex without resetting to slide start.
|
||||
* Used by back()/backToMain()/syncTo() to restore an exact position.
|
||||
*/
|
||||
private resumeSlide(index: number, fragmentIndex: number): void {
|
||||
this.frame.slideIndex = index;
|
||||
this.frame.fragmentIndex = fragmentIndex;
|
||||
const slide = this.currentSlide;
|
||||
if (!slide) return;
|
||||
// Resume position, mirroring enterSlide so going back to a slide lands where
|
||||
// entering it forward does:
|
||||
// - at a saved fragment → that fragment's hold time
|
||||
// - fragmented, pre-first → slide.start (before the first reveal)
|
||||
// - no fragments → restFrame (midpoint), NOT slide.start, so the
|
||||
// slide is visible at rest instead of frozen at its frame-0 (pre-entrance).
|
||||
const seekTime =
|
||||
fragmentIndex >= 0 && fragmentIndex < slide.fragments.length
|
||||
? (slide.fragments[fragmentIndex] ?? slide.start)
|
||||
: slide.fragments.length > 0
|
||||
? slide.start
|
||||
: this.restFrame(slide);
|
||||
this.playTo(seekTime);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to hold time `t` and hold there — a pure, synchronous seek with NO
|
||||
* sustained playback, so a slide can never auto-progress.
|
||||
*
|
||||
* `player.seek(t)` drives the composition's GSAP timeline directly (the player
|
||||
* reaches the same-origin iframe's `__timelines`), and GSAP `.seek()` renders
|
||||
* that frame synchronously AND leaves the timeline paused. So one seek both
|
||||
* repaints and holds — deterministically, in every window including a
|
||||
* backgrounded one. (The previous play-a-frame-then-pause-on-a-timeupdate
|
||||
* "render nudge" left an unfocused audience window playing while it waited for
|
||||
* a throttled tick to pause it — that was the auto-progress / one-side-frozen
|
||||
* flakiness. fragmentIndex is now set by the caller, not on a played tick.)
|
||||
*/
|
||||
private playTo(t: number): void {
|
||||
this.player.seek(t);
|
||||
}
|
||||
|
||||
next(): void {
|
||||
const slide = this.currentSlide;
|
||||
if (!slide) return;
|
||||
const hasMoreFragments = this.frame.fragmentIndex + 1 < slide.fragments.length;
|
||||
if (hasMoreFragments) {
|
||||
// Reveal the next fragment — advance the index and seek to its hold time.
|
||||
this.frame.fragmentIndex += 1;
|
||||
const target = slide.fragments[this.frame.fragmentIndex] ?? slide.end;
|
||||
this.playTo(target);
|
||||
this.emitChange();
|
||||
return;
|
||||
}
|
||||
// No more fragments to reveal — advance to the next slide immediately instead of
|
||||
// playing the current slide out to its end.
|
||||
const slides = this.slidesOf(this.frame.sequenceId);
|
||||
if (this.frame.slideIndex + 1 < slides.length) {
|
||||
this.enterSlide(this.frame.slideIndex + 1);
|
||||
} else if (this.stack.length > 1) {
|
||||
// End of a branch → return to the parent timeline.
|
||||
this.back();
|
||||
}
|
||||
}
|
||||
|
||||
prev(): void {
|
||||
if (this.frame.slideIndex > 0) {
|
||||
this.enterSlide(this.frame.slideIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (this.stack.length > 1) {
|
||||
// First slide of a branch → return to the parent timeline.
|
||||
this.back();
|
||||
}
|
||||
}
|
||||
|
||||
goToSlide(index: number): void {
|
||||
const slides = this.slidesOf(this.frame.sequenceId);
|
||||
if (index >= 0 && index < slides.length) this.enterSlide(index);
|
||||
}
|
||||
|
||||
enterBranch(sequenceId: string): void {
|
||||
const seq = this.show.sequences[sequenceId];
|
||||
if (!seq || seq.slides.length === 0) return;
|
||||
this.stopSlideMedia();
|
||||
this.stack.push({ sequenceId, slideIndex: 0, fragmentIndex: -1 });
|
||||
this.enterSlide(0);
|
||||
}
|
||||
|
||||
back(): void {
|
||||
if (this.stack.length <= 1) return;
|
||||
this.stopSlideMedia();
|
||||
this.stack.pop();
|
||||
// Restore the saved fragmentIndex from the parent frame rather than
|
||||
// resetting to -1 (which enterSlide would do). This preserves the exact
|
||||
// position the presenter was at before entering the branch.
|
||||
this.resumeSlide(this.frame.slideIndex, this.frame.fragmentIndex);
|
||||
}
|
||||
|
||||
backToMain(): void {
|
||||
if (this.stack.length <= 1) return;
|
||||
this.stopSlideMedia();
|
||||
this.stack = [this.stack[0]];
|
||||
this.resumeSlide(this.frame.slideIndex, this.frame.fragmentIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to an absolute position without animation (audience mirroring).
|
||||
* Re-roots the stack to the target sequence, then restores slide+fragment
|
||||
* statically via resumeSlide.
|
||||
*/
|
||||
syncTo(sequenceId: string, slideIndex: number, fragmentIndex: number): void {
|
||||
if (!this.isValidSyncTarget(sequenceId, slideIndex)) return;
|
||||
if (this.isCrossSlide(sequenceId, slideIndex)) this.stopSlideMedia();
|
||||
if (!this.rerootStackTo(sequenceId)) return;
|
||||
this.resumeSlide(slideIndex, fragmentIndex);
|
||||
}
|
||||
|
||||
/** True if the target sequence + slide index resolves to a real slide. */
|
||||
private isValidSyncTarget(sequenceId: string, slideIndex: number): boolean {
|
||||
if (!this.stack[0]) return false;
|
||||
const targetSlides =
|
||||
sequenceId === MAIN ? this.show.slides : (this.show.sequences[sequenceId]?.slides ?? null);
|
||||
if (!targetSlides) return false;
|
||||
return slideIndex >= 0 && slideIndex < targetSlides.length;
|
||||
}
|
||||
|
||||
/** True if the sync target lands on a different slide than current. */
|
||||
private isCrossSlide(sequenceId: string, slideIndex: number): boolean {
|
||||
return this.frame.sequenceId !== sequenceId || this.frame.slideIndex !== slideIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-root the navigation stack to `sequenceId` if we're not already there.
|
||||
* Returns false only when the target branch sequence is empty (no slides),
|
||||
* mirroring the early-return guard in the previous inline form.
|
||||
*/
|
||||
private rerootStackTo(sequenceId: string): boolean {
|
||||
if (this.frame.sequenceId === sequenceId) return true;
|
||||
const base = this.stack[0];
|
||||
if (!base) return false;
|
||||
this.stack = [base];
|
||||
if (sequenceId === MAIN) return true;
|
||||
const seq = this.show.sequences[sequenceId];
|
||||
if (!seq || seq.slides.length === 0) return false;
|
||||
this.stack.push({ sequenceId, slideIndex: 0, fragmentIndex: -1 });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
export interface PresenterPosition {
|
||||
sequenceId: string;
|
||||
slideIndex: number;
|
||||
fragmentIndex: number;
|
||||
}
|
||||
|
||||
const COUNTER_FONT_FAMILY =
|
||||
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
|
||||
|
||||
export type PresenterMediaAction =
|
||||
| "play"
|
||||
| "pause"
|
||||
| "seeking"
|
||||
| "seeked"
|
||||
| "ratechange"
|
||||
| "volumechange"
|
||||
| "ended"
|
||||
| "timeupdate";
|
||||
|
||||
const MEDIA_ACTIONS = new Set<unknown>([
|
||||
"play",
|
||||
"pause",
|
||||
"seeking",
|
||||
"seeked",
|
||||
"ratechange",
|
||||
"volumechange",
|
||||
"ended",
|
||||
"timeupdate",
|
||||
]);
|
||||
const MEDIA_NUMBER_FIELDS = ["currentTime", "volume", "playbackRate"] as const;
|
||||
const MEDIA_BOOLEAN_FIELDS = ["paused", "ended", "muted"] as const;
|
||||
|
||||
interface GotoMessage {
|
||||
type: "goto";
|
||||
sequenceId: string;
|
||||
slideIndex: number;
|
||||
fragmentIndex: number;
|
||||
}
|
||||
|
||||
export interface PresenterMediaMessage {
|
||||
type: "media";
|
||||
sender: "presenter" | "audience";
|
||||
key: string;
|
||||
action: PresenterMediaAction;
|
||||
currentTime: number;
|
||||
paused: boolean;
|
||||
ended: boolean;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
}
|
||||
|
||||
function isRecord(data: unknown): data is Record<string, unknown> {
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGotoMessage(data: unknown): data is GotoMessage {
|
||||
if (!isRecord(data)) return false;
|
||||
const d = data as Record<string, unknown>;
|
||||
return (
|
||||
d["type"] === "goto" &&
|
||||
typeof d["sequenceId"] === "string" &&
|
||||
typeof d["slideIndex"] === "number" &&
|
||||
typeof d["fragmentIndex"] === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function isMediaAction(value: unknown): value is PresenterMediaAction {
|
||||
return MEDIA_ACTIONS.has(value);
|
||||
}
|
||||
|
||||
function isMediaSender(value: unknown): value is PresenterMediaMessage["sender"] {
|
||||
return value === "presenter" || value === "audience";
|
||||
}
|
||||
|
||||
function hasMediaNumberFields(data: Record<string, unknown>): boolean {
|
||||
return MEDIA_NUMBER_FIELDS.every((field) => typeof data[field] === "number");
|
||||
}
|
||||
|
||||
function hasMediaBooleanFields(data: Record<string, unknown>): boolean {
|
||||
return MEDIA_BOOLEAN_FIELDS.every((field) => typeof data[field] === "boolean");
|
||||
}
|
||||
|
||||
function isMediaMessage(data: unknown): data is PresenterMediaMessage {
|
||||
if (!isRecord(data)) return false;
|
||||
const d = data;
|
||||
return (
|
||||
d["type"] === "media" &&
|
||||
isMediaSender(d["sender"]) &&
|
||||
typeof d["key"] === "string" &&
|
||||
isMediaAction(d["action"]) &&
|
||||
hasMediaNumberFields(d) &&
|
||||
hasMediaBooleanFields(d)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the BroadcastChannel connection for a single slideshow element.
|
||||
* Presenter (default) mode: posts position updates to the channel.
|
||||
* Audience mode: listens for goto messages and calls the provided handler.
|
||||
*/
|
||||
/**
|
||||
* Per-deck channel name. The presenter and its audience window load the same URL
|
||||
* (path), so keying on pathname keeps them paired while isolating other decks
|
||||
* presenting on the same origin (which would otherwise cross-talk on a fixed name).
|
||||
*/
|
||||
export function slideshowChannelName(): string {
|
||||
const path = typeof location !== "undefined" ? location.pathname : "";
|
||||
return `hf-slideshow:${path}`;
|
||||
}
|
||||
|
||||
export class SlideshowChannel {
|
||||
private channel: BroadcastChannel | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly mode: "presenter" | "audience",
|
||||
private readonly onGoto: (msg: GotoMessage) => void,
|
||||
private readonly onMedia: (msg: PresenterMediaMessage) => void = () => {},
|
||||
) {
|
||||
try {
|
||||
this.channel = new BroadcastChannel(slideshowChannelName());
|
||||
} catch {
|
||||
// BroadcastChannel unavailable (e.g. unsupported env); degrade silently.
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel.onmessage = (e: MessageEvent) => {
|
||||
if (isGotoMessage(e.data)) {
|
||||
if (mode === "audience") {
|
||||
this.onGoto(e.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isMediaMessage(e.data) && e.data.sender !== mode) {
|
||||
this.onMedia(e.data);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
postPosition(pos: PresenterPosition): void {
|
||||
if (this.mode !== "presenter" || !this.channel) return;
|
||||
const msg: GotoMessage = { type: "goto", ...pos };
|
||||
this.channel.postMessage(msg);
|
||||
}
|
||||
|
||||
postMedia(msg: Omit<PresenterMediaMessage, "type" | "sender">): void {
|
||||
if (!this.channel) return;
|
||||
this.channel.postMessage({ type: "media", sender: this.mode, ...msg });
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.channel) {
|
||||
this.channel.onmessage = null;
|
||||
this.channel.close();
|
||||
this.channel = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the presenter-mode bottom panel: speaker notes + up-next + counter +
|
||||
* elapsed. The live slide is shown ABOVE this panel (the component confines the
|
||||
* player to the top region). Returns the panel HTML only — the component appends
|
||||
* the nav controls separately.
|
||||
*/
|
||||
export function buildPresenterLayout(opts: {
|
||||
notes: string;
|
||||
notesStorageKey: string | null;
|
||||
nextText: string;
|
||||
counterText: string;
|
||||
elapsedText: string;
|
||||
hotspots: { id: string; label: string; target: string }[];
|
||||
}): string {
|
||||
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const escAttr = (s: string) => esc(s).replace(/"/g, """);
|
||||
const notes = esc(opts.notes);
|
||||
// Branch entries for the current slide — the presenter clicks these to enter a
|
||||
// branch (the audience follows). The component wires [data-hotspot-id] to
|
||||
// enterBranch(); positioned pills don't align with the letterboxed slide, so
|
||||
// they live in the console as a list.
|
||||
const branches = opts.hotspots.length
|
||||
? `<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.12em;opacity:.55;">Branches</div>
|
||||
${opts.hotspots
|
||||
.map(
|
||||
(h) =>
|
||||
`<button data-hotspot-id="${escAttr(h.id)}" data-hotspot-target="${escAttr(h.target)}" type="button" title="${escAttr(h.label)}" style="text-align:left;background:rgba(244,183,64,0.14);color:#f4b740;border:1px solid rgba(244,183,64,0.4);border-radius:8px;padding:8px 12px;font-size:15px;cursor:pointer;pointer-events:auto;font-family:inherit;">↳ ${esc(h.label)}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`
|
||||
: "";
|
||||
return `
|
||||
<div data-hf-presenter style="position:absolute;left:0;right:0;bottom:0;height:32%;display:flex;background:#11151f;color:#fff;border-top:2px solid rgba(255,255,255,0.12);box-sizing:border-box;font-family:sans-serif;pointer-events:auto;">
|
||||
<textarea data-hf-presenter-notes data-hf-presenter-notes-key="${escAttr(opts.notesStorageKey ?? "")}" aria-label="Speaker notes" placeholder="No notes for this slide" spellcheck="true" style="flex:1;min-width:0;padding:24px 36px;overflow:auto;font:inherit;font-size:21px;line-height:1.55;color:#fff;background:transparent;border:0;outline:none;resize:none;white-space:pre-wrap;pointer-events:auto;">${notes}</textarea>
|
||||
<div style="width:380px;flex-shrink:0;border-left:1px solid rgba(255,255,255,0.12);padding:24px 28px;display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.12em;opacity:.55;">Up next</div>
|
||||
<div data-hf-presenter-next style="font-size:17px;opacity:.9;line-height:1.4;">${esc(opts.nextText)}</div>
|
||||
${branches}
|
||||
<div style="display:flex;gap:34px;margin-top:auto;">
|
||||
<div><div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-bottom:3px;">Slide</div><div data-hf-presenter-counter style="font-family:${COUNTER_FONT_FAMILY};font-size:23px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:0;">${esc(opts.counterText)}</div></div>
|
||||
<div><div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-bottom:3px;">Elapsed</div><div data-hf-presenter-elapsed style="font-family:${COUNTER_FONT_FAMILY};font-size:23px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:0;">${esc(opts.elapsedText)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`.trim();
|
||||
}
|
||||
|
||||
/** Format elapsed seconds as mm:ss */
|
||||
export function formatElapsed(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Vitest setup: install a minimal in-memory BroadcastChannel polyfill so that
|
||||
* happy-dom tests can exercise the presenter/audience channel code path.
|
||||
* This polyfill is intentionally NOT shipped in production code.
|
||||
*/
|
||||
|
||||
type MsgHandler = (event: MessageEvent) => void;
|
||||
|
||||
const registry = new Map<string, Set<InMemoryBroadcastChannel>>();
|
||||
|
||||
class InMemoryBroadcastChannel {
|
||||
onmessage: MsgHandler | null = null;
|
||||
readonly name: string;
|
||||
private _closed = false;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
let set = registry.get(name);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
registry.set(name, set);
|
||||
}
|
||||
set.add(this);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
postMessage(data: unknown): void {
|
||||
if (this._closed) return;
|
||||
const peers = registry.get(this.name);
|
||||
if (!peers) return;
|
||||
for (const peer of peers) {
|
||||
if (peer === this) continue;
|
||||
peer.onmessage?.(new MessageEvent("message", { data }));
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this._closed) return;
|
||||
this._closed = true;
|
||||
registry.get(this.name)?.delete(this);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis.BroadcastChannel === "undefined") {
|
||||
(globalThis as Record<string, unknown>)["BroadcastChannel"] = InMemoryBroadcastChannel;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_resetSharedPlayerStyleSheet,
|
||||
applyPlayerStyles,
|
||||
getSharedPlayerStyleSheet,
|
||||
PLAYER_STYLES,
|
||||
} from "./styles.js";
|
||||
|
||||
type AdoptingShadowRoot = ShadowRoot & {
|
||||
adoptedStyleSheets: CSSStyleSheet[];
|
||||
};
|
||||
|
||||
function createShadowHost(): AdoptingShadowRoot {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
return host.attachShadow({ mode: "open" }) as AdoptingShadowRoot;
|
||||
}
|
||||
|
||||
describe("getSharedPlayerStyleSheet", () => {
|
||||
beforeEach(() => {
|
||||
_resetSharedPlayerStyleSheet();
|
||||
});
|
||||
|
||||
it("returns the same CSSStyleSheet instance across calls", () => {
|
||||
const a = getSharedPlayerStyleSheet();
|
||||
const b = getSharedPlayerStyleSheet();
|
||||
|
||||
expect(a).not.toBeNull();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("returns null and memoizes the failure when CSSStyleSheet is unavailable", () => {
|
||||
const original = globalThis.CSSStyleSheet;
|
||||
(globalThis as { CSSStyleSheet?: unknown }).CSSStyleSheet = undefined;
|
||||
|
||||
try {
|
||||
expect(getSharedPlayerStyleSheet()).toBeNull();
|
||||
expect(getSharedPlayerStyleSheet()).toBeNull();
|
||||
} finally {
|
||||
globalThis.CSSStyleSheet = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPlayerStyles", () => {
|
||||
beforeEach(() => {
|
||||
_resetSharedPlayerStyleSheet();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("adopts the shared sheet on a fresh shadow root and adds no <style> element", () => {
|
||||
const shadow = createShadowHost();
|
||||
|
||||
applyPlayerStyles(shadow);
|
||||
|
||||
const sheet = getSharedPlayerStyleSheet();
|
||||
expect(sheet).not.toBeNull();
|
||||
expect(shadow.adoptedStyleSheets).toContain(sheet);
|
||||
expect(shadow.querySelector("style")).toBeNull();
|
||||
});
|
||||
|
||||
it("shares one CSSStyleSheet across multiple shadow roots", () => {
|
||||
const shadowA = createShadowHost();
|
||||
const shadowB = createShadowHost();
|
||||
|
||||
applyPlayerStyles(shadowA);
|
||||
applyPlayerStyles(shadowB);
|
||||
|
||||
const adoptedA = shadowA.adoptedStyleSheets.at(-1);
|
||||
const adoptedB = shadowB.adoptedStyleSheets.at(-1);
|
||||
|
||||
expect(adoptedA).toBeDefined();
|
||||
expect(adoptedA).toBe(adoptedB);
|
||||
});
|
||||
|
||||
it("preserves any pre-existing adopted stylesheets", () => {
|
||||
const shadow = createShadowHost();
|
||||
const existing = new CSSStyleSheet();
|
||||
existing.replaceSync(":host { --pre: 1; }");
|
||||
shadow.adoptedStyleSheets = [existing];
|
||||
|
||||
applyPlayerStyles(shadow);
|
||||
|
||||
expect(shadow.adoptedStyleSheets[0]).toBe(existing);
|
||||
expect(shadow.adoptedStyleSheets).toContain(getSharedPlayerStyleSheet());
|
||||
expect(shadow.adoptedStyleSheets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("is idempotent when called repeatedly on the same shadow root", () => {
|
||||
const shadow = createShadowHost();
|
||||
|
||||
applyPlayerStyles(shadow);
|
||||
applyPlayerStyles(shadow);
|
||||
applyPlayerStyles(shadow);
|
||||
|
||||
expect(shadow.adoptedStyleSheets).toHaveLength(1);
|
||||
expect(shadow.querySelectorAll("style")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to a <style> element when adoptedStyleSheets is unsupported", () => {
|
||||
const shadow = createShadowHost();
|
||||
Object.defineProperty(shadow, "adoptedStyleSheets", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
set() {
|
||||
throw new Error("adoptedStyleSheets is not supported in this environment");
|
||||
},
|
||||
});
|
||||
|
||||
applyPlayerStyles(shadow);
|
||||
|
||||
const styleEl = shadow.querySelector("style");
|
||||
expect(styleEl).not.toBeNull();
|
||||
expect(styleEl?.textContent).toBe(PLAYER_STYLES);
|
||||
});
|
||||
|
||||
it("falls back to a <style> element when CSSStyleSheet is unavailable", () => {
|
||||
const original = globalThis.CSSStyleSheet;
|
||||
(globalThis as { CSSStyleSheet?: unknown }).CSSStyleSheet = undefined;
|
||||
|
||||
try {
|
||||
const shadow = createShadowHost();
|
||||
applyPlayerStyles(shadow);
|
||||
|
||||
const styleEl = shadow.querySelector("style");
|
||||
expect(styleEl).not.toBeNull();
|
||||
expect(styleEl?.textContent).toBe(PLAYER_STYLES);
|
||||
} finally {
|
||||
globalThis.CSSStyleSheet = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a <style> element when replaceSync throws", () => {
|
||||
const replaceSyncSpy = vi
|
||||
.spyOn(CSSStyleSheet.prototype, "replaceSync")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("simulated replaceSync failure");
|
||||
});
|
||||
|
||||
try {
|
||||
const shadow = createShadowHost();
|
||||
applyPlayerStyles(shadow);
|
||||
|
||||
expect(shadow.querySelector("style")?.textContent).toBe(PLAYER_STYLES);
|
||||
} finally {
|
||||
replaceSyncSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,544 @@
|
||||
export const PLAYER_STYLES = /* css */ `
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.hfp-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.hfp-iframe {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Opt-in: an interactive composition (e.g. a live slideshow/app with playable
|
||||
media or controls) — let pointer events reach the iframe content. */
|
||||
:host([interactive]) .hfp-container,
|
||||
:host([interactive]) .hfp-iframe {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hfp-poster {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
object-fit: contain;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-shader-loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: #030504;
|
||||
color: #f4f7fb;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: opacity 420ms ease-out, visibility 420ms ease-out;
|
||||
}
|
||||
|
||||
.hfp-shader-loader.hfp-visible,
|
||||
.hfp-shader-loader.hfp-hiding {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.hfp-shader-loader.hfp-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hfp-shader-loader.hfp-hiding {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-panel {
|
||||
display: grid;
|
||||
grid-template-rows: 86px 40px 26px 12px 44px;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(620px, 82%);
|
||||
text-align: center;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-mark {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-mark svg {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 5px rgba(79, 219, 94, 0.16));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-title {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 26px;
|
||||
line-height: 40px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-title-text {
|
||||
color: transparent;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(244, 247, 251, 0.84) 0%,
|
||||
#ffffff 42%,
|
||||
#80efe4 52%,
|
||||
#ffffff 62%,
|
||||
rgba(244, 247, 251, 0.84) 100%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: hfp-shader-loader-sheen 1.9s linear infinite;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-detail {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: rgba(244, 247, 251, 0.62);
|
||||
font-size: 15px;
|
||||
line-height: 26px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-track {
|
||||
width: min(360px, 100%);
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hfp-shader-loader-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #06e3fa, #4fdb5e);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-progress {
|
||||
width: min(420px, 100%);
|
||||
height: 44px;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, 22px);
|
||||
color: rgba(244, 247, 251, 0.48);
|
||||
font: 600 13px/22px "IBM Plex Mono", "SF Mono", "Fira Code", "Courier New", monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 74px;
|
||||
align-items: center;
|
||||
column-gap: 20px;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@keyframes hfp-shader-loader-sheen {
|
||||
from {
|
||||
background-position: 140% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -140% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Theming via CSS custom properties ──
|
||||
*
|
||||
* Override from outside the shadow DOM:
|
||||
* hyperframes-player {
|
||||
* --hfp-controls-bg: linear-gradient(transparent, rgba(0,0,0,0.9));
|
||||
* --hfp-accent: #ff6b6b;
|
||||
* --hfp-font: "Inter", sans-serif;
|
||||
* }
|
||||
*/
|
||||
|
||||
.hfp-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hfp-controls-gap, 12px);
|
||||
padding: var(--hfp-controls-padding, 8px 16px);
|
||||
background: var(--hfp-controls-bg, linear-gradient(transparent, rgba(0, 0, 0, 0.7)));
|
||||
color: var(--hfp-color, #fff);
|
||||
font-family: var(--hfp-font, system-ui, -apple-system, sans-serif);
|
||||
font-size: var(--hfp-font-size, 13px);
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hfp-controls.hfp-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-play-btn {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--hfp-color, #fff);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hfp-play-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Stacked play/pause glyphs that crossfade-morph on toggle (rotate + scale). */
|
||||
.hfp-play-btn .hfp-ico {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
opacity 200ms ease,
|
||||
transform 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.hfp-play-btn .hfp-ico-play {
|
||||
opacity: 1;
|
||||
transform: rotate(0) scale(1);
|
||||
}
|
||||
.hfp-play-btn .hfp-ico-pause {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg) scale(0.4);
|
||||
}
|
||||
.hfp-play-btn.hfp-playing .hfp-ico-play {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg) scale(0.4);
|
||||
}
|
||||
.hfp-play-btn.hfp-playing .hfp-ico-pause {
|
||||
opacity: 1;
|
||||
transform: rotate(0) scale(1);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hfp-play-btn .hfp-ico {
|
||||
transition-duration: 0ms;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hfp-play-btn svg,
|
||||
.hfp-play-btn svg * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-scrubber {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: var(--hfp-scrubber-height, 4px);
|
||||
background: var(--hfp-scrubber-bg, rgba(255, 255, 255, 0.3));
|
||||
border-radius: var(--hfp-scrubber-radius, 2px);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hfp-scrubber:hover {
|
||||
height: var(--hfp-scrubber-height-hover, 6px);
|
||||
}
|
||||
|
||||
.hfp-progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--hfp-accent, #fff);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-time {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hfp-speed-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hfp-speed-btn {
|
||||
background: var(--hfp-speed-btn-bg, rgba(255, 255, 255, 0.15));
|
||||
border: none;
|
||||
border-radius: var(--hfp-speed-btn-radius, 4px);
|
||||
color: var(--hfp-color, #fff);
|
||||
cursor: pointer;
|
||||
font-family: var(--hfp-font, system-ui, -apple-system, sans-serif);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hfp-speed-btn:hover {
|
||||
background: var(--hfp-speed-btn-bg-hover, rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.hfp-speed-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: var(--hfp-menu-bg, rgba(20, 20, 20, 0.95));
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--hfp-menu-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: var(--hfp-menu-radius, 8px);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 80px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||
box-shadow: var(--hfp-menu-shadow, 0 8px 24px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.hfp-speed-menu.hfp-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.hfp-speed-option {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--hfp-menu-color, rgba(255, 255, 255, 0.7));
|
||||
cursor: pointer;
|
||||
font-family: var(--hfp-font, system-ui, -apple-system, sans-serif);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
transition: background 0.1s ease, color 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hfp-speed-option:hover {
|
||||
background: var(--hfp-menu-hover-bg, rgba(255, 255, 255, 0.1));
|
||||
color: var(--hfp-color, #fff);
|
||||
}
|
||||
|
||||
.hfp-speed-option.hfp-active {
|
||||
color: var(--hfp-accent, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hfp-volume-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hfp-mute-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--hfp-color, #fff);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hfp-mute-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hfp-mute-btn svg,
|
||||
.hfp-mute-btn svg * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-volume-slider-wrap {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hfp-volume-wrap:hover .hfp-volume-slider-wrap {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.hfp-volume-slider {
|
||||
width: 56px;
|
||||
height: var(--hfp-scrubber-height, 4px);
|
||||
background: var(--hfp-scrubber-bg, rgba(255, 255, 255, 0.3));
|
||||
border-radius: var(--hfp-scrubber-radius, 2px);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.hfp-volume-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--hfp-accent, #fff);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
// Play glyph: the right-hand blade from the HyperFrames favicon, framed to its
|
||||
// bounding box so it fills the icon. Points right, like a play triangle.
|
||||
export const PLAY_ICON = `<svg width="24" height="24" viewBox="46 21 54 56" fill="currentColor"><path d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"/></svg>`;
|
||||
export const PAUSE_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><rect x="3" y="2" width="4" height="14"/><rect x="11" y="2" width="4" height="14"/></svg>`;
|
||||
export const VOLUME_HIGH_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/><path d="M14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>`;
|
||||
export const VOLUME_LOW_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>`;
|
||||
export const VOLUME_MUTED_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" opacity="0.3"/><line x1="18" y1="7" x2="14" y2="17" stroke="currentColor" stroke-width="2"/></svg>`;
|
||||
|
||||
/**
|
||||
* Process-wide cache for the constructed PLAYER_STYLES sheet. Lazy so the
|
||||
* module stays SSR-safe (CSSStyleSheet is window-scoped) and so a single
|
||||
* sheet can be shared across every shadow root via `adoptedStyleSheets` —
|
||||
* the studio thumbnail grid renders dozens of players, and avoiding N
|
||||
* duplicate `<style>` parses + style-recalc invalidations is the win here.
|
||||
*
|
||||
* `null` after a failed construction attempt = "fall back forever in this
|
||||
* process" (the usual cause is a missing constructor in older runtimes;
|
||||
* retrying every call would just throw the same way).
|
||||
*/
|
||||
let sharedSheet: CSSStyleSheet | null | undefined;
|
||||
|
||||
/**
|
||||
* Returns the shared player stylesheet, or `null` if constructable
|
||||
* stylesheets aren't available in this environment.
|
||||
*
|
||||
* The result is memoized for the life of the module — every shadow root
|
||||
* adopts the same `CSSStyleSheet` instance.
|
||||
*/
|
||||
export function getSharedPlayerStyleSheet(): CSSStyleSheet | null {
|
||||
if (sharedSheet !== undefined) return sharedSheet;
|
||||
|
||||
if (typeof CSSStyleSheet === "undefined") {
|
||||
sharedSheet = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(PLAYER_STYLES);
|
||||
sharedSheet = sheet;
|
||||
return sheet;
|
||||
} catch {
|
||||
sharedSheet = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal hook for tests to clear the memoized sheet. Not part of the
|
||||
* public API.
|
||||
*/
|
||||
export function _resetSharedPlayerStyleSheet(): void {
|
||||
sharedSheet = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install PLAYER_STYLES into a player shadow root. Prefers the shared
|
||||
* constructable stylesheet (one parse, one rule tree, N adopters) and
|
||||
* falls back to a per-instance `<style>` element when the host runtime
|
||||
* lacks `adoptedStyleSheets` support.
|
||||
*
|
||||
* Idempotent: re-applying to a root that already adopts the shared sheet
|
||||
* is a no-op. Pre-existing adopted sheets are preserved (we append, never
|
||||
* replace), so callers further up the chain can keep their styles.
|
||||
*/
|
||||
export function applyPlayerStyles(shadow: ShadowRoot): void {
|
||||
const sheet = getSharedPlayerStyleSheet();
|
||||
const adopted = (shadow as ShadowRoot & { adoptedStyleSheets?: CSSStyleSheet[] })
|
||||
.adoptedStyleSheets;
|
||||
|
||||
if (sheet && Array.isArray(adopted)) {
|
||||
if (!adopted.includes(sheet)) {
|
||||
(shadow as ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] }).adoptedStyleSheets = [
|
||||
...adopted,
|
||||
sheet,
|
||||
];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = PLAYER_STYLES;
|
||||
shadow.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Types and type-guards for the two playback adapter paths the player supports:
|
||||
*
|
||||
* - `RuntimeDurationAdapter` — the HyperFrames runtime exposes `window.__player`
|
||||
* with a `getDuration()` method. This is the standard path for compositions
|
||||
* served through the runtime bridge.
|
||||
*
|
||||
* - `DirectTimelineAdapter` — same-origin standalone compositions can expose
|
||||
* their GSAP master timeline at `window.__timelines` without installing the
|
||||
* full runtime. The player drives play/pause/seek directly against the
|
||||
* timeline object, bypassing the postMessage bridge.
|
||||
*
|
||||
* `PlaybackDurationAdapter` is the discriminated union the probe interval
|
||||
* returns after deciding which path is available.
|
||||
*/
|
||||
|
||||
export interface RuntimeDurationAdapter {
|
||||
getDuration: () => number;
|
||||
}
|
||||
|
||||
export interface DirectTimelineAdapter {
|
||||
duration: () => number;
|
||||
time: () => number;
|
||||
// suppressEvents mirrors GSAP's timeline.seek(position, suppressEvents); pass
|
||||
// false to fire onUpdate (so imperative-visibility compositions repaint on seek).
|
||||
seek: (timeInSeconds: number, suppressEvents?: boolean) => unknown;
|
||||
play: () => unknown;
|
||||
pause: () => unknown;
|
||||
/** Optional: set playback rate (e.g. GSAP's timeScale). Called when the player's playbackRate changes. */
|
||||
timeScale?: (scale: number) => unknown;
|
||||
}
|
||||
|
||||
export type PlaybackDurationAdapter =
|
||||
| { kind: "runtime"; getDuration: () => number }
|
||||
| { kind: "direct-timeline"; timeline: DirectTimelineAdapter; getDuration: () => number };
|
||||
|
||||
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function isRuntimeDurationAdapter(value: unknown): value is RuntimeDurationAdapter {
|
||||
return isObjectRecord(value) && typeof value.getDuration === "function";
|
||||
}
|
||||
|
||||
export function isDirectTimelineAdapter(value: unknown): value is DirectTimelineAdapter {
|
||||
return (
|
||||
isObjectRecord(value) &&
|
||||
typeof value.duration === "function" &&
|
||||
typeof value.time === "function" &&
|
||||
typeof value.seek === "function" &&
|
||||
typeof value.play === "function" &&
|
||||
typeof value.pause === "function"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compLoadColdP95Ms": 2000,
|
||||
"compLoadWarmP95Ms": 1000,
|
||||
"compositionTimeAdvancementRatioMin": 0.95,
|
||||
"scrubLatencyP95IsolatedMs": 80,
|
||||
"scrubLatencyP95InlineMs": 33,
|
||||
"driftMaxMs": 500,
|
||||
"driftP95Ms": 100,
|
||||
"paritySsimMin": 0.93,
|
||||
"allowedRegressionRatio": 0.1
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>perf fixture: 10-video-grid</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #050714;
|
||||
color: #e6e6f0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
background: #111827;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
will-change: transform;
|
||||
}
|
||||
.tile video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.tile .label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
font:
|
||||
600 14px/1 system-ui,
|
||||
sans-serif;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<script src="/vendor/gsap.min.js"></script>
|
||||
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="10"
|
||||
data-fps="30"
|
||||
></div>
|
||||
<script>
|
||||
(function () {
|
||||
var TILE_COUNT = 10;
|
||||
var DURATION_SEC = 10;
|
||||
|
||||
var root = document.getElementById("root");
|
||||
var tiles = [];
|
||||
for (var i = 0; i < TILE_COUNT; i++) {
|
||||
var tile = document.createElement("div");
|
||||
tile.className = "tile";
|
||||
tile.id = "tile-" + i;
|
||||
|
||||
var label = document.createElement("div");
|
||||
label.className = "label";
|
||||
label.textContent = "video " + (i + 1);
|
||||
tile.appendChild(label);
|
||||
|
||||
var video = document.createElement("video");
|
||||
video.id = "video-" + i;
|
||||
video.setAttribute("data-start", "0");
|
||||
video.setAttribute("data-duration", String(DURATION_SEC));
|
||||
video.setAttribute("data-track-index", String(i));
|
||||
video.setAttribute("src", "sample.mp4");
|
||||
video.setAttribute("preload", "auto");
|
||||
video.setAttribute("playsinline", "");
|
||||
video.muted = true;
|
||||
tile.appendChild(video);
|
||||
|
||||
root.appendChild(tile);
|
||||
tiles.push(tile);
|
||||
}
|
||||
|
||||
// Lightweight parent timeline so the player has a non-empty composition
|
||||
// to drive. Each tile gets a subtle scale "breath" over the full
|
||||
// duration — enough to keep GSAP scrubbing real properties without
|
||||
// dominating the rAF budget that the video decoder needs.
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
for (var j = 0; j < tiles.length; j++) {
|
||||
tl.fromTo(
|
||||
tiles[j],
|
||||
{ scale: 0.985 },
|
||||
{ scale: 1, duration: DURATION_SEC, ease: "sine.inOut" },
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>perf fixture: gsap-heavy</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #0b0b12;
|
||||
color: #e6e6f0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tile {
|
||||
position: absolute;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #4f46e5, #ec4899);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
</style>
|
||||
<script src="/vendor/gsap.min.js"></script>
|
||||
<script data-hyperframes-runtime="1" src="/vendor/hyperframe.runtime.iife.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="10"
|
||||
data-fps="60"
|
||||
></div>
|
||||
<script>
|
||||
(function () {
|
||||
var TILE_COUNT = 60;
|
||||
var DURATION_SEC = 10;
|
||||
var COLS = 12;
|
||||
var ROWS = 5;
|
||||
var TILE = 96;
|
||||
var GAP_X = 1920 / COLS;
|
||||
var GAP_Y = 1080 / ROWS;
|
||||
|
||||
var root = document.getElementById("root");
|
||||
var tiles = [];
|
||||
for (var i = 0; i < TILE_COUNT; i++) {
|
||||
var col = i % COLS;
|
||||
var row = Math.floor(i / COLS);
|
||||
var el = document.createElement("div");
|
||||
el.className = "tile";
|
||||
el.style.left = col * GAP_X + (GAP_X - TILE) / 2 + "px";
|
||||
el.style.top = row * GAP_Y + (GAP_Y - TILE) / 2 + "px";
|
||||
el.setAttribute("data-tile-index", String(i));
|
||||
root.appendChild(el);
|
||||
tiles.push(el);
|
||||
}
|
||||
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
for (var j = 0; j < tiles.length; j++) {
|
||||
var t = tiles[j];
|
||||
var phase = j / tiles.length;
|
||||
var start = phase * (DURATION_SEC - 4);
|
||||
tl.to(
|
||||
t,
|
||||
{
|
||||
x: 200 * Math.cos(phase * Math.PI * 2),
|
||||
y: 120 * Math.sin(phase * Math.PI * 2),
|
||||
rotation: 360,
|
||||
scale: 1.4,
|
||||
opacity: 0.6,
|
||||
borderRadius: "48px",
|
||||
duration: 2,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
start,
|
||||
);
|
||||
tl.to(
|
||||
t,
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
borderRadius: "12px",
|
||||
duration: 2,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
start + 2,
|
||||
);
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Player Performance Test Runner
|
||||
*
|
||||
* Boots a static server, launches puppeteer-core against locally-served fixtures,
|
||||
* runs the configured scenarios, then evaluates the collected metrics against
|
||||
* baseline.json via perf-gate.
|
||||
*
|
||||
* Usage:
|
||||
* bun run packages/player/tests/perf/index.ts
|
||||
* bun run packages/player/tests/perf/index.ts --mode enforce
|
||||
* bun run packages/player/tests/perf/index.ts --scenarios load
|
||||
* bun run packages/player/tests/perf/index.ts --runs 5 --headful
|
||||
*
|
||||
* Flags:
|
||||
* --mode <measure|enforce> default: PLAYER_PERF_MODE env or "measure"
|
||||
* --scenarios <list> comma-separated scenario ids; default: all enabled
|
||||
* --runs <n> override per-scenario run count
|
||||
* --fixture <name> single fixture (default: every fixture in fixtures/)
|
||||
* --headful show the browser; default: headless
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 all pass (or measure mode)
|
||||
* 1 scenario crashed
|
||||
* 2 perf gate failed in enforce mode
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runFps } from "./scenarios/02-fps.ts";
|
||||
import { runLoad } from "./scenarios/03-load.ts";
|
||||
import { runScrub } from "./scenarios/04-scrub.ts";
|
||||
import { runDrift } from "./scenarios/05-drift.ts";
|
||||
import { runParity } from "./scenarios/06-parity.ts";
|
||||
import { reportAndGate, type GateMode, type GateResult, type Metric } from "./perf-gate.ts";
|
||||
import { launchBrowser } from "./runner.ts";
|
||||
import { startServer } from "./server.ts";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const RESULTS_DIR = resolve(HERE, "results");
|
||||
const RESULTS_FILE = resolve(RESULTS_DIR, "metrics.json");
|
||||
|
||||
type ScenarioId = "load" | "fps" | "scrub" | "drift" | "parity";
|
||||
|
||||
/**
|
||||
* Per-scenario default `runs` value when the caller didn't pass `--runs`.
|
||||
*
|
||||
* Why `load` gets 5 runs and the others get 3:
|
||||
*
|
||||
* - `load` reports a single p95 over `runs` measurements, so each `run` is
|
||||
* one sample. p95 over n=3 is mostly noise (the 95th percentile of three
|
||||
* numbers is just `max`), so we bump it to 5. We considered 10 — but cold
|
||||
* load is the slowest scenario in the shard (~2s × 5 runs × 2 fixtures =
|
||||
* ~20s with disk cache cleared), and going to 10 would push the load shard
|
||||
* past 30s of pure-measurement wall time per CI invocation.
|
||||
* - `fps` aggregates as `min(ratio)` over runs — 3 runs gives us a worst-
|
||||
* of-three signal, which is what we want for a floor metric. Adding more
|
||||
* runs would only make the ratio strictly smaller (more chances to catch
|
||||
* a stall) and shift the threshold toward false positives from runner
|
||||
* contention rather than real regressions.
|
||||
* - `scrub` and `drift` *pool* their per-run samples (10 seeks/run for
|
||||
* scrub, ~1500 RVFC frames/run for drift) and compute the percentile over
|
||||
* the pooled set. Their effective sample count for the percentile is
|
||||
* `runs × samples_per_run`, not `runs`, so 3 runs already gives 30+ scrub
|
||||
* samples and 4500+ drift samples per shard — well above the n≈30 rule of
|
||||
* thumb for a stable p95.
|
||||
*
|
||||
* TODO(player-perf): revisit `fps: 3` once we have ~2 weeks of CI baseline
|
||||
* data — if `min(ratio)` shows >5% inter-run variance attributable to runner
|
||||
* jitter (not real player regressions), bump to 5 and tighten the
|
||||
* `compositionTimeAdvancementRatioMin` baseline accordingly.
|
||||
*/
|
||||
const DEFAULT_RUNS: Record<ScenarioId, number> = {
|
||||
load: 5,
|
||||
fps: 3,
|
||||
scrub: 3,
|
||||
drift: 3,
|
||||
parity: 3,
|
||||
};
|
||||
|
||||
type ResultsFile = {
|
||||
schemaVersion: 1;
|
||||
timestamp: string;
|
||||
gitSha: string | null;
|
||||
mode: GateMode;
|
||||
scenarios: ScenarioId[];
|
||||
runs: number | null;
|
||||
fixture: string | null;
|
||||
crashed: boolean;
|
||||
passed: boolean;
|
||||
metrics: Metric[];
|
||||
gate: GateResult[];
|
||||
};
|
||||
|
||||
function readGitSha(): string | null {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeResults(file: ResultsFile): void {
|
||||
if (!existsSync(RESULTS_DIR)) {
|
||||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||||
}
|
||||
writeFileSync(RESULTS_FILE, JSON.stringify(file, null, 2) + "\n");
|
||||
console.log(`[player-perf] wrote results to ${RESULTS_FILE}`);
|
||||
}
|
||||
|
||||
type ParsedArgs = {
|
||||
mode: GateMode;
|
||||
scenarios: ScenarioId[];
|
||||
runs: number | null;
|
||||
fixture: string | null;
|
||||
headful: boolean;
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const result: ParsedArgs = {
|
||||
// TODO(player-perf): once baselines have settled on CI for ~1–2 weeks and we
|
||||
// are confident there are no false positives from runner jitter, flip this
|
||||
// default from "measure" to "enforce" — that single line + bumping the
|
||||
// workflow's `--mode=measure` flag in .github/workflows/player-perf.yml is
|
||||
// the entire opt-in. See packages/player/tests/perf/perf-gate.ts for how
|
||||
// `mode` is consumed (measure logs regressions but never fails; enforce
|
||||
// exits non-zero on regression).
|
||||
mode: (process.env.PLAYER_PERF_MODE as GateMode) === "enforce" ? "enforce" : "measure",
|
||||
scenarios: ["load", "fps", "scrub", "drift", "parity"],
|
||||
runs: null,
|
||||
fixture: null,
|
||||
headful: false,
|
||||
};
|
||||
// Normalize `--key=value` into `[--key, value]` so the rest of the loop
|
||||
// only has to handle the space-separated form.
|
||||
const tokens: string[] = [];
|
||||
for (const raw of argv.slice(2)) {
|
||||
if (raw.startsWith("--") && raw.includes("=")) {
|
||||
const eq = raw.indexOf("=");
|
||||
tokens.push(raw.slice(0, eq), raw.slice(eq + 1));
|
||||
} else {
|
||||
tokens.push(raw);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const arg = tokens[i];
|
||||
const next = tokens[i + 1];
|
||||
if (arg === "--mode" && next) {
|
||||
if (next !== "measure" && next !== "enforce") {
|
||||
throw new Error(`--mode must be measure|enforce, got ${next}`);
|
||||
}
|
||||
result.mode = next;
|
||||
i++;
|
||||
} else if (arg === "--scenarios" && next) {
|
||||
result.scenarios = next.split(",").map((s) => s.trim()) as ScenarioId[];
|
||||
i++;
|
||||
} else if (arg === "--runs" && next) {
|
||||
result.runs = parseInt(next, 10);
|
||||
i++;
|
||||
} else if (arg === "--fixture" && next) {
|
||||
result.fixture = next;
|
||||
i++;
|
||||
} else if (arg === "--headful") {
|
||||
result.headful = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv);
|
||||
console.log(
|
||||
`[player-perf] starting: mode=${args.mode} scenarios=${args.scenarios.join(",")} runs=${args.runs ?? "default"} fixture=${args.fixture ?? "all"}`,
|
||||
);
|
||||
|
||||
const server = startServer();
|
||||
console.log(`[player-perf] server listening at ${server.origin}`);
|
||||
|
||||
const browser = await launchBrowser({ headless: !args.headful });
|
||||
console.log("[player-perf] browser launched");
|
||||
|
||||
const metrics: Metric[] = [];
|
||||
let crashed = false;
|
||||
|
||||
try {
|
||||
for (const scenario of args.scenarios) {
|
||||
if (scenario === "load") {
|
||||
const m = await runLoad({
|
||||
browser,
|
||||
origin: server.origin,
|
||||
runs: args.runs ?? DEFAULT_RUNS.load,
|
||||
fixture: args.fixture,
|
||||
});
|
||||
metrics.push(...m);
|
||||
} else if (scenario === "fps") {
|
||||
const m = await runFps({
|
||||
browser,
|
||||
origin: server.origin,
|
||||
runs: args.runs ?? DEFAULT_RUNS.fps,
|
||||
fixture: args.fixture,
|
||||
});
|
||||
metrics.push(...m);
|
||||
} else if (scenario === "scrub") {
|
||||
const m = await runScrub({
|
||||
browser,
|
||||
origin: server.origin,
|
||||
runs: args.runs ?? DEFAULT_RUNS.scrub,
|
||||
fixture: args.fixture,
|
||||
});
|
||||
metrics.push(...m);
|
||||
} else if (scenario === "drift") {
|
||||
const m = await runDrift({
|
||||
browser,
|
||||
origin: server.origin,
|
||||
runs: args.runs ?? DEFAULT_RUNS.drift,
|
||||
fixture: args.fixture,
|
||||
});
|
||||
metrics.push(...m);
|
||||
} else if (scenario === "parity") {
|
||||
const m = await runParity({
|
||||
browser,
|
||||
origin: server.origin,
|
||||
runs: args.runs ?? DEFAULT_RUNS.parity,
|
||||
fixture: args.fixture,
|
||||
});
|
||||
metrics.push(...m);
|
||||
} else {
|
||||
console.warn(`[player-perf] unknown scenario: ${scenario}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
crashed = true;
|
||||
console.error("[player-perf] scenario crashed:", err);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.stop();
|
||||
}
|
||||
|
||||
let report: { passed: boolean; rows: GateResult[] } = { passed: !crashed, rows: [] };
|
||||
if (!crashed && metrics.length > 0) {
|
||||
report = reportAndGate(metrics, args.mode);
|
||||
}
|
||||
|
||||
writeResults({
|
||||
schemaVersion: 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
gitSha: readGitSha(),
|
||||
mode: args.mode,
|
||||
scenarios: args.scenarios,
|
||||
runs: args.runs,
|
||||
fixture: args.fixture,
|
||||
crashed,
|
||||
passed: report.passed && !crashed,
|
||||
metrics,
|
||||
gate: report.rows,
|
||||
});
|
||||
|
||||
if (crashed) {
|
||||
process.exit(1);
|
||||
}
|
||||
if (!report.passed) {
|
||||
process.exit(2);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[player-perf] fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Compares measured perf metrics against baseline.json with an allowed regression ratio.
|
||||
*
|
||||
* Mirrors packages/producer/src/perf-gate.ts: each metric has a baseline value, the
|
||||
* gate computes `max = baseline * (1 + allowedRegressionRatio)`, and any measured
|
||||
* value above max counts as a regression. In "measure" mode the script logs but
|
||||
* never exits non-zero — useful for the first runs while we collect realistic
|
||||
* baselines on the CI runner. Flip to "enforce" once baselines are committed.
|
||||
*/
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_BASELINE_PATH = resolve(HERE, "baseline.json");
|
||||
|
||||
export type Direction = "lower-is-better" | "higher-is-better";
|
||||
|
||||
export type Metric = {
|
||||
/** Display name, e.g. "comp_load_cold_p95_ms" */
|
||||
name: string;
|
||||
/** Key into baseline.json, e.g. "compLoadColdP95Ms" */
|
||||
baselineKey: keyof PerfBaseline;
|
||||
value: number;
|
||||
unit: string;
|
||||
direction: Direction;
|
||||
samples?: number[];
|
||||
};
|
||||
|
||||
export type PerfBaseline = {
|
||||
compLoadColdP95Ms: number;
|
||||
compLoadWarmP95Ms: number;
|
||||
/**
|
||||
* Floor on `(compositionTime advanced) / (wallClock elapsed)` over a sustained
|
||||
* playback window — see packages/player/tests/perf/scenarios/02-fps.ts. A
|
||||
* healthy player keeps up with its intended speed and reads ~1.0; values
|
||||
* below 1.0 mean the composition clock fell behind real time, which is the
|
||||
* actual user-visible jank we want to gate against. Refresh-rate independent
|
||||
* by construction, so it does not saturate to display refresh on high-Hz
|
||||
* runners the way the previous `fpsMin` did. Direction: higher-is-better.
|
||||
*/
|
||||
compositionTimeAdvancementRatioMin: number;
|
||||
scrubLatencyP95IsolatedMs: number;
|
||||
scrubLatencyP95InlineMs: number;
|
||||
driftMaxMs: number;
|
||||
driftP95Ms: number;
|
||||
paritySsimMin: number;
|
||||
allowedRegressionRatio: number;
|
||||
};
|
||||
|
||||
export type GateMode = "measure" | "enforce";
|
||||
|
||||
export type GateResult = {
|
||||
metric: Metric;
|
||||
baseline: number;
|
||||
threshold: number;
|
||||
passed: boolean;
|
||||
ratio: number;
|
||||
};
|
||||
|
||||
export function loadBaseline(path?: string): PerfBaseline {
|
||||
const baselinePath = path ?? process.env.PLAYER_PERF_BASELINE_PATH ?? DEFAULT_BASELINE_PATH;
|
||||
const raw = readFileSync(baselinePath, "utf-8");
|
||||
return JSON.parse(raw) as PerfBaseline;
|
||||
}
|
||||
|
||||
export function evaluateMetric(metric: Metric, baseline: PerfBaseline): GateResult {
|
||||
const baselineValue = baseline[metric.baselineKey];
|
||||
if (typeof baselineValue !== "number") {
|
||||
throw new Error(`[player-perf] baseline missing numeric key: ${String(metric.baselineKey)}`);
|
||||
}
|
||||
const allowed = baseline.allowedRegressionRatio;
|
||||
const threshold =
|
||||
metric.direction === "lower-is-better"
|
||||
? baselineValue * (1 + allowed)
|
||||
: baselineValue * (1 - allowed);
|
||||
const passed =
|
||||
metric.direction === "lower-is-better" ? metric.value <= threshold : metric.value >= threshold;
|
||||
const ratio = baselineValue === 0 ? 0 : metric.value / baselineValue;
|
||||
return { metric, baseline: baselineValue, threshold, passed, ratio };
|
||||
}
|
||||
|
||||
export type GateReport = {
|
||||
passed: boolean;
|
||||
rows: GateResult[];
|
||||
};
|
||||
|
||||
export function reportAndGate(
|
||||
metrics: Metric[],
|
||||
// `mode` is resolved upstream in packages/player/tests/perf/index.ts
|
||||
// (`parseArgs`): the default comes from PLAYER_PERF_MODE env or "measure", and
|
||||
// the CLI flag `--mode=measure|enforce` overrides it. The "flip to enforce"
|
||||
// TODO lives at that call site so it is a one-line change.
|
||||
mode: GateMode,
|
||||
baselinePath?: string,
|
||||
): GateReport {
|
||||
const baseline = loadBaseline(baselinePath);
|
||||
const rows = metrics.map((m) => evaluateMetric(m, baseline));
|
||||
console.log("[PerfGate] mode=" + mode);
|
||||
for (const row of rows) {
|
||||
const status = row.passed ? "PASS" : "FAIL";
|
||||
const dir = row.metric.direction === "lower-is-better" ? "≤" : "≥";
|
||||
console.log(
|
||||
`[PerfGate] ${status} ${row.metric.name} = ${row.metric.value.toFixed(2)}${row.metric.unit} (baseline=${row.baseline}${row.metric.unit}, threshold ${dir} ${row.threshold.toFixed(2)}${row.metric.unit}, ratio=${row.ratio.toFixed(3)})`,
|
||||
);
|
||||
}
|
||||
const failed = rows.filter((r) => !r.passed);
|
||||
if (failed.length === 0) return { passed: true, rows };
|
||||
if (mode === "measure") {
|
||||
console.log(`[PerfGate] ${failed.length} regression(s) detected — measure mode, not failing`);
|
||||
return { passed: true, rows };
|
||||
}
|
||||
console.error(`[PerfGate] ${failed.length} regression(s) detected — enforce mode, failing`);
|
||||
return { passed: false, rows };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import puppeteer, { type Browser, type LaunchOptions, type Page } from "puppeteer-core";
|
||||
|
||||
/**
|
||||
* Puppeteer browser + page helpers shared across all perf scenarios.
|
||||
*
|
||||
* Browser launch args mirror packages/producer/src/parity-harness.ts so we get
|
||||
* the same SwiftShader-backed WebGL output and font hinting between perf runs
|
||||
* and visual parity runs. That parity matters for P0-1c (live-playback parity)
|
||||
* and is harmless for the load/scrub/drift scenarios.
|
||||
*/
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PLAYER_PKG = resolve(HERE, "../..");
|
||||
|
||||
export type LaunchOpts = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
headless?: boolean;
|
||||
};
|
||||
|
||||
export type LoadOpts = {
|
||||
/** Fixture name (must match a directory under tests/perf/fixtures/). */
|
||||
fixture: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Override timeout in ms for the player `ready` event. Default 30s. */
|
||||
readyTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type LoadResult = {
|
||||
/** Wall-clock ms from page navigation start to player `ready` event. */
|
||||
loadMs: number;
|
||||
/** Composition duration as reported by the player (seconds). */
|
||||
duration: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__playerReady?: boolean;
|
||||
__playerReadyAt?: number;
|
||||
__playerNavStart?: number;
|
||||
__playerDuration?: number;
|
||||
__playerError?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function findChromeExecutable(): string | undefined {
|
||||
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH) return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
const candidates = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/chromium",
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function launchBrowser(options: LaunchOpts = {}): Promise<Browser> {
|
||||
const width = options.width ?? 1920;
|
||||
const height = options.height ?? 1080;
|
||||
const executablePath = findChromeExecutable();
|
||||
if (!executablePath) {
|
||||
throw new Error(
|
||||
`[player-perf] no chrome executable found. Set CHROME_PATH or install Google Chrome. (looked in: $CHROME_PATH, $PUPPETEER_EXECUTABLE_PATH, /Applications/Google Chrome.app, /usr/bin/google-chrome)`,
|
||||
);
|
||||
}
|
||||
const launchOptions: LaunchOptions = {
|
||||
executablePath,
|
||||
headless: options.headless ?? true,
|
||||
defaultViewport: {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
},
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-accelerated-2d-canvas",
|
||||
"--enable-webgl",
|
||||
"--ignore-gpu-blocklist",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
"--font-render-hinting=none",
|
||||
"--force-color-profile=srgb",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
`--window-size=${width},${height}`,
|
||||
],
|
||||
};
|
||||
return puppeteer.launch(launchOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate a page to the host shell and wait for the player's `ready` event.
|
||||
* Returns the wall-clock ms between `Page.goto` start and the `ready` event,
|
||||
* along with the composition duration the player reported.
|
||||
*/
|
||||
export async function loadHostPage(
|
||||
page: Page,
|
||||
origin: string,
|
||||
options: LoadOpts,
|
||||
): Promise<LoadResult> {
|
||||
const width = options.width ?? 1920;
|
||||
const height = options.height ?? 1080;
|
||||
const readyTimeoutMs = options.readyTimeoutMs ?? 30_000;
|
||||
const url = `${origin}/host.html?fixture=${encodeURIComponent(options.fixture)}&width=${width}&height=${height}`;
|
||||
|
||||
const t0 = performance.now();
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: readyTimeoutMs });
|
||||
await page.waitForFunction(() => window.__playerReady === true || !!window.__playerError, {
|
||||
timeout: readyTimeoutMs,
|
||||
});
|
||||
const error = await page.evaluate(() => window.__playerError ?? null);
|
||||
if (error) throw new Error(`[player-perf] player reported error during load: ${error}`);
|
||||
const loadMs = performance.now() - t0;
|
||||
const duration = (await page.evaluate(() => window.__playerDuration ?? 0)) ?? 0;
|
||||
return { loadMs, duration };
|
||||
}
|
||||
|
||||
export function percentile(samples: number[], pct: number): number {
|
||||
if (samples.length === 0) return 0;
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((pct / 100) * sorted.length) - 1));
|
||||
return sorted[idx] ?? 0;
|
||||
}
|
||||
|
||||
export function repoPlayerDir(): string {
|
||||
return PLAYER_PKG;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Scenario 02: sustained playback against the composition clock.
|
||||
*
|
||||
* Loads the 10-video-grid fixture, calls `player.play()`, then samples
|
||||
* `__player.getTime()` at fixed wall-clock intervals for ~5 seconds. The
|
||||
* emitted metric is the ratio of composition-time advanced to wall-clock
|
||||
* elapsed:
|
||||
*
|
||||
* composition_time_advancement_ratio = (getTime(end) - getTime(start)) / wallSeconds
|
||||
*
|
||||
* This reads ~1.0 when the runtime is keeping up with its intended playback
|
||||
* speed and falls below 1.0 when the player stalls — a slow video decoder, a
|
||||
* blocked main thread, a GC pause, anything that prevents the composition
|
||||
* clock from advancing at real-time. The metric is independent of the host
|
||||
* display refresh rate by construction: both numerator and denominator are
|
||||
* wall-clock timestamps, neither is a frame count, so a 60Hz, 120Hz, or 240Hz
|
||||
* runner sees the same value for a healthy player.
|
||||
*
|
||||
* Why we replaced the previous rAF-based FPS metric:
|
||||
* The original implementation counted `requestAnimationFrame` ticks per
|
||||
* wall-clock second and asserted `fps >= 55`. On a 120Hz CI runner that
|
||||
* reads ~120 fps regardless of whether the composition is actually
|
||||
* advancing, so the gate passed even when the player was silently stalling.
|
||||
* See PR #400 review (jrusso1020 + miguel-heygen) for the full discussion;
|
||||
* this implementation follows jrusso1020's "first choice" recommendation.
|
||||
*
|
||||
* Per the proposal:
|
||||
* Test 1: Playback frame rate (player-perf-fps)
|
||||
* Load 10-video composition → play 5s → measure how well the player kept
|
||||
* up with the composition clock.
|
||||
*
|
||||
* Methodology details:
|
||||
* - We install the wall-clock sampler before calling `play()` so the very
|
||||
* first post-play tick is captured. We then wait for `__player.isPlaying()`
|
||||
* to flip true (the parent→iframe `play` message is async via postMessage)
|
||||
* and *reset* the sample buffer, so the measurement window only contains
|
||||
* samples taken while the runtime was actively playing the timeline.
|
||||
* - Sampling cadence is 100ms (10 samples/sec). That's fine-grained enough
|
||||
* to spot a half-second stall but coarse enough that the sampler itself
|
||||
* has negligible overhead. With a 5s window we collect ~50 samples; the
|
||||
* ratio is computed from the first and last sample's `getTime()` values.
|
||||
* - We use `setInterval` (not rAF) on purpose: rAF cadence is the metric we
|
||||
* are trying to *avoid* depending on. `setInterval` is wall-clock-driven.
|
||||
*
|
||||
* Outputs one metric:
|
||||
* - composition_time_advancement_ratio_min
|
||||
* (higher-is-better, baseline key compositionTimeAdvancementRatioMin)
|
||||
*
|
||||
* Aggregation: `min(ratio)` across runs because the proposal asserts a floor
|
||||
* — the worst run is the one that gates against regressions.
|
||||
*/
|
||||
|
||||
import type { Browser, Frame, Page } from "puppeteer-core";
|
||||
import { loadHostPage } from "../runner.ts";
|
||||
import type { Metric } from "../perf-gate.ts";
|
||||
|
||||
export type FpsScenarioOpts = {
|
||||
browser: Browser;
|
||||
origin: string;
|
||||
/** Number of measurement runs. */
|
||||
runs: number;
|
||||
/** If null, runs the default fixture (10-video-grid). */
|
||||
fixture: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_FIXTURE = "10-video-grid";
|
||||
const PLAYBACK_DURATION_MS = 5_000;
|
||||
const SAMPLE_INTERVAL_MS = 100;
|
||||
const PLAY_CONFIRM_TIMEOUT_MS = 5_000;
|
||||
const FRAME_LOOKUP_TIMEOUT_MS = 5_000;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** (wallClockMs, compositionTimeSec) pairs collected by the sampler. */
|
||||
__perfPlaySamples?: Array<{ wall: number; comp: number }>;
|
||||
/** setInterval handle used by the sampler; cleared at the end of the window. */
|
||||
__perfPlaySamplerHandle?: number;
|
||||
/** Hyperframes runtime player API exposed inside the composition iframe. */
|
||||
__player?: {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (timeSeconds: number) => void;
|
||||
getTime: () => number;
|
||||
getDuration: () => number;
|
||||
isPlaying: () => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
ratio: number;
|
||||
compElapsedSec: number;
|
||||
wallElapsedSec: number;
|
||||
samples: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the iframe Puppeteer Frame that hosts the fixture composition. The
|
||||
* `<hyperframes-player>` shell wraps an iframe whose URL is derived from the
|
||||
* player's `src` attribute, so we match by path substring rather than full URL.
|
||||
*/
|
||||
async function getFixtureFrame(page: Page, fixture: string): Promise<Frame> {
|
||||
const expected = `/fixtures/${fixture}/`;
|
||||
const deadline = Date.now() + FRAME_LOOKUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const frame = page.frames().find((f) => f.url().includes(expected));
|
||||
if (frame) return frame;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error(`[scenario:fps] fixture frame not found for "${fixture}" within timeout`);
|
||||
}
|
||||
|
||||
async function runOnce(
|
||||
opts: FpsScenarioOpts,
|
||||
fixture: string,
|
||||
idx: number,
|
||||
total: number,
|
||||
): Promise<RunResult> {
|
||||
const ctx = await opts.browser.createBrowserContext();
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
const { duration } = await loadHostPage(page, opts.origin, { fixture });
|
||||
const frame = await getFixtureFrame(page, fixture);
|
||||
|
||||
// Install the wall-clock sampler in the iframe context. We use setInterval
|
||||
// because rAF cadence is exactly the host-display-dependent signal we are
|
||||
// trying NOT to depend on; setInterval is driven by the event loop and
|
||||
// gives us samples at fixed wall-clock cadence regardless of refresh rate.
|
||||
await frame.evaluate((sampleIntervalMs: number) => {
|
||||
window.__perfPlaySamples = [];
|
||||
window.__perfPlaySamplerHandle = window.setInterval(() => {
|
||||
const comp = window.__player?.getTime?.();
|
||||
if (typeof comp !== "number" || !Number.isFinite(comp)) return;
|
||||
window.__perfPlaySamples!.push({
|
||||
wall: performance.timeOrigin + performance.now(),
|
||||
comp,
|
||||
});
|
||||
}, sampleIntervalMs);
|
||||
}, SAMPLE_INTERVAL_MS);
|
||||
|
||||
// Issue play from the host page (parent of the iframe). The player's
|
||||
// public `play()` posts a control message into the iframe.
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("player") as (HTMLElement & { play: () => void }) | null;
|
||||
if (!el) throw new Error("[scenario:fps] player element missing on host page");
|
||||
el.play();
|
||||
});
|
||||
|
||||
// Wait for the runtime to actually transition to playing — this is the
|
||||
// signal that the postMessage round trip + timeline.play() finished.
|
||||
await frame.waitForFunction(() => window.__player?.isPlaying?.() === true, {
|
||||
timeout: PLAY_CONFIRM_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Reset samples now that playback is confirmed running. Anything captured
|
||||
// before this point belongs to the ramp-up window (composition clock at
|
||||
// 0, wall clock advancing) and would skew the ratio toward 0.
|
||||
await frame.evaluate(() => {
|
||||
window.__perfPlaySamples = [];
|
||||
});
|
||||
|
||||
// Sustain playback for the measurement window.
|
||||
await new Promise((r) => setTimeout(r, PLAYBACK_DURATION_MS));
|
||||
|
||||
// Stop the sampler and harvest the samples before pausing the runtime,
|
||||
// so the pause command can't perturb the tail of the sample window.
|
||||
const samples = (await frame.evaluate(() => {
|
||||
if (window.__perfPlaySamplerHandle !== undefined) {
|
||||
clearInterval(window.__perfPlaySamplerHandle);
|
||||
window.__perfPlaySamplerHandle = undefined;
|
||||
}
|
||||
return window.__perfPlaySamples ?? [];
|
||||
})) as Array<{ wall: number; comp: number }>;
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("player") as (HTMLElement & { pause: () => void }) | null;
|
||||
el?.pause();
|
||||
});
|
||||
|
||||
if (samples.length < 2) {
|
||||
throw new Error(
|
||||
`[scenario:fps] run ${idx + 1}/${total}: only ${samples.length} composition-clock samples captured (composition duration ${duration}s)`,
|
||||
);
|
||||
}
|
||||
|
||||
const first = samples[0]!;
|
||||
const last = samples[samples.length - 1]!;
|
||||
const wallElapsedSec = (last.wall - first.wall) / 1000;
|
||||
const compElapsedSec = last.comp - first.comp;
|
||||
const ratio = wallElapsedSec > 0 ? compElapsedSec / wallElapsedSec : 0;
|
||||
|
||||
console.log(
|
||||
`[scenario:fps] run[${idx + 1}/${total}] ratio=${ratio.toFixed(4)} compElapsed=${compElapsedSec.toFixed(3)}s wallElapsed=${wallElapsedSec.toFixed(3)}s samples=${samples.length}`,
|
||||
);
|
||||
|
||||
await page.close();
|
||||
return {
|
||||
ratio,
|
||||
compElapsedSec,
|
||||
wallElapsedSec,
|
||||
samples: samples.length,
|
||||
};
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFps(opts: FpsScenarioOpts): Promise<Metric[]> {
|
||||
const fixture = opts.fixture ?? DEFAULT_FIXTURE;
|
||||
const runs = Math.max(1, opts.runs);
|
||||
console.log(
|
||||
`[scenario:fps] fixture=${fixture} runs=${runs} window=${PLAYBACK_DURATION_MS}ms sampleInterval=${SAMPLE_INTERVAL_MS}ms`,
|
||||
);
|
||||
|
||||
const ratios: number[] = [];
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const result = await runOnce(opts, fixture, i, runs);
|
||||
ratios.push(result.ratio);
|
||||
}
|
||||
|
||||
// Worst run wins: the proposal asserts a floor on this ratio, so a single
|
||||
// bad run (slow decoder, GC pause, host contention) is the one that gates.
|
||||
const ratioMin = Math.min(...ratios);
|
||||
console.log(`[scenario:fps] aggregate min ratio=${ratioMin.toFixed(4)} runs=${runs}`);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "composition_time_advancement_ratio_min",
|
||||
baselineKey: "compositionTimeAdvancementRatioMin",
|
||||
value: ratioMin,
|
||||
unit: "ratio",
|
||||
direction: "higher-is-better",
|
||||
samples: ratios,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Scenario 03: composition load (cold + warm).
|
||||
*
|
||||
* Cold: a fresh BrowserContext per run so the network cache is empty. Measures
|
||||
* the wall-clock time from `page.goto` until the player fires its `ready`
|
||||
* event (host shell sets `window.__playerReady`). This stresses html parse +
|
||||
* runtime IIFE eval + GSAP eval + the player's first composition init.
|
||||
*
|
||||
* Warm: same BrowserContext is reused across runs so the static assets
|
||||
* (player bundle, runtime, GSAP, fixture HTML) are served from disk cache.
|
||||
* This isolates the player's per-composition init cost from network I/O.
|
||||
*
|
||||
* Both metrics report p95 over `runs` samples and feed into perf-gate.ts:
|
||||
* - compLoadColdP95Ms (lower is better)
|
||||
* - compLoadWarmP95Ms (lower is better)
|
||||
*/
|
||||
|
||||
import type { Browser } from "puppeteer-core";
|
||||
import { loadHostPage, percentile } from "../runner.ts";
|
||||
import type { Metric } from "../perf-gate.ts";
|
||||
|
||||
export type LoadScenarioOpts = {
|
||||
browser: Browser;
|
||||
origin: string;
|
||||
/** Number of cold and warm runs each. */
|
||||
runs: number;
|
||||
/** If null, runs the default fixture (gsap-heavy). */
|
||||
fixture: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_FIXTURE = "gsap-heavy";
|
||||
|
||||
export async function runLoad(opts: LoadScenarioOpts): Promise<Metric[]> {
|
||||
const fixture = opts.fixture ?? DEFAULT_FIXTURE;
|
||||
const runs = Math.max(1, opts.runs);
|
||||
console.log(`[scenario:load] fixture=${fixture} runs=${runs}`);
|
||||
|
||||
const cold: number[] = [];
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const ctx = await opts.browser.createBrowserContext();
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
const { loadMs, duration } = await loadHostPage(page, opts.origin, { fixture });
|
||||
cold.push(loadMs);
|
||||
console.log(
|
||||
`[scenario:load] cold[${i + 1}/${runs}] loadMs=${loadMs.toFixed(1)} duration=${duration}s`,
|
||||
);
|
||||
await page.close();
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
const warm: number[] = [];
|
||||
const warmCtx = await opts.browser.createBrowserContext();
|
||||
try {
|
||||
const warmupPage = await warmCtx.newPage();
|
||||
await loadHostPage(warmupPage, opts.origin, { fixture });
|
||||
await warmupPage.close();
|
||||
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const page = await warmCtx.newPage();
|
||||
const { loadMs, duration } = await loadHostPage(page, opts.origin, { fixture });
|
||||
warm.push(loadMs);
|
||||
console.log(
|
||||
`[scenario:load] warm[${i + 1}/${runs}] loadMs=${loadMs.toFixed(1)} duration=${duration}s`,
|
||||
);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await warmCtx.close();
|
||||
}
|
||||
|
||||
const coldP95 = percentile(cold, 95);
|
||||
const warmP95 = percentile(warm, 95);
|
||||
console.log(
|
||||
`[scenario:load] cold p95=${coldP95.toFixed(1)}ms (samples=${cold.length}) warm p95=${warmP95.toFixed(1)}ms (samples=${warm.length})`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "comp_load_cold_p95_ms",
|
||||
baselineKey: "compLoadColdP95Ms",
|
||||
value: coldP95,
|
||||
unit: "ms",
|
||||
direction: "lower-is-better",
|
||||
samples: cold,
|
||||
},
|
||||
{
|
||||
name: "comp_load_warm_p95_ms",
|
||||
baselineKey: "compLoadWarmP95Ms",
|
||||
value: warmP95,
|
||||
unit: "ms",
|
||||
direction: "lower-is-better",
|
||||
samples: warm,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Scenario 04: scrub latency.
|
||||
*
|
||||
* Loads the 10-video-grid fixture, pauses the player, then issues 10 seek
|
||||
* calls in sequence — first through the synchronous "inline" path, then
|
||||
* through the postMessage-driven "isolated" path — and measures the wall-clock
|
||||
* latency from each `seek()` call to the first paint where the iframe's
|
||||
* timeline reports the new time.
|
||||
*
|
||||
* Per the proposal:
|
||||
* Test 2: Scrub latency (player-perf-scrub)
|
||||
* Load composition → seek to 10 positions in sequence → measure time
|
||||
* from seek() call to state update callback
|
||||
* Assert: p95 < 80ms (isolated), p95 < 33ms (inline, Phase 4+)
|
||||
*
|
||||
* Methodology details:
|
||||
* - Both modes are measured in the same page load. Inline runs first so
|
||||
* the isolated mode's monkey-patch (forcing `_trySyncSeek` to return
|
||||
* false) doesn't bleed into the inline samples.
|
||||
* - "Inline" mode is the default behavior of `<hyperframes-player>` when the
|
||||
* iframe is same-origin and exposes `__player.seek()` synchronously.
|
||||
* `seek()` lands the new frame in the same task as the input event.
|
||||
* - "Isolated" mode is forced by replacing the player element's
|
||||
* `_trySyncSeek` method with `() => false`, which sends the player
|
||||
* element through the postMessage bridge — exactly what cross-origin
|
||||
* embeds and Phase 1 (pre-sync) builds did.
|
||||
* - Detection is via a `requestAnimationFrame` watcher inside the iframe
|
||||
* that polls `__player.getTime()` until it is within `MATCH_TOLERANCE_S`
|
||||
* of the requested target. We use a tolerance because the postMessage
|
||||
* bridge converts seconds → frame number → seconds, which can introduce
|
||||
* sub-frame quantization drift even for targets on the canonical fps grid.
|
||||
* - Timing uses `performance.timeOrigin + performance.now()` in both the
|
||||
* host and iframe contexts. `timeOrigin` is consistent across same-process
|
||||
* frames, so the difference is a true wall-clock measurement of latency.
|
||||
* - Seek targets alternate forward/backward across the 10s composition so
|
||||
* no two consecutive seeks land near each other; this avoids the rAF
|
||||
* watcher matching against a stale `getTime()` value before the seek
|
||||
* command is processed.
|
||||
*
|
||||
* Outputs two metrics:
|
||||
* - scrub_latency_p95_inline_ms (lower-is-better, baseline scrubLatencyP95InlineMs)
|
||||
* - scrub_latency_p95_isolated_ms (lower-is-better, baseline scrubLatencyP95IsolatedMs)
|
||||
*
|
||||
* Aggregation: percentile(95) is computed across the pooled per-seek
|
||||
* latencies from every run. With 10 seeks per mode per run × 3 runs we get
|
||||
* 30 samples per mode per CI shard, which is enough for a stable p95.
|
||||
*/
|
||||
|
||||
import type { Browser, Frame, Page } from "puppeteer-core";
|
||||
import { loadHostPage, percentile } from "../runner.ts";
|
||||
import type { Metric } from "../perf-gate.ts";
|
||||
|
||||
export type ScrubScenarioOpts = {
|
||||
browser: Browser;
|
||||
origin: string;
|
||||
/** Number of measurement runs. */
|
||||
runs: number;
|
||||
/** If null, runs the default fixture (10-video-grid). */
|
||||
fixture: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_FIXTURE = "10-video-grid";
|
||||
/** Targets are seconds within the composition (10s duration). */
|
||||
const SEEK_TARGETS: readonly number[] = [1.0, 7.0, 2.0, 8.0, 3.0, 9.0, 4.0, 6.0, 5.0, 0.5];
|
||||
/**
|
||||
* Tolerance window the rAF watcher uses to decide that the iframe's reported
|
||||
* `__player.getTime()` matches the requested seek target. 50ms = 1.5 frames at
|
||||
* 30fps, which absorbs three sources of expected slippage:
|
||||
*
|
||||
* 1. **Frame quantization on the postMessage path.** `_sendControl("seek")`
|
||||
* converts seconds → integer frame number → seconds inside the runtime,
|
||||
* so e.g. a target of 1.0s on a 30fps composition lands at frame 30 →
|
||||
* 1.000s exactly, but a target of 1.005s lands at frame 30 → still
|
||||
* 1.000s, a 5ms quantization error baked into the API itself.
|
||||
* 2. **Sub-frame intra-clip clock advance.** Even with the iframe paused,
|
||||
* between the `seek()` call landing and the next rAF tick, the runtime
|
||||
* may have already nudged time by a fraction of a frame as part of
|
||||
* finalizing the seek; `getTime()` reports the post-finalize value.
|
||||
* 3. **Variable host load + browser jitter on CI.** GitHub runners share
|
||||
* cores, so a noisy neighbor can delay the rAF tick that would otherwise
|
||||
* register the match by tens of ms. Picking a tolerance much tighter
|
||||
* than this would gate against runner contention rather than player
|
||||
* regressions.
|
||||
*
|
||||
* The metric this scenario asserts is *latency to user-visible match*, not
|
||||
* *exact equality of the reported time*, so a 50ms acceptance window is the
|
||||
* intended behavior — but if we ever want to tighten this (e.g. to assert
|
||||
* sub-frame precision on the inline path now that PR #397 documented it),
|
||||
* this is the knob to turn. Configurability is deliberately deferred until
|
||||
* we have a concrete second use case; YAGNI.
|
||||
*
|
||||
* TODO(player-perf): revisit this constant after P0-1b lands and we have ~2
|
||||
* weeks of CI baseline data — if the inline-mode samples consistently cluster
|
||||
* well below 50ms, drop this to e.g. 16ms (1 frame @ 60fps) and split the
|
||||
* tolerance per mode (tighter for inline, current for isolated).
|
||||
*/
|
||||
const MATCH_TOLERANCE_S = 0.05;
|
||||
/** Per-seek timeout; isolated p95 in the proposal is 80ms, so 1s is huge headroom. */
|
||||
const SEEK_TIMEOUT_MS = 1_000;
|
||||
const PAUSE_CONFIRM_TIMEOUT_MS = 5_000;
|
||||
const FRAME_LOOKUP_TIMEOUT_MS = 5_000;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Promise resolved by the iframe rAF watcher with the wall-clock t1 of the matching paint. */
|
||||
__perfScrubAwait?: Promise<number>;
|
||||
__player?: {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (timeSeconds: number) => void;
|
||||
getTime: () => number;
|
||||
getDuration: () => number;
|
||||
isPlaying: () => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type Mode = "inline" | "isolated";
|
||||
|
||||
type RunResult = {
|
||||
inlineLatencies: number[];
|
||||
isolatedLatencies: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the iframe Puppeteer Frame that hosts the fixture composition. Same
|
||||
* helper as 02-fps.ts; duplicated locally so each scenario file is
|
||||
* self-contained.
|
||||
*/
|
||||
async function getFixtureFrame(page: Page, fixture: string): Promise<Frame> {
|
||||
const expected = `/fixtures/${fixture}/`;
|
||||
const deadline = Date.now() + FRAME_LOOKUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const frame = page.frames().find((f) => f.url().includes(expected));
|
||||
if (frame) return frame;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error(`[scenario:scrub] fixture frame not found for "${fixture}" within timeout`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure a single seek's latency.
|
||||
*
|
||||
* Sequence:
|
||||
* 1. Install a rAF watcher in the iframe that resolves with the wall-clock
|
||||
* timestamp of the first paint where `__player.getTime()` is within
|
||||
* tolerance of `target`. Promise is stashed on `window.__perfScrubAwait`.
|
||||
* 2. Capture host wall-clock t0 and call `el.seek(target)` in the same task.
|
||||
* 3. Await the iframe's resolved Promise (returns t1).
|
||||
* 4. Latency = t1 - t0 (ms).
|
||||
*/
|
||||
async function measureSingleSeek(page: Page, frame: Frame, target: number): Promise<number> {
|
||||
await frame.evaluate(
|
||||
(target: number, tolerance: number, timeoutMs: number) => {
|
||||
window.__perfScrubAwait = new Promise<number>((resolve, reject) => {
|
||||
const deadlineWall = performance.timeOrigin + performance.now() + timeoutMs;
|
||||
const tick = () => {
|
||||
const wall = performance.timeOrigin + performance.now();
|
||||
const time = window.__player?.getTime?.() ?? Number.NaN;
|
||||
if (Number.isFinite(time) && Math.abs(time - target) < tolerance) {
|
||||
resolve(wall);
|
||||
return;
|
||||
}
|
||||
if (wall > deadlineWall) {
|
||||
reject(new Error(`[scrub] timeout target=${target} last=${time}`));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
},
|
||||
target,
|
||||
MATCH_TOLERANCE_S,
|
||||
SEEK_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const t0Wall = await page.evaluate((targetSeconds: number) => {
|
||||
const el = document.getElementById("player") as
|
||||
| (HTMLElement & { seek: (t: number) => void })
|
||||
| null;
|
||||
if (!el) throw new Error("[scenario:scrub] player element missing on host page");
|
||||
const wall = performance.timeOrigin + performance.now();
|
||||
el.seek(targetSeconds);
|
||||
return wall;
|
||||
}, target);
|
||||
|
||||
// Puppeteer awaits the Promise we stashed on window and returns its resolved value.
|
||||
const t1Wall = (await frame.evaluate(() => window.__perfScrubAwait as Promise<number>)) as number;
|
||||
|
||||
return t1Wall - t0Wall;
|
||||
}
|
||||
|
||||
async function runScrubBatch(
|
||||
page: Page,
|
||||
frame: Frame,
|
||||
mode: Mode,
|
||||
idx: number,
|
||||
total: number,
|
||||
): Promise<number[]> {
|
||||
const latencies: number[] = [];
|
||||
for (const target of SEEK_TARGETS) {
|
||||
const latency = await measureSingleSeek(page, frame, target);
|
||||
latencies.push(latency);
|
||||
}
|
||||
const p95 = percentile(latencies, 95);
|
||||
console.log(
|
||||
`[scenario:scrub] run[${idx + 1}/${total}] mode=${mode} p95=${p95.toFixed(2)}ms n=${latencies.length}`,
|
||||
);
|
||||
return latencies;
|
||||
}
|
||||
|
||||
async function runOnce(
|
||||
opts: ScrubScenarioOpts,
|
||||
fixture: string,
|
||||
idx: number,
|
||||
total: number,
|
||||
): Promise<RunResult> {
|
||||
const ctx = await opts.browser.createBrowserContext();
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
const { duration } = await loadHostPage(page, opts.origin, { fixture });
|
||||
const requiredDuration = Math.max(...SEEK_TARGETS);
|
||||
if (duration < requiredDuration) {
|
||||
throw new Error(
|
||||
`[scenario:scrub] fixture composition is ${duration.toFixed(2)}s but scrub targets require >= ${requiredDuration}s`,
|
||||
);
|
||||
}
|
||||
const frame = await getFixtureFrame(page, fixture);
|
||||
|
||||
// Defensively pause: the host shell doesn't autoplay, but `pause()` also
|
||||
// cancels any pending autoplay-on-ready behavior and guarantees the
|
||||
// timeline isn't ticking under our seek measurements.
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("player") as (HTMLElement & { pause?: () => void }) | null;
|
||||
el?.pause?.();
|
||||
});
|
||||
await frame.waitForFunction(() => window.__player?.isPlaying?.() === false, {
|
||||
timeout: PAUSE_CONFIRM_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Inline mode first — the player's default `_trySyncSeek` path lands the
|
||||
// seek synchronously when the iframe is same-origin (which it is here).
|
||||
const inlineLatencies = await runScrubBatch(page, frame, "inline", idx, total);
|
||||
|
||||
// Force isolated mode by shadowing `_trySyncSeek` on the instance with
|
||||
// a function that always reports failure. The fallback in `seek()` then
|
||||
// sends the seek through `_sendControl("seek", { frame })`, which is the
|
||||
// same path a cross-origin embed (or a Phase 1 build without sync seek)
|
||||
// would take.
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("player") as
|
||||
| (HTMLElement & { _trySyncSeek?: (t: number) => boolean })
|
||||
| null;
|
||||
if (!el) throw new Error("[scenario:scrub] player element missing on host page");
|
||||
el._trySyncSeek = () => false;
|
||||
});
|
||||
|
||||
const isolatedLatencies = await runScrubBatch(page, frame, "isolated", idx, total);
|
||||
|
||||
await page.close();
|
||||
return { inlineLatencies, isolatedLatencies };
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScrub(opts: ScrubScenarioOpts): Promise<Metric[]> {
|
||||
const fixture = opts.fixture ?? DEFAULT_FIXTURE;
|
||||
const runs = Math.max(1, opts.runs);
|
||||
console.log(
|
||||
`[scenario:scrub] fixture=${fixture} runs=${runs} seeks_per_mode=${SEEK_TARGETS.length} tolerance=${(MATCH_TOLERANCE_S * 1000).toFixed(0)}ms`,
|
||||
);
|
||||
|
||||
const allInline: number[] = [];
|
||||
const allIsolated: number[] = [];
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const result = await runOnce(opts, fixture, i, runs);
|
||||
allInline.push(...result.inlineLatencies);
|
||||
allIsolated.push(...result.isolatedLatencies);
|
||||
}
|
||||
|
||||
const inlineP95 = percentile(allInline, 95);
|
||||
const isolatedP95 = percentile(allIsolated, 95);
|
||||
console.log(
|
||||
`[scenario:scrub] aggregate inline_p95=${inlineP95.toFixed(2)}ms isolated_p95=${isolatedP95.toFixed(2)}ms (runs=${runs} samples_per_mode=${allInline.length})`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "scrub_latency_p95_inline_ms",
|
||||
baselineKey: "scrubLatencyP95InlineMs",
|
||||
value: inlineP95,
|
||||
unit: "ms",
|
||||
direction: "lower-is-better",
|
||||
samples: allInline,
|
||||
},
|
||||
{
|
||||
name: "scrub_latency_p95_isolated_ms",
|
||||
baselineKey: "scrubLatencyP95IsolatedMs",
|
||||
value: isolatedP95,
|
||||
unit: "ms",
|
||||
direction: "lower-is-better",
|
||||
samples: allIsolated,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Scenario 05: media sync drift.
|
||||
*
|
||||
* Loads the 10-video-grid fixture, starts playback, and uses
|
||||
* `requestVideoFrameCallback` on every video element to record
|
||||
* (compositionTime, actualMediaTime) pairs for each decoded frame. Drift is
|
||||
* the absolute difference between the *expected* media time (derived from the
|
||||
* composition time using the runtime's clip transform) and the actual media
|
||||
* time the decoder presented to the compositor.
|
||||
*
|
||||
* Per the proposal:
|
||||
* Test 4: Media sync drift (player-perf-drift)
|
||||
* Load 5-video composition → play for 10 seconds → on each RVFC callback,
|
||||
* record drift between expected and actual media time
|
||||
* Assert: max drift < 500ms, p95 drift < 100ms
|
||||
*
|
||||
* Methodology details:
|
||||
* - We instrument *every* `video[data-start]` element in the fixture. The
|
||||
* proposal called for 5 videos; the 10-video-grid gives us 10 streams in
|
||||
* the same composition, which is a more conservative regression signal.
|
||||
* - The expected media time uses the same transform the runtime applies in
|
||||
* packages/core/src/runtime/media.ts:
|
||||
*
|
||||
* expectedMediaTime = (compositionTime - clip.start) * clip.playbackRate
|
||||
* + clip.mediaStart
|
||||
*
|
||||
* We snapshot `clip.start` / `clip.mediaStart` / `clip.playbackRate` from
|
||||
* each element's dataset + `defaultPlaybackRate` once when the sampler is
|
||||
* installed, so the per-frame work is just a subtract + multiply + abs.
|
||||
* - The runtime's media sync runs on a 50ms `setInterval`. Between syncs the
|
||||
* video element's clock free-runs. The drift we measure here is the
|
||||
* residual after that 50ms loop catches up — i.e. the user-visible glitch
|
||||
* budget. The runtime hard-resyncs when |currentTime - relTime| > 0.5s
|
||||
* (see media.ts), which is exactly the proposal's max-drift ceiling: a
|
||||
* regression past 500ms means the corrective resync kicked in and the
|
||||
* viewer saw a jump.
|
||||
* - We install RVFC *before* calling play(), then reset the sample buffer
|
||||
* once `__player.isPlaying()` flips true. Frames captured during the
|
||||
* postMessage round-trip would compare a non-zero mediaTime against
|
||||
* `getTime() === 0` and inflate drift to several hundred ms — same gotcha
|
||||
* as 02-fps.ts.
|
||||
* - Sustain window is 6s instead of the proposal's 10s because the fixture
|
||||
* composition is exactly 10s long, and we want headroom before the
|
||||
* end-of-timeline pause/clamp behavior. With 10 videos × ~25fps × 6s we
|
||||
* still pool ~1500 samples per run, more than enough for a stable p95.
|
||||
*
|
||||
* Outputs two metrics:
|
||||
* - media_drift_max_ms (lower-is-better, baseline driftMaxMs)
|
||||
* - media_drift_p95_ms (lower-is-better, baseline driftP95Ms)
|
||||
*
|
||||
* Aggregation: max() and percentile(95) across the pooled per-frame drifts
|
||||
* from every video in every run.
|
||||
*/
|
||||
|
||||
import type { Browser, Frame, Page } from "puppeteer-core";
|
||||
import { loadHostPage, percentile } from "../runner.ts";
|
||||
import type { Metric } from "../perf-gate.ts";
|
||||
|
||||
export type DriftScenarioOpts = {
|
||||
browser: Browser;
|
||||
origin: string;
|
||||
/** Number of measurement runs. */
|
||||
runs: number;
|
||||
/** If null, runs the default fixture (10-video-grid). */
|
||||
fixture: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_FIXTURE = "10-video-grid";
|
||||
const PLAYBACK_DURATION_MS = 6_000;
|
||||
const PLAY_CONFIRM_TIMEOUT_MS = 5_000;
|
||||
const FRAME_LOOKUP_TIMEOUT_MS = 5_000;
|
||||
|
||||
type DriftSample = {
|
||||
compTime: number;
|
||||
actualMediaTime: number;
|
||||
clipStart: number;
|
||||
clipMediaStart: number;
|
||||
clipPlaybackRate: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** RVFC samples collected by the iframe-side observer. */
|
||||
__perfDriftSamples?: DriftSample[];
|
||||
/** Set to false to stop sampling at the end of the measurement window. */
|
||||
__perfDriftActive?: boolean;
|
||||
__player?: {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (timeSeconds: number) => void;
|
||||
getTime: () => number;
|
||||
getDuration: () => number;
|
||||
isPlaying: () => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
drifts: number[];
|
||||
videoCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the iframe Puppeteer Frame that hosts the fixture composition. Same
|
||||
* helper as the other scenarios; duplicated locally so each scenario file is
|
||||
* self-contained.
|
||||
*/
|
||||
async function getFixtureFrame(page: Page, fixture: string): Promise<Frame> {
|
||||
const expected = `/fixtures/${fixture}/`;
|
||||
const deadline = Date.now() + FRAME_LOOKUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const frame = page.frames().find((f) => f.url().includes(expected));
|
||||
if (frame) return frame;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error(`[scenario:drift] fixture frame not found for "${fixture}" within timeout`);
|
||||
}
|
||||
|
||||
async function runOnce(
|
||||
opts: DriftScenarioOpts,
|
||||
fixture: string,
|
||||
idx: number,
|
||||
total: number,
|
||||
): Promise<RunResult> {
|
||||
const ctx = await opts.browser.createBrowserContext();
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
const { duration } = await loadHostPage(page, opts.origin, { fixture });
|
||||
const requiredDurationSec = PLAYBACK_DURATION_MS / 1000;
|
||||
if (duration < requiredDurationSec) {
|
||||
throw new Error(
|
||||
`[scenario:drift] fixture composition is ${duration.toFixed(2)}s but drift sample window needs >= ${requiredDurationSec.toFixed(0)}s`,
|
||||
);
|
||||
}
|
||||
const frame = await getFixtureFrame(page, fixture);
|
||||
|
||||
// Install RVFC on every `video[data-start]` element in the iframe. Each
|
||||
// callback records the wall-clock-aligned (compositionTime, mediaTime)
|
||||
// pair plus a snapshot of the clip transform so we can compute drift in
|
||||
// node without re-querying the dataset on every frame.
|
||||
const videoCount = (await frame.evaluate(() => {
|
||||
window.__perfDriftSamples = [];
|
||||
window.__perfDriftActive = true;
|
||||
const videos = Array.from(document.querySelectorAll<HTMLVideoElement>("video[data-start]"));
|
||||
type RvfcMetadata = { mediaTime: number; presentationTime: number };
|
||||
type RvfcVideo = HTMLVideoElement & {
|
||||
requestVideoFrameCallback?: (
|
||||
cb: (now: DOMHighResTimeStamp, metadata: RvfcMetadata) => void,
|
||||
) => number;
|
||||
};
|
||||
let installed = 0;
|
||||
for (const video of videos) {
|
||||
const rvfcVideo = video as RvfcVideo;
|
||||
const rvfc = rvfcVideo.requestVideoFrameCallback;
|
||||
// Headless Chrome supports RVFC; bail quietly on browsers that don't.
|
||||
if (!rvfc) continue;
|
||||
const clipStart = Number.parseFloat(video.dataset.start ?? "0") || 0;
|
||||
const clipMediaStart =
|
||||
Number.parseFloat(video.dataset.playbackStart ?? video.dataset.mediaStart ?? "0") || 0;
|
||||
const rawRate = video.defaultPlaybackRate;
|
||||
const clipPlaybackRate =
|
||||
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
|
||||
const tick = (_now: DOMHighResTimeStamp, metadata: RvfcMetadata) => {
|
||||
if (!window.__perfDriftActive) return;
|
||||
const compTime = window.__player?.getTime?.() ?? Number.NaN;
|
||||
if (Number.isFinite(compTime)) {
|
||||
window.__perfDriftSamples!.push({
|
||||
compTime,
|
||||
actualMediaTime: metadata.mediaTime,
|
||||
clipStart,
|
||||
clipMediaStart,
|
||||
clipPlaybackRate,
|
||||
});
|
||||
}
|
||||
rvfc.call(video, tick);
|
||||
};
|
||||
rvfc.call(video, tick);
|
||||
installed++;
|
||||
}
|
||||
return installed;
|
||||
})) as number;
|
||||
|
||||
if (videoCount === 0) {
|
||||
throw new Error(`[scenario:drift] fixture ${fixture} contains no video[data-start] elements`);
|
||||
}
|
||||
|
||||
// Issue play from the host page; the player posts a control message into
|
||||
// the iframe and the runtime starts the 50ms media sync poll.
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("player") as (HTMLElement & { play: () => void }) | null;
|
||||
if (!el) throw new Error("[scenario:drift] player element missing on host page");
|
||||
el.play();
|
||||
});
|
||||
|
||||
// Wait for the runtime to confirm playing before we trust the samples.
|
||||
await frame.waitForFunction(() => window.__player?.isPlaying?.() === true, {
|
||||
timeout: PLAY_CONFIRM_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Reset the buffer now that playback is live. Anything captured during
|
||||
// the postMessage round-trip would compare a non-zero mediaTime against
|
||||
// `getTime() === 0` and bias drift up by hundreds of ms.
|
||||
await frame.evaluate(() => {
|
||||
window.__perfDriftSamples = [];
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, PLAYBACK_DURATION_MS));
|
||||
|
||||
// Stop sampling first, then pause. Same ordering as 02-fps.ts so the
|
||||
// pause command can't perturb the tail of the measurement window.
|
||||
const samples = (await frame.evaluate(() => {
|
||||
window.__perfDriftActive = false;
|
||||
return window.__perfDriftSamples ?? [];
|
||||
})) as DriftSample[];
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("player") as (HTMLElement & { pause: () => void }) | null;
|
||||
el?.pause();
|
||||
});
|
||||
|
||||
if (samples.length === 0) {
|
||||
throw new Error(
|
||||
`[scenario:drift] run ${idx + 1}/${total}: zero RVFC samples captured (videos=${videoCount}, duration=${duration.toFixed(2)}s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the runtime's transform to derive the expected media time, then
|
||||
// compare against the actual media time the decoder presented. Convert
|
||||
// to ms here so the gate threshold (driftMaxMs / driftP95Ms) compares
|
||||
// apples-to-apples.
|
||||
const drifts: number[] = [];
|
||||
for (const s of samples) {
|
||||
const expectedMediaTime = (s.compTime - s.clipStart) * s.clipPlaybackRate + s.clipMediaStart;
|
||||
const driftMs = Math.abs(s.actualMediaTime - expectedMediaTime) * 1000;
|
||||
drifts.push(driftMs);
|
||||
}
|
||||
|
||||
const max = Math.max(...drifts);
|
||||
const p95 = percentile(drifts, 95);
|
||||
console.log(
|
||||
`[scenario:drift] run[${idx + 1}/${total}] max=${max.toFixed(2)}ms p95=${p95.toFixed(2)}ms videos=${videoCount} samples=${samples.length}`,
|
||||
);
|
||||
|
||||
await page.close();
|
||||
return { drifts, videoCount };
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDrift(opts: DriftScenarioOpts): Promise<Metric[]> {
|
||||
const fixture = opts.fixture ?? DEFAULT_FIXTURE;
|
||||
const runs = Math.max(1, opts.runs);
|
||||
console.log(`[scenario:drift] fixture=${fixture} runs=${runs} window=${PLAYBACK_DURATION_MS}ms`);
|
||||
|
||||
const allDrifts: number[] = [];
|
||||
let lastVideoCount = 0;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const result = await runOnce(opts, fixture, i, runs);
|
||||
allDrifts.push(...result.drifts);
|
||||
lastVideoCount = result.videoCount;
|
||||
}
|
||||
|
||||
// Worst case wins for max; p95 is computed across the pooled per-frame
|
||||
// drifts from every video in every run. The proposal asserts max < 500ms
|
||||
// and p95 < 100ms, so a single bad sample legitimately gates the build.
|
||||
const maxDrift = Math.max(...allDrifts);
|
||||
const p95Drift = percentile(allDrifts, 95);
|
||||
// Coefficient of variation (stddev / mean) is logged here as a soft signal
|
||||
// we can eyeball in CI output. We deliberately do NOT gate on it — the
|
||||
// baseline asserts absolute thresholds (max, p95), and the underlying
|
||||
// distribution is heavy-tailed (most frames are sub-50ms, occasional ones
|
||||
// spike during the 50ms media-sync interval). But CV is a useful early
|
||||
// warning: if it climbs significantly across CI runs while max + p95 stay
|
||||
// green, our jitter assumptions about the runtime's resync loop have
|
||||
// shifted (e.g. if media.ts changes its 50ms `setInterval` cadence) and
|
||||
// we should revisit the baselines before they start producing flakes.
|
||||
// TODO(player-perf): once we have ~2 weeks of CI baseline data, decide
|
||||
// whether to publish CV as a tracked-but-ungated metric in baseline.json
|
||||
// alongside max + p95, or wire it into the Slack regression report.
|
||||
const meanDrift = allDrifts.reduce((a, b) => a + b, 0) / allDrifts.length;
|
||||
const variance = allDrifts.reduce((acc, d) => acc + (d - meanDrift) ** 2, 0) / allDrifts.length;
|
||||
const stddev = Math.sqrt(variance);
|
||||
const cv = meanDrift > 0 ? stddev / meanDrift : 0;
|
||||
console.log(
|
||||
`[scenario:drift] aggregate max=${maxDrift.toFixed(2)}ms p95=${p95Drift.toFixed(2)}ms mean=${meanDrift.toFixed(2)}ms cv=${cv.toFixed(3)} videos=${lastVideoCount} samples=${allDrifts.length} runs=${runs}`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "media_drift_max_ms",
|
||||
baselineKey: "driftMaxMs",
|
||||
value: maxDrift,
|
||||
unit: "ms",
|
||||
direction: "lower-is-better",
|
||||
samples: allDrifts,
|
||||
},
|
||||
{
|
||||
name: "media_drift_p95_ms",
|
||||
baselineKey: "driftP95Ms",
|
||||
value: p95Drift,
|
||||
unit: "ms",
|
||||
direction: "lower-is-better",
|
||||
samples: allDrifts,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Scenario 06: live-playback parity vs synchronous seek.
|
||||
*
|
||||
* Loads the gsap-heavy fixture, plays it from t=0, then captures the rendered
|
||||
* frame at a known timestamp (t≈5.0s, mid-animation). Without releasing the
|
||||
* page, we then synchronously seek the same player back to that exact captured
|
||||
* timestamp and capture a *reference* frame. The two PNGs are diffed with
|
||||
* `ffmpeg -lavfi ssim` and the resulting average SSIM is the parity metric.
|
||||
*
|
||||
* Per the proposal:
|
||||
* Test 5: Live-playback parity (player-perf-parity)
|
||||
* Play composition → freeze at known t → screenshot → seek to same t →
|
||||
* screenshot → compare via SSIM
|
||||
* Assert: SSIM > 0.95 (effectively perfect with deterministic rendering)
|
||||
*
|
||||
* Baseline note (paritySsimMin=0.93, set deliberately wider than the proposal's
|
||||
* 0.95): the host runner is headless Chromium with all the determinism flags
|
||||
* we can practically apply, but the gsap-heavy fixture still has a small
|
||||
* sub-pixel rasterization wobble between "paint immediately after pause()"
|
||||
* and "paint after sync seek." Empirically the worst run sits around 0.96–0.98,
|
||||
* but a 2-point cushion keeps us from chasing flakes on slower CI hardware
|
||||
* while still catching real parity drift (anything < 0.93 means the two
|
||||
* paths produced visibly different pixels, not just sub-pixel jitter).
|
||||
* If we tighten determinism further (e.g. fixed device pixel ratio + forced
|
||||
* software raster) we should ratchet this baseline back up to 0.95.
|
||||
*
|
||||
* Why this matters:
|
||||
* `<hyperframes-player>`'s sync-seek path goes through `_trySyncSeek`, which
|
||||
* for same-origin embeds calls into the iframe runtime's `seek()` directly.
|
||||
* Live playback advances frames via the runtime's animation loop. If those
|
||||
* two paths drift out of agreement — different rounding, different sub-frame
|
||||
* sampling, different state ordering — scrubbing a paused composition will
|
||||
* show different pixels than a paused-during-playback frame at the same time.
|
||||
* This test pins them together visually.
|
||||
*
|
||||
* Methodology details:
|
||||
* - Capture point is t=5.0s. The gsap-heavy fixture is a 10s composition
|
||||
* with 60 tiles each running a staggered 4s out-and-back tween. At 5.0s
|
||||
* a large fraction of those tiles are mid-flight, so the rendered frame
|
||||
* has many distinct, position-sensitive pixels — the worst case for any
|
||||
* sub-frame disagreement between the two paths.
|
||||
* - Live capture uses an iframe-side rAF watcher that polls
|
||||
* `__player.getTime()` every animation frame. When `getTime() >= 5.0`,
|
||||
* the watcher calls `__player.pause()` *from inside the same rAF tick*.
|
||||
* `pause()` is synchronous (it calls `timeline.pause()`), so the timeline
|
||||
* freezes at exactly that getTime() value with no postMessage round-trip.
|
||||
* We then read `getTime()` one more time to capture the canonical frozen
|
||||
* timestamp `T_actual` — that's the ground truth both screenshots target.
|
||||
* - Both screenshots wait for two `requestAnimationFrame` ticks on the host
|
||||
* page before capture. The first rAF flushes any pending style/layout
|
||||
* work; the second rAF guarantees the compositor has painted. This is
|
||||
* the same paint-settlement pattern as packages/producer/src/parity-harness.ts.
|
||||
* - Reference capture issues `el.seek(T_actual)` from the host page. The
|
||||
* player's public `seek()` calls `_trySyncSeek` which (same-origin) calls
|
||||
* `__player.seek()` synchronously, so we don't need a postMessage await.
|
||||
* - SSIM is computed by `ffmpeg -lavfi ssim`, which emits per-channel and
|
||||
* overall scores to stderr. We parse the `All:` value (clamped at 1.0
|
||||
* because ffmpeg occasionally reports 1.000001 for identical inputs).
|
||||
* - Both PNGs and the captured T_actual value are written under
|
||||
* `tests/perf/results/parity/run-N/` for CI artifact upload and local
|
||||
* debugging. The directory is gitignored via the existing
|
||||
* `packages/player/tests/perf/results/` rule.
|
||||
*
|
||||
* Output metric:
|
||||
* - parity_ssim_min (higher-is-better, baseline paritySsimMin = 0.93)
|
||||
*
|
||||
* Aggregation: min() across runs. We want the *worst* observed parity to
|
||||
* pass the gate, so that one bad run can't get masked by averaging.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Browser, Frame, Page } from "puppeteer-core";
|
||||
import { loadHostPage } from "../runner.ts";
|
||||
import type { Metric } from "../perf-gate.ts";
|
||||
|
||||
export type ParityScenarioOpts = {
|
||||
browser: Browser;
|
||||
origin: string;
|
||||
/** Number of measurement runs. */
|
||||
runs: number;
|
||||
/** If null, runs the default fixture (gsap-heavy). */
|
||||
fixture: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_FIXTURE = "gsap-heavy";
|
||||
/** Mid-composition; gsap-heavy is 10s and has many tiles in motion at this point. */
|
||||
const TARGET_TIME_S = 5.0;
|
||||
/** rAF watcher will resolve as soon as getTime() crosses TARGET_TIME_S. */
|
||||
const TARGET_TIMEOUT_MS = 15_000;
|
||||
const PLAY_CONFIRM_TIMEOUT_MS = 5_000;
|
||||
const FRAME_LOOKUP_TIMEOUT_MS = 5_000;
|
||||
/** ffmpeg occasionally reports 1.000001 on identical inputs; clamp to keep
|
||||
* baseline math sane. */
|
||||
const SSIM_CLAMP_MAX = 1.0;
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const RESULTS_DIR = resolve(HERE, "../results/parity");
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Promise resolved by the iframe rAF watcher with the frozen player time (s). */
|
||||
__perfParityPauseAwait?: Promise<number>;
|
||||
__player?: {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (timeSeconds: number) => void;
|
||||
getTime: () => number;
|
||||
getDuration: () => number;
|
||||
isPlaying: () => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
ssim: number;
|
||||
capturedTime: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the iframe Puppeteer Frame that hosts the fixture composition. Same
|
||||
* helper as the other scenarios; duplicated locally so each scenario file is
|
||||
* self-contained.
|
||||
*/
|
||||
async function getFixtureFrame(page: Page, fixture: string): Promise<Frame> {
|
||||
const expected = `/fixtures/${fixture}/`;
|
||||
const deadline = Date.now() + FRAME_LOOKUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const frame = page.frames().find((f) => f.url().includes(expected));
|
||||
if (frame) return frame;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error(`[scenario:parity] fixture frame not found for "${fixture}" within timeout`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for two animation frames on the host page so the compositor has had a
|
||||
* chance to paint the latest player state before we screenshot. First rAF
|
||||
* flushes pending style/layout, second rAF guarantees a painted commit.
|
||||
*/
|
||||
async function waitForPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureDir(path: string): void {
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(path, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `ffmpeg -lavfi ssim` against two PNGs and return the overall SSIM
|
||||
* score. ffmpeg writes the score to stderr in the form:
|
||||
*
|
||||
* [Parsed_ssim_0 @ 0x...] SSIM Y:0.998... U:0.999... V:0.999... All:0.998... (28.3)
|
||||
*
|
||||
* We grab the `All:` value, parse it as a float, and clamp to SSIM_CLAMP_MAX.
|
||||
*
|
||||
* Three failure modes, kept distinct so CI is debuggable without re-running:
|
||||
* - `result.error` (e.g. ENOENT) — ffmpeg never started; the binary is
|
||||
* missing or unexecutable. We surface the OS error so the operator
|
||||
* immediately knows to install ffmpeg on the runner instead of chasing
|
||||
* an "exit=undefined" red herring.
|
||||
* - `result.status !== 0` — ffmpeg started but exited non-zero. Usually a
|
||||
* decode/argument error; stderr has the real message.
|
||||
* - parse failure — ffmpeg ran successfully but its output didn't contain
|
||||
* the expected `All:` token. Indicates a version skew or a no-op input.
|
||||
*
|
||||
* On the second and third failure modes we additionally re-run ffmpeg with
|
||||
* `stats_file` pointed at `<runDir>/ssim-stats.log` so the next CI artifact
|
||||
* upload contains a per-frame SSIM dump alongside the two PNGs. That log is
|
||||
* the cheapest possible bridge between "the assert tripped" and "this pixel
|
||||
* region drifted" — without it, debugging a parity regression means pulling
|
||||
* the PNGs locally and eyeballing them.
|
||||
*/
|
||||
function computeSsim(referencePath: string, actualPath: string, runDir: string): number {
|
||||
const result = spawnSync(
|
||||
"ffmpeg",
|
||||
["-hide_banner", "-i", referencePath, "-i", actualPath, "-lavfi", "ssim", "-f", "null", "-"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
if (result.error) {
|
||||
// spawnSync surfaces ENOENT / EACCES / etc. on `result.error`. status is
|
||||
// null in this case — ffmpeg never actually ran. Calling toString() on
|
||||
// result.status would print "null", which is exactly what produced the
|
||||
// confusing "exit=undefined" line that masked the real ENOENT in CI.
|
||||
throw new Error(
|
||||
`[scenario:parity] ffmpeg could not be started (${(result.error as NodeJS.ErrnoException).code ?? "unknown"}): ${result.error.message}. ` +
|
||||
"Install ffmpeg on the runner (apt-get install -y ffmpeg) — the parity scenario " +
|
||||
"requires it for SSIM scoring.",
|
||||
);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || Buffer.from("")).toString("utf-8");
|
||||
writeSsimStatsOnFailure(referencePath, actualPath, runDir);
|
||||
throw new Error(`[scenario:parity] ffmpeg ssim failed (exit=${result.status}): ${stderr}`);
|
||||
}
|
||||
const stderr = (result.stderr || Buffer.from("")).toString("utf-8");
|
||||
const match = stderr.match(/All:\s*([0-9.]+)/);
|
||||
if (!match) {
|
||||
writeSsimStatsOnFailure(referencePath, actualPath, runDir);
|
||||
throw new Error(`[scenario:parity] could not parse SSIM from ffmpeg stderr: ${stderr}`);
|
||||
}
|
||||
const raw = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(raw)) {
|
||||
writeSsimStatsOnFailure(referencePath, actualPath, runDir);
|
||||
throw new Error(`[scenario:parity] parsed SSIM is not finite: "${match[1]}"`);
|
||||
}
|
||||
return Math.min(SSIM_CLAMP_MAX, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: re-invoke ffmpeg with `stats_file=<runDir>/ssim-stats.log`
|
||||
* so the per-frame SSIM dump lands in the artifact directory. This runs
|
||||
* only on the failure paths in `computeSsim` — a successful parity check
|
||||
* doesn't need the dump. We swallow any error from this helper because
|
||||
* the caller is already on its way to throwing the original failure;
|
||||
* losing the diagnostic dump shouldn't change the surfaced error.
|
||||
*/
|
||||
function writeSsimStatsOnFailure(referencePath: string, actualPath: string, runDir: string): void {
|
||||
try {
|
||||
const statsPath = resolve(runDir, "ssim-stats.log");
|
||||
spawnSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-i",
|
||||
referencePath,
|
||||
"-i",
|
||||
actualPath,
|
||||
"-lavfi",
|
||||
// ffmpeg's lavfi parser uses '\:' to escape the path separator inside
|
||||
// a filter argument. We don't expect ':' in `statsPath` but escape
|
||||
// defensively to keep this robust on weird mounts.
|
||||
`ssim=stats_file=${statsPath.replace(/:/g, "\\:")}`,
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: never let stats-dump failure mask the real error.
|
||||
}
|
||||
}
|
||||
|
||||
async function runOnce(
|
||||
opts: ParityScenarioOpts,
|
||||
fixture: string,
|
||||
idx: number,
|
||||
total: number,
|
||||
): Promise<RunResult> {
|
||||
const ctx = await opts.browser.createBrowserContext();
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
const { duration } = await loadHostPage(page, opts.origin, { fixture });
|
||||
if (duration < TARGET_TIME_S + 0.1) {
|
||||
throw new Error(
|
||||
`[scenario:parity] fixture composition is ${duration.toFixed(2)}s but parity target needs >= ${(TARGET_TIME_S + 0.1).toFixed(2)}s`,
|
||||
);
|
||||
}
|
||||
const frame = await getFixtureFrame(page, fixture);
|
||||
|
||||
// Install the iframe-side rAF watcher *before* we issue play(). The
|
||||
// watcher polls __player.getTime() every animation frame and, the first
|
||||
// time getTime() >= TARGET_TIME_S, calls __player.pause() in the same
|
||||
// tick. pause() is synchronous (it calls timeline.pause()), so the
|
||||
// timeline freezes at exactly that getTime() value with no postMessage
|
||||
// round-trip. The Promise resolves with that frozen value as the
|
||||
// canonical T_actual we'll use for both screenshots.
|
||||
await frame.evaluate(
|
||||
(target: number, timeoutMs: number) => {
|
||||
window.__perfParityPauseAwait = new Promise<number>((resolve, reject) => {
|
||||
const deadlineWall = performance.timeOrigin + performance.now() + timeoutMs;
|
||||
const tick = () => {
|
||||
const player = window.__player;
|
||||
if (!player) {
|
||||
reject(new Error("[parity] __player missing during rAF watcher"));
|
||||
return;
|
||||
}
|
||||
const wall = performance.timeOrigin + performance.now();
|
||||
const time = player.getTime();
|
||||
if (Number.isFinite(time) && time >= target) {
|
||||
// Pause from inside the rAF tick — synchronous in the runtime,
|
||||
// so the timeline can't advance any further before we read
|
||||
// getTime() back out as the canonical frozen value.
|
||||
player.pause();
|
||||
resolve(player.getTime());
|
||||
return;
|
||||
}
|
||||
if (wall > deadlineWall) {
|
||||
reject(new Error(`[parity] timeout waiting for getTime >= ${target} (last=${time})`));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
},
|
||||
TARGET_TIME_S,
|
||||
TARGET_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Start playback from the host page.
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("player") as (HTMLElement & { play: () => void }) | null;
|
||||
if (!el) throw new Error("[scenario:parity] player element missing on host page");
|
||||
el.play();
|
||||
});
|
||||
|
||||
// Confirm the runtime is actually playing before we wait on the rAF
|
||||
// watcher. Without this we can hang waiting for getTime() to advance
|
||||
// when play() hasn't kicked the timeline yet.
|
||||
await frame.waitForFunction(() => window.__player?.isPlaying?.() === true, {
|
||||
timeout: PLAY_CONFIRM_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Block until the iframe watcher pauses the timeline and resolves with
|
||||
// the frozen player time. This is the canonical T_actual for the run.
|
||||
const capturedTime = (await frame.evaluate(
|
||||
() => window.__perfParityPauseAwait as Promise<number>,
|
||||
)) as number;
|
||||
|
||||
if (!Number.isFinite(capturedTime) || capturedTime < TARGET_TIME_S) {
|
||||
throw new Error(
|
||||
`[scenario:parity] watcher resolved with invalid time: ${capturedTime} (target=${TARGET_TIME_S})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Capture frame #1: the live-playback frame frozen by pause().
|
||||
await waitForPaint(page);
|
||||
const actualImage = (await page.screenshot({ type: "png" })) as Buffer | Uint8Array;
|
||||
|
||||
// Capture frame #2: the same time, reached via synchronous seek. The
|
||||
// player is already paused, so seek() lands the timeline directly on
|
||||
// capturedTime via _trySyncSeek -> __player.seek().
|
||||
await page.evaluate((targetSeconds: number) => {
|
||||
const el = document.getElementById("player") as
|
||||
| (HTMLElement & { seek: (t: number) => void })
|
||||
| null;
|
||||
if (!el) throw new Error("[scenario:parity] player element missing on host page");
|
||||
el.seek(targetSeconds);
|
||||
}, capturedTime);
|
||||
|
||||
await waitForPaint(page);
|
||||
const referenceImage = (await page.screenshot({ type: "png" })) as Buffer | Uint8Array;
|
||||
|
||||
// Persist artifacts under results/parity/run-N/ for CI upload and local
|
||||
// inspection. Captured time is written alongside so we can reproduce
|
||||
// a specific run's seek target later.
|
||||
const runDir = resolve(RESULTS_DIR, `run-${idx + 1}`);
|
||||
ensureDir(runDir);
|
||||
const actualPath = resolve(runDir, "actual.png");
|
||||
const referencePath = resolve(runDir, "reference.png");
|
||||
writeFileSync(actualPath, actualImage);
|
||||
writeFileSync(referencePath, referenceImage);
|
||||
writeFileSync(
|
||||
resolve(runDir, "captured-time.txt"),
|
||||
`${capturedTime}\n${TARGET_TIME_S}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const ssim = computeSsim(referencePath, actualPath, runDir);
|
||||
console.log(
|
||||
`[scenario:parity] run[${idx + 1}/${total}] ssim=${ssim.toFixed(6)} captured_time=${capturedTime.toFixed(6)}s artifacts=${runDir}`,
|
||||
);
|
||||
|
||||
await page.close();
|
||||
return { ssim, capturedTime };
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runParity(opts: ParityScenarioOpts): Promise<Metric[]> {
|
||||
const fixture = opts.fixture ?? DEFAULT_FIXTURE;
|
||||
const runs = Math.max(1, opts.runs);
|
||||
console.log(`[scenario:parity] fixture=${fixture} runs=${runs} target=${TARGET_TIME_S}s`);
|
||||
|
||||
// Wipe stale per-run dirs from previous invocations so artifact upload
|
||||
// only contains this run's PNGs. We don't rm -rf the parent dir to avoid
|
||||
// surprising anyone debugging a previous failure.
|
||||
ensureDir(RESULTS_DIR);
|
||||
|
||||
const ssims: number[] = [];
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const result = await runOnce(opts, fixture, i, runs);
|
||||
ssims.push(result.ssim);
|
||||
}
|
||||
|
||||
// Worst case wins. A min < 0.93 means at least one run produced visibly
|
||||
// different pixels between live playback and sync seek at the same time —
|
||||
// which is the regression we're guarding against (see file-level JSDoc
|
||||
// for why the gate is 0.93 rather than the proposal's 0.95).
|
||||
const minSsim = Math.min(...ssims);
|
||||
const meanSsim = ssims.reduce((a, b) => a + b, 0) / ssims.length;
|
||||
console.log(
|
||||
`[scenario:parity] aggregate min=${minSsim.toFixed(6)} mean=${meanSsim.toFixed(6)} runs=${runs}`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "parity_ssim_min",
|
||||
baselineKey: "paritySsimMin",
|
||||
value: minSsim,
|
||||
unit: "ssim",
|
||||
direction: "higher-is-better",
|
||||
samples: ssims,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Static file server for player perf tests.
|
||||
*
|
||||
* Serves all bundles, vendor scripts, fixtures, and the embed host page from
|
||||
* a single origin so the player iframe stays same-origin. Without same-origin
|
||||
* the runtime probe in `_onIframeLoad` falls into the cross-origin catch path
|
||||
* and the `ready` event fires later (or not at all) — which would be measured
|
||||
* as a player-side regression instead of an environment artifact.
|
||||
*
|
||||
* URL routes:
|
||||
* / → host.html (default fixture: gsap-heavy)
|
||||
* /host.html?fixture=<name> → embed page hosting <hyperframes-player>
|
||||
* /player/hyperframes-player.global.js
|
||||
* /vendor/gsap.min.js
|
||||
* /vendor/hyperframe.runtime.iife.js
|
||||
* /fixtures/<name>/<file> → fixture HTML + assets
|
||||
*/
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PLAYER_PKG = resolve(HERE, "../..");
|
||||
const REPO_ROOT = resolve(PLAYER_PKG, "../..");
|
||||
|
||||
function firstExisting(candidates: string[]): string {
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
return candidates[0] ?? "";
|
||||
}
|
||||
|
||||
const PATHS = {
|
||||
player: join(PLAYER_PKG, "dist/hyperframes-player.global.js"),
|
||||
runtime: join(REPO_ROOT, "packages/core/dist/hyperframe.runtime.iife.js"),
|
||||
// bun installs gsap into the package's node_modules in workspace mode, but
|
||||
// hoists it to the repo root if multiple packages share the same version.
|
||||
// Probe both locations so the server works regardless of layout.
|
||||
gsap: firstExisting([
|
||||
join(PLAYER_PKG, "node_modules/gsap/dist/gsap.min.js"),
|
||||
join(REPO_ROOT, "node_modules/gsap/dist/gsap.min.js"),
|
||||
]),
|
||||
fixturesDir: join(HERE, "fixtures"),
|
||||
} as const;
|
||||
|
||||
export type ServeOptions = {
|
||||
port?: number;
|
||||
/** Disables HTTP cache so every request is a "cold" fetch. Used for cold-load scenarios. */
|
||||
noCache?: boolean;
|
||||
};
|
||||
|
||||
export type RunningServer = {
|
||||
port: number;
|
||||
origin: string;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mp3": "audio/mpeg",
|
||||
};
|
||||
|
||||
function mimeFor(path: string): string {
|
||||
const dot = path.lastIndexOf(".");
|
||||
if (dot < 0) return "application/octet-stream";
|
||||
return MIME_TYPES[path.slice(dot).toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function buildHostHtml(fixtureName: string, width: number, height: number): string {
|
||||
const playerSrc = "/player/hyperframes-player.global.js";
|
||||
const fixtureSrc = `/fixtures/${fixtureName}/index.html`;
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>player perf host: ${fixtureName}</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #000; }
|
||||
hyperframes-player { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<hyperframes-player
|
||||
id="player"
|
||||
src="${fixtureSrc}"
|
||||
width="${width}"
|
||||
height="${height}"
|
||||
muted
|
||||
></hyperframes-player>
|
||||
<script>
|
||||
window.__playerReady = false;
|
||||
window.__playerReadyAt = null;
|
||||
window.__playerNavStart = performance.timeOrigin + performance.now();
|
||||
const player = document.getElementById("player");
|
||||
player.addEventListener("ready", function (event) {
|
||||
window.__playerReady = true;
|
||||
window.__playerReadyAt = performance.timeOrigin + performance.now();
|
||||
window.__playerDuration = (event.detail && event.detail.duration) || 0;
|
||||
});
|
||||
player.addEventListener("error", function (event) {
|
||||
window.__playerError = (event.detail && event.detail.message) || "unknown";
|
||||
});
|
||||
</script>
|
||||
<script src="${playerSrc}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function readBunFile(path: string): Promise<Response> {
|
||||
if (!existsSync(path)) {
|
||||
return new Response(`Not found: ${path}`, { status: 404 });
|
||||
}
|
||||
const file = Bun.file(path);
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": mimeFor(path),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function applyCacheHeaders(res: Response, noCache: boolean): Response {
|
||||
if (noCache) {
|
||||
res.headers.set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
res.headers.set("Pragma", "no-cache");
|
||||
res.headers.set("Expires", "0");
|
||||
} else {
|
||||
res.headers.set("Cache-Control", "public, max-age=3600");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function startServer(options: ServeOptions = {}): RunningServer {
|
||||
const noCache = options.noCache ?? false;
|
||||
|
||||
const server = Bun.serve({
|
||||
port: options.port ?? 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/" || path === "/host.html") {
|
||||
const fixture = url.searchParams.get("fixture") || "gsap-heavy";
|
||||
const width = Number(url.searchParams.get("width") || "1920");
|
||||
const height = Number(url.searchParams.get("height") || "1080");
|
||||
const html = buildHostHtml(fixture, width, height);
|
||||
return applyCacheHeaders(
|
||||
new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }),
|
||||
noCache,
|
||||
);
|
||||
}
|
||||
|
||||
if (path === "/player/hyperframes-player.global.js") {
|
||||
return applyCacheHeaders(await readBunFile(PATHS.player), noCache);
|
||||
}
|
||||
|
||||
if (path === "/vendor/hyperframe.runtime.iife.js") {
|
||||
return applyCacheHeaders(await readBunFile(PATHS.runtime), noCache);
|
||||
}
|
||||
|
||||
if (path === "/vendor/gsap.min.js") {
|
||||
return applyCacheHeaders(await readBunFile(PATHS.gsap), noCache);
|
||||
}
|
||||
|
||||
if (path.startsWith("/fixtures/")) {
|
||||
const rel = path.replace(/^\/fixtures\//, "");
|
||||
const filePath = join(PATHS.fixturesDir, rel);
|
||||
if (!filePath.startsWith(PATHS.fixturesDir)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
return applyCacheHeaders(await readBunFile(filePath), noCache);
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// server.port is `number | undefined` in Bun's types (undefined only for unix-socket
|
||||
// servers, which we never use). Narrow it once at startup so the rest of the perf
|
||||
// harness can rely on a numeric origin.
|
||||
const port = server.port;
|
||||
if (port === undefined) {
|
||||
throw new Error("[player-perf] Bun.serve did not assign a TCP port");
|
||||
}
|
||||
return {
|
||||
port,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
async stop() {
|
||||
server.stop(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/hyperframes-player.ts", "src/slideshow/hyperframes-slideshow.ts"],
|
||||
format: ["esm", "cjs", "iife"],
|
||||
globalName: "HyperframesPlayer",
|
||||
noExternal: ["@hyperframes/core"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// fileURLToPath (not URL.pathname): on Windows .pathname yields "/D:/..." with a
|
||||
// leading slash, which breaks resolve() and the alias below.
|
||||
const coreRoot = resolve(fileURLToPath(new URL("../core/src", import.meta.url)));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@hyperframes/core/slideshow": resolve(coreRoot, "slideshow/index.ts"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
setupFiles: ["./src/slideshow/test-setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user