mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
docs(skill): slideshow authoring guidance + standalone harness reference (#1583)
* feat(player): slideshow controller state machine Stack-split from the original ss-player PR (#1581): the SlideshowController state machine (stack-based slide/fragment/branch navigation) and its tests. The <hyperframes-slideshow> web component follows in the next PR. * feat(player): <hyperframes-slideshow> web component + presenter Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow> custom element (wraps <hyperframes-player>, drives the controller), presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook. * feat(studio): slideshow manifest persistence + panel helpers Stack-split from the original ss-studio PR (#1582): the data layer — setSlideshowManifest, the useSlideshowPersist hook, and panel helpers. The editor panel UI follows in the next PR. * feat(studio): slideshow branching editor panel UI Stack-split from the original ss-studio PR (#1582): the SlideshowPanel / SlideshowSubPanels editor UI, right-panel wiring, and app integration. * docs(skill): slideshow authoring guidance + standalone harness reference New /slideshow skill (island schema, slide rules, fragments, branching, validation) + a standalone-harness reference doc, and a router entry in the /hyperframes skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
04a775b58a
commit
b7a1163753
@@ -28,6 +28,7 @@ metadata:
|
|||||||
| You want to… | Go to |
|
| You want to… | Go to |
|
||||||
| ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- |
|
||||||
| **Make a video** (from a URL, brief, topic, GitHub PR, existing footage, or a single element to animate) | the **video router below** (§ Video routing) |
|
| **Make a video** (from a URL, brief, topic, GitHub PR, existing footage, or a single element to animate) | the **video router below** (§ Video routing) |
|
||||||
|
| **Author a slideshow / presentation / pitch deck** — discrete slides, fragments, branching, hotspots | `/slideshow` |
|
||||||
| **Author / edit an HTML composition** — the `data-*` contract, clips, tracks, sub-compositions, variables | `/hyperframes-core` |
|
| **Author / edit an HTML composition** — the `data-*` contract, clips, tracks, sub-compositions, variables | `/hyperframes-core` |
|
||||||
| **Animate** — atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU) | `/hyperframes-animation` |
|
| **Animate** — atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU) | `/hyperframes-animation` |
|
||||||
| **Creative direction** — `design.md`, palettes, typography, narration, beat planning, audio-reactive | `/hyperframes-creative` |
|
| **Creative direction** — `design.md`, palettes, typography, narration, beat planning, audio-reactive | `/hyperframes-creative` |
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
---
|
||||||
|
name: slideshow
|
||||||
|
description: >
|
||||||
|
Author a HyperFrames slideshow composition — a presentation, pitch deck, or
|
||||||
|
interactive deck with discrete slides, fragment reveals, branching sequences,
|
||||||
|
and hotspot navigation. Read when the request is to build or edit a slideshow,
|
||||||
|
presentation, or pitch deck as a HyperFrames composition.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Slideshow authoring contract
|
||||||
|
|
||||||
|
A HyperFrames slideshow is a normal HyperFrames composition — scenes, clips, GSAP timelines — with one extra ingredient: a **JSON island** that declares which scenes are slides and how they connect. The player's `SlideshowController` reads the island and turns the continuous GSAP timeline into a discrete, navigable deck.
|
||||||
|
|
||||||
|
**Read `/hyperframes-core` first** for the base composition contract (clips, tracks, `data-*` attributes, determinism rules). This skill covers only what is new: the island schema, slide writing rules, fragments, branching, validation, and the wrapping component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The two pieces
|
||||||
|
|
||||||
|
### 1. Scenes — declared the normal way
|
||||||
|
|
||||||
|
Every slide is backed by a scene. Declare scenes with `data-composition-id`, `data-start`, `data-duration`, and `data-label`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
data-composition-id="problem"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="8"
|
||||||
|
data-label="The problem"
|
||||||
|
data-width="1920"
|
||||||
|
data-height="1080"
|
||||||
|
>
|
||||||
|
<!-- clips go here -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch slides (reachable only via a hotspot, excluded from the main line) are declared exactly the same way — they just appear only in a `slideSequences` entry in the island, not in the main `slides` array.
|
||||||
|
|
||||||
|
### 2. The JSON island — one script block per composition
|
||||||
|
|
||||||
|
Add exactly one `<script type="application/hyperframes-slideshow+json">` block to the composition HTML. It holds all slideshow metadata:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="application/hyperframes-slideshow+json">
|
||||||
|
{
|
||||||
|
"slides": [...],
|
||||||
|
"slideSequences": [...]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
The island is the single source of truth for slide order, notes, fragment hold-points, hotspots, and branch sequences. Keep it near the top of the `<body>`, before the scene divs, so it is easy to find.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
### `SlideshowManifest` (the top-level island object)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slides": [
|
||||||
|
/* SlideRef[] — the main line, in order */
|
||||||
|
],
|
||||||
|
"slideSequences": [
|
||||||
|
/* SlideSequence[] — off-line branch sequences */
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `SlideRef`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sceneId": "problem",
|
||||||
|
"notes": "Lead with the pain, not the company.",
|
||||||
|
"fragments": [3.5, 5.2, 7.0],
|
||||||
|
"hotspots": [
|
||||||
|
/* SlideHotspot[] */
|
||||||
|
],
|
||||||
|
|
||||||
|
"ttsScript": null,
|
||||||
|
"ttsAudioUrl": null,
|
||||||
|
"ttsDurationMs": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
| ------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `sceneId` | yes | Must match a scene's `data-composition-id` exactly. The lint rule resolves both `data-composition-id` and `.clip[id]`. |
|
||||||
|
| `notes` | no | Presenter-only text. Never shown to the audience. |
|
||||||
|
| `fragments` | no | Array of times (seconds) within the slide's `[start, end]` range — see Fragments below. |
|
||||||
|
| `hotspots` | no | Interactive overlays that trigger a branch — see Branching below. |
|
||||||
|
| `startTime` | no | Optional. Override the matched scene's time bounds; defaults to the scene's start/end. |
|
||||||
|
| `endTime` | no | Optional. Override the matched scene's time bounds; defaults to the scene's start/end. |
|
||||||
|
| `ttsScript`, `ttsAudioUrl`, `ttsDurationMs` | no | **Reserved.** Schema fields exist but TTS playback is not yet wired. Omit unless you are pre-populating for a future build. |
|
||||||
|
|
||||||
|
### `SlideHotspot`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "h1",
|
||||||
|
"label": "How did we calculate this?",
|
||||||
|
"target": "market-deep-dive",
|
||||||
|
"region": { "x": 60, "y": 10, "w": 35, "h": 20 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `id` | yes | Unique within the slide. |
|
||||||
|
| `label` | yes | Tooltip / button text shown to the audience. |
|
||||||
|
| `target` | yes | Must match a `SlideSequence.id` in `slideSequences`. |
|
||||||
|
| `region` | no | Percentage-of-slide bounding box: `{x, y, w, h}` in `0–100`. Omit to render the hotspot as a full-slide labeled button instead. |
|
||||||
|
|
||||||
|
### `SlideSequence`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "market-deep-dive",
|
||||||
|
"label": "Market sizing methodology",
|
||||||
|
"slides": [{ "sceneId": "mkt-1" }, { "sceneId": "mkt-2" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`slides` inside a sequence uses the same `SlideRef` shape as the main line. Fragments and nested hotspots are allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slide writing rules
|
||||||
|
|
||||||
|
These are hard constraints, not suggestions. A slide that violates them will be outright replaced when a reviewer sees it.
|
||||||
|
|
||||||
|
- **Headline is a complete-sentence claim, not a label.** Write "SMBs spend 14 hours/week on manual scheduling" not "Scheduling problem". The sentence should stand alone if the visual is ignored.
|
||||||
|
- **One idea + one visual per slide.** If you are tempted to add a second bullet cluster or a second chart, split the slide.
|
||||||
|
- **Lead with the punchline.** The strongest point goes first — on the slide and in the deck order. Investors read left-to-right, top-to-bottom, and they stop.
|
||||||
|
- **Bottom-up market sizing only.** Never write "$50B TAM" without showing the math. Build from unit economics up: accounts × ACV, or transactions × take-rate.
|
||||||
|
- **Font minimum 30pt equivalent.** At 1920×1080, a headline is 72–96px; body copy is 48px. Never go below 40px for any text the audience must read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragments: reveal hold-points within a slide
|
||||||
|
|
||||||
|
A fragment is a time (in seconds) within a slide's `[start, end]` range where the controller pauses before the next reveal.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. Player enters the slide — seeks to `start`, then plays.
|
||||||
|
2. Controller pauses at `fragments[0]`. The first element's GSAP entrance has just landed.
|
||||||
|
3. User presses Next (or →) — plays to `fragments[1]`, pauses again.
|
||||||
|
4. After the last fragment, Next plays to `slide.end` and holds.
|
||||||
|
5. Next again advances to the next slide.
|
||||||
|
|
||||||
|
Fragment times must be strictly inside `[start, end]`. The lint rule rejects fragments outside that range.
|
||||||
|
|
||||||
|
Fragment times are **absolute composition-timeline positions** — the same coordinate space as `data-start` — not offsets relative to the scene's start.
|
||||||
|
|
||||||
|
Each fragment is a play-to-and-hold, not a seek jump — so every element that enters between the previous hold-point and this one plays its GSAP entrance animation. Design the clip entrance animations to work as sequential reveals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branching: hotspots and slide sequences
|
||||||
|
|
||||||
|
Branch slides are real scenes in the same composition timeline. They are listed only under `slideSequences` and are excluded from main-line navigation — the player never visits them unless a hotspot fires.
|
||||||
|
|
||||||
|
**Navigation model:**
|
||||||
|
|
||||||
|
- Clicking a hotspot pushes `{sequenceId, slideIndex: 0}` onto the nav stack and enters the branch's first slide.
|
||||||
|
- **back()** pops the stack and returns to the exact parent slide (the one that held the hotspot).
|
||||||
|
- **backToMain()** clears the entire stack and returns to the root slide.
|
||||||
|
- Breadcrumb renders from the stack: `Main deck › Market sizing methodology › Slide 2`.
|
||||||
|
- The slide counter inside a branch is scoped to that sequence (`1 of 2`, not the main-deck total).
|
||||||
|
|
||||||
|
**What to avoid:**
|
||||||
|
|
||||||
|
- Do not add branch scene IDs to the main `slides` array. They must appear only inside a `slideSequences` entry. The lint rule flags overlap.
|
||||||
|
- Branch scenes are included in the continuous timeline, so a naive linear video export would include them. Export reads main-line slides only (deferred; flagged in the spec).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worked example: 3-slide deck with fragments and a branch
|
||||||
|
|
||||||
|
### Scene HTML (skeleton)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<body style="margin: 0">
|
||||||
|
<script type="application/hyperframes-slideshow+json">
|
||||||
|
{
|
||||||
|
"slides": [
|
||||||
|
{
|
||||||
|
"sceneId": "hook",
|
||||||
|
"notes": "Open with the stat. Pause on the $40B number."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sceneId": "problem",
|
||||||
|
"notes": "Walk through each pain point one at a time.",
|
||||||
|
"fragments": [11.0, 15.0],
|
||||||
|
"hotspots": [
|
||||||
|
{
|
||||||
|
"id": "h1",
|
||||||
|
"label": "Where does the $40B figure come from?",
|
||||||
|
"target": "market-detail",
|
||||||
|
"region": { "x": 55, "y": 60, "w": 40, "h": 20 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sceneId": "solution",
|
||||||
|
"notes": "One sentence: what we do and who it is for."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"slideSequences": [
|
||||||
|
{
|
||||||
|
"id": "market-detail",
|
||||||
|
"label": "Market sizing methodology",
|
||||||
|
"slides": [{ "sceneId": "mkt-math", "notes": "Bottom-up: 2.3M SMBs × $17k ACV." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Slide 1 — hook -->
|
||||||
|
<div
|
||||||
|
data-composition-id="hook"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="6"
|
||||||
|
data-label="The hook"
|
||||||
|
data-width="1920"
|
||||||
|
data-height="1080"
|
||||||
|
style="position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #0a0a0a"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="clip"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="6"
|
||||||
|
data-track-index="1"
|
||||||
|
style="position: absolute; inset: 0; display: grid; place-items: center"
|
||||||
|
>
|
||||||
|
<h1 id="hook-headline" style="font-size: 80px; color: #fff; font-family: sans-serif">
|
||||||
|
SMBs lose $40B/year to manual scheduling
|
||||||
|
</h1>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide 2 — problem (3 fragments) -->
|
||||||
|
<div
|
||||||
|
data-composition-id="problem"
|
||||||
|
data-start="6"
|
||||||
|
data-duration="15"
|
||||||
|
data-label="The problem"
|
||||||
|
data-width="1920"
|
||||||
|
data-height="1080"
|
||||||
|
style="position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #0a0a0a"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="clip"
|
||||||
|
data-start="6"
|
||||||
|
data-duration="15"
|
||||||
|
data-track-index="1"
|
||||||
|
style="position: absolute; inset: 0; padding: 120px 160px; box-sizing: border-box"
|
||||||
|
>
|
||||||
|
<h2 id="pain-headline" style="font-size: 64px; color: #fff; font-family: sans-serif">
|
||||||
|
Three gaps operators can not close
|
||||||
|
</h2>
|
||||||
|
<p id="pain-1" style="font-size: 48px; color: #ccc; opacity: 0; font-family: sans-serif">
|
||||||
|
No-shows cost 23% of booked revenue
|
||||||
|
</p>
|
||||||
|
<p id="pain-2" style="font-size: 48px; color: #ccc; opacity: 0; font-family: sans-serif">
|
||||||
|
Manual reminders take 4h/week per staff
|
||||||
|
</p>
|
||||||
|
<p id="pain-3" style="font-size: 48px; color: #ccc; opacity: 0; font-family: sans-serif">
|
||||||
|
Rescheduling friction drives 40% churn
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slide 3 — solution -->
|
||||||
|
<div
|
||||||
|
data-composition-id="solution"
|
||||||
|
data-start="21"
|
||||||
|
data-duration="8"
|
||||||
|
data-label="The solution"
|
||||||
|
data-width="1920"
|
||||||
|
data-height="1080"
|
||||||
|
style="position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #0a0a0a"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="clip"
|
||||||
|
data-start="21"
|
||||||
|
data-duration="8"
|
||||||
|
data-track-index="1"
|
||||||
|
style="position: absolute; inset: 0; display: grid; place-items: center"
|
||||||
|
>
|
||||||
|
<h2 id="solution-headline" style="font-size: 72px; color: #fff; font-family: sans-serif">
|
||||||
|
Acme automates scheduling for service SMBs — no-shows down 80% in 90 days
|
||||||
|
</h2>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Branch slide — excluded from main line -->
|
||||||
|
<div
|
||||||
|
data-composition-id="mkt-math"
|
||||||
|
data-start="29"
|
||||||
|
data-duration="7"
|
||||||
|
data-label="Market math"
|
||||||
|
data-width="1920"
|
||||||
|
data-height="1080"
|
||||||
|
style="position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #111"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="clip"
|
||||||
|
data-start="29"
|
||||||
|
data-duration="7"
|
||||||
|
data-track-index="1"
|
||||||
|
style="position: absolute; inset: 0; display: grid; place-items: center"
|
||||||
|
>
|
||||||
|
<p id="mkt-formula" style="font-size: 56px; color: #fff; font-family: sans-serif">
|
||||||
|
2.3M SMBs × $17k ACV = $39B serviceable market
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
|
||||||
|
// Slide 2 fragment entrance animations
|
||||||
|
gsap.registerPlugin(); // load any plugins before use
|
||||||
|
|
||||||
|
const tl = gsap.timeline({ paused: true });
|
||||||
|
window.__timelines["problem"] = tl;
|
||||||
|
|
||||||
|
// Insert positions are absolute composition-timeline times (same as data-start / fragment values).
|
||||||
|
tl.from("#pain-1", { opacity: 0, y: 20, duration: 0.4 }, 11.0);
|
||||||
|
tl.from("#pain-2", { opacity: 0, y: 20, duration: 0.4 }, 15.0);
|
||||||
|
// pain-3 lands at end of slide
|
||||||
|
tl.from("#pain-3", { opacity: 0, y: 20, duration: 0.4 }, 13.0);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key points in the example
|
||||||
|
|
||||||
|
- The island `sceneId` values (`"hook"`, `"problem"`, `"solution"`, `"mkt-math"`) exactly match `data-composition-id` values on scene divs.
|
||||||
|
- `mkt-math` appears only in `slideSequences` — it is never in the top-level `slides` array.
|
||||||
|
- Fragment times (`11.0`, `15.0`) are within the `problem` scene's `[6, 21]` range (times are absolute composition-timeline positions).
|
||||||
|
- The hotspot `region` (`x: 55, y: 60, w: 40, h: 20`) positions the clickable area in the lower-right quadrant of the problem slide.
|
||||||
|
- GSAP timelines are registered on `window.__timelines` and are paused — the HyperFrames engine drives playback; do not call `.play()` at construction time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wrapping component
|
||||||
|
|
||||||
|
Wrap the composition in `<hyperframes-slideshow>` around `<hyperframes-player>` in any embedding context:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<hyperframes-slideshow>
|
||||||
|
<hyperframes-player src="deck.html"></hyperframes-player>
|
||||||
|
</hyperframes-slideshow>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<hyperframes-slideshow>` provides the navigation chrome (Prev / Next buttons, progress dots, breadcrumb, counter), keyboard handling (← / → and Space / Backspace), touch swipe, and hotspot overlays.
|
||||||
|
|
||||||
|
**Presenter mode:** the Present button calls `window.open('?mode=audience')` for a fullscreen audience window; the originating tab becomes the presenter view (current slide reduced, next-slide preview, notes, elapsed timer). Both windows sync via `BroadcastChannel('hf-slideshow')`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running a slideshow standalone (interim)
|
||||||
|
|
||||||
|
The **durable answer** is engine-hosted: `hyperframes preview --slideshow` / studio present mode will host the composition over the real HyperFrames engine, which drives seek-timelines, owns the gesture frame, and reads the island from the composition. That path is coming; prefer it once it ships.
|
||||||
|
|
||||||
|
Until then, standalone demos (a composition opened via the bare player bundle in a browser, without the engine) require workarounds for four gaps: the player does not drive GSAP seek-timelines, the island must be duplicated into the wrapper, audio must live in the parent frame, and animations must be self-driving. These patterns are documented in:
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/slideshow/references/standalone-harness.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not treat the patterns there as the blessed model — they exist only to bridge the gap until the engine-hosted path lands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After authoring or editing a slideshow composition, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx hyperframes lint
|
||||||
|
```
|
||||||
|
|
||||||
|
The slideshow lint rule checks:
|
||||||
|
|
||||||
|
- Every `slide.sceneId` resolves to an existing scene (by `data-composition-id`).
|
||||||
|
- Every `hotspot.target` references a defined `slideSequence` id.
|
||||||
|
- Fragment times fall within each slide's `[start, end]` range.
|
||||||
|
- No two main-line slides overlap in time.
|
||||||
|
|
||||||
|
Fix all violations before previewing. A composition that fails lint will not parse correctly in the player.
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
# 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 four facts:
|
||||||
|
|
||||||
|
1. The bare `<hyperframes-player>` does **not** drive GSAP/Three seek-timelines frame-by-frame — only the engine does. Animations that wait to be seeked stay at frame 0.
|
||||||
|
2. `<hyperframes-slideshow>` 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**. Any gesture-gated API (Audio, AudioContext) must live in the parent — an iframe without its own user activation is permanently autoplay-blocked.
|
||||||
|
4. Autoplay, Three.js, and entrance animations must be **self-driving** because the engine is not present.
|
||||||
|
|
||||||
|
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 (demo.html)
|
||||||
|
|
||||||
|
The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Deck — Slideshow Demo</title>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Load both bundles from packages/player/dist.
|
||||||
|
The global builds register <hyperframes-player> and <hyperframes-slideshow>
|
||||||
|
as custom elements — no import map needed.
|
||||||
|
-->
|
||||||
|
<script src="../../../packages/player/dist/hyperframes-player.global.js"></script>
|
||||||
|
<script src="../../../packages/player/dist/slideshow/hyperframes-slideshow.global.js"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
tabindex="0" is critical — <hyperframes-slideshow> binds keydown
|
||||||
|
(ArrowLeft/Right, Space, Backspace) to itself. Without tabindex the
|
||||||
|
element cannot receive focus and arrow keys are dead.
|
||||||
|
-->
|
||||||
|
<hyperframes-slideshow
|
||||||
|
tabindex="0"
|
||||||
|
style="display: block; position: relative; width: 100vw; height: 100vh"
|
||||||
|
>
|
||||||
|
<hyperframes-player
|
||||||
|
style="position: absolute; inset: 0"
|
||||||
|
src="index.html"
|
||||||
|
></hyperframes-player>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
DUPLICATED ISLAND — keep in sync with the island inside index.html.
|
||||||
|
<hyperframes-slideshow> reads from its own innerHTML, not from the
|
||||||
|
composition the player loads. Every time slides/fragments/hotspots/
|
||||||
|
sequences change in index.html, update this copy too.
|
||||||
|
-->
|
||||||
|
<script type="application/hyperframes-slideshow+json">
|
||||||
|
{
|
||||||
|
"slides": [
|
||||||
|
{ "sceneId": "scene-one", "notes": "..." },
|
||||||
|
{
|
||||||
|
"sceneId": "scene-two",
|
||||||
|
"notes": "...",
|
||||||
|
"fragments": [8.3, 8.6]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"slideSequences": [
|
||||||
|
{
|
||||||
|
"id": "branch-one",
|
||||||
|
"label": "Branch label",
|
||||||
|
"slides": [{ "sceneId": "branch-scene", "notes": "..." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</hyperframes-slideshow>
|
||||||
|
|
||||||
|
<!-- Audio player lives here — see Section 6 -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- In index.html (composition) -->
|
||||||
|
|
||||||
|
<!-- Shared scene CSS — all scenes start hidden -->
|
||||||
|
<style>
|
||||||
|
.scene-frame {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1920px;
|
||||||
|
height: 1080px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0; /* hidden at rest — visibility controller shows the active one */
|
||||||
|
pointer-events: none; /* inactive scenes must not swallow events */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
content-visible-at-rest: mark the first scene's elements with their
|
||||||
|
final non-hidden state so the deck is not blank before the controller
|
||||||
|
fires. The controller calls updateVisibility(0) synchronously on load.
|
||||||
|
|
||||||
|
If using Three.js canvas behind scenes, set background: transparent
|
||||||
|
here and let the 3D canvas + body color supply the fill.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
id="scene-cover"
|
||||||
|
class="scene-frame clip"
|
||||||
|
data-composition-id="cover"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="9"
|
||||||
|
style="background: transparent"
|
||||||
|
>
|
||||||
|
<!-- content here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="scene-problem"
|
||||||
|
class="scene-frame clip"
|
||||||
|
data-composition-id="problem"
|
||||||
|
data-start="9"
|
||||||
|
data-duration="9"
|
||||||
|
style="background: transparent"
|
||||||
|
>
|
||||||
|
<!-- content here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Root timeline — spans the full composition duration -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
var tl = gsap.timeline({ paused: true });
|
||||||
|
// A single to() for the full duration establishes the seekable range
|
||||||
|
tl.to({}, { duration: 108 }); // replace 108 with your total seconds
|
||||||
|
window.__timelines["root"] = tl;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Visibility controller -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var scenes = [
|
||||||
|
{ id: "scene-cover", start: 0, end: 9 },
|
||||||
|
{ id: "scene-problem", start: 9, end: 18 },
|
||||||
|
// ... all scenes including branch scenes
|
||||||
|
];
|
||||||
|
|
||||||
|
var lastActiveId = null;
|
||||||
|
|
||||||
|
function updateVisibility(t) {
|
||||||
|
for (var i = 0; i < scenes.length; i++) {
|
||||||
|
var s = scenes[i];
|
||||||
|
var el = document.getElementById(s.id);
|
||||||
|
if (!el) continue;
|
||||||
|
var active = t >= s.start && t < s.end;
|
||||||
|
el.style.opacity = active ? "1" : "0";
|
||||||
|
el.style.pointerEvents = active ? "auto" : "none";
|
||||||
|
|
||||||
|
if (active && lastActiveId !== s.id) {
|
||||||
|
lastActiveId = s.id;
|
||||||
|
fireEntrance(el); // see Section 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fragment reveals here — see Section 4
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__hfSetTime = updateVisibility;
|
||||||
|
|
||||||
|
// Show first slide immediately — avoids blank on load
|
||||||
|
updateVisibility(0);
|
||||||
|
|
||||||
|
// Hook the root timeline so every seek drives visibility
|
||||||
|
var root = window.__timelines && window.__timelines["root"];
|
||||||
|
if (root) {
|
||||||
|
root.eventCallback("onUpdate", function () {
|
||||||
|
updateVisibility(root.time());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
`<hyperframes-slideshow>` 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 `<hyperframes-slideshow sound>`
|
||||||
|
|
||||||
|
Audio **must** live in the parent page, not the composition iframe. Browsers enforce user-activation for AudioContext and HTMLAudioElement.play() — an iframe without its own activation (i.e., the user never clicked inside it) is permanently autoplay-blocked. The user's keypress lands on the parent, so the parent is the only frame that can get the activation token.
|
||||||
|
|
||||||
|
### Mute toggle — built-in chrome control
|
||||||
|
|
||||||
|
Add the `sound` boolean attribute to `<hyperframes-slideshow>` in demo.html. The component renders a speaker/speaker-muted SVG button as the **leftmost item in the nav capsule**, styled identically to the prev/next ghost buttons. No separate mute button in the composition.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<hyperframes-slideshow tabindex="0" sound style="..."> ... </hyperframes-slideshow>
|
||||||
|
```
|
||||||
|
|
||||||
|
The component:
|
||||||
|
|
||||||
|
- Tracks `muted` state (default `false`); exposes a `muted` getter
|
||||||
|
- Reflects to a `data-hf-muted` attribute on the host when muted
|
||||||
|
- Dispatches `CustomEvent("hf-sound", { detail: { muted }, bubbles: true, composed: true })` on every toggle
|
||||||
|
|
||||||
|
The parent audio player gates on the `hf-sound` event:
|
||||||
|
|
||||||
|
```js
|
||||||
|
var muted = false;
|
||||||
|
var slideshow = document.querySelector("hyperframes-slideshow");
|
||||||
|
if (slideshow) {
|
||||||
|
slideshow.addEventListener("hf-sound", function (e) {
|
||||||
|
muted = e.detail && e.detail.muted === true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// In message handler:
|
||||||
|
if (muted) return; // skip play
|
||||||
|
```
|
||||||
|
|
||||||
|
If `sound` is **not** present on `<hyperframes-slideshow>` (decks without audio), the mute control is hidden — the capsule shows only nav.
|
||||||
|
|
||||||
|
### Composition: post cues unconditionally
|
||||||
|
|
||||||
|
The composition posts sfx cues **unconditionally** — it does not track mute state. The parent gates on `muted`:
|
||||||
|
|
||||||
|
**In the composition (index.html):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Post an sfx cue at transition points — unconditionally.
|
||||||
|
// The parent audio player gates on the slideshow component's mute state.
|
||||||
|
function playSfx(name) {
|
||||||
|
try {
|
||||||
|
parent.postMessage({ type: "hf-sfx", name: name }, "*");
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire at scene transitions:
|
||||||
|
// playSfx("advance") — moving to the next main-line slide
|
||||||
|
// playSfx("back") — returning from a branch
|
||||||
|
// playSfx("branch-enter") — entering a branch
|
||||||
|
// playSfx("fragment") — a fragment item is revealed
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT add a mute button inside the composition. The `#sfx-mute` coral button pattern is removed; the nav capsule in the parent chrome owns mute.
|
||||||
|
|
||||||
|
**In the parent (demo.html):**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Audio elements are preloaded here, in the frame that receives user gestures.
|
||||||
|
var clips = {
|
||||||
|
advance: new Audio("sfx/advance.mp3"),
|
||||||
|
fragment: new Audio("sfx/fragment.mp3"),
|
||||||
|
"branch-enter": new Audio("sfx/branch-enter.mp3"),
|
||||||
|
back: new Audio("sfx/back.mp3"),
|
||||||
|
};
|
||||||
|
clips.advance.volume = 0.45;
|
||||||
|
clips.fragment.volume = 0.4;
|
||||||
|
clips["branch-enter"].volume = 0.4;
|
||||||
|
clips.back.volume = 0.4;
|
||||||
|
Object.keys(clips).forEach(function (k) {
|
||||||
|
clips[k].preload = "auto";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track mute state from the slideshow component's hf-sound event.
|
||||||
|
var muted = false;
|
||||||
|
var slideshow = document.querySelector("hyperframes-slideshow");
|
||||||
|
if (slideshow) {
|
||||||
|
slideshow.addEventListener("hf-sound", function (e) {
|
||||||
|
muted = e.detail && e.detail.muted === true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var unlocked = false;
|
||||||
|
|
||||||
|
function unlock() {
|
||||||
|
if (unlocked) return;
|
||||||
|
unlocked = true;
|
||||||
|
// Prime each clip: play muted then immediately pause/reset.
|
||||||
|
// This moves the clip into the "allowed" state so later plays are instant.
|
||||||
|
Object.keys(clips).forEach(function (name) {
|
||||||
|
var el = clips[name];
|
||||||
|
var v = el.volume;
|
||||||
|
el.volume = 0;
|
||||||
|
el.play()
|
||||||
|
.then(function () {
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
el.volume = v;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
el.volume = v;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock on the first user gesture in the parent frame.
|
||||||
|
window.addEventListener("keydown", unlock, true);
|
||||||
|
window.addEventListener("pointerdown", unlock, true);
|
||||||
|
window.addEventListener("click", unlock, true);
|
||||||
|
|
||||||
|
window.addEventListener("message", function (e) {
|
||||||
|
var d = e.data;
|
||||||
|
if (!d || d.type !== "hf-sfx") return;
|
||||||
|
// Gate on mute state — the component owns this.
|
||||||
|
if (muted) return;
|
||||||
|
var el = clips[d.name];
|
||||||
|
if (!el || !unlocked) return;
|
||||||
|
try {
|
||||||
|
el.currentTime = 0;
|
||||||
|
el.play().catch(function () {});
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sourcing SFX files:** use the HeyGen MCP `search_audio_sounds` tool with `type=sound_effects` and keywords like "whoosh", "click", "transition". Download the results to a local `sfx/` directory next to `demo.html` and reference them by relative path. Do not fetch SFX at render time — the HyperFrames determinism rule forbids runtime network requests; pre-download and commit them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Three.js (optional)
|
||||||
|
|
||||||
|
Add a Three.js scene behind the slides for ambient motion. The key rules:
|
||||||
|
|
||||||
|
- **Own rAF loop** — do not integrate with the HF seek timeline. Three.js runs its own `requestAnimationFrame` loop independent of playhead position.
|
||||||
|
- **One persistent canvas** — create the canvas once; update geometry/materials in-place per scene.
|
||||||
|
- **Guard renderer creation** — WebGL may be unavailable (software-GL environments, some CI contexts). Create the renderer inside try/catch once; if it fails, hide the canvas and expose no-op stubs. Do not spam `console.error` — silence it during creation and restore it in `finally`.
|
||||||
|
- **Full-bleed, behind content** — fix the canvas at `z-index: 0`, `pointer-events: none`, behind scene frames at `z-index: 1`.
|
||||||
|
- **Transparent scene frames** — set scene backgrounds to `transparent` so the 3D canvas shows through. Use a radial-gradient scrim on the text container (not the scene frame itself) to keep type legible while letting 3D show in the margins.
|
||||||
|
- **Expose a mood hook** — export `window.__threeApplyMood(sceneKey)` so the visibility controller can switch particle colors, toggle sub-objects, or change the clear color when the active scene changes.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// In index.html — Three.js setup (module script)
|
||||||
|
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
|
||||||
|
|
||||||
|
var canvas = document.getElementById("three-canvas");
|
||||||
|
var renderer = null;
|
||||||
|
var _err = console.error;
|
||||||
|
console.error = function () {}; // silence THREE's multi-line GPU error during init
|
||||||
|
try {
|
||||||
|
renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
|
||||||
|
} catch (e) {
|
||||||
|
// renderer stays null
|
||||||
|
} finally {
|
||||||
|
console.error = _err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderer) {
|
||||||
|
// Graceful degradation — branded layout is the fallback.
|
||||||
|
canvas.style.display = "none";
|
||||||
|
window.__threeApplyMood = function () {};
|
||||||
|
// Do NOT start the rAF loop.
|
||||||
|
} else {
|
||||||
|
renderer.setSize(1920, 1080);
|
||||||
|
renderer.setPixelRatio(1);
|
||||||
|
canvas.style.cssText =
|
||||||
|
"position:fixed;top:0;left:0;width:1920px;height:1080px;z-index:0;pointer-events:none";
|
||||||
|
|
||||||
|
var scene = new THREE.Scene();
|
||||||
|
var camera = new THREE.PerspectiveCamera(60, 1920 / 1080, 0.1, 1000);
|
||||||
|
camera.position.set(0, 0, 5);
|
||||||
|
|
||||||
|
// --- build your particle system, meshes, etc. here ---
|
||||||
|
|
||||||
|
// Mood config: map sceneId → visual state (colors, sub-object visibility, bg color)
|
||||||
|
var MOODS = {
|
||||||
|
cover: {
|
||||||
|
/* particle color, opacity, bg ... */
|
||||||
|
},
|
||||||
|
problem: {
|
||||||
|
/* ... */
|
||||||
|
},
|
||||||
|
// one entry per scene key
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__threeApplyMood = function (sceneKey) {
|
||||||
|
var m = MOODS[sceneKey] || MOODS["cover"];
|
||||||
|
// update geometry attributes, material opacity, sub-group visibility, etc.
|
||||||
|
};
|
||||||
|
window.__threeApplyMood("cover"); // apply initial state
|
||||||
|
|
||||||
|
// --- own rAF loop ---
|
||||||
|
var lastTime = null;
|
||||||
|
function animate(ts) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
if (lastTime === null) lastTime = ts;
|
||||||
|
var dt = Math.min((ts - lastTime) / 1000, 0.05);
|
||||||
|
lastTime = ts;
|
||||||
|
// update particles, rotate objects, etc.
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSS for transparent scene frames + scrim:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Three.js canvas — always behind everything */
|
||||||
|
#three-canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1920px;
|
||||||
|
height: 1080px;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scene frames are transparent so the 3D canvas shows through */
|
||||||
|
.scene-frame {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1920px;
|
||||||
|
height: 1080px;
|
||||||
|
background: transparent; /* NOT opaque — 3D would be occluded */
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrim on the TEXT container — not the scene frame.
|
||||||
|
Radial gradient: opaque in the center where text is, transparent at edges
|
||||||
|
so 3D shows in the whitespace margins. */
|
||||||
|
.slide-inner.scrim-light {
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse 75% 80% at 50% 50%,
|
||||||
|
rgba(255, 255, 255, 0.88) 30%,
|
||||||
|
rgba(255, 255, 255, 0.6) 65%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Foot-gun checklist
|
||||||
|
|
||||||
|
| Failure | Symptom | One-line fix |
|
||||||
|
| ----------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Island not duplicated in wrapper | Slideshow chrome never renders; no slide counter, no prev/next | Copy the `<script type="application/hyperframes-slideshow+json">` block verbatim into the `<hyperframes-slideshow>` element in demo.html |
|
||||||
|
| Audio in the iframe | All SFX silent | Move Audio elements and unlock logic to demo.html; post `{type:'hf-sfx',name}` from index.html |
|
||||||
|
| No self-clock in composition | All scene frames stacked / wrong slide visible at load | Add the root GSAP timeline (`window.__timelines["root"]`) and the `onUpdate` visibility controller as shown in Section 3 |
|
||||||
|
| Content opacity:0 with no engine | Blank slides — `[data-anim]` elements invisible at rest | Call `updateVisibility(0)` synchronously after defining the controller so the first slide is shown immediately |
|
||||||
|
| Keydown bound to the element without focus | ArrowLeft/Right dead | Add `tabindex="0"` to `<hyperframes-slideshow>` so it can receive keyboard focus |
|
||||||
|
| Opaque scene background occluding Three.js canvas | 3D never visible | Set `background: transparent` on `.scene-frame`; put the visual fill on the text scrim container instead |
|
||||||
|
| WebGL renderer creation spams errors in headless envs | Console noise, rAF loop starts anyway | Silence `console.error` during `new THREE.WebGLRenderer(...)`, restore in `finally`, guard the rAF start on `renderer !== null` |
|
||||||
|
| Branch scene missing from postMessage manifest | Hotspot navigates but slide is blank | Include every scene — main line and branch — in the `scenes` array of the `postTimeline()` message |
|
||||||
|
| Prominent 3D/content in nav-capsule zone | Bright element bleeds behind/beside the nav pill | Keep the bottom-right ~360×140px region clear; add a background-matching gradient overlay on any slide whose 3D mood is bright in that corner |
|
||||||
Reference in New Issue
Block a user