Files
hyperframes/packages/shader-transitions/src/hyper-shader.ts
T
f84cc492de perf(engine): faster shader transitions via page-side WebGL compositing (#832)
* fix(cli): prefer puppeteer cache + numeric version sort (staff review)

Two correctness fixes from PR #821 self-review:

1. Cache priority order. Previous order was hyperframes-managed cache →
   puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which
   lags 17+ releases behind upstream; if a user separately installed a
   newer chrome-headless-shell via @puppeteer/browsers install, the CLI
   would silently hand engine the older HF-cache binary while engine's
   own resolveHeadlessShellPath would have picked the newer one. Flip
   the priority so puppeteer cache wins, matching engine semantics.

2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()`
   over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would
   return `linux-99...` first because character '9' outranks '1'. Parse
   each name into integer segments and compare them numerically.

Tests: add both-caches-populated and linux-148-beats-linux-99 cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(engine): page-side compositing for shader transitions (opt-in spike)

Add an opt-in `--page-side-compositing` flag (CLI) backed by a new engine
config field `enablePageSideCompositing` and env var `HF_PAGE_SIDE_COMPOSITING`.
When set, SDR shader-transition compositions skip the Node-side layered blend
(the hf#677 chain) and instead run the shader inside Chrome via a page-side
WebGL canvas; the engine then captures ONE opaque RGB frame per output frame
via the existing streaming capture path.

This is the strongest non-beginFrame perf lever for Mac users, who cannot
take the beginFrame `~5×` path (Chromium structural limit, crbug.com/40656275).
Stacks on top of the hf#677 1.95× baseline.

Default OFF — existing fixture pins (byte-exact MP4 output) are preserved.
Opt-in path is intentionally PSNR-pinned, not byte-equal (WebGL is f32; Node
is f64). HDR content forces the existing layered path regardless.

Implementation:
- engine: new `EngineConfig.enablePageSideCompositing` (default false).
- producer/fileServer: new `HF_PAGE_SIDE_COMPOSITING_STUB` early-page script
  injected into the served HTML head when the flag is on.
- producer/renderOrchestrator: when the flag + no HDR + no png-sequence,
  route SDR transitions through the streaming path instead of the layered
  HDR stage.
- shader-transitions: new `engineModePageComposite.ts` installs a fullscreen
  WebGL compositor overlay and wraps `window.__hf.seek` so each seek inside
  a transition window captures both scenes via the Chromium
  `drawElementImage` API to GL textures, runs the fragment shader, and
  displays the composited result on the overlay canvas. The engine takes
  one screenshot per frame and sees the composited overlay.
- cli: new `--page-side-compositing` flag sets `HF_PAGE_SIDE_COMPOSITING=true`
  before producer load.
- scripts/page-side-compositing-smoke: bundled-CLI smoke that renders a
  representative fixture with and without the flag, validates the canary
  strings are in the shipped bundles, and writes a wall-time pair.

Determinism trade documented in the engine config doc-comment. The smoke
script enforces the bundled-CLI validation discipline from prior perf work
(see internal feedback note `validate_bundled_cli_not_dev_path`).

Runtime requirement: Chromium's `CanvasDrawElement` feature (already
enabled by the engine's `--enable-features=CanvasDrawElement` launch flag).
When the runtime feature is unavailable, the page-side installer logs a
warning and falls back to opacity-flip mode — the engine still takes the
streaming path; the transition window degrades to a hard scene swap. Vance
will validate on Mac Chrome where the feature is supported.

Co-Authored-By: Vai <vai@heygen.com>

* fix(shader-transitions): use html2canvas for page-side compositor capture

The original drawElementImage approach fails in engine render mode because
the virtual-time shim prevents Chromium from generating paint records for
cloned elements. drawElementImage requires a cached paint record from the
browser's compositor — clones created at capture time never receive one
because (a) shimmed rAFs deadlock inside the seek wrapper, (b) original
rAFs don't produce real paints under virtual-time control, and
(c) layoutsubtree canvases don't apply CSS stylesheet rules to children.

Switch scene capture to html2canvas (foreignObjectRendering: false), the
same JS-based renderer already used by the preview-mode fallback path in
capture.ts. html2canvas reads computed styles and renders via its own
canvas drawing pipeline with no dependency on the browser paint cycle.

Also fixes:
- Engine seek must return the result so Puppeteer awaits async seek
  promises (frameCapture.ts).
- GSAP opacity cache: compositor must restore scene opacity before seek,
  not after — GSAP caches inline values and skips re-writes.
- Support check gates on WebGL availability, not drawElementImage.

Perf: 15-scene shader-perf fixture (28s, 14 transitions, 30fps)
  Baseline (Node-side layered): 137s
  Page-side (html2canvas+WebGL): 33s → 4.1× speedup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(shader-transitions): simplify review fixes for page-side compositor

- Use uploadTexture (zeroes canvas backing store after upload) to prevent
  ~2.2GB transient memory pressure across 280 html2canvas calls per render
- Add ignoreElements + stabilizeTransformedBoxShadows to html2canvas call,
  matching the preview-path capture.ts behavior
- Parallelize from/to scene captures with Promise.all
- Wrap post-capture render in try/finally so opacity is always restored
- Fix WebGL context leak in isPageSideCompositingSupported probe
- Remove dead ResolvedTransition.index field
- Export stabilizeTransformedBoxShadows from capture.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(producer): unify page-side compositing gating and Docker forwarding

Addresses three issues from staff review:

1. ignoreElements filter stripped all in-scene canvases (Chart.js, D3,
   p5.js) — narrowed to data-no-capture only since the compositor canvas
   is a body sibling never in the scene subtree.

2. Docker mode silently dropped --page-side-compositing — thread
   pageSideCompositing through DockerRenderOptions/buildDockerRunArgs
   with regression tests.

3. Fragmented gating across 4 independent sites could disagree:
   - Stub injection gated only on cfg flag (leaked into HDR/alpha)
   - Probe-created fileServer never got the stub
   - needsAlpha (WebM/MOV) not excluded from the gate
   - WebGL-unavailable fallback claimed layered path would run but
     orchestrator had already disabled it

   Fix: compute stub injection at the same site as the layered-bypass
   decision (after hasHdrContent is known), using addPreHeadScript on
   the already-running fileServer. Single predicate now gates both
   decisions, including !needsAlpha.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(engine): two-phase drawElementImage capture for page-side compositing

Replace html2canvas with native drawElementImage for scene capture in
the page-side compositor. drawElementImage reads from the browser's own
paint cache, giving pixel-identical output to the preview path.

The blocker was that cloned elements inside layoutsubtree canvases have
no cached paint record under virtual time — the compositor only paints
when explicitly triggered. Fix: split the seek+composite into two phases
with an engine-forced paint between them.

Phase 1 (seek wrapper, page-side):
  - GSAP seek positions the timeline
  - Clone FROM/TO scenes into visible layoutsubtree staging canvases
  - Set window.__hf_page_composite_pending flag

Engine paint force (frameCapture.ts):
  - Detect pending flag after seek returns
  - Fire micro Page.captureScreenshot (1x1 clip) via CDP to force the
    browser compositor to paint all visible elements including staging
    canvas children

Phase 2 (page.evaluate, page-side):
  - drawElementImage reads the now-valid paint records
  - Upload textures to WebGL, run shader, show GL overlay

Key insight: staging canvases must be visible (not opacity:0) for the
browser to paint their children. They sit at z-index:-9998, behind
the main DOM and covered by the GL overlay during transitions.

Perf: 15-scene fixture (28s, 14 transitions, 30fps):
  Baseline (Node-side layered): 137s
  html2canvas + WebGL:           33s (3.7×)
  drawElementImage + WebGL:      21s (6.6×)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(engine): optimize two-phase compositor hot path

- uploadTextureSource instead of uploadTexture: eliminates ~2.3GB of
  canvas buffer alloc/dealloc churn (persistent staging canvases don't
  need the one-shot zeroing behavior)
- Fold hasPending check into seek page.evaluate: eliminates one CDP
  round-trip per frame (~700 unnecessary IPC calls on non-transition
  frames)
- Fix renderShader error handling: on failure, leave source scenes
  visible as fallback instead of hiding both scenes + GL overlay
  (which produced black frames)
- Move mutable state declarations above resolveComposite to prevent
  TDZ risk on refactor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): staff review — staging cleanup, pending flag, beginFrame guard

- Clear staging canvas children when leaving transition window (prevents
  visible clone bleed-through on transparent compositions)
- Clear __hf_page_composite_pending on all resolveComposite exit paths
- Guard micro-screenshot paint force against beginFrame mode (CDP
  Page.captureScreenshot conflicts with beginFrame compositor control)
- Update CLI flag description: document video/canvas limitation, remove
  stale PSNR claim

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(engine): default-on page-side compositing for SDR shader transitions

Page-side compositing is now enabled by default for SDR shader-transition
renders without video content. The 6.6× speedup applies automatically —
no flag needed.

Auto-disables when:
- HDR content detected
- Alpha output (WebM/MOV/PNG-sequence)
- Composition contains <video> elements (cloneNode loses playback state)
- beginFrame capture mode (Linux headless)

Use --no-page-side-compositing to force the Node-side layered path.

Changes:
- Engine config: enablePageSideCompositing defaults to true
- CLI: flag default flipped to true; --no-page-side-compositing disables
- Orchestrator: added composition.videos.length === 0 gate
- Docker: forwards --no-page-side-compositing when explicitly disabled
- Config tests updated for new default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(engine): support video elements on page-side compositing fast path

Three-phase capture protocol lets shader transitions render video scenes
without falling back to the slow Node-side layered pipeline:

1. Seek → compositor records transition metadata, sets pending flag
2. onBeforeCapture → video frame injector updates <img> replacements
3. prepare → cloneNode picks up current video frames, img.decode() awaits
4. micro-screenshot → forces browser to paint cloned elements
5. resolve → drawElementImage reads paint records, shader composites

Key changes:
- Remove `composition.videos.length === 0` gate from orchestrator
- Split compositor resolve into prepare (clone) + resolve (shader)
- Move onBeforeCapture before compositor prepare in frameCapture.ts
- Await img.decode() on cloned data-URI images to prevent stale frames
- Stop manipulating scene opacity in compositor (GL canvas overlay suffices)
- Add gsap.set declaration for shader-transitions ambient types
- Add video_missing_timing_attrs lint rule for <video> without id/data-start/data-end

Performance: compositions with video now render at 7.5s (6 workers) instead
of 2m38s on the layered path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): auto-inject data-start on video/audio so frame extraction works without explicit attrs

The timing compiler now injects data-start="0" on <video> and <audio>
elements that lack it. This makes discoverMediaFromBrowser() find the
element (it queries video[data-start]), so the frame extraction pipeline
activates automatically. Videos "just work" without requiring authors to
add data-start, data-end, or id attributes.

Also removes the video_missing_timing_attrs lint rule — the compiler
handles the missing attributes automatically, so the lint rule would
only false-positive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): add data-hf-auto-start sentinel on auto-injected video timing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(producer): add discoverVideoVisibilityFromTimeline for runtime video discovery

Seeks the GSAP timeline in Puppeteer to discover when each video's parent
scene is visible (opacity > 0). Uses coarse sampling at 100ms steps followed
by binary search refinement to frame-level precision (1/60s). Only processes
videos with the data-hf-auto-start sentinel so author-specified timing is
never overridden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(producer): integrate runtime video visibility discovery into probe stage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(producer): trigger browser probe for auto-start videos, remove debug logging

The probe stage was skipping browser launch when composition duration was
already known, which meant discoverVideoVisibilityFromTimeline never ran.
Now needsBrowser also checks for data-hf-auto-start sentinel in compiled HTML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(scripts): use mkdtempSync for smoke test work directory

Replaces hardcoded /tmp/hf-page-side-smoke with a unique temp directory
via mkdtempSync to resolve CodeQL "insecure temporary file" alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: format smoke test script

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Vai <vai@heygen.com>
2026-05-15 16:23:12 -07:00

2259 lines
74 KiB
TypeScript

import {
createContext,
setupQuad,
createProgram,
createProgramWithVertex,
createTexture,
uploadTextureSource,
renderShader,
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
type AccentColors,
} from "./webgl.js";
import { getFragSource, type ShaderName } from "./shaders/registry.js";
import { initCapture, captureScene } from "./capture.js";
import { installPageSideCompositor } from "./engineModePageComposite.js";
declare const gsap: {
timeline: (opts: Record<string, unknown>) => GsapTimeline;
set: (target: HTMLElement | string, vars: Record<string, unknown>) => unknown;
to: (target: HTMLElement | string, vars: Record<string, unknown>) => unknown;
fromTo: (
target: HTMLElement | string,
from: Record<string, unknown>,
to: Record<string, unknown>,
) => unknown;
};
interface GsapTimeline {
paused: () => boolean;
play: (from?: number, suppressEvents?: boolean) => GsapTimeline;
pause: (atTime?: number, suppressEvents?: boolean) => GsapTimeline;
time: {
(): number;
(value: number, suppressEvents?: boolean): GsapTimeline;
};
seek?: (position: number | string, suppressEvents?: boolean) => GsapTimeline;
call: (fn: () => void, args: null, position: number) => GsapTimeline;
to: (
target: Record<string, unknown>,
vars: Record<string, unknown>,
position: number,
) => GsapTimeline;
set: (target: string, vars: Record<string, unknown>, position?: number) => GsapTimeline;
from: (target: string, vars: Record<string, unknown>, position?: number) => GsapTimeline;
fromTo: (
target: string,
from: Record<string, unknown>,
to: Record<string, unknown>,
position?: number,
) => GsapTimeline;
[key: string]: unknown;
}
export interface TransitionConfig {
time: number;
shader: ShaderName;
duration?: number;
ease?: string;
}
export interface HyperShaderConfig {
bgColor: string;
accentColor?: string;
scenes: string[];
transitions: TransitionConfig[];
timeline?: GsapTimeline;
compositionId?: string;
previewCaptureFps?: number;
}
interface TransState {
active: boolean;
prog: WebGLProgram | null;
progress: number;
transitionIndex: number;
}
interface CachedTransitionFrame {
sampleIndex: number;
fromBlob: Blob | null;
toBlob: Blob | null;
fromTex: WebGLTexture | null;
toTex: WebGLTexture | null;
}
interface TexturedTransitionFrame extends CachedTransitionFrame {
fromTex: WebGLTexture;
toTex: WebGLTexture;
}
interface CachedTransitionFrameBlend {
a: TexturedTransitionFrame;
b: TexturedTransitionFrame;
mix: number;
}
interface CachedTransition {
index: number;
time: number;
duration: number;
fromId: string;
toId: string;
prog: WebGLProgram;
frames: CachedTransitionFrame[];
cacheKey: string;
dirty: boolean;
ready: boolean;
fallback: boolean;
persisted: boolean;
textureReady: boolean;
texturePromise: Promise<boolean> | null;
textureGeneration: number;
textureAccess: number;
lastError?: string;
}
interface SnapshotLoadingOverlay {
show: () => void;
update: (status: SnapshotLoadingStatus) => void;
hide: () => void;
}
interface SnapshotLoadingStatus {
progress: number;
total: number;
currentTransition?: number;
transitionTotal?: number;
transitionFrame?: number;
transitionFrames?: number;
phase?: "cached" | "capturing" | "finalizing";
}
interface SnapshotCacheEntry {
key: string;
blob: Blob;
width: number;
height: number;
updatedAt: number;
}
interface SceneStyleState {
scene: HTMLElement | null;
opacity: string;
visibility: string;
pointerEvents: string;
}
// Defaults for transition duration/ease. Used by every fallback site in this
// file — meta-write, browser/render mode, and engine mode — so a transition
// without explicit `duration`/`ease` plays the same length and curve in
// preview, the engine's deterministic seek path, and the metadata the
// producer reads to plan compositing.
const DEFAULT_DURATION = 0.7;
const DEFAULT_EASE = "power2.inOut";
const NO_FLIP_VERT_SRC =
"attribute vec2 a_pos; varying vec2 v_uv; void main(){" +
"v_uv=a_pos*0.5+0.5; gl_Position=vec4(a_pos,0,1);}";
const SNAPSHOT_LOADING_PHRASES = [
"Preparing scene transitions",
"Sampling outgoing scene motion",
"Sampling incoming scene motion",
"Caching transition frames",
"Finalizing transition preview",
];
const SNAPSHOT_CACHE_DB = "hyper-shader-preview-cache";
const SNAPSHOT_CACHE_STORE = "frames";
const SNAPSHOT_CACHE_VERSION = 1;
const SNAPSHOT_CACHE_SCHEMA = "v1";
const MAX_TEXTURED_TRANSITIONS = 2;
const TEXTURE_PRELOAD_LOOKAHEAD_SECONDS = 1.25;
const MAX_SNAPSHOT_CACHE_ENTRIES = 1200;
function parseHex(hex: string): [number, number, number] {
const h = hex.replace("#", "");
if (h.length < 6) return [0.5, 0.5, 0.5];
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return [0.5, 0.5, 0.5];
return [r, g, b];
}
function deriveAccentColors(hex: string): AccentColors {
const [r, g, b] = parseHex(hex);
return {
accent: [r, g, b],
dark: [r * 0.35, g * 0.35, b * 0.35],
bright: [Math.min(1, r * 1.5 + 0.2), Math.min(1, g * 1.5 + 0.2), Math.min(1, b * 1.5 + 0.2)],
};
}
function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function resolvePositiveNumber(value: number | undefined, fallback: number): number {
if (value === undefined) return fallback;
return Number.isFinite(value) && value > 0 ? value : fallback;
}
const PLAYER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale";
const PLAYER_LOADING_PARAM = "__hf_shader_loading";
function readPlayerOption(globalName: string, queryName: string): string | null {
const globalValue = (window as unknown as Record<string, unknown>)[globalName];
if (typeof globalValue === "string") return globalValue;
if (typeof globalValue === "number" && Number.isFinite(globalValue)) return String(globalValue);
try {
return new URLSearchParams(window.location.search).get(queryName);
} catch {
return null;
}
}
function resolvePlayerCaptureScale(): number {
const raw = readPlayerOption("__HF_SHADER_CAPTURE_SCALE", PLAYER_CAPTURE_SCALE_PARAM);
const parsed = raw === null ? NaN : Number(raw);
return clampNumber(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, 0.25, 1);
}
function resolvePlayerLoadingMode(): "internal" | "player" | "none" {
const raw = readPlayerOption("__HF_SHADER_LOADING", PLAYER_LOADING_PARAM)?.trim().toLowerCase();
if (raw === "player" || raw === "true" || raw === "1") return "player";
if (raw === "none" || raw === "false" || raw === "0" || raw === "off") return "none";
return "internal";
}
function stableHash(input: string): string {
let hash = 2166136261;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
function getDocumentStyleSignature(doc: Document): string {
const styleText = Array.from(doc.querySelectorAll("style"))
.map((style) => style.textContent || "")
.join("\n");
const linkedStyles = Array.from(doc.querySelectorAll<HTMLLinkElement>('link[rel~="stylesheet"]'))
.map((link) => `${link.href}:${link.getAttribute("integrity") || ""}`)
.join("\n");
return stableHash(`${styleText}\n${linkedStyles}`);
}
function getDocumentScriptSignature(doc: Document): string {
const projectSignature = Array.from(
doc.querySelectorAll<HTMLMetaElement>('meta[name="hyperframes-project-signature"]'),
)
.map((meta) => meta.getAttribute("content") || "")
.join("\n");
const scriptText = Array.from(doc.querySelectorAll<HTMLScriptElement>("script"))
.map((script) => {
const attrs = [
script.type,
script.src,
script.getAttribute("integrity") || "",
script.getAttribute("crossorigin") || "",
script.getAttribute("data-hyperframes-runtime") || "",
].join(":");
return `${attrs}\n${script.src ? "" : script.textContent || ""}`;
})
.join("\n");
return stableHash(`${projectSignature}\n${scriptText}`);
}
function getSceneSignature(sceneId: string): string {
const scene = document.getElementById(sceneId);
if (!scene) return "missing";
return stableHash(
`${getDocumentStyleSignature(document)}\n${getDocumentScriptSignature(document)}\n${getSceneSignatureHtml(scene)}`,
);
}
function removeStyleProperties(el: HTMLElement, properties: string[]): void {
for (const property of properties) {
el.style.removeProperty(property);
}
if (el.getAttribute("style")?.trim() === "") {
el.removeAttribute("style");
}
}
function getSceneSignatureHtml(scene: HTMLElement): string {
const clone = scene.cloneNode(true) as HTMLElement;
// HyperShader and the core runtime mutate these inline styles during seek and
// playback. Cache identity should track authored content, not the last preview
// playhead state.
removeStyleProperties(clone, ["opacity", "visibility", "pointer-events"]);
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
removeStyleProperties(el, ["visibility"]);
});
return clone.outerHTML;
}
function makeSnapshotKey(cacheKey: string, sampleIndex: number, side: "from" | "to"): string {
return `${cacheKey}:sample:${sampleIndex}:${side}`;
}
function openSnapshotDb(): Promise<IDBDatabase | null> {
if (typeof indexedDB === "undefined") return Promise.resolve(null);
return new Promise((resolve) => {
const request = indexedDB.open(SNAPSHOT_CACHE_DB, SNAPSHOT_CACHE_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(SNAPSHOT_CACHE_STORE)) {
db.createObjectStore(SNAPSHOT_CACHE_STORE, { keyPath: "key" });
}
};
request.onerror = () => resolve(null);
request.onblocked = () => resolve(null);
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
snapshotDbPromise = null;
};
resolve(db);
};
});
}
let snapshotDbPromise: Promise<IDBDatabase | null> | null = null;
function resetSnapshotDb(db: IDBDatabase | null): void {
try {
db?.close();
} catch {
// Ignore close failures; the next cache operation will reopen the DB.
}
snapshotDbPromise = null;
}
function getSnapshotDb(): Promise<IDBDatabase | null> {
snapshotDbPromise = snapshotDbPromise || openSnapshotDb();
return snapshotDbPromise;
}
async function getSnapshotEntry(key: string): Promise<SnapshotCacheEntry | null> {
const db = await getSnapshotDb();
if (!db) return null;
return new Promise((resolve) => {
try {
const tx = db.transaction(SNAPSHOT_CACHE_STORE, "readonly");
const request = tx.objectStore(SNAPSHOT_CACHE_STORE).get(key);
request.onerror = () => resolve(null);
request.onsuccess = () => {
const result = request.result;
if (
result &&
typeof result === "object" &&
"blob" in result &&
result.blob instanceof Blob
) {
resolve(result as SnapshotCacheEntry);
} else {
resolve(null);
}
};
} catch {
resetSnapshotDb(db);
resolve(null);
}
});
}
async function putSnapshotEntry(entry: SnapshotCacheEntry): Promise<boolean> {
const db = await getSnapshotDb();
if (!db) return false;
return new Promise((resolve) => {
try {
const tx = db.transaction(SNAPSHOT_CACHE_STORE, "readwrite");
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
tx.objectStore(SNAPSHOT_CACHE_STORE).put(entry);
} catch {
resetSnapshotDb(db);
resolve(false);
}
});
}
function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob | null> {
return new Promise((resolve) => {
try {
canvas.toBlob((blob) => resolve(blob), "image/png");
} catch {
resolve(null);
}
});
}
async function pruneSnapshotCache(
compId: string,
activeCacheKeys: Set<string>,
maxEntries: number = MAX_SNAPSHOT_CACHE_ENTRIES,
): Promise<void> {
const db = await getSnapshotDb();
if (!db) return;
const entries = await new Promise<Array<{ key: string; updatedAt: number }>>((resolve) => {
try {
const tx = db.transaction(SNAPSHOT_CACHE_STORE, "readonly");
const request = tx.objectStore(SNAPSHOT_CACHE_STORE).getAll();
request.onerror = () => resolve([]);
request.onsuccess = () => {
const rows: Array<{ key: string; updatedAt: number }> = [];
for (const result of request.result) {
if (
result &&
typeof result === "object" &&
"key" in result &&
typeof result.key === "string"
) {
const updatedAt =
"updatedAt" in result && typeof result.updatedAt === "number" ? result.updatedAt : 0;
rows.push({ key: result.key, updatedAt });
}
}
resolve(rows);
};
} catch {
resetSnapshotDb(db);
resolve([]);
}
});
const projectPrefix = `${compId}:`;
const isActiveSnapshot = (key: string): boolean => {
for (const cacheKey of activeCacheKeys) {
if (key.startsWith(`${cacheKey}:sample:`)) return true;
}
return false;
};
const staleProjectKeys = entries
.filter((entry) => entry.key.startsWith(projectPrefix) && !isActiveSnapshot(entry.key))
.map((entry) => entry.key);
const staleProjectKeySet = new Set(staleProjectKeys);
const remaining = entries.filter((entry) => !staleProjectKeySet.has(entry.key));
const activeCount = remaining.filter((entry) => isActiveSnapshot(entry.key)).length;
const removable = remaining
.filter((entry) => !isActiveSnapshot(entry.key))
.sort((a, b) => b.updatedAt - a.updatedAt);
const removableBudget = Math.max(0, maxEntries - activeCount);
const overflowKeys =
removable.length > removableBudget
? removable.slice(removableBudget).map((entry) => entry.key)
: [];
const keysToDelete = Array.from(new Set([...staleProjectKeys, ...overflowKeys]));
if (keysToDelete.length === 0) return;
await new Promise<void>((resolve) => {
try {
const tx = db.transaction(SNAPSHOT_CACHE_STORE, "readwrite");
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
const store = tx.objectStore(SNAPSHOT_CACHE_STORE);
for (const key of keysToDelete) {
store.delete(key);
}
} catch {
resetSnapshotDb(db);
resolve();
}
});
}
function blobToTextureSource(blob: Blob): Promise<TexImageSource> {
if (typeof createImageBitmap === "function") {
return createImageBitmap(blob);
}
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("[HyperShader] Failed to decode cached snapshot"));
};
img.src = url;
});
}
function closeTextureSource(source: TexImageSource): void {
if (typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) {
source.close();
}
}
function createRenderTexture(
gl: WebGLRenderingContext,
width: number,
height: number,
): WebGLTexture {
const tex = createTexture(gl);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
return tex;
}
function createFramebuffer(gl: WebGLRenderingContext, tex: WebGLTexture): WebGLFramebuffer {
const fbo = gl.createFramebuffer();
if (!fbo) throw new Error("[HyperShader] Failed to create framebuffer");
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return fbo;
}
function createSnapshotLoadingOverlay(
root: HTMLElement | null,
width: number,
height: number,
): SnapshotLoadingOverlay | null {
const doc = root?.ownerDocument || document;
const host = root || doc.body;
if (!host) return null;
host.querySelector<HTMLElement>("[data-hyper-shader-loading]")?.remove();
const overlay = doc.createElement("div");
overlay.setAttribute("data-hyper-shader-loading", "");
overlay.setAttribute("data-hyperframes-ignore", "");
overlay.setAttribute("data-hyperframes-picker-block", "");
overlay.setAttribute("data-hf-ignore", "");
overlay.setAttribute("data-no-capture", "");
overlay.setAttribute("data-no-inspect", "");
overlay.setAttribute("data-no-pick", "");
overlay.setAttribute("draggable", "false");
overlay.setAttribute("role", "status");
overlay.setAttribute("aria-label", "Preparing scene transitions");
overlay.style.cssText = [
"position:absolute",
"inset:0",
`width:${width}px`,
`height:${height}px`,
"z-index:2147483647",
"display:none",
"place-items:center",
"opacity:1",
"transition:opacity 240ms ease-out",
"background:#030504",
"pointer-events:auto",
"color:#f4f7fb",
"cursor:default",
"touch-action:none",
"user-select:none",
"-webkit-user-select:none",
"-webkit-user-drag:none",
].join(";");
const blockOverlayInteraction = (event: Event): void => {
event.preventDefault();
event.stopPropagation();
};
for (const eventName of ["selectstart", "dragstart", "pointerdown", "mousedown", "touchstart"]) {
overlay.addEventListener(eventName, blockOverlayInteraction, { capture: true });
}
const panel = doc.createElement("div");
panel.setAttribute("draggable", "false");
panel.style.cssText = [
"display:grid",
"grid-template-rows:172px 72px 44px 44px 54px",
"justify-items:center",
"align-items:center",
"gap:0",
"width:min(1040px,82%)",
"padding:42px",
"box-sizing:border-box",
"text-align:center",
"font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
"user-select:none",
"-webkit-user-select:none",
"-webkit-user-drag:none",
].join(";");
const markFrame = doc.createElement("div");
markFrame.setAttribute("data-hf-loader-mark-frame", "");
markFrame.style.cssText = [
"width:172px",
"height:172px",
"display:grid",
"place-items:center",
"overflow:visible",
"transform-origin:50% 50%",
"will-change:transform,opacity",
"user-select:none",
"-webkit-user-select:none",
"-webkit-user-drag:none",
].join(";");
markFrame.innerHTML = [
'<svg width="156" height="156" viewBox="0 0 100 100" fill="none" aria-hidden="true" draggable="false" style="display:block;overflow:visible;filter:drop-shadow(0 0 7px rgba(79,219,94,.2));user-select:none;-webkit-user-select:none">',
'<g data-hf-loader-mark transform="translate(50 50)">',
'<g data-hf-loader-core transform="scale(1)" opacity=".92">',
'<g transform="translate(-50 -50)">',
'<path data-hf-loader-left 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(#hyper-shader-loader-grad-left)"/>',
'<path data-hf-loader-right 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(#hyper-shader-loader-grad-right)"/>',
"</g>",
"</g>",
"</g>",
"<defs>",
'<linearGradient id="hyper-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="hyper-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 mark = markFrame.querySelector("svg");
if (!mark) return null;
mark.setAttribute("draggable", "false");
const phrase = doc.createElement("div");
phrase.style.cssText = [
"width:100%",
"height:72px",
"display:flex",
"align-items:center",
"justify-content:center",
"overflow:hidden",
"white-space:nowrap",
"text-overflow:ellipsis",
"font:600 44px/1.15 Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
"letter-spacing:0",
"text-align:center",
"color:#f4f7fb",
].join(";");
const phraseText = doc.createElement("span");
phraseText.textContent = SNAPSHOT_LOADING_PHRASES[0] ?? "Preparing scene transitions";
const detail = doc.createElement("div");
detail.textContent = "Sampling animated scene frames so shader transitions stay in motion.";
detail.style.cssText = [
"width:min(760px,100%)",
"height:44px",
"overflow:hidden",
"white-space:nowrap",
"text-overflow:ellipsis",
"color:rgba(244,247,251,0.64)",
"font:400 24px/1.5 Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
].join(";");
phrase.appendChild(phraseText);
const track = doc.createElement("div");
track.setAttribute("aria-hidden", "true");
track.style.cssText = [
"width:min(520px,100%)",
"height:10px",
"overflow:hidden",
"border-radius:999px",
"background:rgba(255,255,255,0.1)",
].join(";");
const fill = doc.createElement("div");
fill.style.cssText = [
"width:100%",
"height:100%",
"transform:scaleX(0)",
"transform-origin:left center",
"border-radius:inherit",
"background:linear-gradient(90deg,#06e3fa,#4fdb5e)",
"transition:transform 160ms ease",
].join(";");
const progressText = doc.createElement("div");
progressText.style.cssText = [
"width:min(560px,100%)",
"height:54px",
"overflow:hidden",
"display:grid",
"grid-template-rows:repeat(2,27px)",
"font:500 18px/27px 'IBM Plex Mono','SF Mono','Fira Code',monospace",
"font-variant-numeric:tabular-nums",
"color:rgba(244,247,251,0.48)",
].join(";");
const createProgressRow = (labelText: string) => {
const row = doc.createElement("div");
row.style.cssText = [
"display:grid",
"grid-template-columns:minmax(0,1fr) auto",
"align-items:center",
"column-gap:28px",
"width:100%",
"height:27px",
"white-space:nowrap",
].join(";");
const label = doc.createElement("span");
label.textContent = labelText;
label.style.cssText = [
"overflow:hidden",
"text-overflow:ellipsis",
"text-align:left",
"min-width:0",
].join(";");
const value = doc.createElement("span");
value.style.cssText = ["min-width:76px", "text-align:right"].join(";");
row.appendChild(label);
row.appendChild(value);
progressText.appendChild(row);
return { row, label, value };
};
const transitionStatus = createProgressRow("transition");
const frameStatus = createProgressRow("transition frame");
track.appendChild(fill);
panel.appendChild(markFrame);
panel.appendChild(phrase);
panel.appendChild(detail);
panel.appendChild(track);
panel.appendChild(progressText);
overlay.appendChild(panel);
host.appendChild(overlay);
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
return {
show: () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
overlay.style.display = "grid";
overlay.style.opacity = "1";
overlay.style.pointerEvents = "auto";
},
update: (status: SnapshotLoadingStatus) => {
const { progress, total } = status;
const ratio = total > 0 ? clampNumber(progress / total, 0, 1) : 0;
const phraseIndex = Math.min(
SNAPSHOT_LOADING_PHRASES.length - 1,
Math.floor(ratio * SNAPSHOT_LOADING_PHRASES.length),
);
const nextPhrase = SNAPSHOT_LOADING_PHRASES[phraseIndex] ?? "Preparing scene transitions";
phraseText.textContent = nextPhrase;
fill.style.transform = `scaleX(${ratio})`;
const transitionValue =
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}`
: "";
const phaseLabel =
status.phase === "cached"
? "loading cached transition frames"
: status.phase === "finalizing"
? "finalizing"
: "rendering transition frames";
transitionStatus.label.textContent =
status.currentTransition !== undefined ? "transition" : "transition frames";
transitionStatus.value.textContent = transitionValue;
frameStatus.label.textContent = phaseLabel;
frameStatus.value.textContent = frameValue;
frameStatus.row.style.visibility = frameValue ? "visible" : "hidden";
overlay.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
},
hide: () => {
if (hideTimeout) clearTimeout(hideTimeout);
if (overlay.style.display === "none") return;
overlay.style.opacity = "0";
overlay.style.pointerEvents = "none";
hideTimeout = setTimeout(() => {
overlay.style.display = "none";
overlay.style.opacity = "1";
overlay.style.pointerEvents = "auto";
hideTimeout = null;
}, 240);
},
};
}
export function init(config: HyperShaderConfig): GsapTimeline {
const { bgColor, scenes, transitions } = config;
if (scenes.length !== transitions.length + 1) {
throw new Error(
`[HyperShader] init(): expected scenes.length === transitions.length + 1, got scenes=${scenes.length}, transitions=${transitions.length}`,
);
}
// Verify each scene id resolves to an element with the `.scene` class.
// Capture and compositing later assume both — without this guard the
// texture map gets stale ids and transitions silently no-op.
if (typeof document !== "undefined") {
const missing: string[] = [];
const notScene: string[] = [];
for (const id of scenes) {
const el = document.getElementById(id);
if (!el) {
missing.push(id);
} else if (!el.classList.contains("scene")) {
notScene.push(id);
}
}
if (missing.length > 0) {
throw new Error(`[HyperShader] init(): scene ids not found in DOM: ${missing.join(", ")}`);
}
if (notScene.length > 0) {
throw new Error(
`[HyperShader] init(): elements found but missing .scene class: ${notScene.join(", ")}`,
);
}
}
// Locally redeclared (not imported) because @hyperframes/shader-transitions
// ships as a standalone CDN bundle and must not depend on @hyperframes/engine.
// Keep this in sync with HfTransitionMeta in packages/engine/src/types.ts.
interface HfTransitionMeta {
time: number;
duration: number;
shader: string;
ease: string;
fromScene: string;
toScene: string;
}
type HfWindowWrite = { __hf?: { transitions?: HfTransitionMeta[] } };
if (typeof window !== "undefined") {
const hfWin = window as unknown as HfWindowWrite;
if (hfWin.__hf) {
hfWin.__hf.transitions = transitions.map((t: TransitionConfig, i: number) => ({
time: t.time,
duration: t.duration ?? DEFAULT_DURATION,
shader: t.shader,
ease: t.ease ?? DEFAULT_EASE,
fromScene: scenes[i] ?? "",
toScene: scenes[i + 1] ?? "",
}));
}
}
const accentColors: AccentColors = config.accentColor
? deriveAccentColors(config.accentColor)
: { accent: [1, 0.6, 0.2], dark: [0.4, 0.15, 0], bright: [1, 0.85, 0.5] };
const root = document.querySelector<HTMLElement>("[data-composition-id]");
const compId = config.compositionId || root?.getAttribute("data-composition-id") || "main";
const rawW = Number(root?.getAttribute("data-width"));
const rawH = Number(root?.getAttribute("data-height"));
const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : DEFAULT_WIDTH;
const compHeight = Number.isFinite(rawH) && rawH > 0 ? rawH : DEFAULT_HEIGHT;
// The Hyperframes engine injects a virtual-time shim (window.__HF_VIRTUAL_TIME__)
// during render mode and composites every transition itself from the
// window.__hf.transitions metadata above. Doing GL work or html2canvas captures
// here would (a) waste cycles and (b) leave .scene elements stuck at opacity:0
// because captureScene resolves asynchronously, after the engine has already
// sampled the DOM. In that mode we only need to keep each scene's effective
// opacity correct so queryElementStacking() reports the right visibility.
const isEngineRenderMode =
typeof window !== "undefined" &&
Boolean((window as unknown as { __HF_VIRTUAL_TIME__?: unknown }).__HF_VIRTUAL_TIME__);
if (isEngineRenderMode) {
return initEngineMode(config, scenes, transitions, compId, root);
}
const state: TransState = {
active: false,
prog: null,
progress: 0,
transitionIndex: -1,
};
let glCanvas = document.getElementById("gl-canvas") as HTMLCanvasElement | null;
if (!glCanvas) {
glCanvas = document.createElement("canvas");
glCanvas.id = "gl-canvas";
glCanvas.style.cssText = `position:absolute;top:0;left:0;z-index:100;pointer-events:none;display:none;`;
(root || document.body).appendChild(glCanvas);
}
glCanvas.width = compWidth;
glCanvas.height = compHeight;
glCanvas.style.width = `${compWidth}px`;
glCanvas.style.height = `${compHeight}px`;
const gl = createContext(glCanvas, compWidth, compHeight);
if (!gl) {
console.warn("[HyperShader] WebGL unavailable — shader transitions disabled.");
const fallback = config.timeline || gsap.timeline({ paused: true });
registerTimeline(compId, fallback, config.timeline);
return fallback;
}
const quadBuf = setupQuad(gl);
const programs = new Map<string, WebGLProgram>();
for (const t of transitions) {
if (!programs.has(t.shader)) {
try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
} catch (e) {
console.error(`[HyperShader] Failed to compile "${t.shader}":`, e);
}
}
}
const canvasEl = glCanvas;
const previewCaptureFps = clampNumber(resolvePositiveNumber(config.previewCaptureFps, 30), 1, 60);
const previewCaptureScale = resolvePlayerCaptureScale();
const loadingMode = resolvePlayerLoadingMode();
const previewTextureWidth = Math.max(1, Math.round(compWidth * previewCaptureScale));
const previewTextureHeight = Math.max(1, Math.round(compHeight * previewCaptureScale));
const cachedTransitions: CachedTransition[] = [];
const blendProg = createProgramWithVertex(
gl,
NO_FLIP_VERT_SRC,
[
"precision mediump float;",
"varying vec2 v_uv;",
"uniform sampler2D u_a;",
"uniform sampler2D u_b;",
"uniform float u_mix;",
"void main(){",
"gl_FragColor=mix(texture2D(u_a,v_uv),texture2D(u_b,v_uv),u_mix);",
"}",
].join(""),
);
const blendLoc = {
a: gl.getUniformLocation(blendProg, "u_a"),
b: gl.getUniformLocation(blendProg, "u_b"),
mix: gl.getUniformLocation(blendProg, "u_mix"),
pos: gl.getAttribLocation(blendProg, "a_pos"),
};
const interpolatedFromTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
const interpolatedToTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
const interpolatedFromFbo = createFramebuffer(gl, interpolatedFromTex);
const interpolatedToFbo = createFramebuffer(gl, interpolatedToTex);
let loadingOverlay: SnapshotLoadingOverlay | null = null;
const getLoadingOverlay = (): SnapshotLoadingOverlay | null => {
if (loadingMode !== "internal") return null;
loadingOverlay = loadingOverlay || createSnapshotLoadingOverlay(root, compWidth, compHeight);
return loadingOverlay;
};
let shaderCacheReady = false;
let prewarming = false;
let sceneMutationSuppressionDepth = 0;
let ignoreSceneMutationsUntil = 0;
const scenePointerEvents = new WeakMap<HTMLElement, string>();
const markRuntimeSceneMutation = (): void => {
ignoreSceneMutationsUntil = performance.now() + 120;
};
const rememberScenePointerEvents = (scene: HTMLElement): void => {
if (!scenePointerEvents.has(scene)) {
scenePointerEvents.set(scene, scene.style.pointerEvents);
}
};
const setScenePlaybackState = (scene: HTMLElement, visible: boolean, opacity: string): void => {
rememberScenePointerEvents(scene);
markRuntimeSceneMutation();
scene.style.opacity = opacity;
scene.style.visibility = visible ? "visible" : "hidden";
scene.style.pointerEvents = visible ? (scenePointerEvents.get(scene) ?? "") : "none";
};
const paintScenePairState = (
fromId: string,
toId: string,
fromOpacity: string,
toOpacity: string,
): void => {
scenes.forEach((sceneId) => {
const scene = document.getElementById(sceneId);
if (!scene) return;
if (sceneId === fromId) {
setScenePlaybackState(scene, true, fromOpacity);
} else if (sceneId === toId) {
setScenePlaybackState(scene, true, toOpacity);
} else {
setScenePlaybackState(scene, false, "0");
}
});
};
const disposeCaptureCanvas = (canvas: HTMLCanvasElement): void => {
canvas.width = 0;
canvas.height = 0;
};
const captureLiveScene = (scene: HTMLElement): Promise<HTMLCanvasElement> => {
return captureScene(scene, bgColor, compWidth, compHeight, {
forceVisible: true,
scale: previewCaptureScale,
});
};
const waitForPaint = (): Promise<void> => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
};
const hasFrameTextures = (
frame: CachedTransitionFrame | undefined,
): frame is TexturedTransitionFrame => {
return Boolean(frame?.fromTex && frame.toTex);
};
const selectCachedFrameBlend = (
cache: CachedTransition,
progress: number,
): CachedTransitionFrameBlend | null => {
if (cache.frames.length === 0) return null;
const position = clampNumber(progress, 0, 1) * (cache.frames.length - 1);
const lowerIndex = Math.floor(position);
const upperIndex = Math.ceil(position);
const a = cache.frames[lowerIndex] ?? cache.frames[cache.frames.length - 1];
const b = cache.frames[upperIndex] ?? a;
if (!hasFrameTextures(a) || !hasFrameTextures(b)) return null;
return { a, b, mix: position - lowerIndex };
};
const renderTextureBlend = (
target: WebGLFramebuffer,
texA: WebGLTexture,
texB: WebGLTexture,
mix: number,
): void => {
gl.bindFramebuffer(gl.FRAMEBUFFER, target);
gl.viewport(0, 0, previewTextureWidth, previewTextureHeight);
gl.useProgram(blendProg);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texA);
gl.uniform1i(blendLoc.a, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texB);
gl.uniform1i(blendLoc.b, 1);
gl.uniform1f(blendLoc.mix, mix);
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.enableVertexAttribArray(blendLoc.pos);
gl.vertexAttribPointer(blendLoc.pos, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, compWidth, compHeight);
};
const preloadTransitionTextures = (cache: CachedTransition): void => {
void ensureTransitionTextures(cache).then((loaded) => {
if (!loaded) return;
const now = tl.time();
if (now >= cache.time && now < cache.time + cache.duration) {
tickShader();
}
});
};
const resolveSettledSceneIndex = (currentTime: number): number => {
let index = 0;
for (let i = 0; i < cachedTransitions.length; i += 1) {
const cache = cachedTransitions[i];
if (cache && currentTime >= cache.time + cache.duration) {
index = i + 1;
}
}
return clampNumber(index, 0, scenes.length - 1);
};
const paintSettledSceneState = (currentTime: number): void => {
const visibleIndex = resolveSettledSceneIndex(currentTime);
scenes.forEach((sceneId, index) => {
const scene = document.getElementById(sceneId);
if (!scene) return;
const visible = index === visibleIndex;
setScenePlaybackState(scene, visible, visible ? "1" : "0");
});
};
const applyFallbackTransition = (cache: CachedTransition, progress: number): void => {
const fromScene = document.getElementById(cache.fromId);
const toScene = document.getElementById(cache.toId);
if (!fromScene || !toScene) return;
const eased = progress * progress * (3 - 2 * progress);
canvasEl.style.display = "none";
paintScenePairState(cache.fromId, cache.toId, String(1 - eased), String(eased));
};
const tickShader = () => {
if (prewarming) {
return;
}
const currentTime = tl.time();
const upcoming = cachedTransitions.find((cache) => {
return (
!cache.fallback &&
cache.ready &&
!cache.dirty &&
!cache.textureReady &&
currentTime >= cache.time - TEXTURE_PRELOAD_LOOKAHEAD_SECONDS &&
currentTime < cache.time + cache.duration
);
});
if (upcoming) {
preloadTransitionTextures(upcoming);
}
const activeIndex = cachedTransitions.findIndex((cache) => {
return currentTime >= cache.time && currentTime < cache.time + cache.duration;
});
if (activeIndex < 0) {
state.active = false;
state.transitionIndex = -1;
canvasEl.style.display = "none";
paintSettledSceneState(currentTime);
return;
}
const cache = cachedTransitions[activeIndex];
if (!cache || cache.dirty || !cache.ready) {
canvasEl.style.display = "none";
return;
}
if (cache.fallback) {
state.active = true;
state.transitionIndex = activeIndex;
state.prog = null;
state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1);
applyFallbackTransition(cache, state.progress);
return;
}
if (!cache.textureReady) {
preloadTransitionTextures(cache);
canvasEl.style.display = "none";
return;
}
state.active = true;
state.transitionIndex = activeIndex;
state.prog = cache.prog;
state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1);
markTextureAccess(cache);
const frame = selectCachedFrameBlend(cache, state.progress);
if (!frame) {
canvasEl.style.display = "none";
return;
}
paintScenePairState(cache.fromId, cache.toId, "1", "1");
renderTextureBlend(interpolatedFromFbo, frame.a.fromTex, frame.b.fromTex, frame.mix);
renderTextureBlend(interpolatedToFbo, frame.a.toTex, frame.b.toTex, frame.mix);
canvasEl.style.display = "block";
renderShader(
gl,
quadBuf,
state.prog,
interpolatedFromTex,
interpolatedToTex,
state.progress,
accentColors,
compWidth,
compHeight,
);
};
let tl: GsapTimeline;
if (config.timeline) {
tl = config.timeline;
const duration = Number(root?.getAttribute("data-duration") || "40");
tl.to({ t: 0 }, { t: 1, duration, ease: "none", onUpdate: tickShader }, 0);
} else {
tl = gsap.timeline({ paused: true, onUpdate: tickShader });
}
const originalPlay = tl.play.bind(tl) as (...args: unknown[]) => GsapTimeline;
const originalPause = tl.pause.bind(tl) as (...args: unknown[]) => GsapTimeline;
const originalTime = tl.time.bind(tl) as (...args: unknown[]) => GsapTimeline | number;
const originalSeek =
typeof tl.seek === "function"
? (tl.seek.bind(tl) as (...args: unknown[]) => GsapTimeline)
: null;
const readActualTimelineTime = (): number => {
const value = originalTime();
return typeof value === "number" && Number.isFinite(value) ? value : 0;
};
let publicTimelineTime = readActualTimelineTime();
const updatePublicTimelineTime = (value: unknown): void => {
if (typeof value === "number" && Number.isFinite(value)) {
publicTimelineTime = value;
}
};
const getPlaybackRequestTime = (args: unknown[], fallback: number): number => {
const requestedTime = args[0];
return typeof requestedTime === "number" && Number.isFinite(requestedTime)
? requestedTime
: fallback;
};
const setActualTimelineTime = (time: number, suppressEvents: boolean): GsapTimeline => {
const result = originalTime(time, suppressEvents);
return typeof result === "number" ? tl : result;
};
const suppressSceneMutationTracking = <T>(fn: () => T): T => {
sceneMutationSuppressionDepth += 1;
try {
return fn();
} finally {
sceneMutationSuppressionDepth -= 1;
ignoreSceneMutationsUntil = performance.now() + 120;
}
};
let pendingPlay = false;
let pendingPlayArgs: unknown[] = [];
let cancelResumeAfterPrewarm = false;
tl.play = ((...args: unknown[]) => {
updatePublicTimelineTime(args[0]);
const requestedTime = getPlaybackRequestTime(args, publicTimelineTime);
if (!areAllCachesReady() || !arePlaybackTexturesReady(requestedTime)) {
cancelResumeAfterPrewarm = false;
pendingPlay = true;
pendingPlayArgs = args;
void ensureTransitionCachesReady();
return tl;
}
const result = suppressSceneMutationTracking(() => originalPlay(...args));
publicTimelineTime = readActualTimelineTime();
return result;
}) as GsapTimeline["play"];
tl.pause = ((...args: unknown[]) => {
pendingPlay = false;
pendingPlayArgs = [];
updatePublicTimelineTime(args[0]);
if (prewarming) {
cancelResumeAfterPrewarm = true;
return tl;
}
const result = suppressSceneMutationTracking(() => originalPause(...args));
publicTimelineTime = readActualTimelineTime();
if (args.length > 0) {
tickShader();
}
return result;
}) as GsapTimeline["pause"];
tl.time = ((...args: unknown[]) => {
if (args.length === 0) {
if (!prewarming) {
publicTimelineTime = readActualTimelineTime();
}
return publicTimelineTime;
}
updatePublicTimelineTime(args[0]);
if (prewarming) {
return tl;
}
const result = suppressSceneMutationTracking(() => originalTime(...args));
if (!prewarming) {
publicTimelineTime = readActualTimelineTime();
}
tickShader();
return result;
}) as GsapTimeline["time"];
if (originalSeek) {
tl.seek = ((...args: unknown[]) => {
updatePublicTimelineTime(args[0]);
if (prewarming) {
return tl;
}
const result = suppressSceneMutationTracking(() => originalSeek(...args));
if (!prewarming) {
publicTimelineTime = readActualTimelineTime();
}
tickShader();
return result;
}) as NonNullable<GsapTimeline["seek"]>;
}
initCapture();
glCanvas.style.display = "none";
for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
const fromId = scenes[i];
const toId = scenes[i + 1];
if (!fromId || !toId) continue;
const prog = programs.get(t.shader);
if (!prog) continue;
const dur = t.duration ?? DEFAULT_DURATION;
const ease = t.ease ?? DEFAULT_EASE;
const T = t.time;
const cacheIndex = cachedTransitions.length;
cachedTransitions.push({
index: cacheIndex,
time: T,
duration: dur,
fromId,
toId,
prog,
frames: [],
cacheKey: "",
dirty: true,
ready: false,
fallback: false,
persisted: false,
textureReady: false,
texturePromise: null,
textureGeneration: 0,
textureAccess: 0,
});
tl.call(
() => {
suppressSceneMutationTracking(() => {
const fromScene = document.getElementById(fromId);
const toScene = document.getElementById(toId);
if (!fromScene || !toScene) return;
state.prog = prog;
state.transitionIndex = cacheIndex;
state.progress = 0;
state.active = true;
const cache = cachedTransitions[cacheIndex];
if (cache?.fallback) {
applyFallbackTransition(cache, 0);
return;
}
canvasEl.style.display =
!prewarming && cache?.ready && !cache.dirty && cache.textureReady ? "block" : "none";
paintScenePairState(fromId, toId, "1", "1");
});
},
null,
T,
);
const proxy = { p: 0 };
tl.to(
proxy,
{
p: 1,
duration: dur,
ease,
onUpdate: () => {
state.progress = proxy.p;
},
},
T,
);
tl.call(
() => {
suppressSceneMutationTracking(() => {
state.active = false;
state.transitionIndex = -1;
canvasEl.style.display = "none";
paintSettledSceneState(T + dur);
});
},
null,
T + dur,
);
}
type ShaderReadyState = {
ready: boolean;
progress: number;
total: number;
currentTransition?: number;
transitionTotal?: number;
transitionFrame?: number;
transitionFrames?: number;
phase?: SnapshotLoadingStatus["phase"];
dirtyTransitions: number;
captureScale: number;
textureWidth: number;
textureHeight: number;
fps: number;
loading: boolean;
error?: string;
};
const sampleCountForCache = (cache: CachedTransition): number => {
return Math.max(2, Math.ceil(cache.duration * previewCaptureFps) + 1);
};
const areAllCachesReady = (): boolean => {
return cachedTransitions.every((cache) => cache.ready && !cache.dirty);
};
const getPlaybackTextureWindow = (currentTime: number): CachedTransition[] => {
const selected: CachedTransition[] = [];
const selectedIndexes = new Set<number>();
const addIfNeeded = (cache: CachedTransition): void => {
if (selectedIndexes.has(cache.index)) return;
if (cache.fallback || cache.dirty || !cache.ready) return;
selected.push(cache);
selectedIndexes.add(cache.index);
};
for (const cache of cachedTransitions) {
if (currentTime >= cache.time && currentTime < cache.time + cache.duration) {
addIfNeeded(cache);
}
}
const nextUpcoming = cachedTransitions.find((cache) => cache.time >= currentTime);
if (nextUpcoming) {
addIfNeeded(nextUpcoming);
}
for (const cache of cachedTransitions) {
if (
cache.time >= currentTime &&
cache.time - currentTime <= TEXTURE_PRELOAD_LOOKAHEAD_SECONDS
) {
addIfNeeded(cache);
}
}
return selected.slice(0, MAX_TEXTURED_TRANSITIONS);
};
const arePlaybackTexturesReady = (currentTime: number): boolean => {
return getPlaybackTextureWindow(currentTime).every((cache) => cache.textureReady);
};
let textureAccessCounter = 0;
const disposeTransitionTextures = (cache: CachedTransition): void => {
cache.textureGeneration += 1;
for (const frame of cache.frames) {
if (frame.fromTex) {
gl.deleteTexture(frame.fromTex);
frame.fromTex = null;
}
if (frame.toTex) {
gl.deleteTexture(frame.toTex);
frame.toTex = null;
}
}
cache.textureReady = false;
};
const disposeCachedTransition = (cache: CachedTransition): void => {
disposeTransitionTextures(cache);
cache.texturePromise = null;
cache.frames = [];
cache.ready = false;
cache.fallback = false;
cache.persisted = false;
cache.textureReady = false;
cache.lastError = undefined;
};
const markTextureAccess = (cache: CachedTransition): void => {
textureAccessCounter += 1;
cache.textureAccess = textureAccessCounter;
};
const enforceTextureBudget = (keep: CachedTransition): void => {
const loaded = cachedTransitions
.filter((cache) => cache !== keep && cache.textureReady)
.sort((a, b) => a.textureAccess - b.textureAccess);
while (loaded.length >= MAX_TEXTURED_TRANSITIONS) {
const evict = loaded.shift();
if (!evict) break;
disposeTransitionTextures(evict);
}
};
const buildTransitionCacheKey = (cache: CachedTransition, sampleCount: number): string => {
const source = [
SNAPSHOT_CACHE_SCHEMA,
compId,
cache.index,
cache.fromId,
getSceneSignature(cache.fromId),
cache.toId,
getSceneSignature(cache.toId),
transitions[cache.index]?.shader || "unknown",
cache.time,
cache.duration,
sampleCount,
previewCaptureFps,
previewCaptureScale,
previewTextureWidth,
previewTextureHeight,
compWidth,
compHeight,
].join("|");
return `${compId}:${cache.index}:${stableHash(source)}`;
};
const setShaderReadyState = (status: Partial<ShaderReadyState>) => {
const hfWin = window as unknown as {
__hf?: {
shaderTransitions?: Record<string, ShaderReadyState>;
};
};
hfWin.__hf = hfWin.__hf || {};
hfWin.__hf.shaderTransitions = hfWin.__hf.shaderTransitions || {};
const current = hfWin.__hf.shaderTransitions[compId] || {
ready: false,
progress: 0,
total: 0,
dirtyTransitions: cachedTransitions.length,
captureScale: previewCaptureScale,
textureWidth: previewTextureWidth,
textureHeight: previewTextureHeight,
fps: previewCaptureFps,
loading: true,
};
const next = { ...current, ...status };
next.dirtyTransitions = cachedTransitions.filter((cache) => cache.dirty || !cache.ready).length;
hfWin.__hf.shaderTransitions[compId] = next;
window.parent?.postMessage(
{
source: "hf-preview",
type: "shader-transition-state",
compositionId: compId,
state: next,
},
"*",
);
if (next.loading) {
const overlay = getLoadingOverlay();
overlay?.show();
overlay?.update({
progress: next.progress,
total: next.total,
currentTransition: next.currentTransition,
transitionTotal: next.transitionTotal,
transitionFrame: next.transitionFrame,
transitionFrames: next.transitionFrames,
phase: next.phase,
});
}
if (next.ready) {
loadingOverlay?.hide();
} else if (!next.loading) {
loadingOverlay?.hide();
}
};
const shouldIgnoreSceneMutation = (): boolean => {
return (
prewarming ||
sceneMutationSuppressionDepth > 0 ||
performance.now() < ignoreSceneMutationsUntil ||
!tl.paused()
);
};
const markScenesDirty = (sceneIds: Set<string>): void => {
if (sceneIds.size === 0) return;
let changed = false;
for (const cache of cachedTransitions) {
if (!sceneIds.has(cache.fromId) && !sceneIds.has(cache.toId)) continue;
disposeCachedTransition(cache);
cache.dirty = true;
cache.cacheKey = "";
changed = true;
}
if (!changed) return;
shaderCacheReady = areAllCachesReady();
setShaderReadyState({
ready: shaderCacheReady,
progress: 0,
total: 0,
currentTransition: undefined,
transitionTotal: undefined,
transitionFrame: undefined,
transitionFrames: undefined,
phase: undefined,
loading: false,
});
window.dispatchEvent(
new CustomEvent("hyperShader:dirty", {
detail: { compositionId: compId, scenes: Array.from(sceneIds) },
}),
);
};
const observeSceneEdits = (): MutationObserver[] => {
const sceneElements = scenes
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el instanceof HTMLElement);
const observers: MutationObserver[] = [];
for (const scene of sceneElements) {
const observer = new MutationObserver((mutations) => {
if (shouldIgnoreSceneMutation()) return;
const affected = new Set<string>();
for (const mutation of mutations) {
const target =
mutation.target instanceof Element ? mutation.target : mutation.target.parentElement;
if (!target) continue;
for (const candidate of sceneElements) {
if (candidate === target || candidate.contains(target)) {
affected.add(candidate.id);
}
}
}
markScenesDirty(affected);
});
observer.observe(scene, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
observers.push(observer);
}
const styleObserver = new MutationObserver(() => {
if (shouldIgnoreSceneMutation()) return;
markScenesDirty(new Set(scenes));
});
styleObserver.observe(document.head, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
observers.push(styleObserver);
return observers;
};
const hydrateTransitionCache = async (
cache: CachedTransition,
sampleCount: number,
onProgress: (transitionFrame: number) => void,
): Promise<boolean> => {
const hydratedFrames: CachedTransitionFrame[] = [];
for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) {
const [fromEntry, toEntry] = await Promise.all([
getSnapshotEntry(makeSnapshotKey(cache.cacheKey, sampleIndex, "from")),
getSnapshotEntry(makeSnapshotKey(cache.cacheKey, sampleIndex, "to")),
]);
if (!fromEntry || !toEntry) {
for (const frame of hydratedFrames) {
gl.deleteTexture(frame.fromTex);
gl.deleteTexture(frame.toTex);
}
return false;
}
hydratedFrames.push({
sampleIndex,
fromBlob: fromEntry.blob,
toBlob: toEntry.blob,
fromTex: null,
toTex: null,
});
onProgress(sampleIndex + 1);
}
cache.frames = hydratedFrames;
cache.ready = true;
cache.dirty = false;
cache.fallback = false;
cache.persisted = true;
cache.textureReady = false;
return true;
};
const ensureTransitionTextures = (cache: CachedTransition): Promise<boolean> => {
if (cache.fallback || cache.dirty || !cache.ready) return Promise.resolve(false);
if (cache.textureReady) {
markTextureAccess(cache);
return Promise.resolve(true);
}
if (cache.texturePromise) return cache.texturePromise;
const generation = cache.textureGeneration;
const frames = cache.frames;
const uploadedTextures: WebGLTexture[] = [];
const isStaleTextureJob = (): boolean => {
return cache.textureGeneration !== generation || cache.dirty || cache.frames !== frames;
};
const disposeUploadedTextures = (): void => {
const deleted = new Set<WebGLTexture>();
for (const tex of uploadedTextures) {
if (!deleted.has(tex)) {
gl.deleteTexture(tex);
deleted.add(tex);
}
}
for (const frame of frames) {
if (frame.fromTex && !deleted.has(frame.fromTex)) {
gl.deleteTexture(frame.fromTex);
deleted.add(frame.fromTex);
}
if (frame.toTex && !deleted.has(frame.toTex)) {
gl.deleteTexture(frame.toTex);
deleted.add(frame.toTex);
}
frame.fromTex = null;
frame.toTex = null;
}
};
const getFrameBlob = async (
frame: CachedTransitionFrame,
side: "from" | "to",
): Promise<Blob> => {
const cached = side === "from" ? frame.fromBlob : frame.toBlob;
if (cached) return cached;
const entry = await getSnapshotEntry(
makeSnapshotKey(cache.cacheKey, frame.sampleIndex, side),
);
if (entry?.blob) return entry.blob;
throw new Error("[HyperShader] Cached transition snapshot blob is unavailable");
};
let texturePromise = Promise.resolve(false);
texturePromise = (async () => {
for (const frame of frames) {
if (isStaleTextureJob()) {
disposeUploadedTextures();
return false;
}
if (!frame.fromTex) {
const fromBlob = await getFrameBlob(frame, "from");
if (isStaleTextureJob()) {
disposeUploadedTextures();
return false;
}
const source = await blobToTextureSource(fromBlob);
const tex = createTexture(gl);
uploadedTextures.push(tex);
try {
uploadTextureSource(gl, tex, source);
if (isStaleTextureJob()) {
disposeUploadedTextures();
return false;
}
frame.fromTex = tex;
} finally {
closeTextureSource(source);
}
}
if (!frame.toTex) {
const toBlob = await getFrameBlob(frame, "to");
if (isStaleTextureJob()) {
disposeUploadedTextures();
return false;
}
const source = await blobToTextureSource(toBlob);
const tex = createTexture(gl);
uploadedTextures.push(tex);
try {
uploadTextureSource(gl, tex, source);
if (isStaleTextureJob()) {
disposeUploadedTextures();
return false;
}
frame.toTex = tex;
} finally {
closeTextureSource(source);
}
}
if (cache.persisted) {
frame.fromBlob = null;
frame.toBlob = null;
}
}
if (isStaleTextureJob()) {
disposeUploadedTextures();
return false;
}
cache.textureReady = frames.every((frame) => Boolean(frame.fromTex && frame.toTex));
if (cache.textureReady) {
markTextureAccess(cache);
enforceTextureBudget(cache);
}
return cache.textureReady;
})()
.catch((e) => {
disposeUploadedTextures();
if (isStaleTextureJob()) {
return false;
}
disposeTransitionTextures(cache);
cache.fallback = true;
cache.ready = true;
cache.dirty = false;
cache.lastError = e instanceof Error ? e.message : String(e);
setShaderReadyState({
ready: areAllCachesReady(),
loading: false,
error: cache.lastError,
});
return false;
})
.finally(() => {
if (cache.texturePromise === texturePromise) {
cache.texturePromise = null;
}
});
cache.texturePromise = texturePromise;
return texturePromise;
};
const ensurePlaybackTextureWindow = async (currentTime: number): Promise<void> => {
for (const cache of getPlaybackTextureWindow(currentTime)) {
await ensureTransitionTextures(cache);
}
};
const persistSnapshot = async (
cache: CachedTransition,
sampleIndex: number,
side: "from" | "to",
blob: Blob | null,
): Promise<boolean> => {
if (!blob) return false;
return putSnapshotEntry({
key: makeSnapshotKey(cache.cacheKey, sampleIndex, side),
blob,
width: previewTextureWidth,
height: previewTextureHeight,
updatedAt: cache.index * 1_000_000 + sampleIndex * 2 + (side === "to" ? 1 : 0),
});
};
const captureTransitionCache = async (
cache: CachedTransition,
sampleCount: number,
onProgress: (transitionFrame: number) => void,
): Promise<void> => {
disposeCachedTransition(cache);
let allPersisted = true;
for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) {
const progress = sampleIndex / (sampleCount - 1);
suppressSceneMutationTracking(() => {
originalTime(cache.time + cache.duration * progress, false);
});
await waitForPaint();
const fromScene = document.getElementById(cache.fromId);
const toScene = document.getElementById(cache.toId);
if (!fromScene || !toScene) continue;
const [fromCanvas, toCanvas] = await Promise.all([
captureLiveScene(fromScene),
captureLiveScene(toScene),
]);
const [fromBlob, toBlob] = await Promise.all([
canvasToPngBlob(fromCanvas),
canvasToPngBlob(toCanvas),
]);
disposeCaptureCanvas(fromCanvas);
disposeCaptureCanvas(toCanvas);
if (!fromBlob || !toBlob) {
throw new Error("[HyperShader] Failed to encode transition snapshot");
}
cache.frames.push({ sampleIndex, fromBlob, toBlob, fromTex: null, toTex: null });
const persisted = await Promise.all([
persistSnapshot(cache, sampleIndex, "from", fromBlob),
persistSnapshot(cache, sampleIndex, "to", toBlob),
]);
if (!persisted.every(Boolean)) {
allPersisted = false;
cache.lastError = "[HyperShader] Failed to persist one or more transition snapshots";
}
onProgress(sampleIndex + 1);
}
cache.ready = true;
cache.dirty = false;
cache.fallback = false;
cache.persisted = allPersisted;
cache.textureReady = false;
};
let transitionCachePromise: Promise<void> | null = null;
const ensureTransitionCachesReady = (): Promise<void> => {
if (transitionCachePromise) return transitionCachePromise;
transitionCachePromise = (async () => {
const work = cachedTransitions.filter((cache) => cache.dirty || !cache.ready);
const workItems = work.map((cache) => ({
cache,
sampleCount: sampleCountForCache(cache),
}));
for (const item of workItems) {
item.cache.cacheKey = buildTransitionCacheKey(item.cache, item.sampleCount);
}
const total = workItems.reduce((sum, item) => sum + item.sampleCount, 0);
const firstItem = workItems[0];
let showLoadingOverlay = false;
setShaderReadyState({
ready: false,
progress: 0,
total,
currentTransition: firstItem ? 1 : undefined,
transitionTotal: workItems.length || undefined,
transitionFrame: firstItem ? 0 : undefined,
transitionFrames: firstItem?.sampleCount,
phase: firstItem ? "cached" : undefined,
loading: showLoadingOverlay,
});
if (work.length === 0) {
shaderCacheReady = true;
setShaderReadyState({
ready: true,
progress: 0,
total: 0,
currentTransition: undefined,
transitionTotal: undefined,
transitionFrame: undefined,
transitionFrames: undefined,
phase: undefined,
loading: false,
});
if (pendingPlay) {
const resumeArgs = pendingPlayArgs;
const resumeTime = getPlaybackRequestTime(resumeArgs, publicTimelineTime);
await ensurePlaybackTextureWindow(resumeTime);
if (pendingPlay) {
pendingPlay = false;
pendingPlayArgs = [];
cancelResumeAfterPrewarm = false;
suppressSceneMutationTracking(() => originalPlay(...resumeArgs));
publicTimelineTime = readActualTimelineTime();
}
}
transitionCachePromise = null;
return;
}
publicTimelineTime = readActualTimelineTime();
const wasPaused = tl.paused();
const originalSceneStyles: SceneStyleState[] = scenes.map((id) => {
const scene = document.getElementById(id);
return {
scene,
opacity: scene?.style.opacity ?? "",
visibility: scene?.style.visibility ?? "",
pointerEvents: scene?.style.pointerEvents ?? "",
};
});
prewarming = true;
canvasEl.style.display = "none";
originalPause();
let completed = 0;
try {
for (let workIndex = 0; workIndex < workItems.length; workIndex += 1) {
const item = workItems[workIndex];
if (!item) continue;
const { cache, sampleCount } = item;
const currentTransition = workIndex + 1;
const completedBeforeTransition = completed;
disposeCachedTransition(cache);
try {
setShaderReadyState({
ready: false,
progress: completedBeforeTransition,
total,
currentTransition,
transitionTotal: workItems.length,
transitionFrame: 0,
transitionFrames: sampleCount,
phase: "cached",
loading: showLoadingOverlay,
});
const hydrated = await hydrateTransitionCache(cache, sampleCount, (transitionFrame) => {
completed = completedBeforeTransition + transitionFrame;
setShaderReadyState({
ready: false,
progress: completed,
total,
currentTransition,
transitionTotal: workItems.length,
transitionFrame,
transitionFrames: sampleCount,
phase: "cached",
loading: showLoadingOverlay,
});
});
if (!hydrated) {
completed = completedBeforeTransition;
showLoadingOverlay = true;
setShaderReadyState({
ready: false,
progress: completed,
total,
currentTransition,
transitionTotal: workItems.length,
transitionFrame: 0,
transitionFrames: sampleCount,
phase: "capturing",
loading: true,
});
await captureTransitionCache(cache, sampleCount, (transitionFrame) => {
completed = completedBeforeTransition + transitionFrame;
setShaderReadyState({
ready: false,
progress: completed,
total,
currentTransition,
transitionTotal: workItems.length,
transitionFrame,
transitionFrames: sampleCount,
phase: "capturing",
loading: true,
});
});
if (!cache.persisted && cache.lastError) {
setShaderReadyState({
ready: false,
loading: showLoadingOverlay,
error: cache.lastError,
});
}
}
} catch (e) {
completed = completedBeforeTransition + sampleCount;
cache.fallback = true;
cache.ready = true;
cache.dirty = false;
cache.lastError = e instanceof Error ? e.message : String(e);
console.warn("[HyperShader] Transition capture failed; using CSS fallback:", e);
setShaderReadyState({
ready: false,
progress: completed,
total,
currentTransition,
transitionTotal: workItems.length,
transitionFrame: sampleCount,
transitionFrames: sampleCount,
phase: "finalizing",
loading: showLoadingOverlay,
error: cache.lastError,
});
}
completed = completedBeforeTransition + sampleCount;
}
void pruneSnapshotCache(
compId,
new Set(cachedTransitions.map((cache) => cache.cacheKey).filter(Boolean)),
);
shaderCacheReady = areAllCachesReady();
const finalItem = workItems[workItems.length - 1];
setShaderReadyState({
ready: shaderCacheReady,
progress: completed,
total,
currentTransition: workItems.length,
transitionTotal: workItems.length,
transitionFrame: finalItem?.sampleCount,
transitionFrames: finalItem?.sampleCount,
phase: "finalizing",
loading: false,
});
} catch (e) {
console.warn("[HyperShader] Pre-capture failed, keeping DOM fallback visible:", e);
shaderCacheReady = areAllCachesReady();
setShaderReadyState({
ready: shaderCacheReady,
progress: completed,
total,
phase: "finalizing",
loading: false,
error: e instanceof Error ? e.message : String(e),
});
} finally {
const restoreTimelineTime = publicTimelineTime;
let shouldResume = (pendingPlay || !wasPaused) && !cancelResumeAfterPrewarm;
let resumeArgs = pendingPlay ? pendingPlayArgs : [];
let resumeTime = getPlaybackRequestTime(resumeArgs, restoreTimelineTime);
let hydratedTextureTime: number | null = null;
if (shouldResume) {
await ensurePlaybackTextureWindow(resumeTime);
hydratedTextureTime = resumeTime;
}
shouldResume = (pendingPlay || !wasPaused) && !cancelResumeAfterPrewarm;
resumeArgs = pendingPlay ? pendingPlayArgs : [];
resumeTime = getPlaybackRequestTime(resumeArgs, restoreTimelineTime);
if (shouldResume && resumeTime !== hydratedTextureTime) {
await ensurePlaybackTextureWindow(resumeTime);
}
prewarming = false;
state.active = false;
state.transitionIndex = -1;
canvasEl.style.display = "none";
suppressSceneMutationTracking(() => {
setActualTimelineTime(restoreTimelineTime, false);
});
publicTimelineTime = restoreTimelineTime;
for (const item of originalSceneStyles) {
if (!item.scene) continue;
item.scene.style.opacity = item.opacity;
item.scene.style.visibility = item.visibility;
item.scene.style.pointerEvents = item.pointerEvents;
}
tickShader();
window.dispatchEvent(
new CustomEvent("hyperShader:ready", {
detail: { compositionId: compId, progress: completed, total },
}),
);
transitionCachePromise = null;
if (shouldResume) {
pendingPlay = false;
pendingPlayArgs = [];
cancelResumeAfterPrewarm = false;
suppressSceneMutationTracking(() => originalPlay(...resumeArgs));
publicTimelineTime = readActualTimelineTime();
} else {
pendingPlay = false;
pendingPlayArgs = [];
cancelResumeAfterPrewarm = false;
originalPause();
}
}
})();
return transitionCachePromise;
};
const sceneEditObservers = observeSceneEdits();
window.addEventListener(
"beforeunload",
() => {
for (const observer of sceneEditObservers) {
observer.disconnect();
}
},
{ once: true },
);
const prewarmPromise = Promise.resolve().then(() => ensureTransitionCachesReady());
const hfWin = window as unknown as { __hf?: { shaderTransitionsReady?: Promise<void> } };
hfWin.__hf = hfWin.__hf || {};
hfWin.__hf.shaderTransitionsReady = prewarmPromise;
registerTimeline(compId, tl, config.timeline);
return tl;
}
function registerTimeline(
compId: string,
tl: GsapTimeline,
provided: GsapTimeline | undefined,
): void {
if (!provided) {
const w = window as unknown as { __timelines: Record<string, unknown> };
w.__timelines = w.__timelines || {};
w.__timelines[compId] = tl;
}
}
// Engine-mode initialization: skip every GL/canvas/html2canvas branch and only
// schedule deterministic opacity flips so the producer can read each scene's
// effective opacity at any seek time. tl.set() (zero-duration tweens) is used
// instead of tl.call() because tl.call only fires in the direction of motion —
// the engine's warmup loop seeks forward through transition start times and
// then the main render loop seeks back to t=0, which would leave callback-set
// state stuck. tl.set tweens revert correctly on backward seeks.
function initEngineMode(
config: HyperShaderConfig,
scenes: string[],
transitions: TransitionConfig[],
compId: string,
root: HTMLElement | null,
): GsapTimeline {
const tl: GsapTimeline = config.timeline || gsap.timeline({ paused: true });
// Match the user-facing branch: when the user supplies a timeline, we
// anchor a no-op duration tween at 0 so the timeline length covers the
// composition. Without it a brand-new injected timeline would be empty.
if (config.timeline) {
const duration = Number(root?.getAttribute("data-duration") || "40");
tl.to({ t: 0 }, { t: 1, duration, ease: "none" }, 0);
}
// Initial state: every non-first scene starts hidden. CSS defaults
// .scene to opacity:1, so without this every scene would composite at
// t=0 and the engine's queryElementStacking() would report all of them
// visible — manifesting as ghosting/overlap in the very first frame
// before the first transition fires. tl.set() at position 0 ensures
// the initial state is part of the timeline's seek graph, so reverse
// seeks from inside a later transition correctly restore it.
for (let i = 1; i < scenes.length; i++) {
const sceneId = scenes[i];
if (sceneId) {
tl.set(`#${sceneId}`, { opacity: 0 }, 0);
}
}
for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
const fromId = scenes[i];
const toId = scenes[i + 1];
if (!fromId || !toId) continue;
const dur = t.duration ?? DEFAULT_DURATION;
const T = t.time;
// During the transition both scenes need to be visible so the engine
// can composite each side; afterwards the outgoing scene must drop out
// so it stops contributing to the normal-frame layer composite.
tl.set(`#${toId}`, { opacity: 1 }, T);
tl.set(`#${fromId}`, { opacity: 0 }, T + dur);
}
// Page-side compositing opt-in (default OFF). When the producer launches
// with `EngineConfig.enablePageSideCompositing: true`, it sets the
// sentinel `window.__HF_PAGE_SIDE_COMPOSITING__` via an early stub. We
// detect it here and install the WebGL-on-page composite path on top of
// the opacity-flip timeline. The opacity timeline still runs (the
// installer wraps `window.__hf.seek` AFTER the timeline runs, so DOM
// state at the sampled time is correct before texture capture) but the
// installed compositor overrides the seek's final visible state during
// each transition window with a single shader-composited overlay canvas.
const pageCompositingFlag =
typeof window !== "undefined" &&
Boolean(
(window as unknown as { __HF_PAGE_SIDE_COMPOSITING__?: boolean })
.__HF_PAGE_SIDE_COMPOSITING__,
);
if (pageCompositingFlag) {
const bgColor = config.bgColor ?? "#000";
const accentColors: AccentColors = config.accentColor
? deriveAccentColors(config.accentColor)
: { accent: [1, 0.6, 0.2], dark: [0.4, 0.15, 0], bright: [1, 0.85, 0.5] };
const rawW = Number(root?.getAttribute("data-width"));
const rawH = Number(root?.getAttribute("data-height"));
const compWidth = Number.isFinite(rawW) && rawW > 0 ? rawW : 1920;
const compHeight = Number.isFinite(rawH) && rawH > 0 ? rawH : 1080;
installPageSideCompositor({
scenes,
transitions,
bgColor,
accentColors,
width: compWidth,
height: compHeight,
defaultDuration: DEFAULT_DURATION,
});
}
registerTimeline(compId, tl, config.timeline);
return tl;
}