Skip to content
Open
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
28 changes: 28 additions & 0 deletions docs/concepts/frame-adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,34 @@ All runtime adapters live in the `/hyperframes-animation` skill — invoke it fo
| Lottie / dotLottie | `goToAndStop(timeMs, false)`, raw-frame setters, or player seek APIs | `/hyperframes-animation` |
| Three.js / WebGL | `hf-seek` events plus `window.__hfThreeTime` for deterministic scene rendering | `/hyperframes-animation` |
| Web Animations API | `document.getAnimations()` and `animation.currentTime` | `/hyperframes-animation` |
| Render function | `window.__hfRender` callbacks invoked with the seek time (seconds) | `/hyperframes-animation` |

### Render function (no animation library)

When a composition's visual state is a pure function of time -- a hand-rolled
clock, a React state timeline, or a `<canvas>` / demoscene loop -- there may be
no animation library to seek. The render-function adapter is the
lowest-common-denominator path: register a `render(timeSeconds)` callback and
the runtime calls it with the exact composition time for every captured frame,
in place of the `requestAnimationFrame` loop you would use for live playback.

```html
<canvas id="scene" width="1920" height="1080"></canvas>
<script>
const ctx = document.getElementById("scene").getContext("2d");
function renderAt(timeSeconds) {
ctx.clearRect(0, 0, 1920, 1080);
// ...draw the frame as a pure function of timeSeconds...
}
window.__hfRender = window.__hfRender || [];
window.__hfRender.push(renderAt);
</script>
```

The current seek position is also mirrored onto `window.__hfTime` (seconds) so
draw helpers can read it directly instead of `performance.now()`. As with every
adapter, callbacks must render purely from the time argument -- no `Date.now()`,
no `performance.now()`, no unseeded randomness.

Community adapters are welcome -- if it can seek by frame, it belongs in Hyperframes.

Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/runtime/adapters/render-fn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createRenderFnAdapter } from "./render-fn";

const renderWindow = window as Window & {
__hfRender?: Array<(timeSeconds: number) => void>;
__hfTime?: number;
};

describe("render-fn adapter", () => {
beforeEach(() => {
delete renderWindow.__hfRender;
delete renderWindow.__hfTime;
});

afterEach(() => {
delete renderWindow.__hfRender;
delete renderWindow.__hfTime;
});

it("has correct name", () => {
expect(createRenderFnAdapter().name).toBe("render-fn");
});

describe("seek", () => {
it("invokes the registered callback with the seek time in seconds", () => {
const render = vi.fn();
renderWindow.__hfRender = [render];
createRenderFnAdapter().seek({ time: 2.5 });
expect(render).toHaveBeenCalledWith(2.5);
});

it("invokes every registered callback in sync", () => {
const a = vi.fn();
const b = vi.fn();
renderWindow.__hfRender = [a, b];
createRenderFnAdapter().seek({ time: 1.5 });
expect(a).toHaveBeenCalledWith(1.5);
expect(b).toHaveBeenCalledWith(1.5);
});

it("mirrors the current time onto window.__hfTime", () => {
renderWindow.__hfRender = [vi.fn()];
createRenderFnAdapter().seek({ time: 3.25 });
expect(renderWindow.__hfTime).toBe(3.25);
});

it("clamps negative time to 0", () => {
const render = vi.fn();
renderWindow.__hfRender = [render];
createRenderFnAdapter().seek({ time: -3 });
expect(render).toHaveBeenCalledWith(0);
});

it("is repeatable — same time twice yields the same call", () => {
const render = vi.fn();
renderWindow.__hfRender = [render];
const adapter = createRenderFnAdapter();
adapter.seek({ time: 1 });
adapter.seek({ time: 1 });
expect(render).toHaveBeenNthCalledWith(1, 1);
expect(render).toHaveBeenNthCalledWith(2, 1);
});

it("supports arbitrary seek order (forward, backward, repeat)", () => {
const render = vi.fn();
renderWindow.__hfRender = [render];
const adapter = createRenderFnAdapter();
for (const t of [3, 0.5, 2.5, 0.5]) adapter.seek({ time: t });
expect(render.mock.calls.map((c) => c[0])).toEqual([3, 0.5, 2.5, 0.5]);
});

it("does nothing when no callbacks are registered", () => {
const adapter = createRenderFnAdapter();
expect(() => adapter.seek({ time: 1 })).not.toThrow();
expect(renderWindow.__hfTime).toBeUndefined();
});

it("ignores a non-array __hfRender value", () => {
(renderWindow as { __hfRender?: unknown }).__hfRender = "nope";
const adapter = createRenderFnAdapter();
expect(() => adapter.seek({ time: 1 })).not.toThrow();
});

it("continues rendering remaining callbacks if one throws", () => {
const bad = vi.fn(() => {
throw new Error("boom");
});
const good = vi.fn();
renderWindow.__hfRender = [bad, good];
createRenderFnAdapter().seek({ time: 1 });
expect(good).toHaveBeenCalledWith(1);
});

it("does not invoke callbacks registered during the same seek", () => {
const adapter = createRenderFnAdapter();
const late = vi.fn();
const early = vi.fn(() => {
renderWindow.__hfRender!.push(late);
});
renderWindow.__hfRender = [early];
adapter.seek({ time: 1 });
expect(early).toHaveBeenCalledTimes(1);
expect(late).not.toHaveBeenCalled();
// The late registration is picked up on the next seek.
adapter.seek({ time: 2 });
expect(late).toHaveBeenCalledWith(2);
});
});

describe("discover / pause", () => {
it("discover does not throw and does not require callbacks", () => {
const adapter = createRenderFnAdapter();
expect(() => adapter.discover()).not.toThrow();
});

it("pause is a no-op and does not throw", () => {
renderWindow.__hfRender = [vi.fn()];
const adapter = createRenderFnAdapter();
expect(() => adapter.pause()).not.toThrow();
});
});
});
94 changes: 94 additions & 0 deletions packages/core/src/runtime/adapters/render-fn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { RuntimeDeterministicAdapter } from "../types";
import { swallow } from "../diagnostics";

