Files
Miguel Ángel e36a53d6b6 fix(core): hold render-ready on declared compute, not just adapter loaders (#4065)
* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration

Adds the compute and paint-and-idle inputs to the composition readiness gate,
plus window.__hf.buildReady: a promise registry a piece registers when it has
setup work no adapter can see (mesh building, shader compiles). The runtime
holds render-ready on it and the player shows its loading state instead of a
blank frame while it resolves.

* refactor(core): dedupe the paint-and-idle readiness test setup

Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput
tests as a 12-line clone; extracted the shared doc/tracking setup.

* fix(core): stop the readiness settled-tracker hanging on 2+ pending promises

Promise.all allocates a new promise every call, so comparing ITS identity
across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never
observes settled once 2+ promises are combined: every poll rebuilds a new
combined promise, re-arms, and republishes forever without the caller ever
seeing true. Reproduced with two already-resolved window.__hf.buildReady
entries, which is the exact multi-piece usage the new authoring doc describes.

Fixes both trackers at once with a shared createSettledTracker that compares
the source promise array by element instead of the Promise.all wrapper.

* fix(core): cancel readiness inputs once settleCompositionReadiness settles

computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept
running after the shared timeout won the race, since nothing told the losing
inputs to stop. One AbortSignal now threads through all three inputs and is
aborted the instant the race settles.

* docs(core): fix a stale sync-callback claim in settleCompositionReadiness

paintAndIdleReadinessInput always has a frame to wait on for any real
document, so the sync fast path this docstring described is no longer
reachable with the default inputs except for a viewless document.
Found by the simplify pass on this branch.

* fix(core): clear a stale buildReady entry on runtime teardown

A never-resolved window.__hf.buildReady promise from a torn-down
composition survived into the next init in the same window (window.__hf
is never reset), permanently blocking render-ready for whatever loaded
next -- the exact black-screen class this branch fixes, reproduced
through its own new mechanism. Found by the independent review.

Also fixes a stale hyperframes-player.ts docstring (media/images/fonts
had not been updated to compute + paint-and-idle) and documents the
buildReady key-collision hazard in three.md.

* fix(core): skip the compute readiness wait when no runtime ever ran

computeReadinessInput polled window.__renderReady for the full shared
timeout on any same-origin document with no HyperFrames runtime
injected (a probe-only composition using native video/duration, or a
blank test iframe) — nothing was ever going to set that flag. Checking
window.__hf first (the same signal composition-probe.ts already treats
as "has a runtime") lets it return null instead of waiting.

* fix(player): debounce the loading overlay and diagnose compute timeouts

paintAndIdleReadinessInput always has a frame to wait on for an
attached iframe, so settleCompositionReadiness's synchronous-settle
path is unreachable in practice now — the loading overlay showed on
every single Play, even ones with nothing genuinely pending. Debounce
the overlay itself instead: a wait that resolves fast never shows it.

Also names compute (window.__renderReady) in the stuck-assets timeout
diagnostic, which previously only re-scanned media, images and fonts.

* fix(engine): name a stuck buildReady key in the zero-duration diagnostic

A build promise a composition registers on window.__hf.buildReady that
never resolves left the export path's zero-duration timeout naming
nothing useful — hasHf/hasSeek/duration all looked fine, since the
runtime was genuinely present and just waiting on that one promise.
Probes buildReady's keys for pending ones and names them in the hint.

* fix(engine): stop the buildReady diagnostic from crashing on a rejection

A microtask race against a bare resolved promise rejected the whole
diagnostic expression on any rejected buildReady entry (the runtime's
own createSettledTracker rejects deliberately) and mis-sorted a settled
non-native thenable as pending, since its extra adoption tick loses the
race. Both measured directly, not inferred.

Settle each key onto its own record past one macrotask instead: no
throw, and pending/resolved/rejected are distinguished correctly. A
rejected key gets its own hint, separate from a merely pending one.

* fix(player): report a backgrounded document instead of overclaiming a paint bound

MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one
that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames
ever fire) still rides the full shared 8s timeout, same as before this
input existed. Corrected the comments that claimed otherwise.

That gap is exactly why the stuck-assets diagnostic left paint-and-idle
unreported: there was no accurate way to say it might be the cause. Added
documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded
tab is the reachable, directly observable case.

* refactor(engine): simplify the buildReady diagnostic rewrite

Skip the macrotask wait entirely when a composition registers no
buildReady keys — the common case, since it's opt-in. Nothing to settle
means nothing to wait on.

Collapse buildZeroDurationDiagnostic's inline parameter type into the
existing HfDiagnostic interface instead of maintaining two copies of
the same shape.

* docs(engine): note the setTimeout(0)-thenable gap the review found

An independent adversarial review, executing hostile inputs rather than
tracing, found the macrotask wait can still misreport a thenable that
resolves via its own internal setTimeout(0), since the diagnostic's own
timer is registered before the thenable's. Bounded and self-correcting
(only feeds the message text, and the diagnostic re-runs every ~1s), so
documenting the boundary rather than chasing an unbounded fix.
2026-09-18 11:12:44 -04:00

6.3 KiB

name, description
name description
hyperframes-three Three.js and WebGL adapter patterns for HyperFrames. Use when creating deterministic Three.js scenes, WebGL canvas layers, AnimationMixer timelines, camera motion, shader-driven visuals, or canvas renders that respond to HyperFrames hf-seek events.

