chore(deps,skills): bump Remotion 4.0.441→4.0.484; re-vendor HyperFrames skills at v0.7.17

## Remotion bump (mechanical, semver-safe)

remotion-composer: Remotion 4.0.441 → 4.0.484 (43 patch versions, all
within 4.0.x). Includes the seven core packages: remotion + @remotion/cli,
captions, google-fonts, media, player, transitions.

Smoke test: re-rendered the compound-snowball atelier composition through
the unchanged tool path; final_review status=pass, atelier checks clean.

Note: package.json also carries d3-geo@^3.1.1 forward — this line was
already in the working tree from prior unrelated WIP and is not part of
this bump. Removing it would leave package-lock mismatched, so it's
preserved as-is here; clean up separately when its consumer lands.

## HyperFrames skills re-vendor (0.4.2 → 0.7.17)

The runtime invoked by hyperframes_compose (`npx hyperframes`) was
already pulling 0.7.17 on every render, but the vendored skill docs the
agent reads were frozen at 0.4.2-era. This commit closes that gap.

Re-vendored from upstream commit 3351fb1a (tag v0.7.17, 2026-06-27):

  Re-vendored core 4 (restructured upstream):
  - hyperframes        (slim entry; deep content moved to focused skills)
  - hyperframes-cli    (1 → 7 files; covers validate/inspect/snapshot/
                        benchmark/lambda natively, dropping the obsolete
                        OM-local validate patch)
  - hyperframes-registry
  - website-to-video   (renamed upstream from website-to-hyperframes)

  Newly vendored (8 strategic additions in 0.5–0.7):
  - hyperframes-core        composition contract (data-*/tracks/sub-comps)
  - hyperframes-creative    palette, type, narration, beat planning
  - hyperframes-media       TTS, BGM, SFX, transcription, captions, bg-remove
  - hyperframes-animation   all motion knowledge (rules, blueprints,
                            transitions, 7 runtime adapters)
  - media-use               agent Media OS (one `resolve` verb for
                            BGM/SFX/image/icon; project + global cache)
  - motion-graphics         short design-led motion patterns
  - remotion-to-hyperframes migration guidance (directly relevant since
                            OpenMontage runs both runtimes)
  - music-to-video          beat-synced video using `hyperframes beats`

Intentionally NOT vendored (HF-workflow-specific; would compete with
OpenMontage pipeline routing): embedded-captions, faceless-explainer,
general-video, pr-to-video, product-launch-video, slideshow,
talking-head-recut. Re-evaluate per pipeline need.

PROVENANCE.md refreshed with the new vendor point + re-sync instructions.

## GSAP CDN pin

.agents/skills/hyperframes/SKILL.md: gsap@3.14.2 → gsap@3 (auto-latest 3.x
on jsdelivr; avoids future drift without breaking the API surface).

## Doctrine updates routing to new skill structure

- skills/INDEX.md — HyperFrames row expanded to enumerate the 12 vendored
  skills and their roles.
- skills/meta/animation-runtime-selector.md — runtime decision matrix
  updated for the rename (website-to-hyperframes → website-to-video) and
  three new rows added: beat-synced music videos, Remotion→HF porting,
  and the media-use resolve verb. The HyperFrames composition row in the
  animation-library matrix split into four (core/creative/media/animation)
  per the upstream skill structure.
- skills/core/hyperframes.md — Layer-2 routing skill rewritten to point at
  the new focused skills and all website-to-hyperframes references renamed.