/**
* Render-function adapter for Hyperframes.
*
* The lowest-common-denominator adapter: it drives compositions whose visual
* state is a pure function of time, drawn by a callback the composition
* registers — no animation library required. This is the seek-driven
* replacement for the `requestAnimationFrame` loop such compositions would use
* during live playback, and it covers hand-rolled clocks, React state
* timelines, and `<canvas>` / demoscene effects.
*
* It is also the bridge for design tools that emit a time-driven render
* function rather than a GSAP timeline (e.g. a `render(t)` over React state),
* which previously had no adapter and so produced blank frames under capture.
*
* ## Usage in a composition
*
* Register one or more `render(timeSeconds)` callbacks. The runtime calls them
* with the exact composition time for every captured frame, in place of the
* `requestAnimationFrame` loop you would use for live playback:
*
* ```html
* <canvas id="scene" width="1920" height="1080"></canvas>
* <script>
* const ctx = document.getElementById("scene").getContext("2d");
* function renderAt(timeSeconds) {
* ctx.clearRect(0, 0, 1920, 1080);
* // ...draw the frame as a pure function of timeSeconds...
* }
* window.__hfRender = window.__hfRender || [];
* window.__hfRender.push(renderAt);
* </script>
* ```
*
* The adapter mirrors the current time onto `window.__hfTime` (seconds) while
* driving callbacks, so helper draw routines can read the seek position
* directly instead of `performance.now()`.
*
* ## Determinism
*
* Callbacks MUST render purely from the `timeSeconds` argument — no
* `Date.now()`, no `performance.now()`, no unseeded randomness. The same time
* must always produce the same frame: the runtime seeks forward, backward, and
* out of order, and may seek the same frame more than once.
*/
type RenderFn = (timeSeconds: number) => void;

interface RenderFnWindow extends Window {
/** Compositions register `render(timeSeconds)` callbacks here for the adapter to drive. */
__hfRender?: RenderFn[];
/** Current seek position in seconds, mirrored for poll-style draw helpers. */
__hfTime?: number;
}