Three.js for HyperFrames

HyperFrames supports Three.js through its three runtime adapter. The adapter does not own your scene. It publishes HyperFrames time and dispatches a seek event so your composition can render the exact frame.

Contract

  • Create the scene, camera, renderer, materials, and assets synchronously when possible.
  • Render from HyperFrames time, not wall-clock time.
  • Listen for the hf-seek event and render exactly that time.
  • Load models, textures, and HDRIs before render-critical seeking. Do not fetch them at seek time.
  • Avoid requestAnimationFrame or renderer.setAnimationLoop as the source of truth for render-critical motion.
  • Always set data-duration="<seconds>" on the root [data-composition-id] element. Unlike CSS/WAAPI/Lottie, the three adapter has no duration auto-inference — it only forwards time via hf-seek/__hfThreeTime, it doesn't inspect your scene for an AnimationClip/AnimationMixer length. Without data-duration (and no GSAP timeline), the render engine has no way to know how long to capture and fails with "Composition has zero duration". npx hyperframes lint errors on this (root_composition_missing_duration_source).

The adapter sets window.__hfThreeTime and dispatches new CustomEvent("hf-seek", { detail: { time } }) on each seek.

Basic Pattern

<canvas id="three-layer"></canvas>
<script type="module">
  import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.181.2/+esm";

  const canvas = document.getElementById("three-layer");
  const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
  // Match these to your composition's frame size.
  renderer.setSize(1920, 1080, false);
  renderer.setPixelRatio(1);

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(35, 1920 / 1080, 0.1, 100);
  camera.position.set(0, 0, 6);

  const mesh = new THREE.Mesh(
    new THREE.IcosahedronGeometry(1.4, 4),
    new THREE.MeshStandardMaterial({ color: 0x64d2ff, roughness: 0.38 }),
  );
  scene.add(mesh);
  scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 2));

  function renderAt(time) {
    mesh.rotation.y = time * 0.7;
    mesh.rotation.x = Math.sin(time * 0.6) * 0.16;
    renderer.render(scene, camera);
  }

  window.addEventListener("hf-seek", (event) => {
    renderAt(event.detail.time);
  });

  renderAt(window.__hfThreeTime || 0);
</script>
#three-layer {
  width: 100%;
  height: 100%;
  display: block;
}

Loading Addons (GLTFLoader, OrbitControls, etc.)

For anything under three/addons/, use an importmap so bare specifiers resolve. The HyperFrames lint recognizes both this form and the inline +esm import above — pick whichever your composition needs.

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/"
    }
  }
</script>
<script type="module">
  import * as THREE from "three";
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
  // ...
</script>

Pin the three version in both entries to the same value. Mixing versions across the map and bare imports causes silent breakage.

Heavy Setup (Large Meshes, Shader Compiles)

The runtime already waits for textures/models queued through Three's DefaultLoadingManager before publishing render-ready. It has no visibility into CPU-bound work you do yourself after assets load — building a large procedural mesh, compiling shaders, warming a pipeline. That work can leave the canvas blank for seconds after the runtime and player already say "ready".

If your setup does this kind of work, register a promise on window.__hf.buildReady (declared-compute hold: runtime waits, player shows its loading state instead of a blank frame):

window.__hf = window.__hf || {};
window.__hf.buildReady = window.__hf.buildReady || {};
window.__hf.buildReady["<your-piece-name>"] = buildScene(); // resolves once the scene is actually drawable

Register it synchronously, in the same script block that starts the build — same timing as DefaultLoadingManager, so the runtime's first readiness check already sees it. Only do this for setup an adapter cannot see; render-critical seeking still comes from hf-seek, not this hold. The key must be unique within the composition — a second registration under the same key silently replaces the first, dropping its hold.

AnimationMixer Pattern

For GLTF or authored clip animation, seek the mixer directly:

function renderAt(time) {
  mixer.setTime(time);
  renderer.render(scene, camera);
}

If several mixers exist, seek all of them from the same time.

Good Uses

  • Deterministic 3D objects, product spins, particles with seeded data, and shader plates.
  • Camera moves derived from time.
  • GLTF animation clips when assets are local and loaded before validation completes.

Avoid

  • Using Date.now(), performance.now(), or clock deltas to update scene state.
  • Leaving render-critical work inside a free-running animation loop.
  • Loading remote models or textures at render time.
  • Device-pixel-ratio dependent output. Pin renderer size and pixel ratio for video renders.
  • Post-processing passes that depend on previous frame history unless you can reconstruct state from time.

Validation

After editing a Three.js composition:

npx hyperframes lint
npx hyperframes check

Credits And References

  • HyperFrames adapter source: packages/core/src/runtime/adapters/three.ts.
  • Why data-duration is required here specifically (no auto-inference for this adapter): packages/core/src/runtime/init.ts (resolveAdapterDurationFloorSeconds) and the CSS/WAAPI/Lottie adapters' getInferredDurationSeconds, which the three adapter deliberately does not implement.
  • Three.js WebGLRenderer docs: https://threejs.org/docs/pages/WebGLRenderer.html
  • Three.js AnimationMixer.setTime() docs: https://threejs.org/docs/pages/AnimationMixer.html