diff --git a/docs/concepts/frame-adapters.mdx b/docs/concepts/frame-adapters.mdx index 3edc5b51b6..918d8b5b5c 100644 --- a/docs/concepts/frame-adapters.mdx +++ b/docs/concepts/frame-adapters.mdx @@ -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 `` / 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 + + +``` + +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. diff --git a/packages/core/src/runtime/adapters/render-fn.test.ts b/packages/core/src/runtime/adapters/render-fn.test.ts new file mode 100644 index 0000000000..8a08e97a06 --- /dev/null +++ b/packages/core/src/runtime/adapters/render-fn.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/core/src/runtime/adapters/render-fn.ts b/packages/core/src/runtime/adapters/render-fn.ts new file mode 100644 index 0000000000..1b7b6b264b --- /dev/null +++ b/packages/core/src/runtime/adapters/render-fn.ts @@ -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 `` / 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 + * + * + * ``` + * + * 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. + }, + }; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 684c6db7bd..ba4cbd549d 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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, @@ -1932,6 +1933,7 @@ export function initSandboxRuntimeModular(): void { createMaplibreAdapter(), createD3Adapter(), createTypegpuAdapter(), + createRenderFnAdapter(), createGsapAdapter({ getTimeline: () => state.capturedTimeline }), ] as RuntimeDeterministicAdapter[]; patchVideoTextureCompat(); diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index ff1f846e4e..33bae998af 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -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 ''`. Read indirectly via diff --git a/skills/hyperframes-animation/SKILL.md b/skills/hyperframes-animation/SKILL.md index 5e62d43ce9..85b75410f4 100644 --- a/skills/hyperframes-animation/SKILL.md +++ b/skills/hyperframes-animation/SKILL.md @@ -44,6 +44,7 @@ Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/. | 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 @@ -54,6 +55,7 @@ Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/. - **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 `` 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. diff --git a/skills/hyperframes-animation/adapters/render-fn.md b/skills/hyperframes-animation/adapters/render-fn.md new file mode 100644 index 0000000000..2fbc724e0d --- /dev/null +++ b/skills/hyperframes-animation/adapters/render-fn.md @@ -0,0 +1,119 @@ +--- +name: hyperframes-render-fn +description: Render-function adapter patterns for HyperFrames. Use when a composition has no animation library and draws each frame as a pure function of time — a hand-rolled clock, a React state timeline, or a canvas/demoscene loop — by registering render(timeSeconds) callbacks on window.__hfRender. +--- + +# Render function for HyperFrames + +When a composition's visual state is a pure function of time and there is no animation library to seek, HyperFrames drives it through the `render-fn` adapter. You register a `render(timeSeconds)` callback; the runtime calls it with the exact composition time for every captured frame. This is the seek-driven replacement for the `requestAnimationFrame` loop you would use during live playback. + +Reach for this adapter for hand-rolled clocks, `` / demoscene draw loops, and React (or other framework) state timelines that compute the frame from a single time value — including the time-driven render functions emitted by design tools that do not produce a GSAP timeline. + +## Contract + +- Register every draw callback on `window.__hfRender` (an array of `(timeSeconds: number) => void`). +- Do **not** drive the scene with your own `requestAnimationFrame` loop — the adapter supplies the clock. +- Render purely from the `timeSeconds` argument. No `Date.now()`, no `performance.now()`, no unseeded randomness. The runtime seeks forward, backward, out of order, and may seek the same frame twice; identical time must produce identical pixels. +- Keep canvas dimensions stable with CSS. +- The current seek position is also mirrored onto `window.__hfTime` (seconds) for draw helpers that prefer to read it directly. + +## Canvas Pattern + +```html + + +``` + +```css +.scene-canvas { + width: 100%; + height: 100%; + display: block; +} +``` + +## Framework State Pattern + +For a React (or similar) timeline that renders from a single `time` value, advance the state from the callback instead of from `requestAnimationFrame`, then flush a synchronous render: + +```js +window.__hfRender = window.__hfRender || []; +window.__hfRender.push((timeSeconds) => { + setCompositionTime(timeSeconds); // drives every time-derived value in the tree +}); +``` + +Keep the render synchronous and deterministic: the runtime captures the frame after the callback returns. + +## Seeded Randomness + +When a deterministic effect needs pseudo-randomness, seed it (e.g. mulberry32) so the sequence is identical on every seek: + +```js +function mulberry32(seed) { + return function () { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +``` + +Re-create the generator from the same seed inside each `render` call (or derive it from `timeSeconds`) so the frame never depends on call history. + +## Multiple Callbacks + +Push each draw routine into the same registry: + +```js +window.__hfRender = window.__hfRender || []; +window.__hfRender.push(drawBackground); +window.__hfRender.push(drawForeground); +``` + +HyperFrames invokes them all at the same composition time, in registration order. + +## Good Uses + +- Canvas / demoscene effects (starfields, plasma, particles) driven by a time value. +- Framework state timelines that render the frame from a single clock. +- Time-driven render functions that have no GSAP timeline to seek. + +## Avoid + +- A self-driven `requestAnimationFrame` loop — it does not advance under capture and yields blank or frozen frames. +- Reading `Date.now()` / `performance.now()` inside the callback. +- Unseeded `Math.random()`. +- Asynchronous rendering inside the callback — finish synchronously before returning. + +## Validation + +After editing the composition: + +```bash +npx hyperframes lint +npx hyperframes validate +``` + +## Credits And References + +- HyperFrames adapter source: `packages/core/src/runtime/adapters/render-fn.ts`. +- Frame Adapter concept: `docs/concepts/frame-adapters.mdx`. +- Determinism contract: `docs/concepts/determinism.mdx`.