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:
Vance Ingalls
2026-05-06 01:25:09 -07:00
committed by GitHub
parent 64457d9fe7
commit a7b308b667
23 changed files with 3475 additions and 243 deletions
+40 -20
View File
@@ -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*
+398 -4
View File
@@ -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
+155
View File
@@ -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: