# Scroll-Choreography.json Compilation Pipeline
> How the declarative schema becomes running GSAP ScrollTrigger code.
## v3 runtime contract
New documents use `metadata.version: "3.0.0"`. The web compiler returns a cleanup
function from `initChoreography(root, {lenis})`, scopes its own GSAP matchMedia
context and responds to live reduced motion. Narrow layouts remain in flow.
Native scrolling is the default unless `globals.scrollSmoothing` is nonzero.
A supplied Lenis instance is observed, never driven twice or destroyed.
Defaults are timeline-local; teardown never kills another scene's triggers.
`signalBindings` adds typed inputs: `scroll-progress`, `scroll-velocity`,
`pointer-x`, `pointer-y`, and rectangle-edge `proximity`. Outputs must be named
`--cinematic-*` custom properties. Consume them only in transform/opacity on a
dedicated visual wrapper, never in layout or painted background properties.
```json
{"selector":".lens","input":"proximity","output":"--cinematic-shift",
"from":0,"to":12,"unit":"px","radius":160,"staticValue":0}
```
Put that object in the root `signalBindings` array. The compiler imports
`./runtime/cinematic.mjs` and `./runtime/choreography.mjs`; copy `runtime/` next
to the generated module, or rewrite those imports for your app's library path.
The bindings share GSAP's clock, sleep while idle, and restore owned properties
on teardown. `range` must ascend; input normalization and clamping are explicit.
V2 input remains accepted with a deprecation comment. On the web target,
letter-spacing treatments become bounded scaleX transforms, and background
morphs become opacity-crossfaded paint layers. Those are intentional visual
substitutions, not pixel-identical v2 rendering. V3 rejects directly animated
`letterSpacing` / `backgroundColor` and velocity-driven spacing. Finite offline
video targets retain their existing timeline behavior; signal bindings have no
pointer/proximity meaning in a film and are not rendered there.
The CLI performs focused validation, **not full JSON-Schema certification**.
Inspect and run its output. Tests exercise mapping, lifecycle ownership,
compatibility and invalid inputs; browser evidence is still required per scene.
The older conceptual pipeline below is background, not a promise of extra
generated report files or automatic production certification.
## ▶ It's real: `compile-choreography.mjs`
This pipeline ships as a working, dependency-free Node compiler at the repo root.
It reads a choreography document and emits runnable GSAP ScrollTrigger + Lenis code.
```bash
# compile the bundled example (the schema's examples[0]) and print to stdout
node compile-choreography.mjs --example
# compile your own choreography to a file
node compile-choreography.mjs my-scene.json --out scene.js
# NEW — compile the SAME document to a fixed-time video timeline
node compile-choreography.mjs my-scene.json --target video --out film.js
node compile-choreography.mjs my-scene.json --target video --pace 1.6 --out film.js
# WATCH IT — emit a self-contained preview (skeleton DOM + play/scrub controls)
node compile-choreography.mjs my-scene.json --harness --out preview.html
open preview.html # any browser, no install
# RENDER IT — emit a complete, render-ready HyperFrames composition
node compile-choreography.mjs my-scene.json --target hyperframes --out index.html
npx hyperframes render # in a HyperFrames project → deterministic MP4
```
> The hyperframes target renders the choreography's real `Layer.content`
> (images, text, inline svg) in a default collage layout — reposition the
> `[data-cs-layer]` elements freely; the timeline animates transform/opacity
> only, so any layout works. (`data-cs-layer`, not `data-layer`: HyperFrames
> reserves the latter for its own track runtime.)
The compiler's most important job: it maps the schema's CSS-style property names
(`translateX`, `translateY`, `rotateZ`…) to **GSAP's shorthand** (`x`, `y`,
`rotation`…). GSAP silently ignores the CSS names, so this mapping — centralized
in one table in the compiler — is the difference between motion and a no-op. The
emitted code uses `gsap.timeline` + `ScrollTrigger` per chapter (pin, scrub,
layer parallax, title reveal, colour morph, velocity nodes), Lenis forwarded to
`ScrollTrigger.update`, and a `prefers-reduced-motion` guard that skips all motion.
The sections below document the conceptual pipeline the compiler implements.
## One choreography, two media (`--target video`)
The same `scroll-choreography.json` that drives a scroll page compiles to a
**fixed-time, paused GSAP timeline** for video renderers. One declarative
document → the website *and* its launch film, with the same beats, easings and
depth choreography. This is the bridge between the scroll grammar and
HTML-to-video runtimes ([HyperFrames](https://github.com/heygen-com/hyperframes),
[Remotion](https://remotion.dev)) — see `video/PIPELINE.md` for the full
mixing strategy.
### Scroll → time mapping (FRAME.md §5 pacing)
| Scroll concept | Video translation |
|---|---|
| `pin.pinDuration` (vh) | scene seconds = `vh/100 × pace` (default pace **1.2s/100vh**, taste-guardrails §3.1), clamped to **4–14s** dwell (FRAME.md §5) |
| chapter sequence | sequential scenes; enter `autoAlpha` 0.6s, exit 0.5s before cut |
| layer scroll parallax (`from`→`to`) | timed drift across 90% of the scene |
| `titleReveal.scrollRange` (0–1 fractions) | same fractions × scene duration |
| `atmosphere.colorMorph` | timed `backgroundColor` tween on stage + scene |
| `velocityNodes` | **dropped** — scroll velocity doesn't exist in fixed time (annotated in output) |
| Lenis / ScrollTrigger / reduced-motion guard | **dropped** — a render is a film, not an interactive page |
The DOM contract is unchanged — `[data-chapter]` scenes with `[data-layer]`
and `[data-title]` children — so **one HTML skeleton serves both targets**.
(The web and video targets use `[data-layer]`. The **HyperFrames** target alone
auto-renames it to `[data-cs-layer]` in both skeleton and timeline, because
HyperFrames reserves `data-layer` for its own track runtime — you author
`data-layer` everywhere; the compiler handles the rename for that one target.)
### Using the video output
**HyperFrames** — load as a module; it registers `window.__timelines[id]`
automatically. Set the exported `CHOREOGRAPHY_DURATION` as the composition
root's `data-duration`.
**Remotion** — drive the paused timeline by frame:
```tsx
import { useCurrentFrame, useVideoConfig } from "remotion";
import { gsap } from "gsap";
import { buildChoreographyTimeline, CHOREOGRAPHY_DURATION } from "./film.js";
export const Choreography: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const tl = useMemo(() => buildChoreographyTimeline(gsap), []);
useEffect(() => { tl.seek(frame / fps, false); }, [tl, frame, fps]);
return
{/* the same [data-chapter] skeleton */}
;
};
// durationInFrames = CHOREOGRAPHY_DURATION * fps
```
## Overview
`scroll-choreography.json` is a **declarative, cinematic grammar** for scroll-driven experiences. It does not execute directly. Instead, it passes through the compilation pipeline — now implemented in `compile-choreography.mjs` — producing production-ready GSAP code.
## Input
- `scroll-choreography.json` -- a valid JSON document conforming to the schema
- `taste-guardrails.md` -- banned pattern definitions and cinematic vocabulary
- `performance-budget.md` -- 60fps contract, layer budgets, mobile degradation tiers
## Output
| File | Description |
|------|-------------|
| `gsap-scroll-config.ts` | TypeScript module exporting GSAP timelines, ScrollTriggers, and Lenis config |
| `scroll-choreography.report.md` | Validation report: warnings, errors, performance projections |
---
## Step 1: Validate
### 1.1 Schema Validation
Check JSON conforms to `scroll-choreography.json` schema. All required fields present, types correct, enums valid.
### 1.2 Taste Guardrail Validation
Against `taste-guardrails.md`:
| Check | Rule | Severity |
|-------|------|----------|
| Depth range | All `depth` values in 0.15-1.40 | Error |
| Layer count | No chapter has >7 layers | Error |
| Pin duration | All enabled pins in 150-400vh | Error |
| Transition variety | No adjacent chapters share transition type | Error |
| Title variety | No adjacent chapters share title reveal type | Error |
| No blur animation | No `filter: blur()` references in any property | Error |
| No layout animation | No `width/height/top/left/margin/padding` in properties | Error |
| Breathing room | >=80vh free-scroll between consecutive pinned chapters | Warning |
| Title timing | Title reveal `end` <= 0.70 of pin duration | Warning |
| Stagger limits | Stagger offset in 5-8% range, maxElements <=5 | Warning |
| Depth variety | Depth ratios differ between adjacent chapters | Warning |
### 1.3 Performance Budget Projection
Calculate projected compositor layers per chapter:
```
layer_count = sum(1 for layer in chapter.layers if (
layer.willChange or
layer.depth != 1.0 or
layer.content.type == "video"
)) + 1 # root layer always counts
```
Compare against `performance-budget.md` Layer Count Budget:
- Desktop (>10 layers): Warning
- Tablet (>6 layers): Error
- Mobile (>4 layers): Error
- Budget tier (>2 layers): Error
### 1.4 Velocity Node Validation
- All `threshold` values > 0.1 px/ms
- All `lerpFactor` values in 0.01-0.5 range
- No more than 3 velocityNodes per chapter (performance ceiling)
### Validation Failure Modes
| Failure | Behavior |
|---------|----------|
| Schema validation error | Compilation halts. Report lists all errors with JSON paths. |
| Taste guardrail error | Compilation halts. Specific rule violated, offending value shown. |
| Performance budget warning | Compilation continues with warning. User must acknowledge. |
| Breathing room warning | Compilation continues. Suggests inserting release viewport. |
---
## Step 2: Layer Sort
### 2.1 Sort by Depth (Back to Front)
```typescript
const sortedLayers = chapter.layers.sort((a, b) => a.depth - b.depth);
// ascending: 0.15 (far background) -> 1.40 (foreground overlay)
```
### 2.2 will-change Strategy
Apply `will-change: transform` strategically:
```typescript
// Select up to 3 elements per viewport for will-change promotion
const willChangeCandidates = sortedLayers
.filter(l => l.willChange || l.depth >= 0.60) // prioritize visible layers
.slice(0, 3); // hard cap: 3 elements per viewport
// Apply 200ms before animation starts
// Remove 200ms after animation ends
// Never apply globally, never to text-only elements
```
### 2.3 Motion Density Check
Ensure no more than 3 simultaneous motion types in any 50vh window (taste-guardrails.md §3.8):
```typescript
function countMotionTypes(chapter: Chapter, windowStart: number, windowEnd: number): number {
const activeLayers = chapter.layers.filter(l =>
l.animation.properties.length > 0 &&
l.animation.trigger.start >= windowStart &&
l.animation.trigger.end <= windowEnd
);
const motionTypes = new Set();
activeLayers.forEach(l => {
l.animation.properties.forEach(p => motionTypes.add(p.property));
});
return motionTypes.size; // must be <= 3
}
```
---
## Step 3: ScrollTrigger Generation
### 3.1 Chapter Timelines
Each chapter produces one GSAP timeline:
```typescript
function generateChapterTimeline(chapter: Chapter): gsap.core.Timeline {
const tl = gsap.timeline({
scrollTrigger: {
trigger: `[data-chapter="${chapter.id}"]`,
start: chapter.scrollRange.start + "vh top",
end: chapter.scrollRange.end + "vh top",
// scrub is the chapter's first-layer trigger scrub (NOT the Lenis lerp,
// which lives in globals.scrollSmoothing). Default 0.5 per performance-budget.
scrub: chapter.layers[0]?.animation?.trigger?.scrub ?? 0.5,
pin: chapter.pin?.enabled ?? false,
pinSpacing: chapter.pin?.pinSpacing ?? true,
// anticipatePin reads pin.anticipatorySettle; defaults to 1 when unspecified.
anticipatePin: chapter.pin?.anticipatorySettle ?? 1,
fastScrollEnd: chapter.layers[0]?.animation?.trigger?.fastScrollEnd ?? true,
invalidateOnRefresh: true,
markers: false, // NEVER in production
}
});
// Layer animations as parallel tweens
chapter.layers.forEach(layer => {
const anim = layer.animation;
const props = anim.properties.reduce((acc, prop) => {
const unit = prop.unit || "";
acc[prop.property] = prop.to + unit;
// Store 'from' values as timeline position 0
return acc;
}, {} as Record);
// Set from values at timeline position 0
const fromProps = anim.properties.reduce((acc, prop) => {
const unit = prop.unit || "";
acc[prop.property] = prop.from + unit;
return acc;
}, {} as Record);
tl.fromTo(`[data-layer="${layer.id}"]`, fromProps, {
...props,
ease: anim.properties[0]?.easing || globals.defaultEasing,
duration: 1, // normalized: 0-1 along scroll range
}, 0); // all layers animate in parallel from scroll position 0
});
return tl;
}
```
### 3.2 Title Reveals as Nested Timelines
```typescript
function generateTitleReveal(chapter: Chapter): gsap.core.Timeline | null {
if (!chapter.titleReveal) return null;
const tr = chapter.titleReveal;
const pinDuration = chapter.pin?.pinDuration ?? 200;
// Calculate absolute vh positions from pin percentage
const startVh = pinDuration * tr.scrollRange.start;
const endVh = pinDuration * tr.scrollRange.end;
const titleTl = gsap.timeline({
scrollTrigger: {
trigger: `[data-chapter="${chapter.id}"] .title`,
start: `top+=${startVh}vh top`,
end: `top+=${endVh}vh top`,
scrub: 0.3,
invalidateOnRefresh: true,
}
});
switch (tr.type) {
case "maskReveal":
titleTl.fromTo(".title", {
clipPath: "inset(0 100% 0 0)"
}, {
clipPath: "inset(0 0% 0 0)",
ease: tr.easing || globals.defaultEasing,
duration: 1,
});
break;
case "wordStagger":
// Split text into words, stagger each
titleTl.fromTo(".title .word", {
opacity: 0, y: 30
}, {
opacity: 1, y: 0,
stagger: tr.stagger?.offset ?? 0.06,
ease: tr.easing || globals.defaultEasing,
duration: 0.4,
}, 0);
break;
case "letterSpacingScrub":
titleTl.fromTo(".title", {
letterSpacing: "-0.05em", opacity: 0.3
}, {
letterSpacing: "0.05em", opacity: 1,
ease: "none", // scrub-driven: linear mapping
duration: 1,
});
break;
// ... additional title reveal types handled similarly
}
return titleTl;
}
```
### 3.3 Atmosphere / Background Morph
```typescript
function generateAtmosphere(chapter: Chapter): void {
if (!chapter.atmosphere?.colorMorph) return;
const morph = chapter.atmosphere.colorMorph;
const pinDuration = chapter.pin?.pinDuration ?? 200;
gsap.to(`[data-chapter="${chapter.id}"]`, {
"--bg-color": morph.to, // CSS custom property
scrollTrigger: {
trigger: `[data-chapter="${chapter.id}"]`,
start: `${morph.scrollStart * pinDuration}vh top`,
end: `${morph.scrollEnd * pinDuration}vh top`,
scrub: true,
}
});
}
```
---
## Step 4: Transition Generation
### 4.1 Cinematic Vocabulary Mapping
> **GSAP property names — critical.** GSAP does NOT use CSS transform names.
> Use its shorthand or the tween silently no-ops:
> `x` (not `translateX`), `y` (not `translateY`), `rotation` (not `rotateZ`),
> `rotationX` (not `rotateX`), `rotationY` (not `rotateY`), `scale`, `autoAlpha`
> (opacity + visibility). The table below uses GSAP names.
| Transition Type | GSAP Implementation | Properties Applied |
|----------------|--------------------|--------------------|
| `craneShot` | `y` + `rotationX` | Vertical dolly with subtle tilt. `rotationX`: ±4deg. `transformPerspective`/`perspective-origin: 50% 100%` |
| `whipPan` | `x` + `power4.inOut` | Fast horizontal snap. 0.4s feel via scrub compression |
| `matchCut` | `autoAlpha` crossfade on identical layout | Same positions, content swaps. Layout holds perfectly still |
| `dissolve` | `autoAlpha` 1→0 + `scale` 1→0.97 | Gentle fade with subtle compression |
| `pushIn` | `scale` 1→1.08 + `y` centering | Slow zoom toward subject. Minimal other motion |
| `hardCut` | No animation | Instant transition. No overlap. |
### 4.2 Overlapping ScrollTrigger
```typescript
function generateTransition(transition: Transition): gsap.core.Timeline {
const tl = gsap.timeline({
scrollTrigger: {
trigger: "body", // global transition
start: `${transition.fromChapterEnd - transition.overlap}vh top`,
end: `${transition.toChapterStart + transition.duration}vh top`,
scrub: 0.5,
invalidateOnRefresh: true,
}
});
// Outgoing chapter exit
tl.to(`[data-chapter="${transition.from}"]`, {
...mapTransitionType(transition.type, "exit"),
ease: transition.easing || "power4.inOut",
duration: 0.5,
}, 0);
// Incoming chapter entrance
tl.from(`[data-chapter="${transition.to}"]`, {
...mapTransitionType(transition.type, "enter"),
ease: transition.easing || "power4.inOut",
duration: 0.5,
}, 0.3); // 30% offset for overlap
return tl;
}
```
---
## Step 5: Velocity Wiring
### 5.1 Velocity Detection
```typescript
// From Lenis or raw RAF loop
let velocity = 0;
let lastScrollY = 0;
let lastTime = performance.now();
function trackVelocity() {
const now = performance.now();
const dt = now - lastTime;
const dy = lenis?.scroll || window.scrollY - lastScrollY;
velocity += (dy / dt - velocity) * 0.15; // lerp smoothing
lastScrollY = window.scrollY;
lastTime = now;
}
```
### 5.2 Velocity Node Application
```typescript
function applyVelocityNodes(chapter: Chapter, currentVelocity: number): void {
chapter.velocityNodes?.forEach(node => {
const isAbove = currentVelocity > node.threshold;
const config = isAbove ? node.above : node.below;
if (!config) return;
const lerp = node.lerpFactor ?? 0.1;
// Apply via gsap.quickTo for 60fps performance
chapter.layers.forEach(layer => {
const el = document.querySelector(`[data-layer="${layer.id}"]`);
if (!el) return;
if (config.opacity !== undefined) {
const currentOpacity = parseFloat(gsap.getProperty(el, "opacity") as string);
const targetOpacity = config.opacity;
gsap.set(el, { opacity: currentOpacity + (targetOpacity - currentOpacity) * lerp });
}
if (config.scale !== undefined) {
const currentScale = parseFloat(gsap.getProperty(el, "scale") as string) || 1;
gsap.set(el, { scale: currentScale + (config.scale - currentScale) * lerp });
}
if (config.skewX !== undefined) {
const currentSkew = parseFloat(gsap.getProperty(el, "skewX") as string) || 0;
gsap.set(el, { skewX: currentSkew + (config.skewX - currentSkew) * lerp });
}
if (config.letterSpacing !== undefined) {
gsap.set(el, { letterSpacing: config.letterSpacing });
}
});
});
}
```
### 5.3 RAF Integration
```typescript
function velocityLoop() {
trackVelocity();
chapters.forEach(chapter => {
if (chapter.velocityNodes && chapter.velocityNodes.length > 0) {
// Only process if chapter is in or near viewport
const trigger = ScrollTrigger.getById(chapter.id);
if (trigger && trigger.isActive) {
applyVelocityNodes(chapter, Math.abs(velocity));
}
}
});
requestAnimationFrame(velocityLoop);
}
// Start after all ScrollTriggers are created
ScrollTrigger.addEventListener("refreshInit", () => {
requestAnimationFrame(velocityLoop);
});
```
---
## Output Files
### gsap-scroll-config.ts
```typescript
// Auto-generated from scroll-choreography.json
// Do not edit manually -- recompile instead
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "@studio-freight/lenis";
gsap.registerPlugin(ScrollTrigger);
// ---- Lenis Smooth Scroll ----
export const lenis = new Lenis({
lerp: 0.6, // from globals.scrollSmoothing
smoothWheel: true,
});
// ---- Metadata ----
export const metadata = {
title: "Maison Voss - Quiet Luxury Brand Launch",
targetDevice: "desktop",
totalScrollRange: 2200,
};
// ---- Chapter Timelines ----
export const chapterTimelines: gsap.core.Timeline[] = [];
export function initTimelines() {
// Chapter: hero-manifesto (pinnedHero)
const heroManifestoTl = gsap.timeline({ /* ... */ });
chapterTimelines.push(heroManifestoTl);
// Chapter: editorial-philosophy (editorialLongread)
const editorialPhilosophyTl = gsap.timeline({ /* ... */ });
chapterTimelines.push(editorialPhilosophyTl);
// Chapter: finale-collection (chapteredRelease)
const finaleCollectionTl = gsap.timeline({ /* ... */ });
chapterTimelines.push(finaleCollectionTl);
// ---- Transitions ----
// hero-manifesto -> editorial-philosophy: craneShot
// editorial-philosophy -> finale-collection: dissolve
// ---- Velocity Wiring ----
// velocityLoop starts after refresh
ScrollTrigger.refresh();
}
// ---- Cleanup ----
export function destroyTimelines() {
chapterTimelines.forEach(tl => tl.kill());
ScrollTrigger.getAll().forEach(st => st.kill());
}
```
### scroll-choreography.report.md
```markdown
# Scroll Choreography Compilation Report
## Input: Maison Voss - Quiet Luxury Brand Launch
### Validation Results
| Check | Status | Details |
|-------|--------|---------|
| Schema validation | PASS | All required fields present |
| Depth range | PASS | 6 unique depths across chapters |
| Layer count | PASS | Max 6 layers (chapter 3) |
| Pin duration | PASS | 250vh, 150vh(disabled), 300vh |
| Transition variety | PASS | craneShot, dissolve (different families) |
| Title variety | PASS | maskReveal, wordStagger, letterSpacingScrub |
| No blur animation | PASS | No filter animations detected |
| No layout animation | PASS | Only transform + opacity used |
| Breathing room | PASS | 500-1300 = 800vh between pinned chapters |
| Title timing | PASS | All title reveals end <= 0.70 |
| Stagger limits | PASS | Max 5 elements, offsets in 5-8% range |
### Performance Projection
| Chapter | Layers | will-change | Est. GPU Mem | Status |
|---------|--------|-------------|--------------|--------|
| hero-manifesto | 5 | 3 | ~12MB | OK |
| editorial-philosophy | 4 | 3 | ~12MB | OK |
| finale-collection | 6 | 3 | ~12MB | OK |
### Warnings (0)
_No warnings generated._
### Generated Files
- `gsap-scroll-config.ts` (1,847 lines)
- Compilation time: 340ms
```
---
## Edge Cases & Failure Modes
### Edge Case: Pin Duration at Boundary (150vh or 400vh)
Behavior: Valid. Compilation proceeds normally. Warning generated if exactly at boundary advising review.
### Edge Case: Overlapping Chapter Scroll Ranges
Behavior: Error if overlap > transition.overlap value. Transitions must explicitly declare overlap.
### Edge Case: VelocityNode Threshold Collision
Behavior: If two velocityNodes in same chapter have overlapping thresholds, compilation merges them into a single node with combined properties, using the lower lerpFactor for smoothness.
### Edge Case: Missing transition between adjacent chapters
Behavior: Hard cut is assumed. Warning generated suggesting explicit transition definition.
### Edge Case: Empty animation.properties array
Behavior: Layer is rendered statically. No ScrollTrigger created for that layer. Layer still counts toward compositor budget.
### Edge Case: prefers-reduced-motion detected at runtime
Behavior: All ScrollTrigger instances killed immediately. Pinned sections convert to static flow layout. All content shown in final state. No motion.
### Edge Case: Mobile tier detection at runtime
Behavior: Layer count reduced per Mobile Degradation Matrix. 3D transforms disabled on Tier 3+. Velocity effects disabled on touch devices. Parallax reduced to opacity-only on budget tier.
### Edge Case: Emergency degradation (frame rate drops below target)
Behavior: All parallax disabled immediately. Reduce to opacity-only transitions. Unpin all sections. Log event to analytics. No re-enable without page reload.