This commit is contained in:
calesthio
2026-06-27 14:00:00 -07:00
parent 9a676d6997
commit 46d5514a0a
543 changed files with 79444 additions and 2758 deletions
+197
View File
@@ -0,0 +1,197 @@
---
name: music-to-video
description: "Use when the user has a music track (an audio file, or a video to pull audio from) and wants a beat-synced HyperFrames video, calm to hard-hitting. The music drives everything: one analyzer reads it once, the orchestrator lays out the frames and fills a per-frame plan, and one sub-agent builds each frame. Typography and templates are the floor — a complete video needs zero assets — but any images or videos the user supplies are cut into the frames on the same beat grid (beat-cut / ken-burns). The genre (lyric video, slideshow, kinetic promo) falls out of the per-frame choices; the pipeline never branches on it."
---
# music-to-video — one music-grounded, beat-synced video workflow
Use this skill to turn a **music track** into a beat-synced HyperFrames video. You analyze the track once, lay out the frames, fill in a per-frame plan, and build each frame as a composition. The input is a music track plus optional user images or videos — there is **no narration and no website capture**. Typography and templates are the floor (a complete video needs zero assets); any media the user supplies is cut in on the same beat grid.
You are the **orchestrator**. Work in `videos/<project>/`. Run the steps in order and pass each **Gate** before moving on. Two steps need the user: **Step 3** (plan approval) and **Step 6** (render approval). Do every step yourself except **Step 4**, where you dispatch **one sub-agent per frame**. Keep design and motion rules out of this file — they live in `references/` and the `frame-worker` sub-agent.
`SKILL_DIR` = this skill directory. `PROJECT_DIR` = `videos/<project-name>/`.
Workflow: Step 0 setup → `hyperframes.json` + `assets/bgm.mp3`; Step 1 analyze → `audiomap.json`; Step 2 skeleton → `STORYBOARD.md` (frames, groups `TBD`); Step 3 plan → complete `STORYBOARD.md` + `frame.md`; Step 4 build → `compositions/frames/NN-*.html`; Step 5 assemble → `index.html`; Step 6 render → `renders/video.mp4`.
## Two ideas that shape everything
- **One analyzer, and you trust it.** `analyze-beatgrid.py` is the only beat analyzer — never re-measure beats with another tool or by ear. Its energy / density / rolls / onsets / silences are always reliable. Its `bpm` and `beats_sec` are reliable **only when the music is genuinely rhythmic**; on calm music the grid is a metronome the tracker imposed, so pace by phrases and energy instead and never hard-cut to it. Deciding which case you're in is each frame's `pacing` (Step 2).
- **One frame = one file; groups live inside.** Step 2 cuts the track into **frames**, and each frame becomes one composition file `compositions/frames/NN-<frame_id>.html`, built by one frame-worker. A frame can subdivide into **groups** (each a template or a motion-primitives combo). Extra density goes _inside_ a group, so **frame count tracks distinct treatments, not beats** — a fast track does not blow up the number of sub-agents.
---
## Step 0: Setup, BGM, and inputs
Goal: Establish the music source, create the HyperFrames project, and note any user-supplied media.
The **music is the spine** — establish one track before anything else. This skill is tuned for **fast, high-energy BGM**: a strong beat grid drives the cuts (calm tracks work, but pace by phrase rather than beat). If the user gave you audio — a music file, or a video to pull the audio from — use it. If not, generate one: choose the mood from the user's description (e.g. "driving synthwave", "trap beat", "upbeat corporate") and produce a track via `/hyperframes-media` (`references/bgm.md` — HeyGen retrieval when credentialed, else local Lyria / MusicGen; ElevenLabs or another generator also works). Before generating, run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it)** — it shows whether BGM comes from HeyGen or local MusicGen and, if not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or continue offline with local MusicGen — before generating the track**; don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `/hyperframes-media` → Preflight for the canonical guidance. Either way the track lands at `assets/bgm.mp3`. Stage any user-supplied images or videos so frames can weave them in on the beat grid; otherwise typography carries the whole video.
Initialize only if `hyperframes.json` is missing. Name `<project>` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.
```bash
npx hyperframes init "videos/<project>" --non-interactive --example=blank
mkdir -p "$PROJECT_DIR/assets" "$PROJECT_DIR/renders"
cp "<user-music>" "$PROJECT_DIR/assets/bgm.mp3" # extract from a video first if needed
# only if the user gave you images/videos:
node <SKILL_DIR>/scripts/stage-assets.mjs --from <dir> --hyperframes "$PROJECT_DIR" --into public
```
The **brand** (font + palette) is chosen at Step 3, not here. Don't pick a genre or a track type up front — assets are just an optional ingredient, and the genre emerges from the per-frame choices.
**Gate:** `hyperframes.json` + `assets/bgm.mp3` exist; aspect / length / fps and (if any) the asset inventory are noted.
---
## Step 1: Analyze the music
Goal: Produce the one canonical timing analysis the whole video is built on.
`analyze-beatgrid.py` is the **only** beat analyzer — never re-measure beats with another tool or by ear. It reads the track once and writes `audiomap.json`: energy phases (level / density / feel), onsets + `onset_rate`, rolls, silences, `hard_stops`, `key_moments`, phrases, tempo / grid, and `audio.duration_sec`. It's deterministic — the same file always gives the same map. Most fields are reliable on any music; `bpm` and `beats_sec` are reliable only when the music is genuinely rhythmic, and judging that is the call you make at Step 2.
Prerequisites: Python 3 with `librosa`, `numpy`, and `soundfile` available. If import fails, install them into the active Python environment before running the analyzer:
```bash
python3 -m pip install librosa numpy soundfile
```
```bash
python3 <SKILL_DIR>/scripts/analyze-beatgrid.py "$PROJECT_DIR/assets/bgm.mp3" \
-o "$PROJECT_DIR/audiomap.json" --print
```
**Gate:** `audiomap.json` exists; `audio.duration_sec` is known.
---
## Step 2: Frame skeleton (structure only)
Goal: Read the music and lay out the frames — the skeleton of `STORYBOARD.md`.
Read [`references/frame-skeleton.md`](references/frame-skeleton.md). Turn `audiomap.json` into the **skeleton** of `STORYBOARD.md` yourself — there is no intermediate JSON. Cut the track into **frames** at real musical changes (`hard_stops`, SURGE / DROP `key_moments`, the edges of a roll, a stretch with no onsets, a big energy jump), snapping every boundary to an audiomap anchor. For each frame set `span_sec`, `pacing` (the verdict from Step 1's trust call — `beat_cut` when the grid is real, `phrase_flow` when it's a metronome imposed on calm music), `mood`, and a one-line `feel` (the plain music situation Step 3 matches a template against). Only classify and lay out here: leave every frame's `### Groups` as `TBD (Step 3)` and the frontmatter `style` blank — no templates, copy, color, or fonts. Expect ~16 frames.
**Gate:** frames tile the track (first at 0, last at `duration_s`); each carries `span_sec` + `pacing` + `mood` + `feel`; every `### Groups` is `TBD`; no content anywhere.
---
## Step 3: Fill the plan (user-gated)
Goal: Turn the skeleton into an approved, complete `STORYBOARD.md`.
Read [`references/planning.md`](references/planning.md), [`storyboard-format.md`](references/storyboard-format.md), [`template-catalog.md`](references/template-catalog.md), [`motion-primitive-catalog.md`](references/motion-primitive-catalog.md), and [`montage.md`](references/montage.md) (only if the user supplied assets). Editing the same file in place, do two things:
1. **Pick the brand.** Choose one preset from `../hyperframes-creative/frame-presets/` using the table in `../hyperframes-creative/references/design-spec.md` (match the track's mood; **only its fonts and colors matter** — templates own composition). Copy it into `frame.md` **unmodified** and fill the frontmatter `style` (font + a ≤46 swatch palette) from it.
2. **Fill every frame.** Decide its groups and give each a treatment: a matched template from the catalog (with bound params and real audiomap anchors), a free-compose from the primitive catalog, or an asset treatment that **obeys `pacing`**. Write the copy. You own WHAT (template / primitives + content + anchors); the frame-worker owns HOW — **never write millisecond tweens into the storyboard**.
```bash
node <SKILL_DIR>/scripts/validate-plan.mjs --storyboard "$PROJECT_DIR/STORYBOARD.md" \
--audiomap "$PROJECT_DIR/audiomap.json" --templates <SKILL_DIR>/references/templates
```
Fix every `✗` (hard errors: duration mismatch, frames not tiling the track, a missing `src`); warnings are best-effort. Then show the user a frame-by-frame summary and iterate until they approve.
**Gate:** `frame.md` is a verbatim preset copy; `validate-plan.mjs` exits 0; the user approved the plan.
---
## Step 4: Build frames from the plan
Goal: Build every frame as a self-contained composition file.
Create `compositions/frames/`. Read [`sub-agents/frame-worker.md`](sub-agents/frame-worker.md) and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch **one frame-worker per frame**, in parallel where possible (otherwise in waves). Each worker gets exactly one frame and this context:
```text
PROJECT_DIR: <abs path>
frame_id: <NN-frame_id> # = the frame file stem, e.g. 02-f2; the composition id
Your block: the `## Frame N — <frame_id>` block in PROJECT_DIR/STORYBOARD.md
audiomap: PROJECT_DIR/audiomap.json
frame.md: PROJECT_DIR/frame.md
Materials: for each group, <SKILL_DIR>/references/templates/<id>/index.html (templates) and
<SKILL_DIR>/references/motion-primitives/<id>/ (free); staged assets/ (asset groups)
Contracts: ../hyperframes-core/references/sub-compositions.md + determinism-rules.md
Canvas: <w>×<h> Pacing: <beat_cut|phrase_flow>
Write to: PROJECT_DIR/compositions/frames/<frame_id>.html
```
The worker forks the cited materials, converts every anchor to frame-local seconds (`local_t = track_t span_sec[0]`), gates its groups with 0ms cuts, and writes one seek-safe frame file. **The worker never runs the `hyperframes` CLI** — those commands operate on the assembled project, which doesn't exist yet, so they'd report on the wrong files. The worker just writes to the contract and stops; you verify after assembly (Step 6). As each worker returns, you can confirm its file landed on disk.
**Gate:** every frame has its `compositions/frames/NN-*.html` on disk.
---
## Step 5: Assemble
Goal: Wire the built frames + BGM into the playable `index.html`.
`assemble-index.mjs` is deterministic — no subagent, no judgment. It references each frame file at its cumulative `data-start`, mounts `assets/bgm.mp3` on track 11, and hard-cuts frame → frame (frames tile the track with no gaps, so there is **no transition injector**).
```bash
node <SKILL_DIR>/scripts/assemble-index.mjs --storyboard "$PROJECT_DIR/STORYBOARD.md" \
--hyperframes "$PROJECT_DIR" --audiomap "$PROJECT_DIR/audiomap.json"
```
Fix any `✗` it reports — a missing or blank frame file means that worker wrote a partial file; re-dispatch it (Step 4) and re-assemble.
**Gate:** `index.html` exists; total duration == `audiomap.audio.duration_sec`.
---
## Step 6: Verify and render
Goal: Verify the assembled video, get user approval, and render the final MP4.
Run the CLI on the **assembled project** — that's the correct unit (the per-frame workers couldn't run it). `lint` checks structure, `validate` runs headless Chrome (catching JS errors and missing assets), `inspect` snapshots frames.
```bash
( cd "$PROJECT_DIR" && npx hyperframes lint . && npx hyperframes validate . && npx hyperframes inspect . )
```
Inspect at `t=0`, each frame start, the strongest DROP / SURGE, every `hard_stops[].t`, and the final frame. On failure, make the **cheapest safe fix** yourself: edit the offending `compositions/frames/NN-*.html`. Never change duration or audio timing to hide a sync issue. Once the gates pass, pause for user review, then render only on approval:
```bash
( cd "$PROJECT_DIR" && npx hyperframes render . --skill=music-to-video -q draft -o renders/video.mp4 --fps 30 )
```
**Gate:** `lint` / `validate` / `inspect` passed; the user approved; `renders/video.mp4` exists with audio, duration == `audiomap.audio.duration_sec`. The final reply states the MP4 path and duration.
---
## Resume table
| You have | Continue from |
| -------------------------- | ------------- |
| `assets/bgm.mp3` only | Step 1 |
| `audiomap.json` | Step 2 |
| `STORYBOARD.md` (skeleton) | Step 3 |
| `STORYBOARD.md` (complete) | Step 4 |
| all frame files | Step 5 |
| `index.html` | Step 6 |
## Quick Reference
**Formats:** landscape `1920x1080` by default; portrait `1080x1920`; square `1080x1080`. Set the canvas once in the storyboard frontmatter (`canvas: { w, h, fps }`).
**Scripts** under `scripts/`: `analyze-beatgrid.py` (the one analyzer), `validate-plan.mjs` (plan check), `assemble-index.mjs` (index assembly), `stage-assets.mjs` (stage user media), `lib/storyboard.mjs` (vendored parser). Everything else is the `hyperframes` CLI.
| Read | When |
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| [`references/frame-skeleton.md`](references/frame-skeleton.md) | Step 2: read the music, lay out the frames, set pacing |
| [`references/planning.md`](references/planning.md) · [`storyboard-format.md`](references/storyboard-format.md) | Step 3: pick the brand, fill each frame, write the plan |
| [`references/template-catalog.md`](references/template-catalog.md) | Step 3: pick a template per group |
| [`references/motion-primitive-catalog.md`](references/motion-primitive-catalog.md) | Step 3/4: L0 recipes for free-compose |
| [`references/montage.md`](references/montage.md) | Step 3/4: asset treatments (beat-cut / ken-burns) |
| [`sub-agents/frame-worker.md`](sub-agents/frame-worker.md) | Step 4: dispatch + build one frame |
| `../hyperframes-core/references/subagent-dispatch.md` | Step 4: dispatch sub-agents safely |
| `../hyperframes-creative/references/design-spec.md` | Step 3: pick the preset (the brand) |
## Directory layout
```
music-to-video/
SKILL.md
references/ frame-skeleton.md · planning.md · storyboard-format.md
template-catalog.md · motion-primitive-catalog.md · montage.md
templates/<id>/ { index.html (+ assets/ · program.json) } ← L1 catalog impls
motion-primitives/<id>/ { index.html } (+ ../assets/gsap.min.js shared by recipes) ← L0 catalog impls
scripts/ analyze-beatgrid.py · assemble-index.mjs · validate-plan.mjs · stage-assets.mjs · lib/storyboard.mjs
sub-agents/ frame-worker.md ← the one subagent (one per frame)
```
@@ -0,0 +1,100 @@
# Frame skeleton (Step 2) — read the music, lay out the frames
At Step 2 **you (the orchestrator)** read `audiomap.json` and write the **skeleton** of
`STORYBOARD.md` directly: cut the track into **frames** (one frame = one composition file =
one scene), and for each frame set its **span**, its **pacing** (does this stretch want hard
beat-cuts, or calm phrase/energy flow?), its **mood**, and a one-line **feel** note.
You **classify and lay out the spine only.** You do **not** pick templates, write copy, choose
colors/fonts, or decide a frame's groups — those are Step 3 (the plan fills each frame in
place). Leave every frame's `### Groups` as `TBD (Step 3)` and the frontmatter `style` blank.
There is **no intermediate JSON** — the skeleton _is_ the start of `STORYBOARD.md`. Step 3
edits the same file.
## The trust boundary (read this first)
`audiomap.json` is one analyzer's output. Some fields are robust on **any** music; some are
reliable only when the music is **actually rhythmic**. This decides each frame's `pacing`:
| Field | Trust |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `energy_phases[]` (level / energy / density / feel), `events[]` + `onset_rate`, `rolls[]` (and their **absence**), `silences[]`, `hard_stops[]`, `key_moments[]`, `phrases[]`, `audio.duration_sec` | **Always** — robust measurements |
| `tempo.bpm`, `grid.beats_sec` / `downbeats_sec` **precision** | **Only when the music is rhythmic.** On calm / sparse material the beat grid is a metronome the tracker _imposes_ (often octave-doubled) — usually **more grid beats than real onsets**. Do **not** anchor cuts to it there. |
- **Grid is reliable** when: rolls present, and/or dense phases, and/or high `onset_rate` with a steady grid.
- **Grid is fictional** when: `rolls`≈0, mostly `sparse` phases, low `onset_rate` → pace by `phrases[]` + `energy_phases[]`, not beats.
## How to lay out frames (run in order)
1. **Gestalt.** From `summary` / `tempo` / `audio.duration_sec` + the roll count, write the
frontmatter `compositionId`, `duration_s` (== `audio.duration_sec`), `canvas`, and a
one-line read of the track's density arc.
2. **Cut into frames.** Walk `energy_phases[]` and split where the music genuinely changes
state — at `hard_stops[]`, `SURGE` / `DROP` `key_moments`, the start/end of a `rolls[]` run,
an **onset desert** (a long gap in `events[]`), or a big energy-level jump. Collapse adjacent
phases that are one gesture. Expect **~16 frames**; a short clip may be one.
**Snap every boundary to an audiomap anchor**, then re-snap to the nearest `beats_sec`
(tolerance ≤ ½ beat) **only when the grid is reliable**; on calm material snap to
`phrases[]` / `energy_phases[]` edges instead. Frames **tile the track** (first at 0, last
at `duration_s`, no gaps/overlaps).
3. **Per frame, set `pacing`** — the trust-boundary call:
- **`beat_cut`** — genuinely rhythmic: a roll present, **or** dense, **or** (clearly high `onset_rate` **and** a steady grid). Hard cuts / per-onset reveals may anchor to beats.
- **`phrase_flow`** — calm / sparse: `rolls`≈0, mostly sparse, low `onset_rate`. Do **not** anchor hard cuts to the grid; pace by `phrases[]` + the energy envelope (slow crossfades, long holds).
4. **Per frame, tag `mood`** (13 of: `warm` · `dark` · `hype` · `elegant` · `glitch` ·
`cinematic` · `playful` · `tense` · `dreamy` · `aggressive`) from `energy_phases[].feel` +
energy + any genre cue in the brief/title.
5. **Per frame, write a one-line `feel`** — the plain-language music situation Step 3 matches a
template against (e.g. "accelerating onset stream into a held downbeat", "calm held pad, one
onset desert", "fast sustained-fill roll, no readable message"). This is what the planner
reads against the catalog's **Reach for it when** — keep it concrete, drawn from the robust
fields, never invented.
## What the skeleton looks like
A valid `STORYBOARD.md` with the spine set and every frame's treatment left for Step 3
(full syntax in [`storyboard-format.md`](storyboard-format.md)):
```markdown
---
compositionId: bgm
duration_s: 30.0 # == audiomap.audio.duration_sec
canvas: { w: 1920, h: 1080, fps: 30 }
style: # blank — Step 3 fills it from the chosen frame.md preset
build_notes: ["one paused timeline per frame", "no remote assets"]
---
## Frame 1 — f1
- src: compositions/frames/01-f1.html
- duration: 7.198s # = span length; assembler sums these for cumulative data-start
- span_sec: [0.0, 7.198] # track seconds; frames tile the track
- pacing: beat_cut
- mood: [hype]
- feel: accelerating onset stream building into a held downbeat
### Groups
- TBD (Step 3)
## Frame 2 — f2
- src: compositions/frames/02-f2.html
- duration: 10.4s
- span_sec: [7.198, 17.598]
- pacing: phrase_flow
- mood: [warm, cinematic]
- feel: calm held pad, one long onset desert
### Groups
- TBD (Step 3)
```
## Self-check
- `duration_s == audiomap.audio.duration_sec`; frames tile the track gap-free (first at 0, last at `duration_s`).
- Every frame has `src` + `span_sec` + `duration` + `pacing` + `mood` + a one-line `feel`.
- `pacing` was set from the **robust** fields (energy / density / rolls / onset_rate), never from `bpm` / `beats_sec` alone.
- No frame boundary sits inside a `rolls[]` run or leaves a sub-1-bar fragment.
- Every frame's `### Groups` is `TBD (Step 3)`; `style` is blank. **No template, copy, color, or font anywhere.**
@@ -0,0 +1,58 @@
# Asset treatments — weaving user media onto the beat spine
When the user supplies images/videos, a group can be an **asset treatment** instead of a
typographic template/free-compose. Assets are an **additive ingredient on the same beat
spine** — never a separate pipeline. Typography/templates stay the floor: if no asset fits a
group, fall back to a template/free group (a complete video needs zero assets).
The planner (Step 3) picks the treatment and the clips + anchors (WHAT); the frame-worker
realizes it inside the frame file (HOW). **Obey the frame's `pacing`.**
## The three treatments
### `beat_cut` — one clip per anchor (only on a `beat_cut` frame)
The asset-driven analogue of a per-onset typographic group: cut to a new clip on each anchor
(the frame's beats/onsets from the audiomap). Each clip is a `class="clip"` element
(`<img>` for a photo, **muted** `<video>` for a motion clip) placed at its anchor with
`data-start`/`data-duration`/`data-track-index` per the core clip contract. Between clips,
crossfade the outgoing content to `opacity:0` ending **at** the next anchor, then immediately
**hard-kill** with a `tl.set(..., {opacity:0}, anchor)` — this pair is required (the
`gsap_exit_missing_hard_kill` lint rule; non-linear seeking otherwise bleeds stale frames).
Cut on the **strong** anchors; land a hero clip on a `key_moment`/downbeat.
### `ken_burns` — slow push on one clip (fits a `phrase_flow` frame)
For calm frames: one clip held over the span with a slow scale/translate push (e.g. scale
1.0→1.08 + a small drift) eased across the whole `span_sec` — paced by the frame, not by
beats. No hard cuts. Crossfade in/out at the frame edges. This is the right asset treatment
when the beat grid is unreliable (calm music).
### `bg_under_text` — clip dimmed behind a template/free group
A full-bleed clip dimmed ~3050% as the background of a group whose foreground is a template
or free-compose typographic treatment. The text rides on the same anchors; the clip is the
bed. Use when the user wants their footage present but the message must stay readable.
## Rules
- **`pacing` decides the treatment**: `beat_cut` only on a `beat_cut` frame; on a
`phrase_flow` frame use `ken_burns` or a slow crossfade — **never** per-onset hard cuts on
the (unreliable) calm grid.
- **Clips are muted; the root owns audio.** Mount each `<video class="clip">` **muted**, as a
direct child of the frame root (never nested in another timed element, or the renderer
freezes it). The BGM is the only audio in v1.
- **Crossfades animate `opacity`/`autoAlpha`**, never `visibility`/`display` on a `.clip`
(the framework owns clip visibility — that trips `gsap_animates_clip_element`).
- **Backgrounds dim ~3050%** so any foreground text stays legible.
- Anchors are **track seconds from `audiomap.json`**; the worker subtracts the frame start
to get frame-local time.
- Local staged assets only (`assets/` via `stage-assets.mjs`); never remote URLs.
## Deferred hook (not v1)
A clip that should play **its own sound** (interview cut, lyric clip) needs a sibling
`<audio>` mounted at the **root** by the assembler, with the BGM ducked under it (timeline
volume automation: `tl.to("#el-bgm",{volume:0.15,…},clipStart)``tl.to(…,{volume:0.9,…},
clipEnd)`). The frame-worker mounts no audio. Keep clips muted in v1; wire clip-audio +
ducking only when the user asks.
@@ -0,0 +1,75 @@
# Motion-primitive catalog — the free-compose menu
The atomic layer: one anchor → one micro-move. When no template fits a group, free-compose by
naming primitives from here. Scan **anchor** + **best span** + **what it does**, then pick the
smallest set that carries the group.
## Timing & latency (applies to every primitive)
- **Hard hits are 0ms.** Cuts, palette flips, content swaps, freezes are `tl.set(...)` with no duration — the percussion _is_ the motion. Easing a hit kills it.
- **Lead the anchor.** A move that must _land_ on a beat (a wipe covering the frame, a count-up locking, two blocks colliding) starts **~40190ms early** so it completes ON the anchor. Reactive entrances (something appearing _because_ of the hit) fire 045ms after.
- **Eased entrances: 300500ms** (scale punch, slides, camera pushes). **Macro builds: 8002000ms** spanning a whole roll / silence.
- **Per-bar caps:** one accumulating element per hit (not a burst); a camera move at most once per phrase, never per beat; a dense flip/strobe system runs ≤23s.
- **Tension-builds lock.** A count-up / sequential build / morph must _resolve on_ a downbeat or hard_stop, never trail off mid-bar.
- **Best span means active motion.** The catalog's span guidance is not a license to stretch one primitive over a whole frame. If a free-composed group runs longer than the listed span, add a hold / bed / next primitive, or split the frame into another group at the next musical anchor.
## Catalog
| id | anchor | best span | what it does |
| --------------------- | -------------------------------- | ----------------- | ------------------------------------------------------------------------- |
| `hypercut-whip` | beat / hard_stop | 0.18-0.45s | fast whip-pan hard cut between frames |
| `kinetic-letter-in` | downbeat / phrase | 0.4-1.2s | per-letter kinetic entrance |
| `braam-punch` | drop / surge | 0.2-0.9s active | big impact: scale + weight slam |
| `chromatic-split` | snare / glitch / surge | 0.1-0.6s | RGB channel split / glitch on a word |
| `mask-reveal` | section_start / downbeat | 0.5-1.2s | clip-path mask wipe reveal |
| `screen-shake` | drop / crash / kick | 0.1-0.5s | camera / screen shake jitter |
| `binary-decrypt` | roll / build | 0.8-2.5s | scramble→decode text (binary → word) |
| `dolly-zoom` | phrase / build | 1.2-2.5s | vertigo dolly-zoom (scale vs perspective) |
| `iris-open` | section_start / reveal | 0.6-1.2s | circular iris-open reveal |
| `electric-arc` | accent / glitch | 0.1-0.6s | electric arc / lightning accent |
| `neon-flicker` | hold / texture | 0.5-2.5s | neon-sign flicker |
| `chrome-sweep` | downbeat / reveal | 0.6-1.4s | metallic specular sweep across text |
| `slot-machine-reveal` | roll → downbeat | 0.8-2.0s | slot-machine spin-to-land character reveal |
| `liquid-morph` | phrase / transition | 1.0-2.5s | liquid / blob morph |
| `gooey-metaball` | build / drop | 1.5-3.0s | gooey metaball merge field |
| `3d-card-flip` | downbeat / swap | 0.8-1.6s | 3D card flip (rotateY) |
| `crash-zoom-in` | drop / surge | 0.2-0.8s | violent crash zoom-in |
| `spotlight-sweep` | reveal / hold | 0.8-2.0s | spotlight / gradient sweep over text |
| `outline-to-fill` | downbeat / reveal | 0.8-1.8s | stroke outline → solid fill |
| `counting-punch` | roll → downbeat | 1.0-2.5s | number count-up that punches & locks |
| `particle-burst` | drop / crash | 0.2-1.2s | particle explosion burst |
| `radial-burst-lines` | drop / surge | 0.2-0.8s | radial speed-lines burst |
| `pixel-dissolve` | transition / hard_stop | 0.5-1.5s | pixelated dissolve |
| `datamosh-smear` | glitch / transition | 0.4-1.2s | datamosh / motion smear |
| `text-wave-distort` | hold / texture | 1.0-2.5s | wavy text distortion |
| `bg-flow-field` | energy / whole span (bed) | 4-12s bed | generative curl-noise background bed; compose any foreground move over it |
| `blur-resolve` | stop / final hold | 0.7-2.0s | blur-in to crisp focus, then blur-out on the cut |
| `chromatic-pressure` | snare / glitch | 0.1-0.5s | RGB split / digital tension on a transient |
| `color-grid-shuffle` | onset | 0ms hits; ≤2s run | grid of cells recolored by a deterministic index per onset |
| `content-swap` | beat | 0ms hits; ≤3s run | 0ms swap of stacked nodes: the workhorse percussive move |
| `directional-fill` | beat / reveal | 0.3-1.0s each | directional wipe-fill (scaleX) sweeping across bars |
| `flash-cut` | drop / crash | 0-0.6s | full-frame flash masking a word / color state change |
| `freeze-hold` | hard_stop | 0ms in; 0.5-2s | freeze the moving system and hold it |
| `hard-cut` | beat / hard_stop | 0ms in; 0.3-2s | sample-accurate color-block + word cut |
| `mosaic-pack` | beat / build | 1.5-3.5s | scattered tiles fly in and pack into a grid |
| `negative-space-hold` | silence / hard_stop / final hold | 1-6s hold | kill busy layers, hold one readable mark in empty space |
| `overlay-pop` | accent | 0.2-0.6s in | badge / lower-third overlay pops in over a base |
| `palette-flip` | section change | 0ms flip; 0.5-4s | same layout re-skins via 0ms palette-variable flips |
| `staggered-exit` | phrase / transition | 0.4-1.2s | ordered cascade-out clearing the frame |
| `staggered-reveal` | build | 0.8-2.5s | ordered cascade-in of a stack / list |
| `system-replace` | drop / regime change | 0ms cut | hard-cut the entire visual system, then boot the new one |
| `text-spectral-rays` | phrase / sweep (hero text) | 2.5-5s | volumetric light-rays cast by a wordmark toward a sweeping light cursor |
| `tile-mosaic` | build / reveal | 1.5-3.5s | grid of tiles revealed in a diagonal sweep, assembling a poster |
| `typewriter-reveal` | roll / build | 1.0-3.0s | character / word type-on with caret |
| `value-counter` | roll → downbeat | 1.0-2.5s | count-up that locks on a downbeat / hard_stop |
| `word-grid-burst` | onsets → downbeat | 1.8-3.2s | grid of words revealed per onset, refocus one on a downbeat |
## How to combine
- One dominant system per group; layer at most one texture primitive over one structural primitive.
- Structure on strong beats (cuts, camera, `system-replace` → downbeat / phrase / section_start); texture on weak / syncopated hits (`content-swap`, typewriter letters, chromatic accents).
- A roll is an accumulation container — build during it, hard-cut to a clean layout on the downbeat that ends it.
- `drop``downbeat`: a downbeat is a cut within the regime; a drop is a regime change (`system-replace`, total clear, element-count jump).
- Let silence remove density (`negative-space-hold`).
- Background beds are a layer, not a move: one bed at a time, under foreground primitives.
- `text-spectral-rays` is the hero wordmark treatment; do not stack another visible copy of the same word on top.
@@ -0,0 +1,154 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>18 · 3d_card_flip</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.card-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
perspective: 2400px;
}
.card {
position: relative;
width: 1000px;
height: 580px;
transform-style: preserve-3d;
will-change: transform;
}
.face {
position: absolute;
inset: 0;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 200px;
letter-spacing: -0.04em;
border-radius: 24px;
box-shadow:
0 40px 80px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.08) inset;
}
.face.front {
background: linear-gradient(135deg, #1e1b3a 0%, #0a0a0f 100%);
color: var(--hg-fg);
}
.face.back {
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 60%, #d946ef 100%);
color: var(--hg-fg);
transform: rotateY(180deg);
}
.face .meta {
position: absolute;
top: 36px;
left: 40px;
font-family: "JetBrains Mono", monospace;
font-weight: 600;
font-size: 18px;
letter-spacing: 0.16em;
opacity: 0.6;
}
.card-shadow {
position: absolute;
left: 50%;
bottom: 22%;
width: 1000px;
height: 80px;
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, transparent 70%);
transform: translateX(-50%);
filter: blur(12px);
will-change: opacity, transform;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="card-shadow"></div>
<div class="card-stage">
<div class="card" id="card">
<div class="face front">
<div class="meta">v4 · CURRENT</div>
PRODUCT
</div>
<div class="face back">
<div class="meta">v5 · LAUNCH</div>
v5.0
</div>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo(
"#card",
{ rotateY: 0, scale: 0.92, opacity: 0 },
{ rotateY: 0, scale: 1, opacity: 1, duration: 0.45, ease: "power3.out" },
0.15,
);
// Hold then flip
tl.to("#card", { rotateY: 180, duration: 1.0, ease: "power3.inOut" }, 0.7);
// Shadow squishes mid-flip
tl.to(
".card-shadow",
{ scaleX: 0.6, opacity: 0.4, duration: 0.5, ease: "power2.inOut" },
0.7,
);
tl.to(".card-shadow", { scaleX: 1, opacity: 0.7, duration: 0.5, ease: "power2.inOut" }, 1.2);
// Tiny landing settle
tl.to("#card", { scale: 1.02, duration: 0.12, ease: "power2.out" }, 1.68);
tl.to("#card", { scale: 1, duration: 0.3, ease: "elastic.out(1, 0.5)" }, 1.8);
window.__timelines["main"] = tl;
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,326 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"bgColor", "type":"color", "label":"Page background", "default":"#050507"},
{"id":"colorLow", "type":"color", "label":"Flow — shadow tone", "default":"#0a1430"},
{"id":"colorMid", "type":"color", "label":"Flow — mid tone", "default":"#6a6f9e"},
{"id":"colorHigh", "type":"color", "label":"Flow — highlight tone", "default":"#e8f0ff"},
{"id":"accentColor","type":"color", "label":"Core / glow accent", "default":"#aee4ff"},
{"id":"flowSpeed", "type":"number", "label":"Flow speed", "default":1, "min":0.1, "max":3, "step":0.1}
]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>bg_flow_field</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #050507;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.field {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
display: block;
}
.label {
position: absolute;
left: 48px;
bottom: 40px;
font-size: 22px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.7);
mix-blend-mode: screen;
pointer-events: none;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="4"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="4" data-track-index="1">
<canvas class="field" width="1920" height="1080"></canvas>
<div class="label">bg_flow_field</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// ── Variables (palette + speed); falls back to defaults standalone ──
const defaults = {
bgColor: "#050507",
colorLow: "#0a1430",
colorMid: "#6a6f9e",
colorHigh: "#e8f0ff",
accentColor: "#aee4ff",
flowSpeed: 1,
};
const vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
// sRGB hex -> linear-light [r,g,b] (shader works in linear, gamma-encodes at end)
function hexLin(hex) {
let h = String(hex).replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const r = parseInt(h.substr(0, 2), 16) / 255;
const g = parseInt(h.substr(2, 2), 16) / 255;
const b = parseInt(h.substr(4, 2), 16) / 255;
const lin = (c) => Math.pow(c, 2.2);
return [lin(r), lin(g), lin(b)];
}
const canvas = document.querySelector(".field");
const gl = canvas.getContext("webgl", {
antialias: false,
alpha: false,
preserveDrawingBuffer: true,
});
const VERT = [
"attribute vec2 position;",
"varying vec2 vUv;",
"void main(){",
" vUv = position * 0.5 + 0.5;",
" gl_Position = vec4(position, 0.0, 1.0);",
"}",
].join("\n");
// Curl-noise flow field — ported verbatim from the held-message-living-field
// template's "living field" background. All visible state comes from uniforms.
const FRAG = [
"precision highp float;",
"varying vec2 vUv;",
"uniform float uTime;",
"uniform float uPhase;",
"uniform float uReveal;",
"uniform float uChromAb;",
"uniform float uVignette;",
"uniform float uIntensity;",
"uniform float uSpeed;",
"uniform vec2 uResolution;",
"uniform vec3 uColLow;",
"uniform vec3 uColMid;",
"uniform vec3 uColHigh;",
"uniform vec3 uAccent;",
"float hash21(vec2 p){",
" p = fract(p * vec2(123.34, 456.21));",
" p += dot(p, p + 45.32);",
" return fract(p.x * p.y);",
"}",
"float noise2(vec2 p){",
" vec2 i = floor(p);",
" vec2 f = fract(p);",
" vec2 u = f * f * (3.0 - 2.0 * f);",
" float a = hash21(i);",
" float b = hash21(i + vec2(1.0, 0.0));",
" float c = hash21(i + vec2(0.0, 1.0));",
" float d = hash21(i + vec2(1.0, 1.0));",
" return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);",
"}",
"float fbm(vec2 p){",
" float v = 0.0;",
" float a = 0.5;",
" for(int i=0;i<5;i++){",
" v += a * noise2(p);",
" p *= 2.02;",
" a *= 0.5;",
" }",
" return v;",
"}",
"vec2 curl(vec2 p, float t){",
" float e = 0.6;",
" float n1 = fbm(p + vec2(0.0, e) + t*0.18);",
" float n2 = fbm(p + vec2(0.0, -e) + t*0.18);",
" float n3 = fbm(p + vec2( e, 0.0) + t*0.18);",
" float n4 = fbm(p + vec2(-e, 0.0) + t*0.18);",
" return vec2(n1 - n2, -(n3 - n4));",
"}",
"vec3 palette(float t){",
" t = clamp(t, 0.0, 1.0);",
" vec3 lowMid = mix(uColLow, uColMid, smoothstep(0.0, 0.5, t));",
" return mix(lowMid, uColHigh, smoothstep(0.5, 1.0, t));",
"}",
"vec3 sampleField(vec2 uv, float t, float energy){",
" vec2 p = (uv - 0.5) * vec2(uResolution.x/uResolution.y, 1.0) * 2.4;",
" vec2 c = curl(p * 0.9, t);",
" vec2 q = p + c * (0.45 + 0.55 * energy);",
" float flow = fbm(q * 1.6 + vec2(t * 0.11, -t * 0.07));",
" float bands = sin(flow * 14.0 + t * 1.1);",
" bands = smoothstep(0.0, 1.0, 0.5 + 0.5 * bands);",
" float r = length(p);",
" float core = smoothstep(1.5, 0.05, r) * (0.6 + 0.4 * energy);",
" float glow = exp(-r * 1.6) * (0.55 + 0.45 * energy);",
" float k = clamp(flow * 0.85 + 0.15 * bands + 0.25 * core + 0.6 * glow, 0.0, 1.4);",
" vec3 col = palette(k * 0.9 + 0.1 * sin(t * 0.4));",
" col *= 0.7 + 0.6 * energy;",
" col += uAccent * core * 0.55;",
" return col;",
"}",
"void main(){",
" vec2 uv = vUv;",
" float t = uTime * uSpeed;",
" float e = uIntensity;",
" vec2 dir = normalize(uv - 0.5 + 1e-6);",
" float ca = uChromAb * (0.004 + 0.012 * length(uv - 0.5));",
" vec3 cR = sampleField(uv + dir * ca, t, e);",
" vec3 cG = sampleField(uv, t, e);",
" vec3 cB = sampleField(uv - dir * ca, t, e);",
" vec3 col = vec3(cR.r, cG.g, cB.b);",
" float lum = dot(col, vec3(0.299, 0.587, 0.114));",
" col = mix(vec3(lum), col, 1.05);",
" float rev = uReveal;",
" float sweep = smoothstep(0.0, 0.6, rev) * (1.0 - smoothstep(0.6, 1.0, rev));",
" col += uColHigh * sweep * 0.35;",
" vec3 phaseTint = mix(vec3(0.85, 0.92, 1.05), vec3(1.05, 0.95, 0.85), uPhase);",
" col *= phaseTint;",
" float v = length(uv - 0.5);",
" col *= 1.0 - smoothstep(0.45, 0.95, v) * uVignette;",
" col = col / (col + 1.0);",
" col = pow(col, vec3(1.0/2.2));",
" gl_FragColor = vec4(col, 1.0);",
"}",
].join("\n");
function compile(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
gl.useProgram(prog);
// full-screen triangle
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const posLoc = gl.getAttribLocation(prog, "position");
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
gl.viewport(0, 0, 1920, 1080);
const L = (n) => gl.getUniformLocation(prog, n);
const uLoc = {
uTime: L("uTime"),
uPhase: L("uPhase"),
uReveal: L("uReveal"),
uChromAb: L("uChromAb"),
uVignette: L("uVignette"),
uIntensity: L("uIntensity"),
uSpeed: L("uSpeed"),
uResolution: L("uResolution"),
uColLow: L("uColLow"),
uColMid: L("uColMid"),
uColHigh: L("uColHigh"),
uAccent: L("uAccent"),
};
// animated uniform state (GSAP tweens these .value fields)
const u = {
uTime: { value: 0 },
uPhase: { value: 0.1 },
uReveal: { value: 0 },
uChromAb: { value: 0 },
uVignette: { value: 0 },
uIntensity: { value: 0.3 },
};
const speed = Number(vars.flowSpeed) || 1;
const cLow = hexLin(vars.colorLow);
const cMid = hexLin(vars.colorMid);
const cHigh = hexLin(vars.colorHigh);
const cAcc = hexLin(vars.accentColor);
function render() {
gl.uniform1f(uLoc.uTime, u.uTime.value);
gl.uniform1f(uLoc.uPhase, u.uPhase.value);
gl.uniform1f(uLoc.uReveal, u.uReveal.value);
gl.uniform1f(uLoc.uChromAb, u.uChromAb.value);
gl.uniform1f(uLoc.uVignette, u.uVignette.value);
gl.uniform1f(uLoc.uIntensity, u.uIntensity.value);
gl.uniform1f(uLoc.uSpeed, speed);
gl.uniform2f(uLoc.uResolution, 1920, 1080);
gl.uniform3fv(uLoc.uColLow, cLow);
gl.uniform3fv(uLoc.uColMid, cMid);
gl.uniform3fv(uLoc.uColHigh, cHigh);
gl.uniform3fv(uLoc.uAccent, cAcc);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
render(); // initial frame so a seek to 0 / first paint is correct
// ── Energy-envelope drive (the "living field" breath): all repaint is via
// onUpdate, so the field is fully determined by the timeline position — no
// requestAnimationFrame, seek-safe. ──
tl.eventCallback("onUpdate", render);
tl.to(u.uTime, { value: 4, duration: 4, ease: "none" }, 0);
// energy: settle-in → breathe → SURGE → settle dark for the hold
tl.to(u.uIntensity, { value: 0.6, duration: 1.2, ease: "sine.inOut" }, 0);
tl.to(u.uIntensity, { value: 1.0, duration: 0.7, ease: "power2.in" }, 1.3);
tl.to(u.uIntensity, { value: 0.35, duration: 2.0, ease: "power2.out" }, 2.0);
// chromatic light-leak: light at entry, spikes on the surge, then settles
tl.fromTo(u.uChromAb, { value: 0 }, { value: 0.4, duration: 1.2, ease: "sine.in" }, 0);
tl.to(u.uChromAb, { value: 1.4, duration: 0.5, ease: "power3.in" }, 1.4);
tl.to(u.uChromAb, { value: 0.2, duration: 1.6, ease: "power2.out" }, 1.9);
// a brief highlight sweep across the surge
tl.fromTo(u.uReveal, { value: 0 }, { value: 1, duration: 0.7, ease: "none" }, 1.3);
tl.set(u.uReveal, { value: 0 }, 2.0);
// colour-temperature drift
tl.to(u.uPhase, { value: 0.6, duration: 2.2, ease: "sine.inOut" }, 0.6);
// black mask settles the field to near-black + central glow for the hold
tl.to(u.uVignette, { value: 0.85, duration: 1.6, ease: "power2.out" }, 1.9);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>08 · binary_decrypt</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-cyan: #2afff0;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.crypt {
font-family: "JetBrains Mono", monospace;
font-weight: 700;
font-size: 200px;
letter-spacing: 0.02em;
color: var(--hg-cyan);
text-shadow: 0 0 30px rgba(42, 255, 240, 0.5);
}
.crypt .char {
display: inline-block;
min-width: 1ch;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="crypt">
<span class="char" data-target="D">_</span><span class="char" data-target="E">_</span
><span class="char" data-target="C">_</span><span class="char" data-target="R">_</span
><span class="char" data-target="Y">_</span><span class="char" data-target="P">_</span
><span class="char" data-target="T">_</span>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const SCRAMBLE = "01█▓▒░▀▄■◆◇▪▫";
function scrambleChar(charIdx, phase) {
const tick = Math.floor(phase * 60);
const i = (charIdx * 7 + tick * 13 + 3) % SCRAMBLE.length;
return SCRAMBLE.charAt(i);
}
const cryptState = [{ p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }];
cryptState.forEach((state, idx) => {
tl.to(
state,
{
p: 1,
duration: 0.85,
delay: idx * 0.05,
ease: "power2.out",
onUpdate: function () {
const spans = document.querySelectorAll(".crypt .char");
const span = spans[idx];
if (!span) return;
const target = span.getAttribute("data-target");
if (state.p >= 0.72) {
span.textContent = target;
span.style.color = "var(--hg-fg)";
span.style.textShadow = "0 0 30px rgba(168, 85, 247, 0.8)";
} else {
span.textContent = scrambleChar(idx, state.p);
}
},
},
0.15,
);
});
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,95 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>blur_resolve</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero {
font-weight: 900;
font-size: 320px;
letter-spacing: -0.04em;
line-height: 0.95;
color: var(--hg-fg);
white-space: nowrap;
will-change: filter, transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="hero" id="hero">FOCUS</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Resolve: heavy blur + scaled-up + dim settles into perfect crisp focus.
tl.fromTo(
"#hero",
{ filter: "blur(36px)", scale: 1.08, opacity: 0.25 },
{ filter: "blur(0px)", scale: 1, opacity: 1, duration: 0.7, ease: "power2.out" },
0.05,
);
// Hold crisp through the middle (no keys needed — state persists to ~1.5).
// Blur-out near the end: brief defocus + dim, a cut-away that loops seamlessly.
tl.to(
"#hero",
{ filter: "blur(24px)", opacity: 0.25, duration: 0.25, ease: "power2.in" },
1.7,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>03 · braam_punch</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero {
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
line-height: 0.95;
color: var(--hg-fg);
white-space: nowrap;
will-change: transform, text-shadow;
}
.glow-ring {
position: absolute;
width: 900px;
height: 900px;
border-radius: 50%;
background: radial-gradient(circle, rgba(124, 58, 237, 0.55) 0%, rgba(124, 58, 237, 0) 65%);
filter: blur(20px);
will-change: transform, opacity;
opacity: 0;
}
.flash {
position: absolute;
inset: 0;
background: #fff;
opacity: 0;
pointer-events: none;
will-change: opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="glow-ring"></div>
<div class="hero" id="hero">BRAAM</div>
</div>
<div class="flash"></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set("#hero", { scale: 0.92, opacity: 0 }, 0);
tl.set(".glow-ring", { scale: 0.6, opacity: 0 }, 0);
tl.to("#hero", { opacity: 1, duration: 0.18, ease: "power2.out" }, 0.05);
tl.to(".glow-ring", { scale: 1.2, opacity: 1, duration: 0.45, ease: "power3.out" }, 0.05);
tl.to(
"#hero",
{
scale: 1.18,
textShadow: "0 0 60px rgba(168,85,247,0.9)",
duration: 0.12,
ease: "power4.out",
},
0.25,
);
tl.to(
"#hero",
{
scale: 1.0,
textShadow: "0 0 0px rgba(168,85,247,0)",
duration: 0.7,
ease: "elastic.out(1, 0.5)",
},
0.37,
);
tl.fromTo(
".flash",
{ opacity: 0 },
{ opacity: 0.85, duration: 0.06, ease: "power4.out" },
0.25,
);
tl.to(".flash", { opacity: 0, duration: 0.35, ease: "power3.in" }, 0.31);
tl.to(
".glow-ring",
{ scale: 1.45, opacity: 0.4, duration: 1.0, ease: "power2.out", overwrite: "auto" },
0.55,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,146 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>04 · chromatic_split</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.stack {
position: relative;
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
line-height: 0.95;
white-space: nowrap;
}
.layer {
position: absolute;
top: 0;
left: 0;
mix-blend-mode: screen;
will-change: transform;
}
.layer.r {
color: #ff2a4a;
}
.layer.g {
color: #2afff0;
}
.layer.b {
color: #b56dff;
}
.spacer {
color: transparent;
}
.scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0) 0px,
rgba(255, 255, 255, 0) 3px,
rgba(255, 255, 255, 0.04) 3px,
rgba(255, 255, 255, 0.04) 4px
);
opacity: 0;
pointer-events: none;
will-change: opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="stack">
<span class="spacer">GLITCH</span>
<span class="layer r">GLITCH</span>
<span class="layer g">GLITCH</span>
<span class="layer b">GLITCH</span>
</div>
</div>
<div class="scanlines"></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo(
".stack",
{ opacity: 0, scale: 0.95 },
{ opacity: 1, scale: 1, duration: 0.25, ease: "power2.out" },
0.05,
);
tl.set(".layer.r", { x: -2 }, 0);
tl.set(".layer.g", { x: 2 }, 0);
tl.set(".layer.b", { x: 0 }, 0);
tl.to(".layer.r", { x: -14, duration: 0.06, ease: "steps(1)" }, 0.35);
tl.to(".layer.g", { x: 14, duration: 0.06, ease: "steps(1)" }, 0.35);
tl.to(".layer.r", { x: -4, duration: 0.06, ease: "steps(1)" }, 0.5);
tl.to(".layer.g", { x: 4, duration: 0.06, ease: "steps(1)" }, 0.5);
tl.to(".layer.r", { x: -22, duration: 0.04, ease: "steps(1)" }, 0.85);
tl.to(".layer.g", { x: 22, duration: 0.04, ease: "steps(1)" }, 0.85);
tl.to(".layer.b", { x: 6, duration: 0.04, ease: "steps(1)" }, 0.85);
tl.to(".layer.r", { x: -6, duration: 0.04, ease: "steps(1)" }, 0.97);
tl.to(".layer.g", { x: 6, duration: 0.04, ease: "steps(1)" }, 0.97);
tl.to(".layer.b", { x: -2, duration: 0.04, ease: "steps(1)" }, 0.97);
tl.to(".layer.r", { x: -3, duration: 0.4, ease: "power2.out" }, 1.2);
tl.to(".layer.g", { x: 3, duration: 0.4, ease: "power2.out" }, 1.2);
tl.to(".layer.b", { x: 0, duration: 0.4, ease: "power2.out" }, 1.2);
tl.fromTo(
".scanlines",
{ opacity: 0 },
{ opacity: 0.85, duration: 0.06, ease: "steps(1)" },
0.35,
);
tl.to(".scanlines", { opacity: 0.25, duration: 0.5, ease: "power2.out" }, 1.0);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>13 · chrome_sweep</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.chrome {
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
background: linear-gradient(
110deg,
#404048 0%,
#b4b4be 20%,
#ffffff 45%,
#ffffff 50%,
#d8d8e0 60%,
#5a5a64 85%,
#ffffff 100%
);
background-size: 250% 100%;
background-position: 200% 0;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
will-change: background-position;
filter: drop-shadow(0 6px 0 rgba(0, 0, 0, 0.4));
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage"><div class="chrome">CHROME</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo(
".chrome",
{ opacity: 0, scale: 0.96 },
{ opacity: 1, scale: 1, duration: 0.3, ease: "power3.out" },
0.2,
);
tl.fromTo(
".chrome",
{ backgroundPosition: "200% 0" },
{ backgroundPosition: "-100% 0", duration: 1.4, ease: "power2.inOut" },
0.3,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,154 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>22 · counting_punch</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-cyan: #2afff0;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 30px;
}
.counter {
font-weight: 900;
font-size: 360px;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
color: var(--hg-fg);
will-change: transform, text-shadow;
line-height: 1;
}
.caption {
font-family: "JetBrains Mono", monospace;
font-size: 28px;
color: var(--hg-dim);
letter-spacing: 0.3em;
text-transform: uppercase;
opacity: 0;
}
.underline {
height: 4px;
background: var(--hg-violet-2);
width: 0;
box-shadow: 0 0 20px var(--hg-violet);
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="counter" id="counter">0</div>
<div class="underline" id="underline"></div>
<div class="caption" id="caption">USERS THIS WEEK</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const TARGET = 12847;
const counter = { v: 0 };
let lastShown = 0;
function format(n) {
return Math.floor(n).toLocaleString("en-US");
}
tl.to(
counter,
{
v: TARGET,
duration: 1.15,
ease: "expo.out",
onUpdate: function () {
const el = document.getElementById("counter");
if (!el) return;
const cur = Math.floor(counter.v);
if (cur !== lastShown) {
el.textContent = format(cur);
// tiny scale tick on each digit change
el.style.transform = "scale(1.045)";
el.style.textShadow = "0 0 40px rgba(168, 85, 247, 0.6)";
// browser will paint, then this resets via the next onUpdate
}
lastShown = cur;
},
},
0.15,
);
// Big settle punch at landing
tl.to(
"#counter",
{
scale: 1.12,
textShadow: "0 0 80px rgba(168, 85, 247, 0.9)",
duration: 0.1,
ease: "power4.out",
},
1.3,
);
tl.to(
"#counter",
{
scale: 1.0,
textShadow: "0 0 20px rgba(168, 85, 247, 0.4)",
duration: 0.6,
ease: "elastic.out(1, 0.55)",
},
1.4,
);
// Underline draws
tl.to("#underline", { width: 420, duration: 0.6, ease: "expo.out" }, 1.35);
tl.to("#caption", { opacity: 1, duration: 0.5, ease: "power2.out" }, 1.55);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,155 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>19 · crash_zoom_in</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.zoom-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
will-change: transform;
}
.radial-bg {
position: absolute;
inset: 0;
background: radial-gradient(
circle at center,
rgba(168, 85, 247, 0.18) 0%,
transparent 35%,
rgba(0, 0, 0, 0.6) 80%
);
opacity: 0;
will-change: opacity, transform;
}
.speed-lines {
position: absolute;
inset: 0;
background: repeating-radial-gradient(
circle at 50% 50%,
transparent 0px,
transparent 6px,
rgba(255, 255, 255, 0.08) 7px,
transparent 8px
);
opacity: 0;
will-change: opacity;
mix-blend-mode: screen;
}
.hero {
font-weight: 900;
font-size: 360px;
letter-spacing: -0.05em;
color: var(--hg-fg);
white-space: nowrap;
will-change: transform, filter;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="radial-bg"></div>
<div class="speed-lines"></div>
<div class="zoom-stage"><div class="hero">CRASH</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Hold black for tension
tl.set(".hero", { scale: 0.03, filter: "blur(20px)", opacity: 0 }, 0);
const CRASH = 0.45;
// The crash itself
tl.to(
".hero",
{ scale: 1.0, filter: "blur(0px)", opacity: 1, duration: 0.32, ease: "expo.in" },
CRASH,
);
// Background swoosh in same window
tl.fromTo(
".radial-bg",
{ opacity: 0, scale: 0.4 },
{ opacity: 1, scale: 1, duration: 0.32, ease: "expo.in" },
CRASH,
);
tl.fromTo(
".speed-lines",
{ opacity: 0, scale: 0.2 },
{ opacity: 1, scale: 1.4, duration: 0.32, ease: "expo.in" },
CRASH,
);
// Landing settle
const LAND = CRASH + 0.32;
tl.to(".hero", { scale: 0.96, duration: 0.08, ease: "power3.out" }, LAND);
tl.to(".hero", { scale: 1.0, duration: 0.5, ease: "elastic.out(1, 0.55)" }, LAND + 0.08);
// Quick landing shake
const sh = [9, -7, 5, -4, 2, -1];
sh.forEach((amp, i) => {
tl.to(
".zoom-stage",
{ x: amp, y: amp * 0.5 * (i % 2 ? -1 : 1), duration: 0.03, ease: "steps(1)" },
LAND + i * 0.03,
);
});
tl.to(
".zoom-stage",
{ x: 0, y: 0, duration: 0.1, ease: "power2.out" },
LAND + sh.length * 0.03,
);
// Speed lines fade
tl.to(".speed-lines", { opacity: 0, duration: 0.4, ease: "power2.out" }, LAND + 0.05);
// Radial settles
tl.to(".radial-bg", { opacity: 0.5, duration: 0.5, ease: "power2.out" }, LAND);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>27 · datamosh_smear</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-cyan: #2afff0;
--hg-magenta: #ff2a4a;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.smear-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.smear-hero {
position: relative;
font-weight: 900;
font-size: 320px;
letter-spacing: -0.05em;
line-height: 0.95;
white-space: nowrap;
}
.smear-layer {
position: absolute;
top: 0;
left: 0;
mix-blend-mode: screen;
will-change: transform, opacity, filter;
}
.smear-layer.l1 {
color: #ff2a4a;
}
.smear-layer.l2 {
color: #2afff0;
}
.smear-layer.l3 {
color: #b56dff;
}
.smear-layer.l4 {
color: #ffd166;
}
.smear-layer.l5 {
color: #f5f5fa;
}
.spacer {
color: transparent;
}
.scan-noise {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0) 0px,
rgba(255, 255, 255, 0) 2px,
rgba(255, 255, 255, 0.07) 3px
),
repeating-linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 4px,
rgba(0, 0, 0, 0.15) 5px
);
opacity: 0;
will-change: opacity;
pointer-events: none;
mix-blend-mode: overlay;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="smear-stage">
<div class="smear-hero">
<span class="spacer">SMEAR</span>
<span class="smear-layer l1">SMEAR</span>
<span class="smear-layer l2">SMEAR</span>
<span class="smear-layer l3">SMEAR</span>
<span class="smear-layer l4">SMEAR</span>
<span class="smear-layer l5">SMEAR</span>
</div>
</div>
<div class="scan-noise"></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// All layers start far right, stagger-lag in, then settle near center
const layers = [
".smear-layer.l1",
".smear-layer.l2",
".smear-layer.l3",
".smear-layer.l4",
".smear-layer.l5",
];
layers.forEach((sel, i) => {
tl.fromTo(
sel,
{ x: 360, opacity: 0, filter: "blur(20px)" },
{ x: 0, opacity: 0.85, filter: "blur(0px)", duration: 0.7, ease: "expo.out" },
0.2 + i * 0.08,
);
});
// Final settle: pull layers into proper offset
const finalX = [-6, -2, 0, 2, 4];
layers.forEach((sel, i) => {
tl.to(sel, { x: finalX[i], duration: 0.4, ease: "power2.out" }, 1.1);
});
// Scan noise pulses
tl.fromTo(
".scan-noise",
{ opacity: 0 },
{ opacity: 1, duration: 0.06, ease: "steps(1)" },
0.2,
);
tl.to(".scan-noise", { opacity: 0.25, duration: 0.8, ease: "power2.out" }, 0.6);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,133 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>directional_fill</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 36px;
}
.bar {
position: relative;
width: 1040px;
height: 188px;
border: 3px solid var(--hg-dim);
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
padding: 0 56px;
}
.fill {
position: absolute;
inset: 0;
transform: scaleX(0);
will-change: transform;
}
.fill.a {
background: var(--hg-violet);
transform-origin: left center;
}
.fill.b {
background: var(--hg-violet-2);
transform-origin: right center;
}
.fill.c {
background: #2afff0;
transform-origin: left center;
}
.label {
position: relative;
z-index: 1;
font-weight: 900;
font-size: 96px;
letter-spacing: -0.03em;
line-height: 1;
color: var(--hg-fg);
will-change: color;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="1.8"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="1.8" data-track-index="1">
<div class="stage">
<div class="bar">
<div class="fill a"></div>
<span class="label" id="l1">READY</span>
</div>
<div class="bar">
<div class="fill b"></div>
<span class="label" id="l2">SET</span>
</div>
<div class="bar">
<div class="fill c"></div>
<span class="label" id="l3">FILL</span>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Bar 1 — sweeps left -> right, landing on the beat at ~0.55s.
tl.to(".fill.a", { scaleX: 1, duration: 0.35, ease: "power2.out" }, 0.2);
tl.set("#l1", { color: "var(--hg-bg)" }, 0.55);
// Bar 2 — sweeps right -> left, landing at ~0.85s.
tl.to(".fill.b", { scaleX: 1, duration: 0.35, ease: "power2.out" }, 0.5);
tl.set("#l2", { color: "var(--hg-bg)" }, 0.85);
// Bar 3 — sweeps left -> right, landing at ~1.15s.
tl.to(".fill.c", { scaleX: 1, duration: 0.35, ease: "power2.out" }, 0.8);
tl.set("#l3", { color: "var(--hg-bg)" }, 1.15);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>09 · dolly_zoom</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.layers {
position: absolute;
inset: 0;
overflow: hidden;
}
.bg-far,
.bg-mid,
.bg-near {
position: absolute;
inset: 0;
will-change: transform, filter;
}
.bg-far {
background:
radial-gradient(2px 2px at 20% 30%, var(--hg-dim) 50%, transparent 51%),
radial-gradient(2px 2px at 70% 80%, var(--hg-dim) 50%, transparent 51%),
radial-gradient(1px 1px at 45% 60%, var(--hg-dim) 50%, transparent 51%),
radial-gradient(1px 1px at 85% 25%, var(--hg-dim) 50%, transparent 51%),
radial-gradient(2px 2px at 10% 75%, var(--hg-dim) 50%, transparent 51%),
radial-gradient(1px 1px at 60% 15%, var(--hg-dim) 50%, transparent 51%);
background-size: 400px 400px;
filter: blur(2px);
}
.bg-mid {
background:
linear-gradient(90deg, transparent 49.7%, rgba(168, 85, 247, 0.25) 50%, transparent 50.3%)
0 0 / 200px 200px,
linear-gradient(
180deg,
transparent 49.7%,
rgba(168, 85, 247, 0.15) 50%,
transparent 50.3%
)
0 0 / 200px 200px;
}
.bg-near {
background:
radial-gradient(circle at 15% 50%, rgba(168, 85, 247, 0.4) 0%, transparent 25%),
radial-gradient(circle at 85% 50%, rgba(124, 58, 237, 0.4) 0%, transparent 25%);
}
.subject {
position: relative;
z-index: 5;
font-family: "Playfair Display", serif;
font-style: italic;
font-weight: 900;
font-size: 180px;
letter-spacing: -0.02em;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="layers">
<div class="bg-far"></div>
<div class="bg-mid"></div>
<div class="bg-near"></div>
</div>
<div class="stage"><div class="subject">subject.</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo(
".bg-far",
{ scale: 1, filter: "blur(2px)" },
{ scale: 1.6, filter: "blur(4px)", duration: 1.8, ease: "power2.inOut" },
0.1,
);
tl.fromTo(".bg-mid", { scale: 1 }, { scale: 1.4, duration: 1.8, ease: "power2.inOut" }, 0.1);
tl.fromTo(".bg-near", { scale: 1 }, { scale: 1.2, duration: 1.8, ease: "power2.inOut" }, 0.1);
tl.fromTo(
".subject",
{ opacity: 0, scale: 1.1 },
{ opacity: 1, scale: 1, duration: 0.6, ease: "power3.out" },
0.15,
);
tl.to(".layers", { skewY: -0.5, duration: 1.8, ease: "power2.inOut" }, 0.1);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,144 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>11 · electric_arc</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-cyan: #2afff0;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.arc-svg {
position: absolute;
inset: 0;
}
.arc-path {
fill: none;
stroke: var(--hg-fg);
stroke-width: 3;
stroke-linecap: round;
filter: drop-shadow(0 0 8px var(--hg-cyan)) drop-shadow(0 0 16px var(--hg-cyan));
stroke-dasharray: 1200;
stroke-dashoffset: 1200;
will-change: stroke-dashoffset, opacity;
}
.arc-flash {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 50%, rgba(42, 255, 240, 0.3) 0%, transparent 40%);
opacity: 0;
will-change: opacity;
}
.arc-label {
font-weight: 900;
font-size: 240px;
letter-spacing: -0.04em;
color: var(--hg-fg);
position: relative;
z-index: 2;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<svg class="arc-svg" viewBox="0 0 1920 1080" preserveAspectRatio="none">
<path
class="arc-path"
id="arc1"
d="M 200 540 L 380 480 L 440 580 L 620 500 L 720 600 L 880 510 L 960 540"
/>
<path
class="arc-path"
id="arc2"
d="M 1720 540 L 1540 600 L 1480 500 L 1300 580 L 1200 480 L 1040 570 L 960 540"
/>
<path
class="arc-path"
id="arc3"
d="M 960 200 L 920 360 L 1040 420 L 940 520 L 960 540"
/>
</svg>
<div class="arc-flash"></div>
<div class="arc-label">CHARGE</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo(
".arc-label",
{ opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1, duration: 0.3, ease: "power3.out" },
0.5,
);
const arcs = ["#arc1", "#arc2", "#arc3"];
arcs.forEach((p, i) => {
tl.fromTo(
p,
{ strokeDashoffset: 1200, opacity: 0 },
{ strokeDashoffset: 0, opacity: 1, duration: 0.18, ease: "power4.out" },
0.15 + i * 0.05,
);
});
tl.to(arcs.join(","), { opacity: 0.3, duration: 0.04, ease: "steps(1)" }, 0.55);
tl.to(arcs.join(","), { opacity: 1, duration: 0.04, ease: "steps(1)" }, 0.6);
tl.to(arcs.join(","), { opacity: 0.5, duration: 0.04, ease: "steps(1)" }, 0.7);
tl.to(arcs.join(","), { opacity: 1, duration: 0.04, ease: "steps(1)" }, 0.78);
tl.fromTo(
".arc-flash",
{ opacity: 0 },
{ opacity: 1, duration: 0.06, ease: "steps(1)" },
0.5,
);
tl.to(".arc-flash", { opacity: 0, duration: 0.5, ease: "power2.out" }, 0.6);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,132 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>flash_cut</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.word {
position: absolute;
font-weight: 900;
font-size: 260px;
letter-spacing: -0.04em;
line-height: 0.95;
white-space: nowrap;
color: var(--hg-fg);
will-change: opacity;
}
.accent {
position: absolute;
bottom: 360px;
width: 220px;
height: 14px;
border-radius: 999px;
background: var(--hg-violet);
will-change: background, transform;
}
.flash {
position: absolute;
inset: 0;
background: #ffffff;
opacity: 0;
pointer-events: none;
will-change: opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="1.8"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="1.8" data-track-index="1">
<div class="stage">
<div class="accent" id="accent"></div>
<div class="word" id="w0">READY</div>
<div class="word" id="w1">IMPACT</div>
<div class="word" id="w2">DONE</div>
</div>
<div class="flash" id="flash"></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Initial state: first word visible, others hidden, accent violet.
tl.set("#w0", { opacity: 1 }, 0);
tl.set("#w1", { opacity: 0 }, 0);
tl.set("#w2", { opacity: 0 }, 0);
tl.set("#accent", { backgroundColor: "#7c3aed", scaleX: 1 }, 0);
tl.set("#flash", { opacity: 0 }, 0);
// Two hits; during each white frame, swap underlying word + accent colour.
const hits = [
{ t: 0.5, from: "#w0", to: "#w1", color: "#a855f7", scaleX: 1.45 },
{ t: 1.2, from: "#w1", to: "#w2", color: "#f5f5fa", scaleX: 0.7 },
];
hits.forEach((h) => {
// Instant full-frame white flash on the hit.
tl.set("#flash", { opacity: 1 }, h.t);
// While white (~2 frames at 30fps), swap the revealed state.
tl.set(h.from, { opacity: 0 }, h.t + 0.03);
tl.set(h.to, { opacity: 1 }, h.t + 0.03);
tl.set("#accent", { backgroundColor: h.color, scaleX: h.scaleX }, h.t + 0.03);
// Snap the flash back out within ~3 frames.
tl.set("#flash", { opacity: 0 }, h.t + 0.06);
});
// Graceful loop: dip back to white at the very end and restore the start
// state so the cycle re-enters cleanly on the next pass.
tl.set("#flash", { opacity: 1 }, 1.74);
tl.set("#w2", { opacity: 0 }, 1.77);
tl.set("#w0", { opacity: 1 }, 1.77);
tl.set("#accent", { backgroundColor: "#7c3aed", scaleX: 1 }, 1.77);
tl.set("#flash", { opacity: 0 }, 1.8);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,241 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>17 · gooey_metaball</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-cyan: #2afff0;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.hero {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
color: var(--hg-fg);
z-index: 5;
pointer-events: none;
}
.gooey-stage {
position: absolute;
inset: 0;
filter: url(#gooey-filter);
}
.ball {
position: absolute;
border-radius: 50%;
background: var(--hg-violet-2);
top: 50%;
left: 50%;
will-change: transform;
opacity: 0;
}
.ball.a {
width: 280px;
height: 280px;
background: #7c3aed;
}
.ball.b {
width: 240px;
height: 240px;
background: #a855f7;
}
.ball.c {
width: 200px;
height: 200px;
background: #d946ef;
}
.ball.d {
width: 320px;
height: 320px;
background: #8b5cf6;
}
.ball.e {
width: 180px;
height: 180px;
background: #c084fc;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<svg style="position: absolute; width: 0; height: 0">
<defs>
<filter id="gooey-filter">
<feGaussianBlur in="SourceGraphic" stdDeviation="20" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 22 -10"
result="gooey"
/>
<feComposite in="SourceGraphic" in2="gooey" operator="atop" />
</filter>
</defs>
</svg>
<div class="gooey-stage">
<div class="ball a" id="ballA"></div>
<div class="ball b" id="ballB"></div>
<div class="ball c" id="ballC"></div>
<div class="ball d" id="ballD"></div>
<div class="ball e" id="ballE"></div>
</div>
<div class="hero">GOOEY</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Initial: all balls collapsed at center
["#ballA", "#ballB", "#ballC", "#ballD", "#ballE"].forEach((id) => {
tl.set(id, { xPercent: -50, yPercent: -50, scale: 0, opacity: 0 }, 0);
});
// Spawn outward
tl.to(
"#ballA",
{
xPercent: -50 - 120,
yPercent: -50 + 20,
scale: 1,
opacity: 1,
duration: 0.5,
ease: "back.out(1.6)",
},
0.15,
);
tl.to(
"#ballB",
{
xPercent: -50 + 100,
yPercent: -50 + 50,
scale: 1,
opacity: 1,
duration: 0.55,
ease: "back.out(1.6)",
},
0.2,
);
tl.to(
"#ballC",
{
xPercent: -50 + 30,
yPercent: -50 - 80,
scale: 1,
opacity: 1,
duration: 0.55,
ease: "back.out(1.6)",
},
0.25,
);
tl.to(
"#ballD",
{
xPercent: -50 - 50,
yPercent: -50 - 30,
scale: 1,
opacity: 1,
duration: 0.55,
ease: "back.out(1.6)",
},
0.3,
);
tl.to(
"#ballE",
{
xPercent: -50 + 80,
yPercent: -50 - 40,
scale: 1,
opacity: 1,
duration: 0.55,
ease: "back.out(1.6)",
},
0.18,
);
// Idle drift
tl.to(
"#ballA",
{ xPercent: "+=20", yPercent: "+=12", duration: 1.0, ease: "sine.inOut" },
0.7,
);
tl.to(
"#ballB",
{ xPercent: "-=18", yPercent: "-=10", duration: 1.0, ease: "sine.inOut" },
0.7,
);
tl.to(
"#ballC",
{ xPercent: "-=15", yPercent: "+=20", duration: 1.0, ease: "sine.inOut" },
0.7,
);
tl.to(
"#ballD",
{ xPercent: "+=25", yPercent: "+=8", duration: 1.0, ease: "sine.inOut" },
0.7,
);
tl.to(
"#ballE",
{ xPercent: "-=22", yPercent: "+=18", duration: 1.0, ease: "sine.inOut" },
0.7,
);
// Reveal text behind
tl.fromTo(
".hero",
{ opacity: 0, scale: 0.95 },
{ opacity: 1, scale: 1, duration: 0.5, ease: "power3.out" },
0.6,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>hard_cut</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
}
.panel {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
will-change: opacity;
}
.word {
font-weight: 900;
font-size: 340px;
letter-spacing: -0.05em;
line-height: 0.9;
text-transform: uppercase;
white-space: nowrap;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage" id="stage"></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Deterministic palette + word list. Variation is derived from index only.
const BG = ["#7c3aed", "#0a0a0f", "#ff2a4a", "#2afff0", "#a855f7", "#f5f5fa", "#0a0a0f"];
const FG = ["#f5f5fa", "#a855f7", "#0a0a0f", "#0a0a0f", "#0a0a0f", "#7c3aed", "#f5f5fa"];
const WORDS = ["CUT", "NOW", "HARD", "SNAP", "STOP", "GO", "CUT"];
const STEP = 0.28; // beat interval
const N = WORDS.length;
// Build one full-frame panel per cut, all hidden but the first.
const stage = document.getElementById("stage");
for (let i = 0; i < N; i++) {
const panel = document.createElement("div");
panel.className = "panel";
panel.id = "p" + i;
panel.style.background = BG[i];
panel.style.opacity = i === 0 ? "1" : "0";
const word = document.createElement("div");
word.className = "word";
word.style.color = FG[i];
word.textContent = WORDS[i];
panel.appendChild(word);
stage.appendChild(panel);
}
// Sample-accurate 0ms cuts: at each beat, hide current panel, show next.
// tl.set has no duration and no ease — brutal instant switching.
for (let i = 1; i < N; i++) {
const t = i * STEP;
tl.set("#p" + (i - 1), { opacity: 0 }, t);
tl.set("#p" + i, { opacity: 1 }, t);
}
// Loop gracefully: snap back to the first panel before the clip ends so a
// re-entry starts clean on the same color/word it began with.
tl.set("#p" + (N - 1), { opacity: 0 }, N * STEP);
tl.set("#p0", { opacity: 1 }, N * STEP);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>01 · hypercut_whip</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero {
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
line-height: 0.95;
color: var(--hg-fg);
white-space: nowrap;
will-change: transform, filter;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage"><div class="hero" id="hero">HYPERCUT</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set("#hero", { x: 0, filter: "blur(0px)" }, 0);
tl.to("#hero", { x: 1800, filter: "blur(30px)", duration: 0.18, ease: "power4.in" }, 1.65);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>10 · iris_open</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.iris-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
clip-path: circle(0% at 50% 50%);
will-change: clip-path;
background: linear-gradient(135deg, #1a0033 0%, #0a0a0f 60%);
}
.iris-stage .hero {
font-family: "Playfair Display", serif;
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
line-height: 0.95;
color: var(--hg-fg);
white-space: nowrap;
}
.iris-ring {
position: absolute;
border-radius: 50%;
border: 2px solid var(--hg-violet);
box-shadow: 0 0 40px var(--hg-violet);
opacity: 0;
will-change: transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="iris-stage">
<div class="iris-ring" style="width: 200px; height: 200px"></div>
<div class="hero">IRIS</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set(".iris-stage", { clipPath: "circle(0% at 50% 50%)" }, 0);
tl.fromTo(
".iris-ring",
{ scale: 0, opacity: 0.9 },
{ scale: 9, opacity: 0, duration: 0.85, ease: "expo.out" },
0.2,
);
tl.to(
".iris-stage",
{ clipPath: "circle(75% at 50% 50%)", duration: 0.9, ease: "expo.out" },
0.3,
);
tl.from(".iris-stage .hero", { y: 40, opacity: 0, duration: 0.6, ease: "power3.out" }, 0.6);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>02 · kinetic_letter_in</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero {
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
line-height: 0.95;
color: var(--hg-fg);
white-space: nowrap;
}
.char {
display: inline-block;
will-change: transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="hero" id="hero">
<span class="char">K</span><span class="char">I</span><span class="char">N</span
><span class="char">E</span><span class="char">T</span><span class="char">I</span
><span class="char">C</span>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(
".char",
{ y: 80, opacity: 0, stagger: 0.045, ease: "back.out(2)", duration: 0.55 },
0.05,
);
tl.to("#hero", { letterSpacing: "0.04em", duration: 0.6, ease: "power2.out" }, 0.7);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,131 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>16 · liquid_morph</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.blob-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.blob {
position: absolute;
width: 1100px;
height: 800px;
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 60%, #d946ef 100%);
clip-path: polygon(
50% 5%,
65% 8%,
78% 16%,
88% 30%,
92% 50%,
88% 70%,
78% 84%,
65% 92%,
50% 95%,
35% 92%,
22% 84%,
12% 70%,
8% 50%,
12% 30%,
22% 16%,
35% 8%
);
will-change: clip-path, transform;
filter: blur(0.5px);
}
.hero {
font-family: "Playfair Display", serif;
font-style: italic;
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
color: var(--hg-fg);
white-space: nowrap;
position: relative;
z-index: 2;
mix-blend-mode: difference;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="blob-wrap"><div class="blob"></div></div>
<div class="stage"><div class="hero">liquid</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const BLOB_A =
"polygon(50% 5%, 65% 8%, 78% 16%, 88% 30%, 92% 50%, 88% 70%, 78% 84%, 65% 92%, 50% 95%, 35% 92%, 22% 84%, 12% 70%, 8% 50%, 12% 30%, 22% 16%, 35% 8%)";
const BLOB_B =
"polygon(38% 10%, 58% 4%, 75% 14%, 90% 28%, 95% 46%, 86% 60%, 80% 78%, 62% 88%, 46% 94%, 30% 86%, 16% 74%, 6% 58%, 10% 40%, 20% 22%, 30% 14%, 38% 10%)";
const BLOB_C =
"polygon(46% 8%, 62% 10%, 80% 20%, 92% 36%, 90% 52%, 92% 70%, 76% 86%, 58% 90%, 44% 94%, 28% 88%, 14% 76%, 10% 60%, 12% 44%, 18% 26%, 30% 12%, 38% 8%)";
tl.set(".blob", { clipPath: BLOB_A, scale: 0.95 }, 0);
tl.to(".blob", { clipPath: BLOB_B, scale: 1.05, duration: 0.7, ease: "power2.inOut" }, 0.2);
tl.to(".blob", { clipPath: BLOB_C, scale: 1.0, duration: 0.7, ease: "power2.inOut" }, 0.9);
tl.fromTo(
".hero",
{ opacity: 0, y: 30 },
{ opacity: 1, y: 0, duration: 0.6, ease: "power3.out" },
0.4,
);
tl.to(".blob", { rotation: 6, duration: 1.5, ease: "sine.inOut" }, 0.2);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>05 · mask_reveal</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero {
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
line-height: 0.95;
color: var(--hg-fg);
white-space: nowrap;
}
.mask-wrap {
position: relative;
overflow: hidden;
padding: 0 60px;
}
.mask-clip {
clip-path: inset(0 100% 0 0);
will-change: clip-path;
}
.sweep {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
background: var(--hg-violet);
box-shadow:
0 0 40px var(--hg-violet),
0 0 80px var(--hg-violet);
left: 0;
opacity: 0;
will-change: transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="mask-wrap">
<div class="hero mask-clip">REVEAL</div>
<div class="sweep"></div>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set(".mask-clip", { clipPath: "inset(0 100% 0 0)" }, 0);
tl.set(".sweep", { x: 0, opacity: 0 }, 0);
tl.to(".sweep", { opacity: 1, duration: 0.08, ease: "power2.out" }, 0.1);
tl.to(".sweep", { x: 1300, duration: 0.75, ease: "expo.out" }, 0.15);
tl.to(".mask-clip", { clipPath: "inset(0 0% 0 0)", duration: 0.75, ease: "expo.out" }, 0.18);
tl.to(".sweep", { opacity: 0, duration: 0.3, ease: "power2.in" }, 0.85);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>mosaic_pack</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.grid {
display: grid;
grid-template-columns: repeat(6, 200px);
grid-template-rows: repeat(4, 200px);
gap: 18px;
}
.tile {
border-radius: 14px;
will-change: transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2.4"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2.4" data-track-index="1">
<div class="stage">
<div class="grid" id="grid"></div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Deterministic palette + grid build (6x4 = 24 cells).
const PALETTE = ["#7c3aed", "#a855f7", "#f5f5fa", "#2a2a3a", "#b56dff", "#6b6b78"];
const COLS = 6;
const ROWS = 4;
const COUNT = COLS * ROWS;
const grid = document.getElementById("grid");
const tiles = [];
for (let i = 0; i < COUNT; i++) {
const tile = document.createElement("div");
tile.className = "tile";
tile.style.background = PALETTE[i % PALETTE.length];
grid.appendChild(tile);
tiles.push(tile);
}
// Scatter each tile from a deterministic offset/rotation, then pack into place.
// Land order ripples diagonally (col + row) for a deterministic "assembling" feel.
const STEP = 0.07;
for (let i = 0; i < COUNT; i++) {
const col = i % COLS;
const row = Math.floor(i / COLS);
const dx = (((i * 53) % 200) - 100) * 9; // far-flung scatter X
const dy = (((i * 89) % 200) - 100) * 7; // far-flung scatter Y
const rot = ((i * 37) % 80) - 40; // deterministic tilt
const order = col + row; // diagonal land sequence
tl.fromTo(
tiles[i],
{ x: dx, y: dy, rotation: rot, scale: 0.6, autoAlpha: 0.15 },
{
x: 0,
y: 0,
rotation: 0,
scale: 1,
autoAlpha: 1,
duration: 0.6,
ease: "back.out(1.6)",
},
0.1 + order * STEP,
);
}
// Settle the packed poster with a tiny breath so the loop seats cleanly.
tl.to("#grid", { scale: 1.02, duration: 0.18, ease: "power2.out" }, 1.9);
tl.to("#grid", { scale: 1, duration: 0.32, ease: "power2.inOut" }, 2.08);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>12 · neon_flicker</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.neon {
font-family: "Playfair Display", serif;
font-style: italic;
font-weight: 900;
font-size: 280px;
letter-spacing: -0.02em;
color: #ff64a0;
text-shadow:
0 0 8px #ff64a0,
0 0 24px #ff64a0,
0 0 60px #ff1a7a,
0 0 120px #ff1a7a;
will-change: opacity, text-shadow;
}
.neon-bg {
position: absolute;
inset: 0;
background: radial-gradient(
circle at 50% 50%,
rgba(255, 26, 122, 0.12) 0%,
transparent 60%
);
opacity: 0;
will-change: opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="neon-bg"></div>
<div class="stage"><div class="neon">neon</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set(".neon", { opacity: 0 }, 0);
const neonPattern = [
{ t: 0.2, o: 1 },
{ t: 0.26, o: 0 },
{ t: 0.3, o: 1 },
{ t: 0.38, o: 0.3 },
{ t: 0.42, o: 1 },
{ t: 0.5, o: 0.5 },
{ t: 0.55, o: 1 },
];
neonPattern.forEach((p) => {
tl.to(".neon", { opacity: p.o, duration: 0.02, ease: "steps(1)" }, p.t);
});
tl.to(".neon-bg", { opacity: 1, duration: 0.4, ease: "power2.out" }, 0.5);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>21 · outline_to_fill</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.outline-stack {
position: relative;
font-family: "Playfair Display", serif;
font-weight: 900;
font-size: 320px;
letter-spacing: -0.04em;
line-height: 0.95;
white-space: nowrap;
}
.stroke-layer {
position: absolute;
top: 0;
left: 0;
color: transparent;
-webkit-text-stroke: 3px var(--hg-violet-2);
text-shadow: 0 0 30px rgba(168, 85, 247, 0.5);
will-change:
opacity,
-webkit-text-stroke;
}
.fill-layer {
position: relative;
color: var(--hg-fg);
clip-path: inset(100% 0 0 0);
will-change: clip-path;
}
.spacer {
color: transparent;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="outline-stack">
<span class="spacer">Premium</span>
<span class="stroke-layer">Premium</span>
<span class="fill-layer">Premium</span>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Stroke draws in
tl.fromTo(
".stroke-layer",
{ opacity: 0, scale: 1.04 },
{ opacity: 1, scale: 1, duration: 0.45, ease: "power3.out" },
0.15,
);
// Fill rises from bottom
tl.set(".fill-layer", { clipPath: "inset(100% 0 0 0)" }, 0);
tl.to(".fill-layer", { clipPath: "inset(0% 0 0 0)", duration: 1.0, ease: "expo.out" }, 0.55);
// Stroke recedes once fill arrives
tl.to(".stroke-layer", { opacity: 0.35, duration: 0.6, ease: "power2.out" }, 1.0);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,132 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>palette_flip</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.kicker {
font-weight: 700;
font-size: 38px;
letter-spacing: 0.42em;
text-transform: uppercase;
margin-bottom: 40px;
}
.word {
font-weight: 900;
font-size: 300px;
letter-spacing: -0.05em;
line-height: 0.9;
white-space: nowrap;
}
.bars {
display: flex;
gap: 26px;
margin-top: 56px;
}
.bar {
width: 220px;
height: 26px;
border-radius: 4px;
}
.bar.b2 {
width: 140px;
}
.bar.b3 {
width: 90px;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2.6"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2.6" data-track-index="1">
<div class="stage">
<div class="kicker" id="kicker">REGIME</div>
<div class="word" id="word">REGIME</div>
<div class="bars">
<div class="bar b1"></div>
<div class="bar b2"></div>
<div class="bar b3"></div>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Four distinct palette regimes — same layout, re-skinned on each flip.
const regimes = [
{ bg: "#0a0a0f", fg: "#f5f5fa", accent: "#7c3aed", kicker: "#a855f7" }, // dark / violet
{ bg: "#f4ead8", fg: "#1a1410", accent: "#ff5a47", kicker: "#c4452f" }, // cream / coral
{ bg: "#08222b", fg: "#eafaf6", accent: "#1fd1b0", kicker: "#7fe3d2" }, // navy / teal
{ bg: "#000000", fg: "#ffce3a", accent: "#ffce3a", kicker: "#7a6010" }, // black / amber
];
const beat = 0.6;
const scene = "#scene";
const word = "#word";
const kicker = "#kicker";
const bars = ".bar";
// Instant 0ms flips on every beat — cycle through the regimes deterministically.
// The motion IS the colour change; layout never moves.
const flips = 5;
for (let i = 0; i < flips; i++) {
const p = regimes[i % regimes.length];
const t = i * beat;
tl.set(scene, { backgroundColor: p.bg }, t);
tl.set(word, { color: p.fg }, t);
tl.set(kicker, { color: p.kicker }, t);
tl.set(bars, { backgroundColor: p.accent }, t);
}
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>24 · particle_burst</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero {
font-weight: 900;
font-size: 320px;
letter-spacing: -0.05em;
color: var(--hg-fg);
white-space: nowrap;
will-change: transform, opacity;
position: relative;
z-index: 10;
}
.particle-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.particle {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--hg-violet-2);
box-shadow: 0 0 12px var(--hg-violet-2);
opacity: 0;
will-change: transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="particle-stage" id="particles"></div>
<div class="stage"><div class="hero">SPARK</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Build N particles deterministically
const N = 36;
const container = document.getElementById("particles");
const particles = [];
for (let i = 0; i < N; i++) {
const p = document.createElement("div");
p.className = "particle";
// Vary size per particle for visual interest
const sz = 4 + (i % 4) * 3;
p.style.width = sz + "px";
p.style.height = sz + "px";
// Slight color variation
const hue = ((i * 17) % 60) - 30;
p.style.filter = `hue-rotate(${hue}deg)`;
container.appendChild(p);
particles.push(p);
}
// Hero punch + flash entry
tl.fromTo(
".hero",
{ scale: 0.85, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.3, ease: "power4.out" },
0.3,
);
// Particles burst out
particles.forEach((p, i) => {
const angle = (i / N) * Math.PI * 2 + i * 0.137; // golden-angle jitter
const radius = 500 + (i % 5) * 90 + (i % 3) * 40;
const tx = Math.cos(angle) * radius;
const ty = Math.sin(angle) * radius;
const dur = 1.1 + (i % 5) * 0.08;
tl.fromTo(
p,
{ x: 0, y: 0, scale: 1, opacity: 1 },
{ x: tx, y: ty, scale: 0, opacity: 0, duration: dur, ease: "expo.out" },
0.35 + (i % 6) * 0.012,
);
});
// Hero settle wobble
tl.to(".hero", { scale: 1.04, duration: 0.1, ease: "power3.out" }, 0.35);
tl.to(".hero", { scale: 1.0, duration: 0.5, ease: "elastic.out(1, 0.5)" }, 0.45);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>26 · pixel_dissolve</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero {
font-weight: 900;
font-size: 320px;
letter-spacing: -0.05em;
color: var(--hg-fg);
white-space: nowrap;
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 60%, #d946ef 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.grid {
position: absolute;
inset: 0;
display: grid;
will-change: contents;
pointer-events: none;
}
.cell {
background: var(--hg-bg);
will-change: opacity, transform;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage"><div class="hero">DISSOLVE</div></div>
<div class="grid" id="grid"></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const COLS = 24;
const ROWS = 14;
const grid = document.getElementById("grid");
grid.style.gridTemplateColumns = `repeat(${COLS}, 1fr)`;
grid.style.gridTemplateRows = `repeat(${ROWS}, 1fr)`;
const cells = [];
for (let i = 0; i < COLS * ROWS; i++) {
const c = document.createElement("div");
c.className = "cell";
grid.appendChild(c);
cells.push(c);
}
// Deterministic shuffle: hash function based on i+j
function hash(i) {
return ((i * 9301 + 49297) % 233280) / 233280;
}
const order = cells.map((c, i) => ({ c, i, h: hash(i) })).sort((a, b) => a.h - b.h);
// All cells start visible (covering hero), fade out in shuffled order
const TOTAL = 0.85;
order.forEach((o, k) => {
const t = 0.35 + (k / order.length) * TOTAL;
tl.to(o.c, { opacity: 0, duration: 0.06, ease: "steps(1)" }, t);
});
// After dissolve, gentle hero scale settle (it was always visible, now exposed)
tl.fromTo(".hero", { scale: 1.03 }, { scale: 1.0, duration: 0.5, ease: "power2.out" }, 1.0);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,160 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>25 · radial_burst_lines</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero {
font-weight: 900;
font-size: 320px;
letter-spacing: -0.05em;
color: var(--hg-fg);
white-space: nowrap;
position: relative;
z-index: 10;
will-change: transform;
}
.burst-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.line {
position: absolute;
width: 5px;
height: 180px;
background: linear-gradient(to top, transparent 0%, var(--hg-fg) 50%, transparent 100%);
transform-origin: bottom center;
opacity: 0;
will-change: transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="burst-stage" id="bursts"></div>
<div class="stage"><div class="hero">BAM</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Build 20 lines
const N = 20;
const container = document.getElementById("bursts");
const lines = [];
for (let i = 0; i < N; i++) {
const l = document.createElement("div");
l.className = "line";
const angle = (i / N) * 360;
const innerRadius = 220;
// Rotate around center, push outward via transform-origin trick.
// Easier: use translate then rotate
l.style.transform = `rotate(${angle}deg) translateY(-${innerRadius}px) scaleY(0)`;
container.appendChild(l);
lines.push({ el: l, angle });
}
// Hero punch
tl.fromTo(
".hero",
{ scale: 0.7, opacity: 0 },
{ scale: 1.1, opacity: 1, duration: 0.15, ease: "power4.out" },
0.3,
);
tl.to(".hero", { scale: 1.0, duration: 0.4, ease: "elastic.out(1, 0.5)" }, 0.45);
// Burst lines expand
lines.forEach((l, i) => {
const innerRadius = 220 + (i % 3) * 30; // vary radius
const len = 200 + (i % 4) * 40; // vary length
const ang = l.angle;
tl.set(
l.el,
{
rotation: ang,
x: 0,
y: 0,
scaleY: 0,
opacity: 0,
transformOrigin: "center bottom",
},
0,
);
// GSAP transforms compose, so we set the rotation + translate via separate properties
// Use a custom transform string approach
tl.to(
l.el,
{
scaleY: 1,
opacity: 1,
duration: 0.18,
ease: "power4.out",
onUpdate: function () {
const p = this.progress();
const t = -innerRadius - len * p * 0.2;
l.el.style.transform = `rotate(${ang}deg) translateY(${t}px) scaleY(${p})`;
l.el.style.opacity = (p > 0.7 ? 1 - (p - 0.7) / 0.3 : 1).toString();
},
},
0.32 + (i % 5) * 0.018,
);
});
// Hold then fade
tl.to(".line", { opacity: 0, duration: 0.4, ease: "power2.out" }, 0.9);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>07 · screen_shake</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.hero {
font-weight: 900;
font-size: 280px;
letter-spacing: -0.04em;
line-height: 0.95;
color: var(--hg-fg);
white-space: nowrap;
}
.shake-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
will-change: transform;
}
.impact-bg {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 50%, rgba(255, 42, 74, 0.18) 0%, transparent 60%);
opacity: 0;
will-change: opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="impact-bg"></div>
<div class="shake-stage"><div class="hero">IMPACT</div></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo(
".hero",
{ scale: 0.6, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.15, ease: "power4.out" },
0.3,
);
tl.fromTo(
".impact-bg",
{ opacity: 0 },
{ opacity: 1, duration: 0.06, ease: "steps(1)" },
0.3,
);
tl.to(".impact-bg", { opacity: 0, duration: 0.6, ease: "power2.in" }, 0.4);
const shakeStart = 0.32;
const shakeAmps = [28, -24, 20, -22, 16, -18, 14, -14, 10, -8, 6, -5, 3, -2];
shakeAmps.forEach((amp, i) => {
tl.to(
".shake-stage",
{ x: amp, y: amp * 0.7 * (i % 2 ? -1 : 1), duration: 0.03, ease: "steps(1)" },
shakeStart + i * 0.03,
);
});
tl.to(
".shake-stage",
{ x: 0, y: 0, duration: 0.1, ease: "power2.out" },
shakeStart + shakeAmps.length * 0.03,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,130 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>15 · slot_machine_reveal</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.slot {
font-family: "JetBrains Mono", monospace;
font-weight: 700;
font-size: 280px;
letter-spacing: 0.04em;
color: var(--hg-fg);
display: flex;
gap: 30px;
}
.slot .digit {
display: inline-block;
min-width: 1ch;
text-shadow: 0 0 40px rgba(168, 85, 247, 0.6);
}
.slot-label {
position: absolute;
bottom: 30%;
left: 50%;
transform: translateX(-50%);
font-family: "JetBrains Mono", monospace;
font-size: 22px;
color: var(--hg-dim);
letter-spacing: 0.4em;
text-transform: uppercase;
opacity: 0;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="slot">
<span class="digit" data-target="5">0</span>
<span class="digit" data-target=".">0</span>
<span class="digit" data-target="5">0</span>
<span class="digit" data-target="x">0</span>
</div>
</div>
<div class="slot-label">FINAL VERSION</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const DIGITS = "0123456789";
const slotStates = [{ p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }];
slotStates.forEach((state, idx) => {
tl.to(
state,
{
p: 1,
duration: 1.0,
delay: idx * 0.08,
ease: "power3.out",
onUpdate: function () {
const spans = document.querySelectorAll(".slot .digit");
const span = spans[idx];
if (!span) return;
const target = span.getAttribute("data-target");
if (state.p >= 0.85) {
span.textContent = target;
span.style.color = "var(--hg-violet-2)";
} else {
const tick = Math.floor(state.p * 80 + idx * 3);
span.textContent = DIGITS.charAt(tick % 10);
}
},
},
0.15,
);
});
tl.to(".slot-label", { opacity: 1, duration: 0.5, ease: "power2.out" }, 1.2);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>20 · spotlight_sweep</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.dim-layer {
position: absolute;
inset: 0;
background: #03030a;
}
.hero-bright,
.hero-dim {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: "Playfair Display", serif;
font-weight: 900;
font-size: 320px;
letter-spacing: -0.04em;
white-space: nowrap;
}
.hero-dim {
color: rgba(245, 245, 250, 0.06);
}
.hero-bright {
color: var(--hg-fg);
text-shadow:
0 0 30px rgba(168, 85, 247, 0.4),
0 0 80px rgba(168, 85, 247, 0.2);
-webkit-mask-image: radial-gradient(
circle 360px at -400px 50%,
#000 0%,
rgba(0, 0, 0, 0.6) 60%,
transparent 80%
);
mask-image: radial-gradient(
circle 360px at -400px 50%,
#000 0%,
rgba(0, 0, 0, 0.6) 60%,
transparent 80%
);
will-change: mask-image;
}
.spotlight-glow {
position: absolute;
inset: 0;
background: radial-gradient(
circle 380px at -400px 50%,
rgba(168, 85, 247, 0.18) 0%,
transparent 60%
);
will-change: background;
mix-blend-mode: screen;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="dim-layer"></div>
<div class="hero-dim">DISCOVER</div>
<div class="hero-bright" id="bright">DISCOVER</div>
<div class="spotlight-glow" id="glow"></div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Animate the spotlight x position via mask-image (use a proxy + onUpdate for smoothness)
const state = { x: -400 };
tl.to(
state,
{
x: 2300,
duration: 1.4,
ease: "power2.inOut",
onUpdate: function () {
const m = `radial-gradient(circle 360px at ${state.x}px 50%, #000 0%, rgba(0,0,0,0.6) 60%, transparent 80%)`;
const g = `radial-gradient(circle 380px at ${state.x}px 50%, rgba(168, 85, 247, 0.18) 0%, transparent 60%)`;
const bright = document.getElementById("bright");
const glow = document.getElementById("glow");
if (bright) {
bright.style.maskImage = m;
bright.style.webkitMaskImage = m;
}
if (glow) glow.style.background = g;
},
},
0.3,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>staggered_exit</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 320px);
grid-template-rows: repeat(2, 320px);
gap: 36px;
}
.card {
display: flex;
align-items: center;
justify-content: center;
border-radius: 28px;
background: linear-gradient(135deg, var(--hg-violet), var(--hg-violet-2));
color: var(--hg-fg);
font-weight: 900;
font-size: 72px;
letter-spacing: -0.02em;
box-shadow: 0 24px 60px rgba(124, 58, 237, 0.35);
will-change: transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="grid">
<div class="card">NORTH</div>
<div class="card">EAST</div>
<div class="card">SOUTH</div>
<div class="card">WEST</div>
<div class="card">PEAK</div>
<div class="card">EDGE</div>
<div class="card">CORE</div>
<div class="card">FLUX</div>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const cards = gsap.utils.toArray(".card");
const hold = 0.4; // fully visible hold before the cascade begins
const step = 0.11; // per-element offset
const exitDur = 0.45;
// start fully visible
tl.set(cards, { autoAlpha: 1, y: 0 }, 0);
// ordered exit: each card slides down + fades, one after another
for (let i = 0; i < cards.length; i++) {
tl.to(
cards[i],
{ y: 80, autoAlpha: 0, duration: exitDur, ease: "power3.in" },
hold + i * step,
);
}
// snap all back to the visible start state so the next loop begins full
tl.set(cards, { autoAlpha: 1, y: 0 }, 1.95);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,42 @@
# Using `text-spectral-rays` — it OWNS its wordmark
`text-spectral-rays` is a **self-contained WebGL hero-text renderer**. From ONE rasterized
glyph mask it draws **both** the solid wordmark **and** the spectral rays that emanate from
it. Letters and rays share the same mask, so they are always perfectly registered.
## The one trap: never give the word a second source
The ghost / doubled-wordmark artifact comes from splitting the word across two sources:
-**Wrong** — use the shader as a "rays-only background" and draw the visible letters
with a **separate DOM element** (or stack a second text move like `content_swap` /
`chromatic_pressure` on the same word). The DOM font (e.g. Inter) and the shader's raster
font (Arial Black / Impact fallback) differ in width, shape, and position, so the ray
edges never line up with the DOM letters → a misregistered ghost. **Deleting the shader's
letter terms does NOT fix it** — the ray mask itself is still the second, misaligned copy
of the word.
-**Right** — let the shader render the wordmark. There is exactly ONE source, so a
ghost is structurally impossible.
## Integrate in one pass
1. **It IS the wordmark.** Hide any DOM logo for that word (keep it only as an invisible
layout spacer if a tagline/CTA below depends on its box). Never stack a discrete text
move on the same word.
2. **One timeline.** Merge its `progress` / `effectMix` state tweens onto the group's master
timeline and repaint via `tl.eventCallback("onUpdate", render)` — no second timeline, no
`requestAnimationFrame`.
3. **Align the cursor to the word.** `mouse.y` must equal the mask's vertical center. If you
move the rasterized word off frame-center (e.g. up, to leave room for a tagline), shift
the cursor's `y` by the same amount — otherwise the rays cast at the wrong angle.
4. **Local raster only.** Rasterize the word with a bundled / system font (no CDN font);
upload the mask + colour canvases as textures.
5. **Entrance.** Slam the whole canvas (autoAlpha + a scale punch, `transform-origin` on the
word's optical center) on the hit; let `effectMix` bloom the rays just after. The solid
letters are present the instant the canvas reveals.
## Pairs with
A background bed (`bg-flow-field`) or **separate** supporting elements (tagline, CTA, rule)
— never a second treatment of its own word.
@@ -0,0 +1,313 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"text", "type":"string", "label":"Wordmark", "default":"SPECTRAL"},
{"id":"textColor", "type":"color", "label":"Wordmark fill", "default":"#f6ff7a"},
{"id":"bgColor", "type":"color", "label":"Stage background", "default":"#000000"},
{"id":"sweepSpeed", "type":"number", "label":"Light sweep speed", "default":1, "min":0.2, "max":3, "step":0.1}
]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>text_spectral_rays</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #000000;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
background: #000;
}
.vfx-canvas {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
display: block;
}
.label {
position: absolute;
left: 48px;
bottom: 40px;
font-size: 22px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.6);
mix-blend-mode: screen;
pointer-events: none;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="4"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="4" data-track-index="1">
<canvas class="vfx-canvas" width="1920" height="1080" aria-hidden="true"></canvas>
<div class="label">text_spectral_rays</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
(function () {
const W = 1920;
const H = 1080;
const defaults = {
text: "SPECTRAL",
textColor: "#f6ff7a",
bgColor: "#000000",
sweepSpeed: 1,
};
const vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
const canvas = document.querySelector(".vfx-canvas");
const gl = canvas.getContext("webgl", {
premultipliedAlpha: false,
preserveDrawingBuffer: true,
});
// animated state — GSAP tweens these; mouse (the light cursor) is derived
const state = { progress: 0, effectMix: 0 };
const mouse = { x: 0, y: H * 0.5 };
const speed = Number(vars.sweepSpeed) || 1;
// ── Rasterize the wordmark into a white MASK + a coloured SRC canvas ──
function drawWord(ctx, fill) {
ctx.clearRect(0, 0, W, H);
let fs = 340;
const stack = "900 " + fs + 'px "Arial Black", Impact, "Inter", system-ui, sans-serif';
ctx.font = stack;
const maxW = 1600;
const measured = ctx.measureText(vars.text).width || 1;
if (measured > maxW) {
fs = Math.floor((fs * maxW) / measured);
ctx.font = "900 " + fs + 'px "Arial Black", Impact, "Inter", system-ui, sans-serif';
}
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = fill;
ctx.fillText(vars.text, W * 0.5, H * 0.5);
}
const maskCanvas = document.createElement("canvas");
maskCanvas.width = W;
maskCanvas.height = H;
drawWord(maskCanvas.getContext("2d"), "#ffffff");
const colorCanvas = document.createElement("canvas");
colorCanvas.width = W;
colorCanvas.height = H;
drawWord(colorCanvas.getContext("2d"), vars.textColor);
// ── WebGL fallback: flat fill (keeps the timeline registered) ──
if (!gl) {
const ctx = canvas.getContext("2d");
ctx.fillStyle = vars.bgColor;
ctx.fillRect(0, 0, W, H);
tl.to(state, { progress: 1, duration: 3.6, ease: "none" }, 0.2);
window.__timelines["main"] = tl;
return;
}
const VERT = [
"attribute vec2 position;",
"varying vec2 vUv;",
"void main(){",
" vUv = position * 0.5 + 0.5;",
" gl_Position = vec4(position, 0.0, 1.0);",
"}",
].join("\n");
// Volumetric spectral light-rays cast by the text mask toward a moving
// light cursor — ported from the vfx-text-cursor golden sample, made
// generic (reads any text mask/colour). All state from uniforms.
const FRAG = [
"precision highp float;",
"varying vec2 vUv;",
"uniform vec2 resolution;",
"uniform vec2 mouse;",
"uniform float maskY;",
"uniform float maskScale;",
"uniform float effectMix;",
"uniform sampler2D src;",
"uniform sampler2D colorSrc;",
"#define PI 3.141593",
"#define SAMPLES 112.0",
"float hash(vec2 p){ return fract(sin(dot(p, vec2(489.0,589.0))) * 492.0) * 2.0 - 1.0; }",
"float hash(vec3 p){ return fract(sin(dot(p, vec3(489.0,589.0,58.0))) * 492.0) * 2.0 - 1.0; }",
"vec2 hash2(vec3 p){ return vec2(hash(p), hash(p + 1.0)); }",
"vec4 readMask(vec2 uv){ if(uv.x<0.0||uv.x>1.0||uv.y<0.0||uv.y>1.0){return vec4(0.0);} return texture2D(src, uv); }",
"vec4 readColor(vec2 uv){ if(uv.x<0.0||uv.x>1.0||uv.y<0.0||uv.y>1.0){return vec4(0.0);} return texture2D(colorSrc, uv); }",
"vec3 spectrum(float x){ return cos((x - vec3(0.0,0.5,1.0)) * vec3(0.6,1.0,0.5) * PI); }",
"void main(){",
" vec2 uv = vUv;",
" vec2 center = vec2(0.5, 0.5);",
" vec2 maskUv = (uv - center) / maskScale + center + vec2(0.0, maskY / resolution.y);",
" float mask = readMask(maskUv).r;",
" vec4 col0 = readColor(maskUv);",
" vec2 p = uv * 2.0 - 1.0;",
" p.x *= resolution.x / resolution.y;",
" vec2 mp = mouse / resolution;",
" mp.y = 1.0 - mp.y;",
" mp = mp * 2.0 - 1.0;",
" mp.x *= resolution.x / resolution.y;",
" vec2 rp = p;",
" vec2 d = (mp - p) / SAMPLES;",
" float acc = 0.0;",
" for(float i = 0.0; i < SAMPLES; i++){",
" rp += d;",
" rp += hash2(vec3(rp, i)) * 0.5 / SAMPLES;",
" vec2 uv2 = rp;",
" uv2.x /= resolution.x / resolution.y;",
" uv2 = uv2 * 0.5 + 0.5;",
" vec2 rayUv = (uv2 - center) / maskScale + center + vec2(0.0, maskY / resolution.y);",
" acc += readMask(rayUv).r / SAMPLES;",
" }",
" vec4 c = vec4(0.0, 0.0, 0.0, 1.0);",
" c -= acc * 0.24;",
" c += vec4(spectrum(cos(acc * 4.9)), 1.0) * acc * 3.65;",
" c.rgb = max(c.rgb - 0.035, 0.0);",
" c.rgb *= 1.16;",
" float cursorX = mouse.x / resolution.x;",
" float sweep = exp(-pow((uv.x - cursorX) * 6.2, 2.0));",
" float textProximity = smoothstep(0.006, 0.34, acc);",
" float longRay = smoothstep(0.004, 0.18, acc);",
" vec3 sweepColor = vec3(0.14, 0.65, 0.85) + spectrum(cursorX * 1.8) * 0.28;",
" c.rgb += sweepColor * sweep * (0.34 + textProximity * 2.35);",
" c.rgb += spectrum(cursorX + acc * 2.0) * longRay * 0.58;",
" c.rgb += col0.rgb * acc * 1.18;",
" c.rgb += col0.rgb * sweep * mask * 0.92;",
" c.rgb *= effectMix;",
" c.rgb = mix(c.rgb, col0.rgb, smoothstep(0.02, 0.75, col0.a));",
" c.rgb += vec3(1.0) * smoothstep(0.04, 0.38, mask) * 0.12 * effectMix;",
" float grain = hash(vec3(uv.xyy + mouse.x * 0.0007));",
" float dust = smoothstep(0.02, 0.38, acc) + smoothstep(0.03, 0.22, mask);",
" c.rgb += grain * 0.07 * (0.42 + dust) * effectMix;",
" c.rgb += spectrum(grain + cursorX) * abs(grain) * 0.045 * smoothstep(0.02, 0.5, acc) * effectMix;",
" c.a = 1.0;",
" gl_FragColor = c;",
"}",
].join("\n");
function compile(type, source) {
const s = gl.createShader(type);
gl.shaderSource(s, source);
gl.compileShader(s);
return s;
}
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
function createTexture(source) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return t;
}
const maskTex = createTexture(maskCanvas);
const colorTex = createTexture(colorCanvas);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const posLoc = gl.getAttribLocation(program, "position");
const L = (n) => gl.getUniformLocation(program, n);
const uRes = L("resolution");
const uMouse = L("mouse");
const uMaskY = L("maskY");
const uMaskScale = L("maskScale");
const uMix = L("effectMix");
const uSrc = L("src");
const uColorSrc = L("colorSrc");
function render() {
mouse.x = state.progress * W;
mouse.y = H * 0.5;
gl.viewport(0, 0, W, H);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, maskTex);
gl.uniform1i(uSrc, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, colorTex);
gl.uniform1i(uColorSrc, 1);
gl.uniform2f(uRes, W, H);
gl.uniform2f(uMouse, mouse.x, mouse.y);
gl.uniform1f(uMaskY, 0);
gl.uniform1f(uMaskScale, 1);
gl.uniform1f(uMix, state.effectMix);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
render(); // initial frame (seek-to-0 correctness)
// All repaint via the timeline's onUpdate — fully seek-safe, no rAF.
tl.eventCallback("onUpdate", render);
// effect punches in, the light cursor sweeps L→R across the wordmark,
// then fades for a clean loop.
tl.set(state, { progress: 0, effectMix: 0 }, 0);
tl.to(state, { effectMix: 1, duration: 0.45, ease: "power3.out" }, 0.15);
tl.to(state, { progress: 1, duration: 3.4 / speed, ease: "none" }, 0.2);
tl.to(state, { effectMix: 0, duration: 0.4, ease: "power2.in" }, 3.6);
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>28 · text_wave_distort</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-cyan: #2afff0;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.wave-hero {
font-family: "Playfair Display", serif;
font-style: italic;
font-weight: 900;
font-size: 280px;
letter-spacing: -0.02em;
white-space: nowrap;
text-shadow: 0 0 40px rgba(168, 85, 247, 0.5);
}
.wave-hero .char {
display: inline-block;
will-change: transform;
}
/* per-char solid colors marching across a violet→cyan ramp */
.wave-hero .char:nth-child(1) {
color: #7c3aed;
}
.wave-hero .char:nth-child(2) {
color: #a855f7;
}
.wave-hero .char:nth-child(3) {
color: #d946ef;
}
.wave-hero .char:nth-child(4) {
color: #ec4899;
}
.wave-hero .char:nth-child(5) {
color: #2afff0;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
<div class="stage">
<div class="wave-hero" id="hero">
<span class="char">f</span><span class="char">l</span><span class="char">u</span
><span class="char">i</span><span class="char">d</span>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Explicit fromTo entry — safer than .from() in paused/seek mode
tl.fromTo(
"#hero .char",
{ y: 60, opacity: 0 },
{ y: 0, opacity: 1, stagger: 0.06, ease: "back.out(1.8)", duration: 0.5 },
0.1,
);
// Continuous wave: phase 0→4 cycles over 1.5s, amplitude envelope rise→settle
const state = { phase: 0 };
tl.to(
state,
{
phase: 4,
duration: 1.5,
ease: "none",
onUpdate: function () {
const chars = document.querySelectorAll("#hero .char");
const p = state.phase / 4;
let amp;
if (p < 0.35) amp = (p / 0.35) * 24;
else if (p < 0.7)
amp = 24 - ((p - 0.35) / 0.35) * 12; // 24 → 12
else amp = 12 - ((p - 0.7) / 0.3) * 4; // 12 → 8
chars.forEach((c, i) => {
const y = amp * Math.sin(state.phase * Math.PI * 2 + i * 0.45);
// Use gsap.set so it cooperates with GSAP's transform tracking
gsap.set(c, { y });
});
},
},
0.65,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,144 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>tile_mosaic</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.mosaic {
position: relative;
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 6px;
width: 1440px;
height: 960px;
}
.tile {
border-radius: 4px;
will-change: transform, opacity;
}
.title {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 220px;
letter-spacing: -0.03em;
color: var(--hg-fg);
text-shadow: 0 8px 40px rgba(0, 0, 0, 0.55);
will-change: transform, opacity;
pointer-events: none;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2.4"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2.4" data-track-index="1">
<div class="stage">
<div class="mosaic" id="mosaic"></div>
<div class="title" id="title">MOSAIC</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const COLS = 6;
const ROWS = 4;
const STEP = 0.06;
// Deterministic palette; tile colour chosen by index, no randomness.
const PALETTE = [
"#7c3aed",
"#a855f7",
"#6d28d9",
"#c084fc",
"#5b21b6",
"#8b5cf6",
"#9333ea",
"#4c1d95",
];
const mosaic = document.getElementById("mosaic");
const tiles = [];
for (let i = 0; i < COLS * ROWS; i++) {
const row = Math.floor(i / COLS);
const col = i % COLS;
const cell = document.createElement("div");
cell.className = "tile";
const a = PALETTE[(row + col) % PALETTE.length];
const b = PALETTE[(row + col + 3) % PALETTE.length];
cell.style.background = "linear-gradient(135deg, " + a + " 0%, " + b + " 100%)";
mosaic.appendChild(cell);
tiles.push({ el: cell, diag: row + col });
}
// Diagonal sweep: reveal order governed by (row + col), explicit per-tile timing.
for (const t of tiles) {
tl.fromTo(
t.el,
{ autoAlpha: 0, scale: 0.6 },
{ autoAlpha: 1, scale: 1, duration: 0.4, ease: "back.out(1.7)" },
t.diag * STEP,
);
}
// Title settles over the finished, dense mosaic.
tl.fromTo(
"#title",
{ autoAlpha: 0, scale: 1.12 },
{ autoAlpha: 1, scale: 1, duration: 0.45, ease: "power2.out" },
1.7,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>typewriter_reveal</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.line {
display: flex;
align-items: baseline;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-weight: 800;
font-size: 200px;
letter-spacing: 0.02em;
line-height: 0.95;
white-space: nowrap;
}
.char {
display: inline-block;
opacity: 0;
will-change: opacity;
}
.char.space {
width: 0.4em;
}
.caret {
display: inline-block;
width: 0.09em;
height: 0.86em;
margin-left: 0.06em;
background: var(--hg-violet-2);
will-change: opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2.4"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2.4" data-track-index="1">
<div class="stage">
<div class="line" id="line">
<span class="char">S</span><span class="char">Y</span><span class="char">S</span
><span class="char">T</span><span class="char">E</span><span class="char">M</span
><span class="char space">&nbsp;</span><span class="char">O</span
><span class="char">N</span><span class="char">L</span><span class="char">I</span
><span class="char">N</span><span class="char">E</span
><span class="caret" id="caret"></span>
</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const chars = gsap.utils.toArray(".char");
const step = 0.07;
// Explicit per-character reveal: one set() per char at time i * step.
chars.forEach((char, i) => {
tl.set(char, { opacity: 1 }, i * step);
});
const lastT = (chars.length - 1) * step;
// Caret rides just past the last revealed char and blinks a finite number
// of times (no repeat:-1), then the phrase locks crisp.
tl.set("#caret", { opacity: 1 }, 0);
tl.to(
"#caret",
{ opacity: 0, duration: 0.09, ease: "steps(1)", repeat: 8, yoyo: true },
lastT + step,
);
tl.set("#caret", { opacity: 0 }, 2.32);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>word_grid_burst</title>
<script src="../assets/gsap.min.js"></script>
<style>
:root {
--hg-violet: #7c3aed;
--hg-violet-2: #a855f7;
--hg-bg: #0a0a0f;
--hg-fg: #f5f5fa;
--hg-dim: #6b6b78;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--hg-bg);
font-family: "Inter", system-ui, sans-serif;
color: var(--hg-fg);
-webkit-font-smoothing: antialiased;
}
#scene {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
}
.stage {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-rows: repeat(3, 1fr);
align-items: center;
justify-items: center;
padding: 140px 120px;
gap: 24px;
}
.cell {
font-weight: 900;
font-size: 92px;
letter-spacing: -0.03em;
line-height: 0.95;
color: var(--hg-fg);
white-space: nowrap;
will-change: transform, opacity;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="2.8"
data-width="1920"
data-height="1080"
>
<div id="scene" class="clip" data-start="0" data-duration="2.8" data-track-index="1">
<div class="stage">
<div class="cell">PULSE</div>
<div class="cell">FORGE</div>
<div class="cell">SURGE</div>
<div class="cell">VOLT</div>
<div class="cell">CORE</div>
<div class="cell">FLUX</div>
<div class="cell">SPARK</div>
<div class="cell">DRIVE</div>
<div class="cell">BLAZE</div>
<div class="cell">WAVE</div>
<div class="cell">SHIFT</div>
<div class="cell">BOLT</div>
<div class="cell">RUSH</div>
<div class="cell">PEAK</div>
<div class="cell">IGNITE</div>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Deterministic reveal order: scatter sequence across the 15-cell grid.
const order = [7, 0, 12, 4, 9, 2, 14, 6, 11, 1, 8, 3, 13, 5, 10];
const cells = gsap.utils.toArray(".cell");
const step = 0.13;
const FOCUS = 7; // chosen focal cell ("DRIVE")
// Each cell pops in on its own "onset" — explicit per-cell timing.
order.forEach((idx, i) => {
tl.fromTo(
cells[idx],
{ autoAlpha: 0, scale: 0.8 },
{ autoAlpha: 1, scale: 1, duration: 0.22, ease: "back.out(1.8)" },
i * step,
);
});
// Downbeat refocus (~2.3s): dim the whole grid, pop the focal word.
tl.to(".cell", { autoAlpha: 0.25, duration: 0.18, ease: "power2.out" }, 2.3);
tl.to(
cells[FOCUS],
{
autoAlpha: 1,
scale: 1.25,
color: "var(--hg-violet-2)",
duration: 0.22,
ease: "back.out(2)",
},
2.32,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,104 @@
# Planning (Step 3) — pick the brand, fill every frame
At Step 3 **you (the orchestrator)** turn the Step-2 skeleton into a complete, approved
`STORYBOARD.md`. You edit the **same file** in place: pick the brand spine, then for each
frame decide its **groups**, give each group a treatment, bind real beat anchors, and write
the copy.
Your mantra: **music is the spine; a template is a head start, not a cage; typography is the
floor and assets are an optional ingredient on the same beat grid.**
**You own WHAT, not HOW.** You name the template / primitives, the content, the brand, the
anchor seconds, and the intent. The frame-worker (Step 4) decides HOW — micro-timing,
realization, intra-frame cuts. **Never write millisecond tweens into the storyboard.**
## Inputs
- The Step-2 skeleton already in `STORYBOARD.md` — frames with `span_sec` + `pacing` + `mood` + `feel`.
- `audiomap.json` — timing truth; read the real anchor seconds inside each frame's span.
- [`template-catalog.md`](template-catalog.md) — the template selection menu.
- [`motion-primitive-catalog.md`](motion-primitive-catalog.md) — the free-compose menu (L0 recipes).
- [`montage.md`](montage.md) — asset treatments (only if the user supplied images/videos).
- User brief / supplied copy — topic, mood, exact words to keep.
## Step A — pick the brand spine (one preset, unmodified)
The whole video shares one type family + palette. Pick **one ready-made preset** from
`../hyperframes-creative/frame-presets/` using the preset table in
`../hyperframes-creative/references/design-spec.md` — choose by the track's mood + the brief,
and **only its fonts + colors matter** (templates own composition + motion; the preset only
sets the look). Copy it in **unmodified**:
```bash
cp ../hyperframes-creative/frame-presets/<preset>/FRAME.md "$PROJECT_DIR/frame.md"
```
Then fill the storyboard frontmatter `style` from it: the `font` from its `typography:` and a
≤46 swatch `palette` from its `colors:`. **Quote the hex / family verbatim — never invent or
round.** Every group's palette params draw from this one palette; that unity is what makes
different templates read as one piece.
## Step B — per frame, decide its groups
A frame is usually **one group** (one template or one free composition spanning the frame).
Subdivide into 2+ groups **only when a single treatment can't cover the frame** — e.g. a busy
opener plus a closing lockup. When you split, cut at a **real audiomap anchor** inside the
frame's span (a `key_moment` / `phrase` edge / onset-cluster gap), **never inside a `rolls[]`
run**, and keep every group **≥ ~1 bar**. Density does **not** force more groups — a dense
frame is usually ONE group whose template absorbs the density internally (a meta-template like
`poster-tile-mosaic`). **Group count tracks distinct treatments, not beats.**
## Step C — per group, pick a treatment (exactly one of three)
### A. Match a template
Read [`template-catalog.md`](template-catalog.md). Match the group's `feel` + `mood` + `pacing`
to a template's **Reach for it when**; take the closest fit. Then bind it:
- Fill `params` (keys from the catalog entry) — your copy into text slots, palette from the brand spine, `duration` = the group's span length.
- Fill `role_bindings` with this group's **real anchor seconds** read from `audiomap.json` over its span (not example times).
- If the template's natural stop and the group's span end disagree, snap to the nearest anchor.
### B. Free-compose (no template fits)
Write a `free_design` — one visual thesis from [`motion-primitive-catalog.md`](motion-primitive-catalog.md)
(a dominant system + the named L0 primitives + a density topology) + `anchors` (the real
beat / onset seconds the moves ride). Free-compose is a **first-class** choice, written as
carefully as a matched group — never a failure.
### C. Asset treatment (only when the user supplied assets and they fit)
Make it an `asset` group ([`montage.md`](montage.md)). **Obey `pacing`:** on a `beat_cut`
frame use `beat_cut` (one clip per anchor) or `bg_under_text`; on a `phrase_flow` frame use
`ken_burns` or a slow crossfade — **never** per-onset hard cuts. Assets are additive: if none
fits a group, fall back to template / free (typography is the floor — a complete video needs
no assets).
## Copy (you own the words)
- Keep exact user words; else invent with taste, on the brief's mood.
- **Message vs texture:** a readable word holds ≥1 beat (headline 38, sentence 410), stable + focal; a word held <1 beat is texture (strobe / grid / ticks). Never force a message onto a sub-beat — demote it to texture.
- Place copy into the template's text params, onto a free group's anchors, or as an asset group's `overlay_copy`. Declare the anchor + accumulate / stagger intent; leave micro-timing to the worker.
- A closing logo / CTA lands on the final hit / hard stop and holds through trailing silence.
## Transitions (you do not emit them)
Everything is a **0ms hard cut** for now. **frame → frame** is owned by the assembler
(back-to-back files); adjacent `span_sec` already imply the cut. **group → group inside a
frame** is owned by the worker on its frame timeline; you only set each group's `span_sec`.
## Write + validate
Complete `STORYBOARD.md` ([`storyboard-format.md`](storyboard-format.md)), then run
`node scripts/validate-plan.mjs` and fix every `✗`. Show the user a frame-by-frame summary and
iterate until approved.
## Self-check
- `frame.md` is a verbatim copy of one preset; frontmatter `style.font` / `style.palette` are drawn from it (exact values).
- Every frame became ≥1 group; groups tile the frame span in order; no group < ~1 bar; no group boundary inside a `rolls[]` run.
- Each group is exactly one of template / free_design / asset.
- Template `params` keys match the catalog entry; `role_bindings` / `anchors` use real audiomap seconds.
- Asset treatments obey `pacing` (no `beat_cut` on a `phrase_flow` frame).
- Every group's palette draws from the one brand palette.
- `duration_s == audiomap.audio.duration_sec`; `validate-plan.mjs` passes.
@@ -0,0 +1,94 @@
# STORYBOARD.md format — frames → groups
`STORYBOARD.md` is the single reviewable plan the user approves at Step 3 and the **manual**
each frame-worker follows at Step 4. It is **hierarchical**: one block per **frame**, written
as a `## Frame N — <frame_id>` heading (the parser recognizes `Frame`). **One frame = one
scene = one composition file.** Inside each frame block are its **groups** (the treatment
units). The assembler reads the frame level (`duration``data-start`, `src`); the worker
reads its own frame block. A frame's **composition id = its `src` file stem**
(`compositions/frames/01-f1.html``01-f1`), which the worker uses as `data-composition-id`
and the `window.__timelines` key.
Step 2 writes the skeleton (frame fields, groups `TBD`); Step 3 fills the groups + brand. It
is a build spec, not code — the planner writes WHAT, the worker decides HOW. **Never write
millisecond tweens here.**
## File shape
YAML frontmatter (the video-wide spine) + one `## Frame N — <frame_id>` block per frame.
```markdown
---
compositionId: bgm
duration_s: 30.0 # == audiomap.audio.duration_sec, exactly
canvas: { w: 1920, h: 1080, fps: 30 }
style: # brand spine — from the chosen frame.md preset (Step 3)
font: "EB Garamond / Inter / JetBrains Mono" # the preset's typography, verbatim
palette: ["#FAF9F5", "#141413", "#CC785C", "#181715"] # ≤46 swatches from the preset's colors
assets: false # false, or a note like "assets/ has 6 user photos"
build_notes: ["one paused timeline per frame", "no remote assets"]
avoid: ["generic slideshow", "tiny unreadable hero text"]
---
## Frame 1 — f1
- src: compositions/frames/01-f1.html # worker writes here; assembler refs it; stem (01-f1) = composition id
- duration: 7.198s # = span length; the assembler reads this for cumulative data-start
- span_sec: [0.0, 7.198] # track seconds; frames tile the track
- pacing: beat_cut # beat_cut | phrase_flow (from the skeleton; obey it)
- mood: [hype]
- feel: accelerating onset stream into a held downbeat
### Groups
- **g1** — template: `intro-kinetic-cascade`
- span_sec: [0.0, 4.017] # frame-LOCAL build is 0-based; these are TRACK seconds (worker subtracts frame start)
- params: { theme: "light", icon: "bolt", phrases: "[…]", climax: "{…}" }
- role_bindings: { phrase: { times: [0.14, 0.55, 0.87] }, climax: { in: 3.79, iconAt: 4.9 } }
- copy: "GROWTH THROUGH CREATIVITY"
- **g2** — free_design
- span_sec: [4.017, 7.198]
- free_design: { dominant_system: "per-onset typography", primitives: ["content-swap", "braam-punch"], density_topology: "accumulate" }
- anchors: [4.10, 4.80, 5.50, 6.20] # onset seconds the reveals ride (from audiomap)
- copy: ["BUILD", "SHIP", "REPEAT"]
## Frame 2 — f2
```
## Frame block — required fields
| field | meaning |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| heading `## Frame N — <frame_id>` | `frame_id` matches the `src` stem; `N` = the 1-based index. |
| `src` | `compositions/frames/NN-<frame_id>.html` — where the worker writes; the assembler references it. Stem = `data-composition-id`. |
| `duration` | the frame span length in seconds (e.g. `7.198s`) — **required**; the assembler sums these for cumulative `data-start`. |
| `span_sec` | `[start, end]` track seconds. `duration = end start`. |
| `pacing` | `beat_cut` \| `phrase_flow` — from the skeleton; the worker must obey (no hard-cut on `phrase_flow`). |
| `mood`, `feel` | from the skeleton; tone + the one-line music situation the planner matched against. |
| `### Groups` list | ≥1 group; groups tile the frame span in order. |
## Group entry — exactly one of three kinds
Every group is **template** OR **free_design** OR **asset** — never two, never none. All three
carry `span_sec` (track seconds, tiling the frame) and may carry `copy`.
- **template** — `template: <catalog id>` + `params` (keys from the catalog entry) + `role_bindings` (real audiomap anchor seconds) + `copy`.
- **free_design** — `free_design: { dominant_system, primitives: [catalog ids], density_topology }` + `anchors` (real beat / onset seconds) + `copy`.
- **asset** — `asset: { treatment, clips: [public/…], anchors?, overlay_copy? }`. `treatment``beat_cut` (one clip per anchor — only on a `beat_cut` frame) | `ken_burns` (slow push — fits `phrase_flow`) | `bg_under_text` (clip dimmed behind a template / free group). See [`montage.md`](montage.md).
## Rules
- Frames tile the track (gap-free, first at 0, last at `duration_s`); a frame's groups tile its span; no group < ~1 bar; no group boundary inside a `rolls[]` run.
- `params` keys come from the template's [`template-catalog.md`](template-catalog.md) entry.
- All anchor seconds are **track seconds from `audiomap.json`** — the worker converts to frame-local by subtracting the frame start.
- A `phrase_flow` frame MUST NOT use `beat_cut` asset treatment or per-onset hard cuts.
- The brand `style` is set once; every group's palette draws from it.
- Reviewable prose-plus-data; keep it scannable. No GSAP, no millisecond timing.
## Self-check (the planner runs `validate-plan.mjs`)
- frontmatter has `compositionId`, `duration_s` (== audiomap), `canvas`, `style`.
- every frame has `span_sec` + `src` + positive `duration` + `pacing` + ≥1 group; frames tile the track.
- every group is exactly one of template / free_design / asset; template ids exist in the catalog; anchors are real audiomap seconds; `phrase_flow` frames have no `beat_cut`.
@@ -0,0 +1,106 @@
# Template catalog — the selection menu (Step 3)
Reverse-engineered, asset-free **group templates**. This file is the **only** thing the
planner reads to pick one — you do **not** open the template's `index.html` to choose. One
template realizes **one group** of a frame (a frame may stack several groups; the brand spine
unifies them).
**How to pick.** For each group, read its frame's `pacing` + `mood` + the one-line music
situation from the skeleton, then scan **Reach for it when** + **Best span** below and take the
closest fit. If nothing fits, **free-compose** from
[`motion-primitive-catalog.md`](motion-primitive-catalog.md) — that is a first-class choice, not a
failure.
**Brand comes from `frame.md`, not the template.** Templates ship their own demo palette
(`theme` / `palette` / color params). Fill those params from the project's `frame.md` (the
chosen preset's colors + fonts) so every group reads as one piece — the template supplies the
**motion + layout**, the preset supplies the **look**.
**Params** are listed so you can fill content slots in the storyboard. Exact semantics +
defaults live in each template's `index.html` (`data-composition-variables`) — the frame-worker
reads those at build time; you only name the values.
Pacing tag: every template below is **beat_cut** except `held-message-living-field`
(**phrase_flow**). Never put a beat_cut template on a phrase_flow frame.
## Group duration discipline
Template choice is per **group**, not per frame. A frame longer than a template's **Best span**
should usually split into multiple groups at real audiomap anchors (`SURGE`, `DROP`, roll edge,
hard_stop, phrase edge) instead of stretching one template across the whole frame.
- **Best span** below is the active treatment span: the part where the template's system is doing
meaningful motion. A short readable hold at the end is fine; a long empty tail means pick another
group.
- **Frames over ~6s** usually need 2+ groups. Beat-cut exceptions are rich programs with real
sub-phases (`poster-tile-mosaic`, sometimes `card-flyby`); phrase-flow exceptions use
`held-message-living-field`.
- **Roll templates are one-roll tools.** If a frame has two rolls or a drop between rolls, split it
into two groups.
- Do not extend by slowing every tween. Preserve the template's motion feel, then fill extra time
with a hold / palette change / new group.
---
### card-flyby
- **What** — a depth column of cards rolls forward through perspective; each landing beat tumbles the next card into the front slot with a solid colored wipe, the old front falls toward camera, dwells shrink card-to-card so the deck accelerates into a held final card.
- **Reach for it when** — a stream of discrete onsets that **accelerate** (gaps shrinking / a build into a downbeat) and you want to flash a **sequence of items** — titles, projects, posters, tiles — one per hit, climaxing on a held card.
- **Best span** — **4-6.5s** for 4-7 landings plus a short final hold; split at the next downbeat if it wants to run **>7s**.
- **Params** — `theme`, `bgColor`, `cards`, `landings`, `yaw`
### held-message-living-field · phrase_flow
- **What** — a readable mark (logo / word / title) held dead still over a soft, color-shifting blurred field; only the field breathes.
- **Reach for it when** — a **calm / sparse** stretch with an onset desert — energy present but few or no onsets (a held pad or riser); you have one word or mark to hold and let breathe.
- **Best span** — **6-16s**; this is the long-group exception. Under 4s feels underdeveloped; over ~20s needs a state change or another group.
- **Params** — `markText`, `titleText`, `tagText`, `palette`, `flowSpeed`, `duration`
### held-text-strobe-burst
- **What** — a dead-still word whose letters flip through texture-filled frames (texture-clipped fill + per-frame tint + bg color) every ~3 frames, in short bursts pinned to a roll.
- **Reach for it when** — a **dense, hard-hitting roll / fill** and a single word you want to strobe through textures for a few bars. (Ships texture-mask PNGs under `assets/`.)
- **Best span** — **1.2-3.5s**; strobe fatigue starts fast, so cap at ~4s and cut to a cleaner system.
- **Params** — `markText`, `fontStyle`, `markScale`, `idleColor`, `idleInk`, `frames`, `strobePlan`, `decor`, `duration`
### intro-kinetic-cascade
- **What** — a line laid out as a sequence of big editorial **phrases** (each a stacked poster with one enlarged hero word), revealed word-by-word on its anchors, hard cut between phrases, climaxing on a phrase that slides in with a swappable ringing **icon** (bell / cursor / sparkle / emoji / SVG).
- **Reach for it when** — an **intro / opening statement**: a short line to land word-by-word as big type, climaxing on one keyword + an icon. Medium-or-more energy, steady grid.
- **Best span** — **3.5-7s** for 2-4 phrase beats; if the statement needs more time, make the next clause a new group.
- **Params** — `theme`, `icon`, `phrases`, `climax`
### logo-split-lockup-pulse
- **What** — a two-part mark joined at center splits left↔right to open a gap, grows a center word-lockup one word per onset (key word lands on the downbeat surge), snap-closes on a hit, then pulses with the beat.
- **Reach for it when** — a short **logo / brand sting** (not a typed sentence): fast dense onsets + a sustained roll bed to pulse on, with a left/right bracketing mark.
- **Best span** — **2-4s**; at **>4.5s** it reads like a sting stretched too long. Follow with a separate held-lockup / next idea group.
- **Params** — `bgColor`, `markColor`, `textColor`, `leftMark`, `rightMark`, `word1`, `word2`, `word3`, `word4`
### poster-tile-mosaic
- **What** — a packed **mosaic** of different-sized colored tiles that tessellate to fill the frame (no overlap), driven by interchangeable beat-synced operations: staggered enter/exit, locked global recolor, snake-fill + overlay.
- **Reach for it when** — many discrete, individually-placeable onsets (a hit for every tile move) with distinct sub-phases you want articulated differently (accumulate → recolor-on-roll → fill-then-drop). A dense section best held as **one** rich tile program rather than split.
- **Best span** — **4-7s**, up to **8s** only when the program has clear sub-phases. If the music changes regime, split even if the tile system could continue.
- **Params** — `bgColor`, `tiles`, `bands`, `gap`, `showText`, `labels`, `program`
### roll-flipbook-word-cycle
- **What** — a hi-hat roll drives a centred word that flips every 16th-note through a word list; optionally the flicker resolves and locks into a final phrase.
- **Reach for it when** — a **fast sustained-fill roll** (hundreds of hits/min, ~16th-note) with no single readable message — fill the roll with a rapidly-cycling word flipbook.
- **Best span** — **1.2-3.8s**, one roll into one resolve. Two rolls, or a drop between rolls, means two groups.
- **Params** — `bgColor`, `textColor`, `accentColor`, `flipWords`, `resolveText`, `periodChar`
### split-anchor-word-slot
- **What** — a held left anchor column of fixed-word rows beside a torn-paper word-slot box on the right, driven by beat-synced operators: anchor lock-in, slot word-group cycle (in/out + per-line color), full-scene background flip, per-beat jitter, box-zoom exit wipe. Row count + number of flips are data.
- **Reach for it when** — a short section with a **held idea** (a brand / name to anchor on the left) **and** a stream of onsets popping separate words on the right, plus a dense run to ride a shake on and a strong downbeat to wipe out into.
- **Best span** — **3-6s**; above ~6.5s the fixed anchor goes stale unless the right slot enters a new group/program.
- **Params** — `bgColor`, `anchors`, `theme`, `showText`, `program`
### typewriter-phrase-keyword-shuffle
- **What** — words type in one-per-onset to spell a phrase, then one keyword cycles typefaces on the beat while everything else holds dead still.
- **Reach for it when** — a steady grid with a **continuous onset stream** (no desert): a phrase to type out, then a keyword to shuffle. The inverse of `held-message-living-field` (which wants an onset desert).
- **Best span** — **2.5-5s**; if the phrase cannot type and shuffle inside ~5s, reduce words or split the sentence across groups.
- **Params** — `bgColor`, `textColor`, `accentColor`, `lead1`, `lead2`, `lead3`, `keyword`, `periodChar`
@@ -0,0 +1,527 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"theme", "type":"string", "label":"Palette theme — aurora | ember | mono | slate", "default":"aurora"},
{"id":"bgColor", "type":"color", "label":"Stage background override (empty = theme bg)", "default":""},
{"id":"cards", "type":"string", "label":"Deck JSON — array of {tag,title,meta,color?}", "default":""},
{"id":"landings", "type":"string", "label":"Landing beats JSON — abs seconds, one per card", "default":""},
{"id":"yaw", "type":"number", "label":"Scene yaw (deg, side tilt of the whole deck)", "default":-12}
]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Card Flyby · accelerating 3D deck cascade — reference impl</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
background: #06070d;
overflow: hidden;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
background: radial-gradient(1300px 820px at 50% 52%, #0d1018 0%, #06070d 70%, #04050a 100%);
overflow: hidden;
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
}
/* .stage carries the perspective; .scene carries the static yaw. The cards' own
rotateX drives the tumble-forward — the scene only holds the side tilt. */
.stage {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 1080px;
perspective: 2400px;
perspective-origin: 50% 55%;
}
.scene {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 1080px;
transform-style: preserve-3d;
transform: rotateY(-12deg);
}
.card {
position: absolute;
left: calc(50% - 700px);
top: calc(50% - 395px);
width: 1400px;
height: 790px;
border-radius: 32px;
will-change: transform, opacity;
--wipe: 0;
--color: #444;
transform-origin: 50% 50%;
}
/* Wireframe (always present, opacity tracks the slot). */
.wire {
position: absolute;
inset: 0;
border-radius: 28px;
border: 1.5px solid rgba(255, 255, 255, 0.42);
box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.012);
overflow: hidden;
}
.wire .wire-label {
position: absolute;
left: 68px;
top: 60px;
font-size: 26px;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
}
.wire .wire-corner {
position: absolute;
right: 68px;
top: 60px;
width: 34px;
height: 34px;
border: 1.5px solid rgba(255, 255, 255, 0.32);
border-radius: 50%;
}
.wire .wire-bar {
position: absolute;
left: 68px;
right: 68px;
bottom: 68px;
height: 1.5px;
background: rgba(255, 255, 255, 0.22);
}
/* Solid colored face — revealed via an angled clip-path wipe driven by --wipe.
Polygon sweeps upper-left → lower-right at ~72°. wipe=0 collapsed off-element,
wipe=1 covers the full card. */
.solid {
position: absolute;
inset: 0;
border-radius: 28px;
/* gentle top→bottom tonal gradient (theme-derived shades) for depth */
background: linear-gradient(
158deg,
var(--c1, var(--color)) 0%,
var(--c2, var(--color)) 100%
);
box-shadow:
0 34px 100px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.14);
overflow: hidden;
/* the title is the hero — centered in the card */
display: flex;
align-items: center;
justify-content: center;
padding: 90px 104px;
clip-path: polygon(
0% 0%,
calc(var(--wipe) * 200% - 30%) 0%,
calc(var(--wipe) * 200% - 80%) 100%,
0% 100%
);
}
.solid::before {
content: "";
position: absolute;
inset: 0;
border-radius: 28px;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.13) 0%,
rgba(255, 255, 255, 0.03) 40%,
rgba(0, 0, 0, 0.14) 100%
);
pointer-events: none;
}
.solid .title {
font-size: 184px;
font-weight: 800;
letter-spacing: -0.035em;
line-height: 0.92;
color: var(--ink, #ffffff);
text-align: center;
max-width: 100%;
overflow-wrap: break-word;
text-wrap: balance;
}
.solid .play {
position: absolute;
right: 68px;
bottom: 70px;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.solid .play::after {
content: "";
width: 0;
height: 0;
border-left: 22px solid #ffffff;
border-top: 14px solid transparent;
border-bottom: 14px solid transparent;
margin-left: 5px;
}
</style>
</head>
<body>
<!-- duration 6.0s = a 6-card accelerating cascade (1 card lands per beat, dwells shrinking) -->
<div
data-hf-id="hf-cf-root"
id="root"
data-composition-id="card-flyby"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="6.0"
data-root="true"
>
<div data-hf-id="hf-cf-stage" class="stage">
<div data-hf-id="hf-cf-scene" class="scene" id="scene"></div>
</div>
<script>
(function () {
// ════════════════════════════════════════════════════════════════════
// CARD FLYBY · accelerating 3D deck cascade
// ────────────────────────────────────────────────────────────────────
// A depth column of N cards rolls FORWARD. On each landing beat the next
// card tumbles up into the front slot (s0) and WIPES in its solid colored
// face (angled clip-path), the previous front card tumbles forward + falls
// down toward the camera on exit, and every card behind advances one slot.
// Dwells shrink card-to-card → the deck reads as an accelerating rush into
// a held final card. Asset-free: wireframe + solid CSS cards, system fonts.
//
// COMPOSE BY DATA — `cards` (the deck) and `landings` (one beat per card)
// are the two data surfaces. Card count is data: 3/6/9 cards all "just
// work"; landings default to an accelerating schedule derived from count.
// ════════════════════════════════════════════════════════════════════
window.__timelines = window.__timelines || {};
// force3D is set per card tween/set below (NOT a global default — a global
// would leak onto the plain-object padding tween and warn). It keeps a 3D
// matrix on every transform so cards never collapse to 2D: a card whose
// rotX/Y/Z all hit 0 (the front s0 pose) would otherwise drop out of the 3D
// stacking context and paint in DOM order, letting back cards render on top.
// ── 1. Variables ──────────────────────────────────────────────────────
var defaults = { theme: "aurora", bgColor: "", cards: "", landings: "", yaw: -12 };
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
// ── Palette themes — each is a COHESIVE ramp (one temperature/saturation
// family) so any card count reads as a designed set. card.color still
// overrides per card. White-on-card stays AA at the card type sizes. ──
var THEMES = {
// cool jewel arc: teal → ocean → indigo → violet → plum
aurora: {
bg: "#06060e",
ink: "#ffffff",
colors: ["#0e5f63", "#15517e", "#283f86", "#4a3a83", "#6a3380", "#8a2e63"],
},
// warm sunset arc: wine → brick → rust → amber → ochre → bronze
ember: {
bg: "#0e0805",
ink: "#fff6ee",
colors: ["#5c2030", "#7e2828", "#9c3b1c", "#a9551a", "#8a5a1c", "#5f4626"],
},
// sleek monochrome blue staircase (dark → mid)
mono: {
bg: "#060912",
ink: "#ffffff",
colors: ["#11243f", "#16314f", "#1b3d63", "#214a78", "#27568d", "#2d63a2"],
},
// muted cool slate — understated / editorial
slate: {
bg: "#0b0e12",
ink: "#eef2f6",
colors: ["#232a33", "#2b3540", "#33414e", "#3b4c5c", "#43576a", "#4b6278"],
},
};
var TH = THEMES[String(vars.theme || "").trim()] || THEMES.aurora;
// tiny hex helpers (deterministic, no deps) — derive shades + soft ink.
function parseHex(h) {
h = String(h || "")
.trim()
.replace(/^#/, "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length !== 6) return null;
var n = parseInt(h, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function clamp8(v) {
return Math.max(0, Math.min(255, Math.round(v)));
}
function toHex(c) {
return (
"#" +
[c.r, c.g, c.b]
.map(function (v) {
return clamp8(v).toString(16).padStart(2, "0");
})
.join("")
);
}
function mix(hex, t, amt) {
var c = parseHex(hex);
if (!c) return hex;
return toHex({
r: c.r + (t.r - c.r) * amt,
g: c.g + (t.g - c.g) * amt,
b: c.b + (t.b - c.b) * amt,
});
}
var BLACK = { r: 0, g: 0, b: 0 },
WHITE = { r: 255, g: 255, b: 255 };
function darken(hex, amt) {
return mix(hex, BLACK, amt);
}
function lighten(hex, amt) {
return mix(hex, WHITE, amt);
}
var DEFAULT_CARDS = [
{ title: "FADEGLOW" },
{ title: "SAUL BASS" },
{ title: "EULER" },
{ title: "HERMÈS" },
{ title: "DOSSIER" },
{ title: "BLACK HOLES" },
];
// landings tuned by hand on the sandbox: accelerating dwells, final card holds ~1.6s.
var DEFAULT_LANDINGS = [0.4, 1.7, 2.7, 3.45, 4.0, 4.4];
var DUR = 6.0; // matches data-duration
var YAW = Number(vars.yaw);
if (!isFinite(YAW)) YAW = -12;
function parseJSON(v) {
if (!v) return null;
if (typeof v !== "string") return v;
try {
return JSON.parse(v);
} catch (e) {
return null;
}
}
var CARDS = parseJSON(vars.cards);
if (!CARDS || !CARDS.length) CARDS = DEFAULT_CARDS;
var N = CARDS.length;
// ── 2. Landing schedule (accelerating cascade) ─────────────────────────
// gaps between landings shrink geometrically; the final card lands early
// enough to hold a beat before the cut.
function genLandings(n, total) {
var start = 0.4;
var hold = Math.min(1.6, total * 0.27);
var end = Math.max(start + 0.3, total - hold);
if (n <= 1) return [start];
var ratio = 0.78,
weights = [],
sum = 0,
i;
for (i = 0; i < n - 1; i++) {
var w = Math.pow(ratio, i);
weights.push(w);
sum += w;
}
var span = end - start,
acc = start,
times = [start];
for (i = 0; i < n - 1; i++) {
acc += (span * weights[i]) / sum;
times.push(acc);
}
return times;
}
var LANDED = parseJSON(vars.landings);
if (!LANDED || LANDED.length !== N) {
LANDED = N === DEFAULT_CARDS.length ? DEFAULT_LANDINGS.slice() : genLandings(N, DUR);
}
// per-card transition duration derived from the gap into it (faster as we accelerate).
function transDur(i) {
var gap = i === 0 ? (N > 1 ? LANDED[1] - LANDED[0] : 0.4) : LANDED[i] - LANDED[i - 1];
return Math.max(0.12, Math.min(0.42, gap * 0.32));
}
// ── 3. Build the deck DOM from data ────────────────────────────────────
var scene = document.getElementById("scene");
scene.style.transform = "rotateY(" + YAW + "deg)";
// bg: explicit bgColor override wins; else the theme's field color. The radial
// lifts the centre slightly and sinks the edges, both derived from the bg.
var BG = vars.bgColor && String(vars.bgColor).trim() ? vars.bgColor : TH.bg;
document.documentElement.style.background = BG;
document.body.style.background = BG;
var rootEl = document.getElementById("root");
rootEl.style.background =
"radial-gradient(1300px 820px at 50% 52%, " +
lighten(BG, 0.1) +
" 0%, " +
BG +
" 70%, " +
darken(BG, 0.4) +
" 100%)";
function el(cls, parent) {
var d = document.createElement("div");
d.className = cls;
if (parent) parent.appendChild(d);
return d;
}
var cardEls = [];
for (var i = 0; i < N; i++) {
var c = CARDS[i] || {};
var card = el("card", scene);
card.id = "card-" + i;
// color: per-card override, else cycle the cohesive theme ramp. A gentle
// top→bottom shade pair (--c1/--c2) gives each face tonal depth.
var col = c.color || TH.colors[i % TH.colors.length];
card.style.setProperty("--color", col);
card.style.setProperty("--c1", lighten(col, 0.05));
card.style.setProperty("--c2", darken(col, 0.22));
card.style.setProperty("--ink", TH.ink);
var wire = el("wire", card);
var label = el("wire-label", wire);
label.textContent = String(i + 1).padStart(3, "0");
el("wire-corner", wire);
el("wire-bar", wire);
var solid = el("solid", card);
var title = el("title", solid);
title.textContent = c.title || "";
el("play", solid);
cardEls.push(card);
}
// ── 4. SLOT poses ───────────────────────────────────────────────────────
// rotX is the key. Cards behind tilt BACK (top edge away, +rotX); the front
// is upright (rotX 0); on exit a card continues forward into a face-down
// tumble (-rotX) while dropping down + toward camera. Depth from perspective
// (Z) + the cards' rotateX, not a camera move.
var SLOT = {
far: { x: 360, y: 380, z: -1080, rotX: 84, scale: 0.74, opacity: 0, zi: 60 },
s3: { x: 280, y: 290, z: -820, rotX: 66, scale: 0.8, opacity: 0.32, zi: 70 },
s2: { x: 195, y: 200, z: -560, rotX: 46, scale: 0.86, opacity: 0.58, zi: 80 },
s1: { x: 105, y: 105, z: -290, rotX: 22, scale: 0.93, opacity: 0.85, zi: 90 },
s0: { x: 0, y: 0, z: 0, rotX: 0, scale: 1.0, opacity: 1.0, zi: 100 },
exit: { x: -90, y: 620, z: 420, rotX: -88, scale: 1.1, opacity: 0, zi: 110 },
};
// pose for a card whose index is `rel` slots behind the current front card
// (rel 0 = front, +ve = behind, -ve = already exiting / gone).
function poseForRel(rel) {
if (rel <= -1) return SLOT.exit;
if (rel === 0) return SLOT.s0;
if (rel === 1) return SLOT.s1;
if (rel === 2) return SLOT.s2;
if (rel === 3) return SLOT.s3;
return SLOT.far;
}
var tl = gsap.timeline({ paused: true });
function poseTween(idx, slot, t, dur, ease) {
// z-index is set instantly at the swap start so paint order matches the
// slot — CSS can't reliably depth-sort non-intersecting planes.
tl.set("#card-" + idx, { zIndex: slot.zi }, t);
tl.to(
"#card-" + idx,
{
x: slot.x,
y: slot.y,
z: slot.z,
rotationX: slot.rotX,
scale: slot.scale,
opacity: slot.opacity,
force3D: true,
duration: dur,
ease: ease || "power2.inOut",
},
t,
);
}
// ── 5. Initial pose (front index = -1: nothing landed yet) ─────────────
// card j sits at rel = j + 1, so card0 starts at s1, card1 at s2, etc. Apply
// via gsap.set (so the paused render at t=0 shows the staged stack) AND via
// tl.set at 0 (so scrubbing back to 0 restores it).
for (var j = 0; j < N; j++) {
var s = poseForRel(j + 1);
var props = {
x: s.x,
y: s.y,
z: s.z,
rotationX: s.rotX,
scale: s.scale,
opacity: s.opacity,
zIndex: s.zi,
force3D: true,
"--wipe": 0,
};
gsap.set("#card-" + j, props);
tl.set("#card-" + j, props, 0);
}
// ── 6. Per-landing orchestration ───────────────────────────────────────
// On landing i, card i becomes front. Tween the live window [i-1 .. i+4]:
// i-1 tumbles to exit, i rolls into s0 (+ wipe), the rest advance toward
// the front. Cards outside the window are already at rest (far / gone).
for (var L = 0; L < N; L++) {
var dur = transDur(L);
var t = Math.max(0, LANDED[L] - dur);
for (var k = L - 1; k <= L + 4; k++) {
if (k < 0 || k >= N) continue;
var rel = k - L;
var ease = rel === 0 ? "power3.out" : rel < 0 ? "power2.in" : "power2.inOut";
poseTween(k, poseForRel(rel), t, dur, ease);
}
// the landing card wipes in its solid face as it rolls up.
tl.to(
"#card-" + L,
{
"--wipe": 1,
duration: dur * 0.85,
ease: "power2.inOut",
},
t + dur * 0.15,
);
}
// pad the timeline so duration() >= data-duration.
tl.to({}, { duration: DUR }, 0);
tl.seek(0);
window.__timelines["card-flyby"] = tl;
})();
</script>
</div>
</body>
</html>
@@ -0,0 +1,13 @@
{
"_": "Default card-flyby program. `theme` picks a cohesive palette; `cards` is the deck (each card = a big centered title; `color` is OPTIONAL — omit it and the card takes the next color from the theme ramp); `landings` is one onset per card (accelerating cadence; omit to auto-derive). Pass these as the matching composition-variables; empty vars fall back to these values.",
"theme": "aurora",
"cards": [
{ "title": "FADEGLOW" },
{ "title": "SAUL BASS" },
{ "title": "EULER" },
{ "title": "HERMÈS" },
{ "title": "DOSSIER" },
{ "title": "BLACK HOLES" }
],
"landings": [0.4, 1.7, 2.7, 3.45, 4.0, 4.4]
}
@@ -0,0 +1,485 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"bgColor", "type":"color", "label":"Page background", "default":"#050507"},
{"id":"colorLow", "type":"color", "label":"Flow — shadow tone", "default":"#0a1430"},
{"id":"colorMid", "type":"color", "label":"Flow — mid tone", "default":"#6a6f9e"},
{"id":"colorHigh", "type":"color", "label":"Flow — highlight tone", "default":"#e8f0ff"},
{"id":"accentColor","type":"color", "label":"Core / glow accent", "default":"#aee4ff"},
{"id":"markText", "type":"string", "label":"Monogram", "default":"HF"},
{"id":"titleText", "type":"string", "label":"Title", "default":"HYPERFRAMES"},
{"id":"tagText", "type":"string", "label":"Tagline", "default":"HTML · SHADERS · MP4"},
{"id":"flowSpeed", "type":"number", "label":"Flow speed", "default":1, "min":0.1, "max":3, "step":0.1}
]'
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>WebGL Textures Playground — Template</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/three@0.147.0/build/three.min.js"></script>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* --bg / --mark-top / --mark-bottom / --line are set from variables in JS */
html,
body {
background: var(--bg, #050507);
overflow: hidden;
font-family: "Inter", system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg, #050507);
}
.sf-stage {
position: absolute;
inset: 0;
}
.sf-canvas {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
display: block;
}
.sf-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
color: #fff;
text-align: center;
mix-blend-mode: screen;
pointer-events: none;
}
.sf-mark {
font-size: 280px;
font-weight: 800;
letter-spacing: -18px;
line-height: 1;
display: flex;
gap: 0;
opacity: 0;
filter: drop-shadow(0 0 60px rgba(180, 220, 255, 0.4));
}
.sf-mark-letter {
display: inline-block;
background: linear-gradient(
180deg,
var(--mark-top, #ffffff) 0%,
var(--mark-bottom, #8aa9ff) 100%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
transform: translateY(0);
}
.sf-rule {
width: 0;
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
var(--line, rgba(255, 255, 255, 0.9)) 50%,
transparent 100%
);
opacity: 0;
}
.sf-title {
font-size: 64px;
font-weight: 700;
letter-spacing: 18px;
opacity: 0;
clip-path: inset(0 100% 0 0);
text-transform: uppercase;
}
.sf-tag {
font-size: 18px;
font-weight: 500;
letter-spacing: 8px;
color: rgba(255, 255, 255, 0.65);
opacity: 0;
text-transform: uppercase;
}
.sf-grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.06;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
</style>
</head>
<body>
<div
data-hf-id="hf-a0d4"
id="root"
data-composition-id="webgl-textures-playground"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="12"
data-root="true"
>
<div data-hf-id="hf-clsq" class="sf-stage">
<canvas data-hf-id="hf-yk51" class="sf-canvas" width="1920" height="1080"></canvas>
<div data-hf-id="hf-7z0g" class="sf-overlay">
<div data-hf-id="hf-yo0z" class="sf-mark"><!-- letters injected from markText --></div>
<div data-hf-id="hf-nig2" class="sf-rule"></div>
<div data-hf-id="hf-gjx6" class="sf-title"></div>
<div data-hf-id="hf-e7y8" class="sf-tag"></div>
</div>
<div data-hf-id="hf-ku55" class="sf-grain"></div>
</div>
</div>
<script>
(function () {
// ── 1. Resolve variables (read ONCE during init) ──────────────────
var defaults = {
bgColor: "#050507",
colorLow: "#0a1430",
colorMid: "#6a6f9e",
colorHigh: "#e8f0ff",
accentColor: "#aee4ff",
markText: "HF",
titleText: "HYPERFRAMES",
tagText: "HTML · SHADERS · MP4",
flowSpeed: 1,
};
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
// sRGB hex -> linear-light vec3 (the shader works in linear space and
// gamma-encodes at the end, so converting here makes the picked color
// land close to what a color picker shows).
function hexToLinear(hex) {
var h = String(hex).replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var r = parseInt(h.substr(0, 2), 16) / 255;
var g = parseInt(h.substr(2, 2), 16) / 255;
var b = parseInt(h.substr(4, 2), 16) / 255;
var lin = function (c) {
return Math.pow(c, 2.2);
};
return new THREE.Vector3(lin(r), lin(g), lin(b));
}
var root = document.getElementById("root");
var canvas = root.querySelector(".sf-canvas");
var W = 1920;
var H = 1080;
// ── 2. Apply text + CSS-driven colors ─────────────────────────────
var docEl = document.documentElement;
docEl.style.setProperty("--bg", vars.bgColor);
docEl.style.setProperty("--mark-top", vars.colorHigh);
docEl.style.setProperty("--mark-bottom", vars.colorMid);
docEl.style.setProperty("--line", vars.colorHigh);
var markEl = root.querySelector(".sf-mark");
markEl.innerHTML = "";
String(vars.markText)
.split("")
.forEach(function (ch) {
var span = document.createElement("span");
span.className = "sf-mark-letter";
span.textContent = ch;
markEl.appendChild(span);
});
root.querySelector(".sf-title").textContent = vars.titleText;
root.querySelector(".sf-tag").textContent = vars.tagText;
// ── 3. WebGL flow field ───────────────────────────────────────────
var renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: false,
alpha: false,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
var scene = new THREE.Scene();
var camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
var uniforms = {
uTime: { value: 0.0 },
uPhase: { value: 0.0 },
uReveal: { value: 0.0 },
uChromAb: { value: 0.0 },
uVignette: { value: 0.0 },
uIntensity: { value: 0.0 },
uResolution: { value: new THREE.Vector2(W, H) },
uSpeed: { value: Number(vars.flowSpeed) || 1.0 },
// ── COLOR CONTROLS (driven by the composition variables) ──
uColLow: { value: hexToLinear(vars.colorLow) },
uColMid: { value: hexToLinear(vars.colorMid) },
uColHigh: { value: hexToLinear(vars.colorHigh) },
uAccent: { value: hexToLinear(vars.accentColor) },
};
var vert = [
"varying vec2 vUv;",
"void main(){",
" vUv = uv;",
" gl_Position = vec4(position.xy, 0.0, 1.0);",
"}",
].join("\n");
var frag = [
"precision highp float;",
"varying vec2 vUv;",
"uniform float uTime;",
"uniform float uPhase;",
"uniform float uReveal;",
"uniform float uChromAb;",
"uniform float uVignette;",
"uniform float uIntensity;",
"uniform float uSpeed;",
"uniform vec2 uResolution;",
"uniform vec3 uColLow;",
"uniform vec3 uColMid;",
"uniform vec3 uColHigh;",
"uniform vec3 uAccent;",
"float hash21(vec2 p){",
" p = fract(p * vec2(123.34, 456.21));",
" p += dot(p, p + 45.32);",
" return fract(p.x * p.y);",
"}",
"float noise2(vec2 p){",
" vec2 i = floor(p);",
" vec2 f = fract(p);",
" vec2 u = f * f * (3.0 - 2.0 * f);",
" float a = hash21(i);",
" float b = hash21(i + vec2(1.0, 0.0));",
" float c = hash21(i + vec2(0.0, 1.0));",
" float d = hash21(i + vec2(1.0, 1.0));",
" return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);",
"}",
"float fbm(vec2 p){",
" float v = 0.0;",
" float a = 0.5;",
" for(int i=0;i<5;i++){",
" v += a * noise2(p);",
" p *= 2.02;",
" a *= 0.5;",
" }",
" return v;",
"}",
"vec2 curl(vec2 p, float t){",
" float e = 0.6;",
" float n1 = fbm(p + vec2(0.0, e) + t*0.18);",
" float n2 = fbm(p + vec2(0.0, -e) + t*0.18);",
" float n3 = fbm(p + vec2( e, 0.0) + t*0.18);",
" float n4 = fbm(p + vec2(-e, 0.0) + t*0.18);",
" return vec2(n1 - n2, -(n3 - n4));",
"}",
// 3-stop gradient palette built from the color variables.
// t≈0 -> shadow, t≈0.5 -> mid, t≈1 -> highlight.
"vec3 palette(float t){",
" t = clamp(t, 0.0, 1.0);",
" vec3 lowMid = mix(uColLow, uColMid, smoothstep(0.0, 0.5, t));",
" return mix(lowMid, uColHigh, smoothstep(0.5, 1.0, t));",
"}",
"vec3 sampleField(vec2 uv, float t, float energy){",
" vec2 p = (uv - 0.5) * vec2(uResolution.x/uResolution.y, 1.0) * 2.4;",
" vec2 c = curl(p * 0.9, t);",
" vec2 q = p + c * (0.45 + 0.55 * energy);",
" float flow = fbm(q * 1.6 + vec2(t * 0.11, -t * 0.07));",
" float bands = sin(flow * 14.0 + t * 1.1);",
" bands = smoothstep(0.0, 1.0, 0.5 + 0.5 * bands);",
" float r = length(p);",
" float core = smoothstep(1.5, 0.05, r) * (0.6 + 0.4 * energy);",
" float glow = exp(-r * 1.6) * (0.55 + 0.45 * energy);",
" float k = clamp(flow * 0.85 + 0.15 * bands + 0.25 * core + 0.6 * glow, 0.0, 1.4);",
" vec3 col = palette(k * 0.9 + 0.1 * sin(t * 0.4));",
" col *= 0.7 + 0.6 * energy;",
" col += uAccent * core * 0.55;",
" return col;",
"}",
"void main(){",
" vec2 uv = vUv;",
" float t = uTime * uSpeed;",
" float e = uIntensity;",
" vec2 dir = normalize(uv - 0.5 + 1e-6);",
" float ca = uChromAb * (0.004 + 0.012 * length(uv - 0.5));",
" vec3 cR = sampleField(uv + dir * ca, t, e);",
" vec3 cG = sampleField(uv, t, e);",
" vec3 cB = sampleField(uv - dir * ca, t, e);",
" vec3 col = vec3(cR.r, cG.g, cB.b);",
" float lum = dot(col, vec3(0.299, 0.587, 0.114));",
" col = mix(vec3(lum), col, 1.05);",
" float rev = uReveal;",
" float sweep = smoothstep(0.0, 0.6, rev) * (1.0 - smoothstep(0.6, 1.0, rev));",
" col += uColHigh * sweep * 0.35;",
" vec3 phaseTint = mix(vec3(0.85, 0.92, 1.05), vec3(1.05, 0.95, 0.85), uPhase);",
" col *= phaseTint;",
" float v = length(uv - 0.5);",
" col *= 1.0 - smoothstep(0.45, 0.95, v) * uVignette;",
" col = col / (col + 1.0);",
" col = pow(col, vec3(1.0/2.2));",
" gl_FragColor = vec4(col, 1.0);",
"}",
].join("\n");
var mat = new THREE.ShaderMaterial({
uniforms: uniforms,
vertexShader: vert,
fragmentShader: frag,
});
var geo = new THREE.PlaneGeometry(2, 2);
var mesh = new THREE.Mesh(geo, mat);
scene.add(mesh);
function renderFrame() {
renderer.render(scene, camera);
}
renderFrame();
// Continuous loop: ensures the WebGL backbuffer always has fresh
// pixels when puppeteer takes a screenshot. All visible state comes
// from uniforms driven by the GSAP timeline, so output is fully
// determined by the timeline position (deterministic across renders).
function loop() {
renderFrame();
window.requestAnimationFrame(loop);
}
window.requestAnimationFrame(loop);
// ── 4. Deterministic timeline (unchanged motion design) ───────────
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({
paused: true,
onUpdate: renderFrame,
});
tl.to(uniforms.uTime, { value: 12.0, duration: 12, ease: "none" }, 0);
tl.to(uniforms.uIntensity, { value: 0.55, duration: 3.0, ease: "sine.inOut" }, 0);
tl.to(uniforms.uIntensity, { value: 1.0, duration: 1.2, ease: "power2.in" }, 3.0);
tl.to(uniforms.uIntensity, { value: 0.55, duration: 3.0, ease: "power2.out" }, 4.2);
tl.to(uniforms.uIntensity, { value: 0.3, duration: 4.8, ease: "sine.inOut" }, 7.2);
tl.fromTo(
uniforms.uChromAb,
{ value: 0.0 },
{ value: 0.4, duration: 3.0, ease: "sine.in" },
0,
);
tl.to(uniforms.uChromAb, { value: 1.6, duration: 1.0, ease: "power3.in" }, 3.0);
tl.to(uniforms.uChromAb, { value: 0.25, duration: 1.5, ease: "power2.out" }, 4.0);
tl.to(uniforms.uChromAb, { value: 0.18, duration: 6.5, ease: "sine.inOut" }, 5.5);
tl.fromTo(
uniforms.uReveal,
{ value: 0.0 },
{ value: 1.0, duration: 1.2, ease: "none" },
3.4,
);
tl.set(uniforms.uReveal, { value: 0.0 }, 4.6);
tl.to(uniforms.uPhase, { value: 0.6, duration: 5.0, ease: "sine.inOut" }, 1.5);
tl.to(uniforms.uPhase, { value: 0.15, duration: 5.5, ease: "sine.inOut" }, 6.5);
tl.to(uniforms.uVignette, { value: 0.85, duration: 3.0, ease: "power2.out" }, 4.2);
var mark = root.querySelector(".sf-mark");
var letters = root.querySelectorAll(".sf-mark-letter");
var rule = root.querySelector(".sf-rule");
var title = root.querySelector(".sf-title");
var tag = root.querySelector(".sf-tag");
tl.fromTo(
mark,
{
opacity: 0,
scale: 1.18,
filter: "blur(24px) drop-shadow(0 0 60px rgba(180,220,255,0.4))",
},
{
opacity: 1,
scale: 1.0,
filter: "blur(0px) drop-shadow(0 0 80px rgba(180,220,255,0.55))",
duration: 0.9,
ease: "power3.out",
},
3.6,
);
tl.fromTo(
letters,
{ y: 80 },
{ y: 0, duration: 1.0, ease: "expo.out", stagger: 0.08 },
3.6,
);
tl.fromTo(
rule,
{ width: 0, opacity: 0 },
{ width: 360, opacity: 0.85, duration: 0.8, ease: "power2.out" },
5.1,
);
tl.fromTo(
title,
{ opacity: 0, clipPath: "inset(0 100% 0 0)" },
{ opacity: 1, clipPath: "inset(0 0% 0 0)", duration: 0.95, ease: "power3.out" },
5.3,
);
tl.fromTo(
tag,
{ opacity: 0, y: 12 },
{ opacity: 0.7, y: 0, duration: 0.8, ease: "sine.out" },
6.0,
);
tl.to(mark, { scale: 1.04, duration: 5.0, ease: "sine.inOut" }, 7.0);
tl.to([mark, rule, title, tag], { opacity: 0, duration: 0.6, ease: "power1.in" }, 11.4);
window.__timelines["webgl-textures-playground"] = tl;
})();
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More