Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@
"concepts/variables",
"concepts/data-attributes",
"guides/gsap-animation",
"guides/frame-sources",
"concepts/frame-adapters",
"concepts/determinism",
"guides/html-in-canvas",
Expand Down
27 changes: 27 additions & 0 deletions docs/guides/frame-sources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
title: "Frame sources"
description: "Run deterministic scene code on the HyperFrames timeline."
---

HyperFrames can drive scene code that exposes a function of time without converting it to GSAP. Register that function against a timed host using `window.__hyperframes.registerFrameSource` before runtime initialization completes.

```javascript
const unregister = window.__hyperframes.registerFrameSource({
element: document.getElementById("scene"),
ready: initializeScene(),
render: (sourceTime, signal) => drawScene(sourceTime, signal),
dispose: () => releaseScene(),
});
```

The host uses the usual composition attributes: `data-composition-id`, `data-start`, `data-duration`, and `data-track-index`. Declare the root's dimensions, FPS, and duration. A frame source does not need a dummy GSAP timeline. Add `data-no-timeline` to the host, and to a root that registers no timeline; otherwise `hyperframes lint` reports `missing_timeline_registry` and each render waits 45 seconds for timeline registration.

The callback receives seconds in source time: playback inpoint (`data-playback-start`) plus the rate-adjusted time since the host's resolved start. Existing playback rate and speed-ramp semantics apply. Timing is read again on every seek, so move, trim and rate edits do not require rewriting the animation code. Each overlapping scene needs its own source instance.

`ready` holds initialization and export capture. Return a promise from `render` when drawing is asynchronous: capture waits for it before taking pixels. Repeated and out-of-order times must produce the same frame. HyperFrames owns playback; do not start a second clock.

Draws are serialized per source. Rapid preview seeks coalesce while a draw is in flight; sequential export seeks draw every requested frame. Setup and draw failures are delivered to the capture completion barrier. A later successful seek can recover from a frame failure.

Call `unregister()` when replacing a source. Removal of its host or runtime teardown also unregisters it. The callback's `AbortSignal` is aborted, queued work is cancelled, capture waiters are released, and `dispose` runs once. Stop any underlying work in response to the signal or in `dispose`; cancellation cannot forcibly interrupt synchronous JavaScript.

Registration does not expose arbitrary scene code as editable keyframes. Manual element edits need stable identities and persistent parameters or overrides. Imported iframe sources also need screenshot capture and an explicit message bridge; this API does not grant access to sandboxed DOM.
31 changes: 31 additions & 0 deletions packages/core/src/runtime/entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,37 @@ const neverDecodes = (clip: HTMLElement) => {
describe("runtime entry", () => {
afterEach(resetRuntimeGlobals);

it("drives an async frame source without a GSAP timeline and waits before capture", async () => {
const root = mountRoot();
root.setAttribute("data-duration", "10");
root.appendChild(document.createElement("div"));
window.__timelines = {};
Object.defineProperty(document, "readyState", { configurable: true, get: () => "loading" });
await evaluateRuntime();
let finish!: () => void;
const first = new Promise<void>((resolve) => {
finish = resolve;
});
const render = vi
.fn()
.mockImplementationOnce(() => first)
.mockResolvedValue(undefined);
window.__hyperframes!.registerFrameSource({ element: root, render });
delete (document as { readyState?: unknown }).readyState;
document.dispatchEvent(new Event("DOMContentLoaded"));
await vi.waitFor(() => expect(render).toHaveBeenCalled());
window.__player!.seek(1.2);
let captured = false;
const capture = window.__hfWaitForSeekCompletion!().then(() => {
captured = true;
});
await Promise.resolve();
expect(captured).toBe(false);
finish();
await capture;
expect(render).toHaveBeenLastCalledWith(1.2, expect.any(AbortSignal));
});

it("paints no timed clip, from script evaluation until the first visibility pass decides it", async () => {
servePreview();
const root = mountRoot();
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/runtime/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getVariables } from "./getVariables";
import { clearRuntimeData, registerRuntimeDataHandler, setRuntimeData } from "./runtimeData";
import { runScriptsAfterFonts } from "./afterFonts";
import { AFTER_FONTS_SCRIPTS } from "../compiler/scriptRuns";
import { hasFrameSources, registerFrameSource } from "./frameSources";

type HyperframeWindow = Window & {
__hyperframeRuntimeBootstrapped?: boolean;
Expand All @@ -25,6 +26,7 @@ type HyperframeWindow = Window & {
registerRuntimeDataHandler: typeof registerRuntimeDataHandler;
setRuntimeData: typeof setRuntimeData;
clearRuntimeData: typeof clearRuntimeData;
registerFrameSource: typeof registerFrameSource;
};
};

Expand All @@ -38,6 +40,7 @@ type HyperframeWindow = Window & {
installAuthoredOpacityCapture();
installAuthoredMediaCapture();
installFlatGsapTransforms();
window.__hfHasFrameSources = hasFrameSources;

hideTimedClipsUntilFirstPass();
deferMediaUntilDue();
Expand All @@ -53,6 +56,7 @@ deferMediaUntilDue();
registerRuntimeDataHandler,
setRuntimeData,
clearRuntimeData,
registerFrameSource,
};

function bootstrapHyperframeRuntime(): void {
Expand Down
240 changes: 240 additions & 0 deletions packages/core/src/runtime/frameSources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFrameSourceAdapter, hasFrameSources, registerFrameSource } from "./frameSources";
import { resetSeekDispatchState, waitForSeekCompletion } from "./adapters/seek-dispatch";
import { createRuntimeStartTimeResolver } from "./startResolver";

function deferred() {
let resolve!: () => void;
let reject!: (reason: Error) => void;
const promise = new Promise<void>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}

const adapters: ReturnType<typeof createFrameSourceAdapter>[] = [];
const adapter = () => {
const runtime = createFrameSourceAdapter({
start: (element) => createRuntimeStartTimeResolver({}).resolveStartForElement(element, 0),
duration: (element) => createRuntimeStartTimeResolver({}).resolveDurationForElement(element),
});
adapters.push(runtime);
return runtime;
};
const disposers: Array<() => void> = [];
function mount(start = "0", duration = "10") {
const element = document.createElement("section");
element.setAttribute("data-start", start);
element.setAttribute("data-duration", duration);
document.body.append(element);
return element;
}
beforeEach(() => resetSeekDispatchState());
afterEach(() => {
for (const runtime of adapters.splice(0)) runtime.revert?.();
for (const dispose of disposers.splice(0)) dispose();
document.body.innerHTML = "";
resetSeekDispatchState();
});

describe("frame sources", () => {
it("holds capture through setup and asynchronous drawing", async () => {
const setup = deferred();
const frame = deferred();
const render = vi.fn(() => frame.promise);
disposers.push(registerFrameSource({ element: mount(), ready: setup.promise, render }));
const runtime = adapter();
runtime.seek({ time: 2 });
let captured = false;
const capture = waitForSeekCompletion().then(() => {
captured = true;
});
await Promise.resolve();
expect(render).not.toHaveBeenCalled();
expect(captured).toBe(false);
setup.resolve();
await vi.waitFor(() => expect(render).toHaveBeenCalledWith(2, expect.any(AbortSignal)));
expect(captured).toBe(false);
frame.resolve();
await capture;
expect(captured).toBe(true);
});

it("serializes drawing and coalesces queued scrubs to the latest time", async () => {
const frame = deferred();
const render = vi.fn().mockImplementationOnce(() => frame.promise);
disposers.push(registerFrameSource({ element: mount(), render }));
const runtime = adapter();
runtime.seek({ time: 1 });
await vi.waitFor(() => expect(render).toHaveBeenCalledTimes(1));
runtime.seek({ time: 2 });
runtime.seek({ time: 3 });
expect(render).toHaveBeenCalledTimes(1);
frame.resolve();
await waitForSeekCompletion();
expect(render.mock.calls.map(([time]) => time)).toEqual([1, 3]);
});

it("renders every sequential export request, including repeated and reverse time", async () => {
const render = vi.fn();
disposers.push(registerFrameSource({ element: mount(), render }));
const runtime = adapter();
for (const time of [7, 2, 2, 0, 9]) {
runtime.seek({ time });
await waitForSeekCompletion();
}
expect(render.mock.calls.map(([time]) => time)).toEqual([7, 2, 2, 0, 9]);
});

it("rereads clip timing, trim inpoints and rates after edits", async () => {
const element = mount("3", "4");
element.setAttribute("data-playback-start", "1");
element.setAttribute("data-playback-rate", "2");
const render = vi.fn();
disposers.push(registerFrameSource({ element, render }));
const runtime = adapter();
runtime.seek({ time: 4 });
await waitForSeekCompletion();
expect(render).toHaveBeenLastCalledWith(3, expect.any(AbortSignal));
element.setAttribute("data-start", "0");
element.setAttribute("data-playback-start", "2");
runtime.seek({ time: 1 });
await waitForSeekCompletion();
expect(render).toHaveBeenLastCalledWith(4, expect.any(AbortSignal));
runtime.seek({ time: 8 });
await waitForSeekCompletion();
expect(render).toHaveBeenCalledTimes(2);
});

it("surfaces failed setup even when its clip has not started", async () => {
const render = vi.fn();
disposers.push(
registerFrameSource({
element: mount("5"),
ready: Promise.reject(new Error("font failed")),
render,
}),
);
const runtime = adapter();
runtime.discover();
await expect(waitForSeekCompletion()).rejects.toThrow("font failed");
expect(render).not.toHaveBeenCalled();
});

it("surfaces draw failure and allows the next seek to recover", async () => {
const render = vi
.fn()
.mockRejectedValueOnce(new Error("draw failed"))
.mockResolvedValue(undefined);
disposers.push(registerFrameSource({ element: mount(), render }));
const runtime = adapter();
runtime.seek({ time: 2 });
await expect(waitForSeekCompletion()).rejects.toThrow("draw failed");
runtime.seek({ time: 3 });
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
expect(render).toHaveBeenLastCalledWith(3, expect.any(AbortSignal));
});

it("unregisters stalled setup and allows replacing the same host", async () => {
const element = mount();
const cleanup = vi.fn();
const unregister = registerFrameSource({
element,
ready: deferred().promise,
render: vi.fn(),
dispose: cleanup,
});
const runtime = adapter();
runtime.seek({ time: 0 });
expect(() => registerFrameSource({ element, render: vi.fn() })).toThrow("already has");
unregister();
unregister();
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
expect(cleanup).toHaveBeenCalledTimes(1);
const render = vi.fn();
disposers.push(registerFrameSource({ element, render }));
runtime.seek({ time: 1 });
await waitForSeekCompletion();
expect(render).toHaveBeenCalledTimes(1);
});

it("removal aborts an active draw and releases capture", async () => {
const element = mount();
let signal: AbortSignal | undefined;
const render = vi.fn((_time: number, abort: AbortSignal) => {
signal = abort;
return deferred().promise;
});
const cleanup = vi.fn();
disposers.push(registerFrameSource({ element, render, dispose: cleanup }));
const runtime = adapter();
runtime.seek({ time: 0 });
await vi.waitFor(() => expect(render).toHaveBeenCalledTimes(1));
element.remove();
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
expect(signal?.aborted).toBe(true);
expect(cleanup).toHaveBeenCalledTimes(1);
});

it("keeps readiness promises stable and releases sources on teardown", async () => {
const ready = deferred();
const cleanup = vi.fn();
disposers.push(
registerFrameSource({
element: mount(),
ready: ready.promise,
render: vi.fn(),
dispose: cleanup,
}),
);
const runtime = adapter();
const first = runtime.getReadyPromise?.();
expect(runtime.getReadyPromise?.()).toBe(first);
runtime.revert?.();
await expect(first).resolves.toBeDefined();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(runtime.getReadyPromise?.()).toBeNull();
});

it("draws overlapping independent sources without serializing them together", async () => {
const a = deferred();
const b = deferred();
const renderA = vi.fn(() => a.promise);
const renderB = vi.fn(() => b.promise);
disposers.push(
registerFrameSource({ element: mount(), render: renderA }),
registerFrameSource({ element: mount(), render: renderB }),
);
adapter().seek({ time: 1 });
await vi.waitFor(() => {
expect(renderA).toHaveBeenCalled();
expect(renderB).toHaveBeenCalled();
});
a.resolve();
b.resolve();
await waitForSeekCompletion();
});
it("drains a queued seek after an earlier draw fails and retains the failure for capture", async () => {
const first = deferred();
const render = vi
.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValue(undefined);
const unregister = registerFrameSource({ element: mount(), render });
disposers.push(unregister);
expect(hasFrameSources()).toBe(true);
const runtime = adapter();
runtime.seek({ time: 1 });
await vi.waitFor(() => expect(render).toHaveBeenCalledTimes(1));
runtime.seek({ time: 2 });
const capture = expect(waitForSeekCompletion()).rejects.toThrow("first draw failed");
first.reject(new Error("first draw failed"));
await capture;
expect(render.mock.calls.map(([time]) => time)).toEqual([1, 2]);
runtime.seek({ time: 3 });
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
unregister();
expect(hasFrameSources()).toBe(false);
});
});
Loading
Loading