# Standalone HyperFrames Slideshow Harness ## 1. Interim framing — why this exists These patterns are a **temporary workaround** for standalone demos. The durable solution is engine-hosted: a future `hyperframes preview --slideshow` / studio present mode will host the composition over the real HyperFrames engine, which drives seek-timelines frame-by-frame, owns the gesture frame, and reads the slideshow island directly from the composition. When that path ships, most of what follows collapses. Until then, a standalone slideshow opened via the bare player bundle must work around three facts: 1. The composition must expose a seekable `window.__timelines.root` timeline. Anything outside that seek path, such as Three.js loops or imperative entrance effects, must be self-driving. 2. `` reads the slideshow island from its **own innerHTML** (the wrapper element), not from the composition the player loads. The island must be duplicated into the wrapper. 3. The composition runs in the player's **iframe**; user keypresses and pointer events land on the **parent page**. Wrapper-owned SFX/global audio should live in the parent, where the activation token is reliable. Normal slide media stays in the composition and is stopped by the slideshow player on slide exit. Do not treat these as the blessed authoring model. When the engine-hosted path ships, compositions authored the normal way will just work. **Living reference implementations:** - `registry/examples/airbnb-deck/index.html` + `demo.html` — full pattern set (Three.js, fragments, SFX, branch slide) - `registry/examples/startup-pitch/index.html` — minimal version (no 3D), good starting point --- ## 2. The parent wrapper (`index.html` for deliverables, `demo.html` in examples) The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio. For public or user-facing generated projects, make this wrapper the root `index.html` so opening the project in a browser runs the slideshow. Put the raw HyperFrames composition in a separate path such as `composition/index.html`. In repo examples you may still see this file called `demo.html`; that name is a reference pattern, not the preferred handoff for a standalone deck. ```html My Deck — Slideshow Demo ``` `interactive` is required for decks with clickable page content or media controls. Without it, iframe pointer events are disabled by the player shell and a click on the composition can be interpreted as a player play/pause toggle instead of a slide interaction. ### Presenter media bridge for interactive media Presenter/audience mode syncs slide position through a deck-scoped `BroadcastChannel`. If the presenter is expected to play, pause, seek, mute, or change rate on media inside the composition, mirror those native media events over the same channel. Keep the media element as the source of truth; do not mirror a custom button's private state. Audible playback has one extra browser constraint: a `BroadcastChannel` message does not carry the presenter's user activation into the audience window. The audience window should try presenter-driven playback muted first, because browsers usually allow muted autoplay; it may still reject `media.play()` even while it accepts remote `currentTime` updates. If that happens, do not keep chasing presenter `timeupdate` messages; show an audience-side unlock control, store the latest play intent, and retry playback from that intent after the audience window receives a click/key gesture. ```js (function () { if (typeof BroadcastChannel === "undefined") return; var sender = new URLSearchParams(location.search).get("mode") === "audience" ? "audience" : "presenter"; var channel = new BroadcastChannel("hf-slideshow:" + location.pathname); var applyingRemote = false; var lastTimeBroadcast = 0; var pendingPlayByKey = {}; var blockedPlayByKey = {}; var mutedPlaybackByKey = {}; var unlockButton = null; function frameDocument() { var player = document.querySelector("hyperframes-player"); var frame = player && player.iframeElement; try { return frame && frame.contentDocument ? frame.contentDocument : null; } catch { return null; } } function mediaNodes() { var doc = frameDocument(); return doc ? Array.from(doc.querySelectorAll("video,audio")) : []; } function mediaKey(el, index) { return el.id ? "id:" + el.id : el.tagName.toLowerCase() + ":" + index; } function findMedia(key) { return mediaNodes().find(function (el, index) { return mediaKey(el, index) === key; }); } function syncMediaState(el, msg, allowTimeSync) { if (typeof msg.playbackRate === "number") el.playbackRate = msg.playbackRate; if (typeof msg.volume === "number") el.volume = Math.max(0, Math.min(1, msg.volume)); if (sender === "audience" && mutedPlaybackByKey[msg.key]) { el.muted = true; } else if (typeof msg.muted === "boolean") { el.muted = msg.muted; } if ( allowTimeSync && typeof msg.currentTime === "number" && Math.abs((el.currentTime || 0) - msg.currentTime) > 0.35 ) { el.currentTime = Math.max(0, msg.currentTime); } } function hasBlockedPlay() { return Object.keys(blockedPlayByKey).length > 0; } function hideAudienceUnlockIfClear() { if (hasBlockedPlay() || !unlockButton) return; unlockButton.remove(); unlockButton = null; } function showAudienceUnlock() { if (sender !== "audience" || unlockButton) return; unlockButton = document.createElement("button"); unlockButton.type = "button"; unlockButton.textContent = "Enable audience media"; unlockButton.style.cssText = "position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:100000;border:0;border-radius:999px;padding:12px 18px;background:#fff;color:#111827;box-shadow:0 10px 32px rgba(0,0,0,.28);font:700 14px/1 system-ui,sans-serif;cursor:pointer;"; unlockButton.addEventListener("click", retryBlockedPlays); document.body.appendChild(unlockButton); } function rememberBlockedPlay(msg) { pendingPlayByKey[msg.key] = msg; blockedPlayByKey[msg.key] = true; showAudienceUnlock(); } function clearBlockedPlay(key) { delete blockedPlayByKey[key]; hideAudienceUnlockIfClear(); } function tryPlay(el, msg) { if (sender === "audience") mutedPlaybackByKey[msg.key] = true; syncMediaState(el, msg, true); if (sender === "audience") el.muted = true; try { var playResult = el.play(); if (playResult && typeof playResult.then === "function") { playResult .then(function () { clearBlockedPlay(msg.key); }) .catch(function () { rememberBlockedPlay(msg); }); } else { clearBlockedPlay(msg.key); } } catch (e) { rememberBlockedPlay(msg); } } function retryBlockedPlays() { wireMedia(); applyingRemote = true; try { Object.keys(pendingPlayByKey).forEach(function (key) { var msg = pendingPlayByKey[key]; var el = findMedia(key); if (el && msg) tryPlay(el, msg); }); } finally { setTimeout(function () { applyingRemote = false; }, 300); } } function publish(el, index, action) { if (sender !== "presenter") return; if (applyingRemote) return; if (action === "timeupdate") { var now = performance.now(); if (now - lastTimeBroadcast < 450 && !el.paused) return; lastTimeBroadcast = now; } channel.postMessage({ type: "media", sender, key: mediaKey(el, index), action, currentTime: el.currentTime || 0, paused: el.paused, ended: el.ended, muted: el.muted, volume: el.volume, playbackRate: el.playbackRate, }); } function wireMedia() { mediaNodes().forEach(function (el, index) { if (el.dataset.hfPresenterMediaSync === "1") return; el.dataset.hfPresenterMediaSync = "1"; [ "play", "pause", "seeking", "seeked", "ratechange", "volumechange", "ended", "timeupdate", ].forEach(function (name) { el.addEventListener(name, function () { publish(el, index, name); }); }); }); } channel.addEventListener("message", function (event) { var msg = event.data; if (!msg || msg.type !== "media" || msg.sender === sender) return; if (sender === "audience" && blockedPlayByKey[msg.key] && msg.action === "timeupdate") { pendingPlayByKey[msg.key] = msg; showAudienceUnlock(); return; } var el = findMedia(msg.key); if (!el) return; applyingRemote = true; try { if ( msg.action === "play" || (sender === "audience" && msg.action === "timeupdate" && msg.paused === false && el.paused) ) { pendingPlayByKey[msg.key] = msg; tryPlay(el, msg); } else { syncMediaState(el, msg, true); } if (msg.action === "pause" || msg.action === "ended") { delete pendingPlayByKey[msg.key]; delete mutedPlaybackByKey[msg.key]; clearBlockedPlay(msg.key); el.pause(); } } catch { } finally { setTimeout(function () { applyingRemote = false; }, 300); } }); wireMedia(); window.addEventListener("load", wireMedia); window.addEventListener("keydown", retryBlockedPlays, true); window.addEventListener("pointerdown", retryBlockedPlays, true); setInterval(wireMedia, 1000); })(); ``` ### Custom media visualizers For waveform, beat-grid, canvas, or timeline players, wire visual state to the native media element. This keeps native controls, custom controls, presenter sync, slide-exit cleanup, and global mute in one event path. ```js function wireMediaDrivenVisualizer(media, renderFrame, fireCrossedEvents) { var mediaFrame = 0; var lastTime = media.currentTime || 0; function update() { var time = media.currentTime || 0; if (Math.abs(time - lastTime) < 1.5 && time >= lastTime) { fireCrossedEvents(lastTime, time); } lastTime = time; renderFrame(time, media); } function start() { if (!media.requestVideoFrameCallback || mediaFrame) return; mediaFrame = media.requestVideoFrameCallback(function () { mediaFrame = 0; update(); if (!media.paused && !media.ended) start(); }); } media.addEventListener("play", start); media.addEventListener("playing", start); media.addEventListener("pause", update); media.addEventListener("ended", update); media.addEventListener("timeupdate", update); media.addEventListener("seeking", function () { lastTime = media.currentTime || 0; renderFrame(lastTime, media); }); media.addEventListener("seeked", update); media.addEventListener("ratechange", update); media.addEventListener("volumechange", update); renderFrame(media.currentTime || 0, media); } ``` Do not use `requestAnimationFrame` inside compositions for media sync; composition lint rejects wall-clock loops. Prefer `HTMLVideoElement.requestVideoFrameCallback()` for smooth video-tied updates and rely on native `timeupdate`/seek events as the fallback. Use a dedicated wiring marker such as `data-media-sync-wired`. Do not reuse a marker like `data-wired` for both "timeline DOM already rendered" and "media event listeners attached"; pre-rendered timeline HTML will otherwise skip listener setup. ### Editable presenter notes The shared `` presenter already renders speaker notes as an editable textarea and stores edits in `localStorage`. Do not add deck-specific note editors when the shared player is available. For interim custom wrappers that cannot use the shared presenter chrome, use this deterministic storage contract exactly so notes migrate cleanly: ```js const NOTES_STORAGE_PREFIX = "hf-slideshow:presenter-notes:v1:"; function notesDeckKey(slideshowEl) { const explicit = slideshowEl.getAttribute("notes-storage-key"); if (explicit && explicit.trim()) return explicit.trim(); const playerSrc = slideshowEl.querySelector("hyperframes-player")?.getAttribute("src") || ""; let resolvedPlayerSrc = playerSrc; try { resolvedPlayerSrc = new URL(playerSrc, location.href).href; } catch {} return `${location.origin}${location.pathname}|${document.title}|${resolvedPlayerSrc}`; } function notesStorageKey(slideshowEl, position, slide) { return `${NOTES_STORAGE_PREFIX}${JSON.stringify([ notesDeckKey(slideshowEl), position.sequenceId, position.slideIndex, slide.sceneId || "", ])}`; } function readPresenterNotes(slideshowEl, position, slide) { const key = notesStorageKey(slideshowEl, position, slide); try { const stored = localStorage.getItem(key); return stored == null ? slide.notes || "" : stored; } catch { return slide.notes || ""; } } function wirePresenterNotes(textarea, slideshowEl, position, slide) { const key = notesStorageKey(slideshowEl, position, slide); textarea.value = readPresenterNotes(slideshowEl, position, slide); textarea.addEventListener("input", function () { try { localStorage.setItem(key, textarea.value); } catch {} }); } ``` Clearing the textarea must save an empty string, not remove the local value, because a presenter may intentionally blank a manifest note for their run. Use `notes-storage-key="stable-deck-id"` on `` when a standalone demo has a stable project id; otherwise the fallback key isolates by page, title, and player `src`. --- ## 3. Playhead-driven scene visibility Without the engine, scenes are driven by a `root` GSAP timeline that the composition manages on its own clock. The visibility controller reads `window.__timelines.root.time()` via that timeline's `onUpdate` callback and sets `opacity` accordingly. Only the active scene is visible. The key insight: scene backgrounds must be `transparent` (not opaque) if you want a Three.js canvas behind them; the body/html background and scene inline `background` set the visual fill. For converted source pages, preserve the original page's visual design, motion language, interactive behavior, media behavior, and presentation affordances as closely as practical. Port source-specific widgets exactly where practical: custom canvas players, waveform/timeline decorations, expanding rings, playheads, hover states, and event wiring are source material, not optional polish. Also audit for atypical page movement: scroll-scrubbed cameras, parallax, pinned sections, horizontal scrollers, section snapping, translated/scaled world layers, or zoom-to-element navigation. Scroll is often the source's transition trigger, so extract the scroll-progress stops, easing, and camera/focus states, then re-host that motion on slideshow navigation through timeline positions, fragments, or reusable harness hooks. Do not recreate the browser's literal page-scroll-down motion inside a slide; translate it into camera travel/zoom from one focus area to the next. If the same mechanical behavior appears across decks, move it into the player or this harness instead of copying a fragile one-off script. ### Navigation camera transitions for converted pages When a source page uses scroll to move a translated/scaled world, slideshow navigation usually seeks directly to each slide's hold frame. That seek bypasses any in-timeline interpolation near the scene boundary, so a deck can compute the right camera positions and still appear to jump. Add an explicit standalone navigation transition for manual slide changes, while keeping normal HyperFrames timeline seeks static and deterministic. Use this pattern only for direct-open/presenter slideshow UI. Do not depend on CSS transitions for rendered video output; rendered compositions must still be correct when seeking a single frame. ```css #world { transform-origin: 0 0; will-change: transform; transition: transform 760ms cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease; } #world.hf-camera-static { transition: opacity 0.3s ease; } ``` ```js var currentCamera = null; var currentSlideIndex = null; function cameraTransform(cam) { return "translate(" + cam.tx + "px," + cam.ty + "px) scale(" + cam.s + ")"; } function setWorldCamera(world, cam, animate) { if (!cam) return; if (!animate) world.classList.add("hf-camera-static"); world.style.transform = cameraTransform(cam); world.style.opacity = "1"; if (!animate) { world.getBoundingClientRect(); world.classList.remove("hf-camera-static"); } currentCamera = cam; } function slideIndexAtTime(t, slideDuration, slideCount) { return Math.max(0, Math.min(slideCount - 1, Math.floor(t / slideDuration))); } function updateCameraForTime(t, opts) { var nextSlideIndex = slideIndexAtTime(t, SLIDE_DURATION, SLIDES.length); var jumpedBetweenSlides = currentSlideIndex !== null && nextSlideIndex !== currentSlideIndex && Math.abs(t - lastTime) > 1.2; var animateCamera = Boolean( window.__hfCameraTransitionsEnabled && jumpedBetweenSlides && !(opts && opts.staticCamera), ); var cam = cameraAtTime(t); setWorldCamera(world, cam, animateCamera); currentSlideIndex = nextSlideIndex; } ``` During measurement, temporarily remove the transform with `hf-camera-static`, compute all element union rects, then restore `currentCamera` without animation. On initial load, resize, and validation-style seeks, call `updateCameraForTime(t, { staticCamera: true })`. In the standalone wrapper, set `iframe.contentWindow.__hfCameraTransitionsEnabled = true` after the player iframe is available. That keeps the exported composition seekable while letting presenter navigation glide between focal points. Before validation, resolve source font variables. HyperFrames lint accepts concrete generic stacks such as `system-ui, sans-serif` and `ui-monospace, monospace`, or real `@font-face` declarations pointing at local font files. It does not accept `font-family: var(--f-body)` / `var(--f-mono)` as a render-safe family. ```html
``` --- ## 4. Imperative entrances on slide-activate The engine-hosted path drives GSAP seek-timelines frame by frame. Without it, seek-timeline tweens never fire. Instead, fire imperative `gsap.from()` calls each time a scene becomes active — these run on GSAP's own ticker and are independent of any playhead. Fragment reveals use playhead-crossing: the visibility controller checks whether the playhead has passed each fragment's hold-time and fires an animation on the first crossing. Bunch fragment hold-times near the scene start (within the first 300–500 ms of the scene) so successive ArrowRight presses feel like snappy sequential reveals rather than long waits. ```js // --- Entrance animations --- function fireEntrance(sceneEl) { // [data-anim] marks elements that should entrance on slide-activate. // Add data-anim to eyebrows, headlines, subheads, and card grids. var animEls = sceneEl.querySelectorAll("[data-anim]"); if (!animEls.length) return; gsap.from(animEls, { opacity: 0, y: 28, duration: 0.4, stagger: 0.07, ease: "power2.out", overwrite: true, // cancel any in-flight animation on rapid slide changes }); } // --- Fragment reveals --- // Fragment config: times in absolute composition timeline seconds, // bunched near the scene start for snappy successive reveals. var fragments = [ { time: 9.3, id: "prob-item1", revealed: false }, { time: 9.6, id: "prob-item2", revealed: false }, ]; function revealFragment(id) { var el = document.getElementById(id); if (!el) return; gsap.fromTo( el, { opacity: 0, x: -24 }, { opacity: 1, x: 0, duration: 0.35, ease: "power2.out", overwrite: true }, ); } // Inside updateVisibility(t): for (var f = 0; f < fragments.length; f++) { if (!fragments[f].revealed && t >= fragments[f].time) { fragments[f].revealed = true; revealFragment(fragments[f].id); } } // On problem scene re-entry, reset all fragment states: if (active && lastActiveId !== s.id && s.id === "scene-problem") { for (var f = 0; f < fragments.length; f++) { fragments[f].revealed = false; var pEl = document.getElementById(fragments[f].id); if (pEl) gsap.set(pEl, { opacity: 0, clearProps: "transform" }); } } ``` Fragment items start with `opacity: 0` in CSS. The visibility controller reveals them; the entrance driver does not touch them until crossing. --- ## 5. The scenes bootstrap postMessage `` must know each scene's time range to map a `sceneId` to a playhead position. Without the engine injecting this at runtime, the composition must post it manually after load. Post the manifest from the composition (index.html), not the parent wrapper: ```js // In index.html — post after a brief delay so the parent frame has settled (function () { var FPS = 30; var totalSeconds = 108; // match your composition's data-duration var totalFrames = totalSeconds * FPS; var scenes = [ // EVERY scene — including branch scenes — must appear here. // id must match data-composition-id; start/duration in seconds. { id: "cover", start: 0, duration: 9 }, { id: "problem", start: 9, duration: 9 }, { id: "solution", start: 18, duration: 9 }, // ... all main-line scenes ... // branch scene — listed last, NOT in main slides array in the island { id: "market-sizing", start: 99, duration: 9 }, ]; function postTimeline() { parent.postMessage( { source: "hf-preview", type: "timeline", durationInFrames: totalFrames, scenes: scenes, }, "*", ); } // ~300ms delay after load to let the parent settle if (document.readyState === "complete") { setTimeout(postTimeline, 300); } else { window.addEventListener("load", function () { setTimeout(postTimeline, 300); }); } })(); ``` Omitting any scene (including branch scenes) from this manifest means the slideshow component cannot seek to it. Include every scene declared in the HTML, even scenes only reachable via a hotspot. --- ## 6. Audio/SFX — built-in mute control via `` Wrapper-owned SFX should live in the parent page. Browsers enforce user-activation for AudioContext and HTMLAudioElement.play() — an iframe without its own activation (i.e., the user never clicked inside it) is often autoplay-blocked. The user's keypress lands on the parent, so the parent is the reliable frame for click/transition sound effects. Normal slide media should stay in the composition. The slideshow player now stops slide media automatically on slide/sequence changes by calling `hyperframes-player.stopMedia()`, which pauses iframe `