export function createRenderFnAdapter(): RuntimeDeterministicAdapter {
const getCallbacks = (): RenderFn[] => {
const list = (window as RenderFnWindow).__hfRender;
return Array.isArray(list) ? list : [];
};

return {
name: "render-fn",

discover: () => {
// Nothing to discover — callbacks are read lazily on seek so that
// registrations made after bootstrap (the common case: composition
// scripts run after the runtime mounts) are always picked up.
},

seek: (ctx) => {
const callbacks = getCallbacks();
if (callbacks.length === 0) return;
const time = Math.max(0, Number(ctx.time) || 0);
(window as RenderFnWindow).__hfTime = time;
// Snapshot before iterating: a callback may register another callback,
// and a newcomer must not be invoked mid-seek (nor loop the iteration).
for (const render of callbacks.slice()) {
try {
render(time);
} catch (err) {
// Keep rendering the remaining callbacks if one throws.
swallow("runtime.adapters.render-fn.site1", err);
}
}
},

pause: () => {
// No-op: a render function is a pure function of time, so there is no
// running clock to stop. The next seek fully defines the frame.
},
};
}
2 changes: 2 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createGoogleMapsAdapter } from "./adapters/google-maps";
import { createMaplibreAdapter } from "./adapters/maplibre";
import { createD3Adapter } from "./adapters/d3";
import { createTypegpuAdapter } from "./adapters/typegpu";
import { createRenderFnAdapter } from "./adapters/render-fn";
import {
patchVideoTextureCompat,
patchWebGLVideoTextureCompat,
Expand Down Expand Up @@ -1932,6 +1933,7 @@ export function initSandboxRuntimeModular(): void {
createMaplibreAdapter(),
createD3Adapter(),
createTypegpuAdapter(),
createRenderFnAdapter(),
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
] as RuntimeDeterministicAdapter[];
patchVideoTextureCompat();
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/runtime/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,25 @@ declare global {
* window.__hfD3.push(transition);
*/
__hfD3?: unknown[];
/**
* Render-function callbacks for compositions whose visual state is a pure
* function of time (hand-rolled clocks, React state timelines, canvas /
* demoscene loops) — the seek-driven replacement for a
* `requestAnimationFrame` loop. The adapter calls each with the exact
* composition time (seconds) for every captured frame:
* window.__hfRender = window.__hfRender || [];
* window.__hfRender.push((timeSeconds) => drawFrame(timeSeconds));
*
* Callbacks must render purely from the time argument (no `Date.now()`,
* no `performance.now()`, no unseeded randomness).
*/
__hfRender?: Array<(timeSeconds: number) => void>;
/**
* Current seek position in seconds, mirrored by the render-function adapter
* while it drives `__hfRender` callbacks. Read it from draw helpers instead
* of `performance.now()` to stay on the deterministic seek clock.
*/
__hfTime?: number;
/**
* Render-time variable overrides injected by the engine when the user
* passes `hyperframes render --variables '<json>'`. Read indirectly via
Expand Down
2 changes: 2 additions & 0 deletions skills/hyperframes-animation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/<id>.
| TypeGPU / WebGPU (`navigator.gpu`, WGSL, compute pipelines) | `adapters/typegpu.md` |
| HTML-as-texture + WebGL/GLSL post-fx (capture live DOM via `drawElementImage`) | `adapters/html-in-canvas-patterns.md` |
| Named text-animation effects (24 IDs via external `animate-text` skill) | `adapters/animate-text.md` |
| Render function — no animation library (`window.__hfRender`, time-driven draw) | `adapters/render-fn.md` |

## Picking a runtime

Expand All @@ -54,6 +55,7 @@ Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/<id>.
- **CSS** for simple repeated motifs, decoration, shimmer — no JavaScript animation cost.
- **WAAPI** for native browser keyframes without a GSAP dependency.
- **TypeGPU / WebGPU** for GPU-rendered canvases (particles, liquid glass, custom shaders).
- **Render function** when the scene is a pure function of time with no animation library — a hand-rolled clock, a React state timeline, or a `<canvas>` draw loop. Register a `render(timeSeconds)` callback on `window.__hfRender` instead of a `requestAnimationFrame` loop.

Multiple runtimes can coexist in one composition. Each registers its instances on the runtime-specific global so HyperFrames can seek all of them in one pass.

Expand Down
Loading