mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat: cache shader transition preview frames (#634)
* feat: cache shader transition preview frames * fix: move shader transition loading to player
This commit is contained in:
+40
-20
@@ -52,18 +52,35 @@ Show a static image before playback starts:
|
||||
|
||||
## 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 |
|
||||
| `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 |
|
||||
| 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 |
|
||||
| `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.
|
||||
|
||||
### Mobile audio
|
||||
|
||||
@@ -98,6 +115,8 @@ player.ready; // boolean (read-only)
|
||||
player.playbackRate; // number (read/write)
|
||||
player.muted; // boolean (read/write)
|
||||
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)
|
||||
@@ -157,14 +176,15 @@ function StudioPreview({ src }: { src: string }) {
|
||||
|
||||
## 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 |
|
||||
| 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) => {
|
||||
|
||||
@@ -323,6 +323,137 @@ describe("HyperframesPlayer parent-frame media", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shader transition preview controls ──
|
||||
//
|
||||
// Shader transition capture scale and loading UI ownership are player-level
|
||||
// preview concerns. The player forwards those options into the iframe before
|
||||
// the composition runs, then renders transition-prep progress from runtime
|
||||
// messages when `shader-loading="player"` is enabled.
|
||||
|
||||
describe("HyperframesPlayer shader transition options", () => {
|
||||
type PlayerWithIframe = HTMLElement & {
|
||||
iframeElement: HTMLIFrameElement;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await import("./hyperframes-player.js");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("observes shader capture scale and loading attributes", () => {
|
||||
const player = document.createElement("hyperframes-player");
|
||||
const Ctor = player.constructor as typeof HTMLElement & {
|
||||
observedAttributes: string[];
|
||||
};
|
||||
|
||||
expect(Ctor.observedAttributes).toContain("shader-capture-scale");
|
||||
expect(Ctor.observedAttributes).toContain("shader-loading");
|
||||
});
|
||||
|
||||
it("passes shader options through src query parameters", () => {
|
||||
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
||||
player.setAttribute("shader-capture-scale", "0.5");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute("src", "/api/projects/demo/preview?x=1#stage");
|
||||
|
||||
const url = new URL(player.iframeElement.src);
|
||||
expect(url.pathname).toBe("/api/projects/demo/preview");
|
||||
expect(url.searchParams.get("x")).toBe("1");
|
||||
expect(url.searchParams.get("__hf_shader_capture_scale")).toBe("0.5");
|
||||
expect(url.searchParams.get("__hf_shader_loading")).toBe("player");
|
||||
expect(url.hash).toBe("#stage");
|
||||
});
|
||||
|
||||
it("injects shader options into srcdoc before composition scripts run", () => {
|
||||
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
||||
player.setAttribute("shader-capture-scale", "0.5");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute(
|
||||
"srcdoc",
|
||||
'<!doctype html><html><head><script src="composition.js"></script></head><body></body></html>',
|
||||
);
|
||||
|
||||
const srcdoc = player.iframeElement.srcdoc;
|
||||
expect(srcdoc).toContain('window.__HF_SHADER_CAPTURE_SCALE="0.5";');
|
||||
expect(srcdoc).toContain('window.__HF_SHADER_LOADING="player";');
|
||||
expect(srcdoc.indexOf("data-hyperframes-player-shader-options")).toBeLessThan(
|
||||
srcdoc.indexOf("composition.js"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows and hides the player-owned shader loader from transition state messages", () => {
|
||||
vi.useFakeTimers();
|
||||
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
||||
player.setAttribute("shader-loading", "player");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const iframeWindow = player.iframeElement.contentWindow;
|
||||
expect(iframeWindow).toBeTruthy();
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow,
|
||||
data: {
|
||||
source: "hf-preview",
|
||||
type: "shader-transition-state",
|
||||
compositionId: "main",
|
||||
state: {
|
||||
loading: true,
|
||||
progress: 3,
|
||||
total: 10,
|
||||
currentTransition: 1,
|
||||
transitionTotal: 2,
|
||||
transitionFrame: 3,
|
||||
transitionFrames: 5,
|
||||
phase: "capturing",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const loader = player.shadowRoot?.querySelector(".hfp-shader-loader");
|
||||
expect(loader?.classList.contains("hfp-visible")).toBe(true);
|
||||
expect(loader?.textContent).toContain("1/2");
|
||||
expect(loader?.textContent).toContain("3/5");
|
||||
|
||||
const playEvents: Event[] = [];
|
||||
player.addEventListener("play", (event) => playEvents.push(event));
|
||||
loader?.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true }));
|
||||
expect(playEvents).toHaveLength(0);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow,
|
||||
data: {
|
||||
source: "hf-preview",
|
||||
type: "shader-transition-state",
|
||||
compositionId: "main",
|
||||
state: { loading: false, ready: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow,
|
||||
data: {
|
||||
source: "hf-preview",
|
||||
type: "shader-transition-state",
|
||||
compositionId: "main",
|
||||
state: { loading: false, ready: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(loader?.classList.contains("hfp-visible")).toBe(false);
|
||||
expect(loader?.classList.contains("hfp-hiding")).toBe(true);
|
||||
vi.advanceTimersByTime(420);
|
||||
expect(loader?.classList.contains("hfp-hiding")).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shared stylesheet (adoptedStyleSheets) ──
|
||||
//
|
||||
// Every player constructed in the same document should adopt the *same*
|
||||
|
||||
@@ -20,6 +20,114 @@ function getSharedSheet(): CSSStyleSheet | null {
|
||||
const DEFAULT_FPS = 30;
|
||||
const RUNTIME_CDN_URL =
|
||||
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
|
||||
const SHADER_CAPTURE_SCALE_ATTR = "shader-capture-scale";
|
||||
const SHADER_LOADING_ATTR = "shader-loading";
|
||||
const SHADER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale";
|
||||
const SHADER_LOADING_PARAM = "__hf_shader_loading";
|
||||
|
||||
export type ShaderLoadingMode = "composition" | "player" | "none";
|
||||
|
||||
interface ShaderTransitionState {
|
||||
ready?: boolean;
|
||||
progress?: number;
|
||||
total?: number;
|
||||
currentTransition?: number;
|
||||
transitionTotal?: number;
|
||||
transitionFrame?: number;
|
||||
transitionFrames?: number;
|
||||
phase?: "cached" | "capturing" | "finalizing";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface ShaderLoaderElements {
|
||||
root: HTMLDivElement;
|
||||
fill: HTMLDivElement;
|
||||
title: HTMLSpanElement;
|
||||
detail: HTMLDivElement;
|
||||
transitionValue: HTMLSpanElement;
|
||||
frameLabel: HTMLSpanElement;
|
||||
frameValue: HTMLSpanElement;
|
||||
frameRow: HTMLDivElement;
|
||||
}
|
||||
|
||||
const SHADER_LOADING_PHRASES = [
|
||||
"Preparing scene transitions",
|
||||
"Sampling outgoing scene motion",
|
||||
"Sampling incoming scene motion",
|
||||
"Caching transition frames",
|
||||
"Finalizing transition preview",
|
||||
];
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
class HyperframesPlayer extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
@@ -33,6 +141,8 @@ class HyperframesPlayer extends HTMLElement {
|
||||
"poster",
|
||||
"playback-rate",
|
||||
"audio-src",
|
||||
SHADER_CAPTURE_SCALE_ATTR,
|
||||
SHADER_LOADING_ATTR,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,6 +152,15 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private posterEl: HTMLImageElement | null = null;
|
||||
private controlsApi: ReturnType<typeof createControls> | null = null;
|
||||
private resizeObserver: ResizeObserver;
|
||||
private shaderLoaderEl: HTMLDivElement;
|
||||
private shaderLoaderFillEl: HTMLDivElement;
|
||||
private shaderLoaderTitleEl: HTMLSpanElement;
|
||||
private shaderLoaderDetailEl: HTMLDivElement;
|
||||
private shaderLoaderTransitionValueEl: HTMLSpanElement;
|
||||
private shaderLoaderFrameLabelEl: HTMLSpanElement;
|
||||
private shaderLoaderFrameValueEl: HTMLSpanElement;
|
||||
private shaderLoaderFrameRowEl: HTMLDivElement;
|
||||
private shaderLoaderHideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private _ready = false;
|
||||
private _duration = 0;
|
||||
@@ -141,6 +260,16 @@ class HyperframesPlayer extends HTMLElement {
|
||||
|
||||
this.container.appendChild(this.iframe);
|
||||
this.shadow.appendChild(this.container);
|
||||
const shaderLoader = this._createShaderLoader();
|
||||
this.shaderLoaderEl = shaderLoader.root;
|
||||
this.shaderLoaderFillEl = shaderLoader.fill;
|
||||
this.shaderLoaderTitleEl = shaderLoader.title;
|
||||
this.shaderLoaderDetailEl = shaderLoader.detail;
|
||||
this.shaderLoaderTransitionValueEl = shaderLoader.transitionValue;
|
||||
this.shaderLoaderFrameLabelEl = shaderLoader.frameLabel;
|
||||
this.shaderLoaderFrameValueEl = shaderLoader.frameValue;
|
||||
this.shaderLoaderFrameRowEl = shaderLoader.frameRow;
|
||||
this.shadow.appendChild(this.shaderLoaderEl);
|
||||
|
||||
// Clicking the bare player surface toggles play/pause.
|
||||
// Ignore shadow-DOM control interactions so overlay clicks don't double-handle.
|
||||
@@ -167,8 +296,9 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this._setupParentAudioFromUrl(this.getAttribute("audio-src")!);
|
||||
// srcdoc wins over src per HTML spec when both are set; mirror both attributes
|
||||
// so the browser applies the standard precedence rules.
|
||||
if (this.hasAttribute("srcdoc")) this.iframe.srcdoc = this.getAttribute("srcdoc")!;
|
||||
if (this.hasAttribute("src")) this.iframe.src = this.getAttribute("src")!;
|
||||
if (this.hasAttribute("srcdoc"))
|
||||
this.iframe.srcdoc = this._prepareSrcdoc(this.getAttribute("srcdoc")!);
|
||||
if (this.hasAttribute("src")) this.iframe.src = this._prepareSrc(this.getAttribute("src")!);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -176,6 +306,8 @@ class HyperframesPlayer extends HTMLElement {
|
||||
window.removeEventListener("message", this._onMessage);
|
||||
this.iframe.removeEventListener("load", this._onIframeLoad);
|
||||
if (this._probeInterval) clearInterval(this._probeInterval);
|
||||
if (this.shaderLoaderHideTimeout) clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
this._teardownMediaObserver();
|
||||
this.controlsApi?.destroy();
|
||||
for (const m of this._parentMedia) {
|
||||
@@ -190,7 +322,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
case "src":
|
||||
if (val) {
|
||||
this._ready = false;
|
||||
this.iframe.src = val;
|
||||
this.iframe.src = this._prepareSrc(val);
|
||||
}
|
||||
break;
|
||||
case "srcdoc":
|
||||
@@ -198,7 +330,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
// srcdoc and let src take over. Always reset readiness; the iframe will
|
||||
// load a new document either way.
|
||||
this._ready = false;
|
||||
if (val !== null) this.iframe.srcdoc = val;
|
||||
if (val !== null) this.iframe.srcdoc = this._prepareSrcdoc(val);
|
||||
else this.iframe.removeAttribute("srcdoc");
|
||||
break;
|
||||
case "width":
|
||||
@@ -234,6 +366,10 @@ class HyperframesPlayer extends HTMLElement {
|
||||
case "audio-src":
|
||||
if (val) this._setupParentAudioFromUrl(val);
|
||||
break;
|
||||
case SHADER_CAPTURE_SCALE_ATTR:
|
||||
case SHADER_LOADING_ATTR:
|
||||
this._reloadShaderOptions();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +491,21 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this.setAttribute("playback-rate", String(r));
|
||||
}
|
||||
|
||||
get shaderCaptureScale() {
|
||||
return Number(normalizeShaderCaptureScale(this.getAttribute(SHADER_CAPTURE_SCALE_ATTR)) ?? "1");
|
||||
}
|
||||
set shaderCaptureScale(scale: number) {
|
||||
this.setAttribute(SHADER_CAPTURE_SCALE_ATTR, String(scale));
|
||||
}
|
||||
|
||||
get shaderLoading() {
|
||||
return normalizeShaderLoadingMode(this.getAttribute(SHADER_LOADING_ATTR));
|
||||
}
|
||||
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");
|
||||
}
|
||||
@@ -384,6 +535,236 @@ class HyperframesPlayer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _shaderCaptureScaleParam(): string | null {
|
||||
return normalizeShaderCaptureScale(this.getAttribute(SHADER_CAPTURE_SCALE_ATTR));
|
||||
}
|
||||
|
||||
private _shaderLoadingMode(): ShaderLoadingMode {
|
||||
return normalizeShaderLoadingMode(this.getAttribute(SHADER_LOADING_ATTR));
|
||||
}
|
||||
|
||||
private _prepareSrc(src: string): string {
|
||||
return withShaderQueryParams(src, this._shaderCaptureScaleParam(), this._shaderLoadingMode());
|
||||
}
|
||||
|
||||
private _prepareSrcdoc(srcdoc: string): string {
|
||||
return injectShaderOptionsIntoSrcdoc(
|
||||
srcdoc,
|
||||
this._shaderCaptureScaleParam(),
|
||||
this._shaderLoadingMode(),
|
||||
);
|
||||
}
|
||||
|
||||
private _reloadShaderOptions(): void {
|
||||
if (this._shaderLoadingMode() !== "player") {
|
||||
this._resetShaderLoader();
|
||||
}
|
||||
if (this.hasAttribute("srcdoc")) {
|
||||
this.iframe.srcdoc = this._prepareSrcdoc(this.getAttribute("srcdoc") || "");
|
||||
return;
|
||||
}
|
||||
if (this.hasAttribute("src")) {
|
||||
this.iframe.src = this._prepareSrc(this.getAttribute("src") || "");
|
||||
}
|
||||
}
|
||||
|
||||
private _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 title = document.createElement("div");
|
||||
title.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";
|
||||
title.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(title);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private _showShaderLoader(): void {
|
||||
if (this.shaderLoaderHideTimeout) {
|
||||
clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
}
|
||||
this.shaderLoaderEl.classList.remove("hfp-hiding");
|
||||
this.shaderLoaderEl.classList.add("hfp-visible");
|
||||
}
|
||||
|
||||
private _hideShaderLoader(): void {
|
||||
if (this.shaderLoaderEl.classList.contains("hfp-hiding")) {
|
||||
if (!this.shaderLoaderHideTimeout) this._scheduleShaderLoaderHideCleanup();
|
||||
return;
|
||||
}
|
||||
if (!this.shaderLoaderEl.classList.contains("hfp-visible")) return;
|
||||
this.shaderLoaderEl.classList.add("hfp-hiding");
|
||||
this.shaderLoaderEl.classList.remove("hfp-visible");
|
||||
this._scheduleShaderLoaderHideCleanup();
|
||||
}
|
||||
|
||||
private _scheduleShaderLoaderHideCleanup(): void {
|
||||
if (this.shaderLoaderHideTimeout) clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = setTimeout(() => {
|
||||
this.shaderLoaderEl.classList.remove("hfp-hiding");
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
}, 420);
|
||||
}
|
||||
|
||||
private _resetShaderLoader(): void {
|
||||
if (this.shaderLoaderHideTimeout) {
|
||||
clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
}
|
||||
this.shaderLoaderEl.classList.remove("hfp-visible", "hfp-hiding");
|
||||
this.shaderLoaderFillEl.style.transform = "scaleX(0)";
|
||||
this.shaderLoaderTransitionValueEl.textContent = "";
|
||||
this.shaderLoaderFrameValueEl.textContent = "";
|
||||
this.shaderLoaderFrameRowEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
private _updateShaderLoader(status: ShaderTransitionState): void {
|
||||
if (this._shaderLoadingMode() !== "player") {
|
||||
this._resetShaderLoader();
|
||||
return;
|
||||
}
|
||||
if (status.ready || !status.loading) {
|
||||
this._hideShaderLoader();
|
||||
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.shaderLoaderTitleEl.textContent =
|
||||
SHADER_LOADING_PHRASES[phraseIndex] || "Preparing scene transitions";
|
||||
this.shaderLoaderDetailEl.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.shaderLoaderFillEl.style.transform = `scaleX(${ratio})`;
|
||||
|
||||
this.shaderLoaderTransitionValueEl.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.shaderLoaderFrameLabelEl.textContent =
|
||||
status.phase === "cached"
|
||||
? "cached transition frames"
|
||||
: status.phase === "finalizing"
|
||||
? "finalizing transition frames"
|
||||
: "rendering transition frames";
|
||||
this.shaderLoaderFrameValueEl.textContent = frameValue;
|
||||
this.shaderLoaderFrameRowEl.style.visibility = frameValue ? "visible" : "hidden";
|
||||
this.shaderLoaderEl.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
|
||||
this._showShaderLoader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach into the runtime's `window.__player.seek` directly, skipping the
|
||||
* postMessage hop. Same-origin only — cross-origin embeds throw a
|
||||
@@ -426,6 +807,18 @@ class HyperframesPlayer extends HTMLElement {
|
||||
const data = e.data;
|
||||
if (!data || data.source !== "hf-preview") return;
|
||||
|
||||
if (data.type === "shader-transition-state") {
|
||||
const state: ShaderTransitionState =
|
||||
data.state && typeof data.state === "object" ? data.state : {};
|
||||
this._updateShaderLoader(state);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("shadertransitionstate", {
|
||||
detail: { compositionId: data.compositionId, state },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "state") {
|
||||
this._currentTime = (data.frame ?? 0) / DEFAULT_FPS;
|
||||
const wasPlaying = !this._paused;
|
||||
@@ -501,6 +894,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _onIframeLoad() {
|
||||
let attempts = 0;
|
||||
this._runtimeInjected = false;
|
||||
this._resetShaderLoader();
|
||||
// A fresh iframe means a fresh runtime — `mediaOutputMuted` and the
|
||||
// autoplay-blocked latch are both reset inside it. The web component's
|
||||
// `_audioOwner` must reset to match, otherwise a composition switch on
|
||||
|
||||
@@ -31,6 +31,161 @@ export const PLAYER_STYLES = /* css */ `
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user