chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
@@ -0,0 +1,382 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 3 — Assembly Focus Reveal (hyperframes)</title>
<!--
HyperFrames composition.tsx.
Choreography (5 phases, 5 seconds total):
0.00 0.67s "Just use" assembles in a discrete sequence (with hold)
0.73 1.23s Pink logo pops in with back.out elastic
1.50 2.20s "Just use" slides left + fades; container recenters around brand
2.67 3.57s Camera zooms 5.5× into the logo (scale + counter-translate)
3.67 5.00s Logo breathes (sine onUpdate, multiplicative on pop scale)
Key differences :
- Single paused GSAP timeline registered to window.__timelines["main"]
- brandTextWidth measured via getBoundingClientRect after document.fonts.ready
- HERO_FINAL_OFFSET_X derived from real measurement, not estimate
- Breathing uses the onUpdate (multiplicative) form so it multiplies
onto the pop scale, not a fromTo + yoyo which would overwrite the pop value
- Three nested transform layers: zoom-scale → zoom-translate → recenter-shift
- Logo is loaded from a static PNG asset
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--bg: #000000;
--text-white: #ffffff;
--pink: #e91e63;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg);
font-family: "Google Sans", "Roboto", Inter, system-ui, sans-serif;
color: var(--text-white);
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Three nested transform layers (outer → inner) */
.zoom-scale {
transform-origin: center center;
will-change: transform;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-translate {
will-change: transform;
display: flex;
align-items: center;
justify-content: center;
}
.recenter-shift {
display: flex;
align-items: center;
}
/* Companion text — fixed width to prevent assembly jitter */
.companion {
width: 600px;
display: flex;
justify-content: flex-end;
margin-right: 30px;
white-space: nowrap;
color: var(--text-white);
font-size: 140px;
font-weight: 400;
line-height: 1;
}
/* Brand group: text + hero icon */
.brand-group {
display: flex;
align-items: center;
gap: 20px;
}
.brand-text {
color: var(--text-white);
font-size: 140px;
font-weight: 700;
white-space: nowrap;
line-height: 1;
}
.hero {
display: flex;
align-items: center;
justify-content: center;
width: 140px;
height: 140px;
/* initial scale(0) set by GSAP fromTo */
}
.hero .logo-mark {
width: 100%;
height: 100%;
display: block;
}
/* Hidden probe used for text measurement */
.measure-probe {
position: absolute;
left: -99999px;
top: -99999px;
visibility: hidden;
white-space: pre;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="5"
data-width="1920"
data-height="1080"
>
<div
class="stage clip"
data-start="0"
data-duration="5"
data-track-index="1"
id="brand-stage"
>
<div class="zoom-scale" data-layout-allow-overflow>
<div class="zoom-translate">
<div class="recenter-shift">
<div class="companion">
<span class="companion-text">J</span>
</div>
<div class="brand-group" data-layout-allow-overflow>
<span class="brand-text">Hyperframes</span>
<div class="hero">
<!-- Inline-SVG placeholder mark — swap for your logo image -->
<svg
class="logo-mark"
viewBox="0 0 100 100"
role="img"
aria-label="hyperframes logo"
>
<defs>
<linearGradient id="hfMark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8b7bff" />
<stop offset="1" stop-color="#3ddc97" />
</linearGradient>
</defs>
<rect x="4" y="4" width="92" height="92" rx="22" fill="url(#hfMark)" />
<text
x="50"
y="63"
text-anchor="middle"
font-family="Inter, system-ui, sans-serif"
font-size="40"
font-weight="800"
fill="#fff"
>
HF
</text>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
/* ================================================================
CONSTANTS — composition dimensions baked in.
================================================================ */
const W = 1920,
H = 1080;
const TOTAL_DURATION = 5.0;
const COMPANION_WIDTH = 600;
const COMPANION_GAP = 30; // margin-right on .companion
const BRAND_FONT_SIZE = 140;
const HERO_GAP = 20;
const HERO_SIZE = 140;
const TIMING = {
// Phase 1: companion assembly
textStart: 0.0,
textEnd: 0.67,
// Phase 2: hero pop
popStart: 0.73,
popDur: 0.5,
// Phase 3: slide-out + recenter
slideStart: 1.5,
slideDur: 0.7,
// Phase 4: zoom
zoomStart: 2.67,
zoomDur: 0.9,
// Phase 5: breathing
breathStart: 3.67,
};
const FINAL_RECENTER_OFFSET = -180; // pre-calculated, tuned for visual feel
/* ================================================================
DISCRETE TEXT SEQUENCE for the companion assembly.
Converted from the source frame-based sequence to seconds at 30fps.
================================================================ */
const SEQUENCE = [
{ t: 0.0, text: "J" },
{ t: 0.07, text: "Jus" },
{ t: 0.13, text: "Just" },
{ t: 0.27, text: "Just" }, // hold for pacing
{ t: 0.4, text: "Just u" },
{ t: 0.53, text: "Just us" },
{ t: 0.67, text: "Just use" },
];
/* ================================================================
TIMELINE — built synchronously so HyperFrames can seek it.
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
{
/* Measure the brand text width with a hidden DOM probe. */
const probe = document.createElement("span");
probe.className = "measure-probe";
probe.style.font = `700 ${BRAND_FONT_SIZE}px "Google Sans", "Roboto", Inter, system-ui, sans-serif`;
probe.style.whiteSpace = "pre";
probe.style.lineHeight = "1";
probe.textContent = "Hyperframes"; // MUST match the rendered .brand-text casing
document.body.appendChild(probe);
const brandTextWidth = probe.getBoundingClientRect().width;
probe.remove();
/* Derive the hero's post-Phase-3 offset from viewport center.
baseHeroOffset = (C + G + B + L) / 2 <- heroSize cancels out
HERO_FINAL_OFFSET_X = baseHeroOffset + FINAL_RECENTER_OFFSET */
const baseHeroOffset = (COMPANION_WIDTH + COMPANION_GAP + brandTextWidth + HERO_GAP) / 2;
const HERO_FINAL_OFFSET_X = baseHeroOffset + FINAL_RECENTER_OFFSET;
/* ============================================================
PHASE 1: Discrete companion assembly
============================================================ */
const textEl = document.querySelector(".companion-text");
for (const entry of SEQUENCE) {
tl.set(textEl, { textContent: entry.text }, entry.t);
}
/* ============================================================
PHASE 2: Hero pops in (elastic)
============================================================ */
tl.fromTo(
".hero",
{ scale: 0 },
{
scale: 1,
duration: TIMING.popDur,
ease: "back.out(2)", // spring(stiffness:200, damping:12)
},
TIMING.popStart,
);
/* ============================================================
PHASE 3: Companion exit + container recenter
Two concurrent tweens at the same timeline position.
============================================================ */
tl.to(
".companion",
{
opacity: 0,
x: -80,
duration: TIMING.slideDur,
ease: "power3.out", // spring(stiffness:100, damping:20)
},
TIMING.slideStart,
);
tl.to(
".recenter-shift",
{
x: FINAL_RECENTER_OFFSET,
duration: TIMING.slideDur,
ease: "power3.out",
},
TIMING.slideStart,
);
/* ============================================================
PHASE 4: Zoom — scale (outer) + counter-translate (middle) + brand text exits.
============================================================ */
tl.to(
".zoom-scale",
{
scale: 5.5,
duration: TIMING.zoomDur,
ease: "power2.out", // spring(stiffness:80, damping:20, mass:1.5)
},
TIMING.zoomStart,
);
tl.to(
".zoom-translate",
{
x: -HERO_FINAL_OFFSET_X,
y: 0,
duration: TIMING.zoomDur,
ease: "power2.out",
},
TIMING.zoomStart,
);
// Brand text fades out + slides left so the logo gets all the zoom space.
tl.to(
".brand-text",
{
opacity: 0,
x: -600,
duration: TIMING.zoomDur * 0.4,
ease: "power2.out",
},
TIMING.zoomStart,
);
/* ============================================================
PHASE 5: Breathing — onUpdate so it MULTIPLIES on the hero's
final pop scale, doesn't overwrite it.
============================================================ */
const heroEl = document.querySelector(".hero");
const HERO_FINAL_SCALE = 1.0;
const SCALE_PERIOD = 1.5; // seconds per cycle
const SCALE_AMP = 0.04;
const ROTATE_AMP = 2;
const breathDur = TOTAL_DURATION - TIMING.breathStart;
tl.to(
{ tick: 0 },
{
tick: 1,
duration: breathDur,
ease: "none",
onUpdate: function () {
const idleTime = Math.max(0, tl.time() - TIMING.breathStart);
const omega = (idleTime / SCALE_PERIOD) * Math.PI * 2;
gsap.set(heroEl, {
scale: HERO_FINAL_SCALE * (1 + Math.sin(omega) * SCALE_AMP),
rotation: Math.sin(omega) * ROTATE_AMP,
});
},
},
TIMING.breathStart,
);
}
</script>
</body>
</html>
@@ -0,0 +1,649 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 09 — Split Comparison Reveal</title>
<!--
HyperFrames composition.tsx.
Choreography (3 phases, 5 seconds total):
0.17 0.83s Title slides down from top: "Build Video With HyperFrames"
0.50 1.83s Left card "HTML Composition" enters from left (+16° rotateY)
0.83 1.83s Right card "Render Pipeline" enters from right (-16° rotateY)
1.67 2.17s Left pill badge "Seekable Timeline" pops in with back.out(1.7)
2.00 2.50s Right pill badge "Render Ready" pops in
0 5.00s Continuous floating: cards y ±6 px / rotation ±1° (phase-opposed)
Badges y ±5 px (slow shared sine)
and .card-tilt (static rotateY + float rotation) so entry and float
don't fight on the same alias
- All continuous floating consolidated into one shared scene-ticker onUpdate
(6 gsap.set calls per frame, batched by the browser)
- Phase offset Math.PI between the two cards' floats — opposed breathing
- Card images are HyperFrames workflow mockups (no asset files needed)
- Ambient dual-glow via two radial-gradients in a single overlay
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--bg-dark: #0a1415;
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.65);
--brand-cyan: #18d9e8;
--brand-cyan-glow: rgba(24, 217, 232, 1);
--brand-green: #7bea5a;
--brand-green-glow: rgba(123, 234, 90, 1);
--glass-bg: rgba(255, 255, 255, 0.08);
--border: rgba(255, 255, 255, 0.12);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg-dark);
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
color: var(--text-primary);
}
.stage {
position: absolute;
inset: 0;
overflow: hidden;
}
/* ============================================================
BACKGROUND
============================================================ */
.bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 35% 50%, rgba(24, 217, 232, 0.2), transparent 45%),
radial-gradient(ellipse at 72% 50%, rgba(123, 234, 90, 0.16), transparent 45%),
linear-gradient(135deg, #3a3a3a 0%, #17211f 30%, #0b2328 48%, #1f3518 70%, #343434 100%);
}
/* ============================================================
TITLE
============================================================ */
.title {
position: absolute;
top: 60px;
left: 50%;
transform: translateX(-50%);
font-size: 88px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
letter-spacing: 0;
white-space: nowrap;
will-change: transform, opacity;
/* initial opacity 0 + y -40 set via gsap.set */
}
.title .accent {
color: var(--brand-cyan);
text-shadow: 0 0 34px rgba(24, 217, 232, 0.28);
}
/* ============================================================
CARDS ROW
============================================================ */
.cards-row {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 76px;
padding-top: 30px;
perspective: 980px;
perspective-origin: 50% 42%;
transform-style: preserve-3d;
}
/* Two nested wrappers per card */
.card {
transform-style: preserve-3d;
}
.card-pos {
perspective: 980px;
perspective-origin: 50% 45%;
transform-style: preserve-3d;
will-change: transform, opacity;
/* initial x/scale/opacity set via gsap.set */
}
.card-tilt {
transform-style: preserve-3d;
will-change: transform;
/* initial rotationY set via gsap.set */
}
.card-content {
width: 720px;
transform-style: preserve-3d;
}
.card-image {
width: 100%;
height: 500px;
border-radius: 24px;
overflow: hidden;
border: 1px solid var(--border);
position: relative;
transform: translateZ(0) rotateX(0.01deg);
backface-visibility: hidden;
}
.card-left .card-tilt {
transform-origin: 100% 50%;
}
.card-right .card-tilt {
transform-origin: 0% 50%;
}
.card-label {
margin-top: 26px;
text-align: center;
font-size: 54px;
font-weight: 700;
color: var(--text-primary);
transform: translateZ(34px);
text-shadow: 0 0 30px rgba(24, 217, 232, 0.16);
}
.card-subtitle {
margin-top: 8px;
text-align: center;
font-size: 24px;
color: var(--text-secondary);
transform: translateZ(26px);
}
/* Left card: shadow falls right (positive x in box-shadow = right) */
.card-left .card-image {
box-shadow:
30px 30px 60px rgba(0, 0, 0, 0.45),
0 0 60px rgba(24, 217, 232, 0.2);
}
/* Right card: shadow falls left */
.card-right .card-image {
box-shadow:
-30px 30px 60px rgba(0, 0, 0, 0.45),
0 0 60px rgba(123, 234, 90, 0.2);
}
/* ============================================================
CARD IMAGE PLACEHOLDERS (mock UI)
============================================================ */
/* Left card — HTML Composition: gradient + timed clip grid */
.card-left .card-image {
background: linear-gradient(
135deg,
rgba(12, 38, 42, 0.96) 0%,
rgba(18, 66, 63, 0.9) 52%,
rgba(32, 57, 38, 0.94) 100%
);
}
.mock-templates {
position: absolute;
inset: 0;
padding: 56px 66px;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 22px;
opacity: 0.22;
}
.mock-thumb {
background: linear-gradient(135deg, rgba(24, 217, 232, 0.2), rgba(123, 234, 90, 0.08));
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 14px;
transform: translateZ(18px);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.18);
}
.mock-thumb .label {
font-size: 18px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
}
/* Right card — Render Pipeline: validation rows */
.card-right .card-image {
background: linear-gradient(
135deg,
rgba(23, 45, 34, 0.96) 0%,
rgba(17, 70, 73, 0.9) 50%,
rgba(38, 57, 45, 0.94) 100%
);
}
.mock-team {
position: absolute;
inset: 0;
padding: 66px 72px;
display: flex;
flex-direction: column;
gap: 20px;
justify-content: center;
opacity: 0.24;
}
.mock-row {
display: flex;
align-items: center;
gap: 14px;
padding: 15px 20px;
background: rgba(255, 255, 255, 0.06);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
transform: translateZ(18px);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.18);
}
.mock-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
flex-shrink: 0;
background: linear-gradient(135deg, var(--brand-green), var(--brand-cyan));
}
.mock-avatar.purple {
background: linear-gradient(135deg, var(--brand-cyan), #38bdf8);
}
.mock-avatar.green {
background: linear-gradient(135deg, var(--brand-green), var(--brand-cyan));
}
.mock-avatar.orange {
background: linear-gradient(135deg, #facc15, var(--brand-green));
}
.mock-row .name {
font-size: 22px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.mock-row .status {
margin-left: auto;
font-size: 16px;
color: var(--brand-green);
}
/* ============================================================
FLOATING BADGES
============================================================ */
.badge {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 22px;
border-radius: 999px;
background-color: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
will-change: transform, opacity;
z-index: 50;
}
.badge-left {
border: 1px solid rgba(24, 217, 232, 0.4);
box-shadow: 0 0 30px rgba(24, 217, 232, 0.35);
}
.badge-right {
border: 1px solid rgba(123, 234, 90, 0.4);
box-shadow: 0 0 30px rgba(123, 234, 90, 0.35);
}
.badge-icon-wrap {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--bg-dark);
flex-shrink: 0;
}
.badge-left .badge-icon-wrap {
background-color: var(--brand-cyan);
}
.badge-right .badge-icon-wrap {
background-color: var(--brand-green);
}
.badge-label {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
/* ============================================================
AMBIENT GLOW + VIGNETTE
============================================================ */
.ambient-glow {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 30% 50%, var(--brand-cyan-glow) 0%, transparent 35%),
radial-gradient(ellipse at 70% 50%, var(--brand-green-glow) 0%, transparent 35%);
opacity: 0.13;
pointer-events: none;
}
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.45) 100%);
pointer-events: none;
z-index: 400;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="5"
data-width="1920"
data-height="1080"
>
<div
class="stage clip"
data-start="0"
data-duration="5"
data-track-index="1"
id="scene-stage"
>
<div class="bg"></div>
<!-- Title -->
<div class="title" id="title">Build Video With <span class="accent">HyperFrames</span></div>
<!-- Cards row -->
<div class="cards-row">
<!-- Left card: HTML Composition -->
<div class="card card-left" id="card-left">
<div class="card-pos">
<div class="card-tilt">
<div class="card-content">
<div class="card-image">
<div class="mock-templates">
<div class="mock-thumb"><span class="label">HTML</span></div>
<div class="mock-thumb"><span class="label">CSS</span></div>
<div class="mock-thumb"><span class="label">GSAP</span></div>
<div class="mock-thumb"><span class="label">Audio</span></div>
<div class="mock-thumb"><span class="label">Captions</span></div>
<div class="mock-thumb"><span class="label">Assets</span></div>
</div>
</div>
<div class="card-label">HTML Composition</div>
<div class="card-subtitle">Timed DOM clips, media, and motion</div>
</div>
</div>
</div>
</div>
<!-- Right card: Render Pipeline -->
<div class="card card-right" id="card-right">
<div class="card-pos">
<div class="card-tilt">
<div class="card-content">
<div class="card-image">
<div class="mock-team">
<div class="mock-row">
<div class="mock-avatar purple"></div>
<span class="name">Register timeline</span>
<span class="status">● seekable</span>
</div>
<div class="mock-row">
<div class="mock-avatar green"></div>
<span class="name">Validate layout</span>
<span class="status">● clean</span>
</div>
<div class="mock-row">
<div class="mock-avatar orange"></div>
<span class="name">Render frames</span>
<span class="status">● stable</span>
</div>
<div class="mock-row">
<div class="mock-avatar"></div>
<span class="name">Publish MP4</span>
<span class="status">● ready</span>
</div>
</div>
</div>
<div class="card-label">Render Pipeline</div>
<div class="card-subtitle">Preview, check, render, publish</div>
</div>
</div>
</div>
</div>
</div>
<!-- Floating badges -->
<div class="badge badge-left" id="badge-left">
<div class="badge-icon-wrap">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 2L2 7L12 12L22 7L12 2Z" />
<path d="M2 17L12 22L22 17" />
<path d="M2 12L12 17L22 12" />
</svg>
</div>
<span class="badge-label">Seekable Timeline</span>
</div>
<div class="badge badge-right" id="badge-right">
<div class="badge-icon-wrap">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M17 21V19C17 16.7909 15.2091 15 13 15H5C2.79086 15 1 16.7909 1 19V21" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21V19C22.9986 17.1771 21.765 15.5857 20 15.13" />
<path
d="M16 3.13C17.7699 3.58317 19.0078 5.17799 19.0078 7.005C19.0078 8.83201 17.7699 10.4268 16 10.88"
/>
</svg>
</div>
<span class="badge-label">Render Ready</span>
</div>
<div class="ambient-glow"></div>
</div>
<div class="vignette"></div>
</div>
<script>
/* ================================================================
CONSTANTS
================================================================ */
const W = 1920,
H = 1080;
const TOTAL_DUR = 5.0;
const TIMING = {
// Phase 1: title
titleAt: 0.17,
titleDur: 0.67,
// Phase 2: cards
leftAt: 0.5,
rightAt: 0.83,
entryDur: 0.7,
slideDist: 100,
baseTilt: 18,
// Phase 3: badges
badgeLeftAt: 1.67,
badgeRightAt: 2.0,
badgeEntryDur: 0.5,
// Continuous float
floatYSpeed: 0.02 * 30, // = 0.6 rad/sec
floatYAmp: 6,
floatRSpeed: 0.015 * 30, // = 0.45 rad/sec
floatRAmp: 1,
badgeYSpeed: 0.025 * 30, // = 0.75 rad/sec
badgeYAmp: 5,
};
/* ================================================================
BADGE POSITIONS — set via CSS left/top once (not tweened)
================================================================ */
const badgeLeftEl = document.getElementById("badge-left");
const badgeRightEl = document.getElementById("badge-right");
badgeLeftEl.style.left = W * 0.12 + "px"; // 230 px
badgeLeftEl.style.top = H * 0.35 + "px"; // 378 px
badgeLeftEl.style.position = "absolute";
badgeRightEl.style.left = W * 0.75 + "px"; // 1440 px
badgeRightEl.style.top = H * 0.38 + "px"; // 410 px
badgeRightEl.style.position = "absolute";
/* ================================================================
INITIAL STATES (via gsap.set, before the timeline runs)
================================================================ */
gsap.set("#title", { opacity: 0, y: -40 });
gsap.set("#card-left .card-pos", { x: -TIMING.slideDist, scale: 0.8, opacity: 0, y: 0 });
gsap.set("#card-right .card-pos", { x: TIMING.slideDist, scale: 0.8, opacity: 0, y: 0 });
gsap.set("#card-left .card-tilt", { rotationY: TIMING.baseTilt });
gsap.set("#card-right .card-tilt", { rotationY: -TIMING.baseTilt });
gsap.set(["#badge-left", "#badge-right"], { scale: 0, opacity: 0, y: 0 });
/* ================================================================
TIMELINE
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
/* ----------------------------------------------------------------
PHASE 1: Title slides down
---------------------------------------------------------------- */
tl.to(
"#title",
{
opacity: 1,
y: 0,
duration: TIMING.titleDur,
ease: "power3.out", // spring(stiffness:100, damping:16)
},
TIMING.titleAt,
);
/* ----------------------------------------------------------------
PHASE 2: Cards enter from opposite sides
---------------------------------------------------------------- */
tl.to(
"#card-left .card-pos",
{
x: 0,
scale: 1,
opacity: 1,
duration: TIMING.entryDur,
ease: "power3.out",
},
TIMING.leftAt,
);
tl.to(
"#card-right .card-pos",
{
x: 0,
scale: 1,
opacity: 1,
duration: TIMING.entryDur,
ease: "power3.out",
},
TIMING.rightAt,
);
/* ----------------------------------------------------------------
PHASE 3: Badges pop in
---------------------------------------------------------------- */
tl.to(
"#badge-left",
{
scale: 1,
opacity: 1,
duration: TIMING.badgeEntryDur,
ease: "back.out(1.7)",
},
TIMING.badgeLeftAt,
);
tl.to(
"#badge-right",
{
scale: 1,
opacity: 1,
duration: TIMING.badgeEntryDur,
ease: "back.out(1.7)",
},
TIMING.badgeRightAt,
);
/* ----------------------------------------------------------------
CONTINUOUS FLOATING — shared scene-ticker onUpdate
Six gsap.set calls per frame, batched by the browser.
---------------------------------------------------------------- */
const leftPos = document.querySelector("#card-left .card-pos");
const rightPos = document.querySelector("#card-right .card-pos");
const leftTilt = document.querySelector("#card-left .card-tilt");
const rightTilt = document.querySelector("#card-right .card-tilt");
tl.to(
{ tick: 0 },
{
tick: 1,
duration: TOTAL_DUR,
ease: "none",
onUpdate: function () {
const t = tl.time();
// Cards float in opposition (phase π apart).
const lY = Math.sin(t * TIMING.floatYSpeed) * TIMING.floatYAmp;
const lR = Math.sin(t * TIMING.floatRSpeed) * TIMING.floatRAmp;
const rY = Math.sin(t * TIMING.floatYSpeed + Math.PI) * TIMING.floatYAmp;
const rR = Math.sin(t * TIMING.floatRSpeed + Math.PI) * TIMING.floatRAmp;
gsap.set(leftPos, { y: lY });
gsap.set(rightPos, { y: rY });
gsap.set(leftTilt, { rotationY: TIMING.baseTilt + lR });
gsap.set(rightTilt, { rotationY: -TIMING.baseTilt + rR });
// Badges — small shared y oscillation.
const bY = Math.sin(t * TIMING.badgeYSpeed) * TIMING.badgeYAmp;
gsap.set(badgeLeftEl, { y: bY });
gsap.set(badgeRightEl, { y: bY });
},
},
0,
);
</script>
</body>
</html>
@@ -0,0 +1,520 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 7 — HyperFrames Decrypt Pan Track</title>
<!--
HyperFrames composition.tsx.
Choreography (4 phases, 7 seconds total):
0.0 0.7s Shot 1 static text fades in + rises ("HyperFrames renders")
0.7 1.6s Hacker-flip decodes the accent word ("video")
2.8 3.5s Horizontal pan to Shot 2 with parallax exit + scale-in
3.7 6.1s Cursor-tracked typing: "HTML, CSS and JS become MP4"
(NOT @the source/layout-utils, NOT a charWidthRatio constant)
- Bar width pre-allocated from the full text — never tweened
- Camera follows cursor by tweening the strip's `x`, with a piecewise
Math.min(initialOffset, trackingOffset) for the two-phase camera
- Hacker-flip glyph flicker via deterministic int hash, not Math.random
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--bg: linear-gradient(
135deg,
#3a3a3a 0%,
#17211f 30%,
#0b2328 48%,
#1f3518 70%,
#343434 100%
);
--text-dark: #f8fafc;
--text-highlight: #18d9e8;
--text-highlight-2: #7bea5a;
--search-bg: rgba(14, 24, 23, 0.82);
--cursor: #7bea5a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg);
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
color: var(--text-dark);
}
.viewport {
position: absolute;
inset: 0;
overflow: hidden;
}
.strip {
display: flex;
height: 100%;
will-change: transform;
}
.shot {
width: 1920px;
height: 100%;
position: relative;
flex-shrink: 0;
}
/* ============================================================
SHOT 1 — "HyperFrames renders video"
============================================================ */
.shot1-content {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0; /* GSAP fromTo fades in */
}
.shot1-row {
display: flex;
align-items: baseline;
gap: 0.4em;
font-size: 130px;
}
.shot1-static {
font-weight: 500;
color: var(--text-dark);
text-shadow: 0 0 36px rgba(24, 217, 232, 0.18);
}
.shot1-accent {
font-weight: 700;
display: flex;
perspective: 800px; /* required for the per-glyph rotateX */
}
.flip-glyph {
position: relative;
display: inline-block;
min-width: 0;
}
.flip-glyph.space {
min-width: 0.4em;
}
.flip-glyph .ghost {
opacity: 0;
}
.flip-glyph .anim {
position: absolute;
left: 0;
top: 0;
width: 100%;
color: var(--text-highlight);
background: linear-gradient(135deg, var(--text-highlight) 0%, var(--text-highlight-2) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 24px rgba(24, 217, 232, 0.32))
drop-shadow(0 0 36px rgba(123, 234, 90, 0.22));
opacity: 0;
transform: perspective(600px) rotateX(90deg);
transform-origin: bottom;
backface-visibility: hidden;
}
/* ============================================================
SHOT 2 — Search bar with cursor-tracked typing
============================================================ */
.shot2-bar {
position: absolute;
top: 50%;
/* left set by JS once we know the bar's world coordinate */
height: 240px;
background: var(--search-bg);
border: 1px solid rgba(123, 234, 90, 0.22);
box-shadow:
0 0 60px rgba(24, 217, 232, 0.2),
0 0 96px rgba(123, 234, 90, 0.14),
inset 0 0 32px rgba(24, 217, 232, 0.08);
border-radius: 999px;
display: flex;
align-items: center;
padding-left: 120px;
padding-right: 180px;
opacity: 0; /* GSAP fromTo fades in */
transform-origin: left center;
}
.search-text {
font-size: 120px;
font-weight: 400;
color: var(--text-dark);
text-shadow: 0 0 24px rgba(248, 250, 252, 0.16);
white-space: pre;
line-height: 1;
}
.search-cursor {
font-size: 120px;
color: var(--cursor);
text-shadow: 0 0 26px rgba(123, 234, 90, 0.42);
margin-left: 4px;
font-weight: 300;
line-height: 1;
}
/* Hidden probe used by measureNodeWidth */
.measure-probe {
position: absolute;
left: -99999px;
top: -99999px;
visibility: hidden;
white-space: pre;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="7"
data-width="1920"
data-height="1080"
>
<div class="viewport" data-layout-allow-overflow>
<div class="strip" data-layout-allow-overflow>
<!-- =====================================================
SHOT 1 — "HyperFrames renders video"
===================================================== -->
<div
id="shot-decode"
class="shot shot1 clip"
data-start="0"
data-duration="3.5"
data-track-index="1"
>
<div class="shot1-content">
<div class="shot1-row">
<span class="shot1-static">HyperFrames renders</span>
<span class="shot1-accent" aria-label="video">
<!-- .flip-glyph spans generated by JS -->
</span>
</div>
</div>
</div>
<!-- =====================================================
SHOT 2 — Search bar typing
===================================================== -->
<div
id="shot-typing"
class="shot shot2 clip"
data-start="2.5"
data-duration="4.5"
data-track-index="2"
>
<div class="shot2-bar">
<span class="search-text"></span><span class="search-cursor">_</span>
</div>
</div>
</div>
</div>
</div>
<script>
/* ================================================================
CONSTANTS — all baked at setup time.
================================================================ */
const W = 1920,
H = 1080;
const COMPOSITION_DURATION = 7;
const FPS_HASH = 60; // synthetic clock for the flicker hash
const TIMING = {
// Phase 1: shot1 text fades in + rises
shot1EntryStart: 0.0,
shot1EntryDur: 0.67,
// Phase 2: hacker-flip "video"
flipStart: 0.7,
flipStagger: 0.066, // ~2 frames at 30fps per glyph
flipDuration: 0.55,
// Phase 3: horizontal pan
panStart: 2.83,
panDuration: 0.67,
// Phase 4: cursor-tracked typing
typingStart: 3.67, // panStart + panDuration + 0.17 buffer
charRate: 0.083, // seconds per character (~2.5 frames at 30fps)
};
const FULL_TEXT = "HTML, CSS and JS become MP4";
const FONT_SIZE = 120;
const PADDING_LEFT = 120;
const PADDING_RIGHT = 180;
const CURSOR_VIS_W = FONT_SIZE * 0.6; // visual cursor width approximation
const CURSOR_TARGET = W * 0.7; // screen X where cursor locks
const BAR_LEFT_MARGIN = 80; // initial left margin in Phase 4
const PARALLAX_DIST = 400; // px Shot 1 moves extra during pan
/* ================================================================
BUILD DOM — flip glyphs (Shot 1) generated synchronously.
The accent text is "video" → 5 glyphs.
================================================================ */
const accentEl = document.querySelector(".shot1-accent");
const ACCENT_WORD = "video";
ACCENT_WORD.split("").forEach((char, index) => {
const span = document.createElement("span");
span.className = "flip-glyph" + (char === " " ? " space" : "");
span.dataset.char = char;
span.dataset.index = String(index);
// Each glyph needs its own font-size to match the surrounding row.
span.style.fontSize = "130px";
const ghost = document.createElement("span");
ghost.className = "ghost";
ghost.textContent = char === " " ? " " : char;
const anim = document.createElement("span");
anim.className = "anim";
anim.textContent = char === " " ? " " : char;
span.append(ghost, anim);
accentEl.appendChild(span);
});
/* ================================================================
TEXT MEASUREMENT — uses a hidden DOM probe so we capture
real letter-spacing, kerning, and font-feature widths.
Must run AFTER document.fonts.ready.
================================================================ */
function measureNodeWidth(text, font) {
const probe = document.createElement("span");
probe.className = "measure-probe";
probe.style.font = font;
probe.style.whiteSpace = "pre";
probe.textContent = text;
document.body.appendChild(probe);
const width = probe.getBoundingClientRect().width;
probe.remove();
return width;
}
/* ================================================================
TIMELINE BUILD — fires after fonts are ready so measurements
use the real rendered metrics, not fallback fonts.
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Register early so HF can find it; tweens are added below.
window.__timelines["main"] = tl;
document.fonts.ready.then(() => {
const searchFont = `400 ${FONT_SIZE}px Inter, system-ui, sans-serif`;
const fullTextWidth = measureNodeWidth(FULL_TEXT, searchFont);
const barWidth = PADDING_LEFT + fullTextWidth + CURSOR_VIS_W + PADDING_RIGHT;
// Pre-allocate the bar's final width — no width tweens ever.
const barEl = document.querySelector(".shot2-bar");
barEl.style.width = barWidth + "px";
// Initial bar position in Shot 2's local space.
// We position it at LEFT_MARGIN from the shot's left edge.
barEl.style.left = BAR_LEFT_MARGIN + "px";
barEl.style.transform = "translateY(-50%)"; // vertical center
// ============================================================
// PHASE 1: Shot 1 text fade in + rise
// ============================================================
tl.fromTo(
".shot1-content",
{ opacity: 0, y: 30 },
{
opacity: 1,
y: 0,
duration: TIMING.shot1EntryDur,
ease: "power2.out",
},
TIMING.shot1EntryStart,
);
// ============================================================
// PHASE 2: Hacker-flip "video"
// ============================================================
const CHAR_POOL = "abcdefghijklmnopqrstuvwxyz";
const FLICKER = 3;
const REVEAL_AT = 0.6;
// Deterministic 32-bit mix — replaces Math.random / seeded RNG.
function pseudoHash(i, t) {
return ((i * 374761393 + t * 668265263) >>> 0) % CHAR_POOL.length;
}
document.querySelectorAll(".flip-glyph").forEach((glyph) => {
const index = Number(glyph.dataset.index);
const real = glyph.dataset.char === " " ? " " : glyph.dataset.char;
const anim = glyph.querySelector(".anim");
const start = TIMING.flipStart + index * TIMING.flipStagger;
tl.fromTo(
anim,
{ rotationX: 90, opacity: 0, "--p": 0 },
{
rotationX: 0,
opacity: 1,
"--p": 1,
duration: TIMING.flipDuration,
ease: "back.out(1.6)", // spring(stiffness:150, damping:14)
onUpdate: function () {
const p = Number(gsap.getProperty(anim, "--p"));
if (p >= REVEAL_AT) {
if (anim.textContent !== real) anim.textContent = real;
} else {
const localFrame = Math.floor((tl.time() - start) * FPS_HASH);
const bucket = Math.max(0, Math.floor(localFrame / FLICKER));
anim.textContent = CHAR_POOL[pseudoHash(index, bucket)];
}
},
},
start,
);
});
// ============================================================
// PHASE 3: Horizontal pan + parallax exit + Shot 2 entry
// All three tweens at the same timeline position run in parallel.
// ============================================================
// (a) Camera pan — strip slides one full viewport left.
tl.to(
".strip",
{
x: -W,
duration: TIMING.panDuration,
ease: "power3.inOut", // cinematic slow-in-slow-out
},
TIMING.panStart,
);
// (b) Shot 1 parallax exit — content moves EXTRA -PARALLAX_DIST.
tl.to(
".shot1-content",
{
x: -PARALLAX_DIST,
duration: TIMING.panDuration,
ease: "power3.inOut",
},
TIMING.panStart,
);
// Shot 1 fades out partway through the pan so the eye lands on Shot 2.
tl.to(
".shot1-content",
{
opacity: 0,
duration: TIMING.panDuration * 0.4,
ease: "power2.out",
},
TIMING.panStart,
);
// (c) Shot 2 bar entry — fade + scale with mild overshoot ("landing").
tl.fromTo(
".shot2-bar",
{ opacity: 0, scale: 0.8 },
{
opacity: 1,
scale: 1,
duration: TIMING.panDuration,
ease: "back.out(1.2)",
},
TIMING.panStart,
);
// ============================================================
// PHASE 4: Cursor-tracked typing.
//
// The strip is now at x = -W (Shot 2 fully on screen). For the
// cursor-track effect we'll *further* shift the strip by a piecewise
// amount: hold initial offset while the empty bar's cursor is left
// of CURSOR_TARGET, then follow once typing pushes it past.
// ============================================================
const searchTextEl = document.querySelector(".search-text");
const stripEl = document.querySelector(".strip");
const CURSOR_WIDTH_HALF = CURSOR_VIS_W / 2;
const STRIP_BASE_X = -W; // strip's x at the end of Phase 3
// Initial Phase-4 offset = 0 (no additional shift on top of -W).
// i.e. bar already sits at BAR_LEFT_MARGIN inside Shot 2.
const INITIAL_OFFSET = 0;
// Typing driver — a clock that runs from 0 to FULL_TEXT.length.
const typingProxy = { progress: 0 };
const typingDur = FULL_TEXT.length * TIMING.charRate;
const typingEnd = TIMING.typingStart + typingDur;
const postTypingHold = Math.max(0, COMPOSITION_DURATION - typingEnd);
tl.to(
typingProxy,
{
progress: FULL_TEXT.length,
duration: typingDur,
ease: "none",
onUpdate: function () {
const charsTyped = Math.min(FULL_TEXT.length, Math.floor(typingProxy.progress));
const visibleText = FULL_TEXT.slice(0, charsTyped);
if (searchTextEl.textContent !== visibleText) {
searchTextEl.textContent = visibleText;
}
// Measure current visible width to compute cursor screen position.
// Cheap because Inter's metrics are cached after the first call.
const visibleW =
visibleText.length === 0 ? 0 : measureNodeWidth(visibleText, searchFont);
// Cursor X in *Shot 2's* coordinate system:
// shot left edge → bar left edge (BAR_LEFT_MARGIN)
// → cursor within bar (PADDING_LEFT + visibleW + CURSOR_WIDTH_HALF)
const cursorXInShot2 = BAR_LEFT_MARGIN + PADDING_LEFT + visibleW + CURSOR_WIDTH_HALF;
// For the cursor to land at CURSOR_TARGET on the *screen*, the
// strip must be shifted by:
// stripX = CURSOR_TARGET - cursorXInShot2 - W (the -W accounts
// for Shot 2 being the SECOND shot in the strip)
const trackingStripX = CURSOR_TARGET - cursorXInShot2 - W;
// Piecewise: hold STRIP_BASE_X + INITIAL_OFFSET until tracking
// would pan FURTHER LEFT than the base, then follow.
const finalStripX = Math.min(STRIP_BASE_X + INITIAL_OFFSET, trackingStripX);
gsap.set(stripEl, { x: finalStripX });
},
},
TIMING.typingStart,
);
if (postTypingHold > 0) {
tl.to(
".search-cursor",
{
opacity: 0.22,
duration: postTypingHold / 2,
repeat: 1,
yoyo: true,
ease: "none",
},
typingEnd,
);
}
});
</script>
</body>
</html>
@@ -0,0 +1,468 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 10 — HyperFrames Morph Press Interact</title>
<!--
HyperFrames composition.tsx.
Choreography (4 phases, 5.5 seconds total):
0.17 0.64s Hero "HyperFrames" + logo asset fade up (opacity + y rise)
0.17 5.50s Logo asset rotates ±4° continuously (sine onUpdate)
2.17 2.62s Hero shrinks (1 → 0.6) and fades (1 → 0)
2.17 2.62s "Build video from HTML" CTA pill pops in with back.out(2) overshoot
2.33 2.66s CTA text fades + lifts into place (after container is recognizable)
2.83s Cursor hard-cuts in at off-screen bottom-right
2.83 3.83s Cursor approaches via spring path to (W/2 + 120, H/2 + 50)
3.83 3.98s Click DOWN: both cursor and CTA compress to scale 0.9 (sync)
4.17 4.42s Click UP: both return to scale 1.0 (sync)
DOM + GSAP opacity tweens; z-index keeps incoming above outgoing
- Press synchronization uses single GSAP target array ["#cta", "#cursor"]
to guarantee perfectly identical tween values
- Cursor opacity uses a 0.001-second fromTo for hard-cut step change
- Logo uses a local static image asset referenced with a plain URL
- Breathing rotation only on the logo, not on the whole hero (avoids
conflict with the morph exit scale tween)
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--hf-bg: #050711;
--hf-bg-2: #0b1020;
--hf-ink: #f8fafc;
--hf-muted: #cbd5e1;
--hf-purple: #7c3aed;
--hf-purple-soft: #a78bfa;
--hf-cyan: #18d9e8;
--hf-green: #22c55e;
--hf-glass: rgba(248, 250, 252, 0.08);
--white: #ffffff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background:
radial-gradient(ellipse at 50% 42%, rgba(24, 217, 232, 0.14), transparent 50%),
radial-gradient(ellipse at 62% 48%, rgba(124, 58, 237, 0.2), transparent 56%),
radial-gradient(ellipse at 42% 58%, rgba(34, 197, 94, 0.1), transparent 58%),
linear-gradient(135deg, #050711 0%, #090b18 48%, #120717 100%);
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
color: var(--hf-ink);
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
/* ============================================================
HERO LOCKUP (Phase 1 — exits during morph)
============================================================ */
.hero {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 54px;
will-change: transform, opacity;
}
.hero-text {
font-size: 220px;
font-weight: 800;
color: var(--hf-ink);
letter-spacing: 0;
line-height: 1;
margin: 0;
text-shadow:
0 0 34px rgba(167, 139, 250, 0.18),
0 0 72px rgba(24, 217, 232, 0.1);
}
.hero-text .brand-accent {
color: var(--hf-purple-soft);
text-shadow:
0 0 34px rgba(124, 58, 237, 0.45),
0 0 82px rgba(167, 139, 250, 0.22);
}
.hero-logo {
width: 300px;
height: 300px;
margin-left: 8px;
will-change: transform;
display: flex;
align-items: center;
justify-content: center;
}
.hero-logo .logo-mark {
width: 100%;
height: 100%;
display: block;
border-radius: 36px;
box-shadow:
0 0 64px rgba(24, 217, 232, 0.32),
0 0 112px rgba(34, 197, 94, 0.18),
0 0 150px rgba(124, 58, 237, 0.12);
}
/* ============================================================
CTA PILL (Phase 2 — pops in during morph)
============================================================ */
.cta {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.96) 0%,
rgba(24, 217, 232, 0.9) 48%,
rgba(34, 197, 94, 0.96) 100%
);
padding: 70px 200px;
border-radius: 200px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid rgba(24, 217, 232, 0.55);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.42),
0 0 72px rgba(24, 217, 232, 0.24),
0 0 120px rgba(124, 58, 237, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.24),
inset 0 0 34px var(--hf-glass);
will-change: transform, opacity;
/* initial scale 0 + opacity 0 set by gsap.set() */
}
.cta-text {
color: var(--hf-bg);
font-size: 110px;
font-weight: 700;
white-space: nowrap;
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.3),
0 0 22px rgba(255, 255, 255, 0.12);
will-change: transform, opacity;
/* initial opacity 0 + y 10 set by gsap.set() */
}
/* ============================================================
CURSOR (Phase 3 — hard-cuts in)
============================================================ */
.cursor {
position: absolute;
left: 0;
top: 0;
width: 120px;
height: 120px;
will-change: transform, opacity;
/* initial x/y/opacity set by gsap.set() */
}
.cursor .cursor-svg {
width: 100%;
height: 100%;
display: block;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="5.5"
data-width="1920"
data-height="1080"
>
<div class="stage clip" data-start="0" data-duration="5.5" data-track-index="1" id="stage">
<!-- =====================================================
HERO LOCKUP — "HyperFrames" + logo
===================================================== -->
<div
class="hero clip"
id="hero"
data-start="0"
data-duration="2.62"
data-track-index="2"
data-layout-allow-overflow
aria-hidden="true"
>
<h1 class="hero-text">Hyper<span class="brand-accent">Frames</span></h1>
<div class="hero-logo" id="hero-logo">
<!-- Inline-SVG placeholder mark — swap for your logo image -->
<svg class="logo-mark" viewBox="0 0 100 100" role="img" aria-label="HyperFrames">
<defs>
<linearGradient id="hfMark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8b7bff" />
<stop offset="1" stop-color="#3ddc97" />
</linearGradient>
</defs>
<rect x="4" y="4" width="92" height="92" rx="22" fill="url(#hfMark)" />
<text
x="50"
y="63"
text-anchor="middle"
font-family="Inter, system-ui, sans-serif"
font-size="40"
font-weight="800"
fill="#fff"
>
HF
</text>
</svg>
</div>
</div>
<!-- =====================================================
CTA PILL — "Build video from HTML"
===================================================== -->
<div class="cta" id="cta">
<span class="cta-text">Build video from HTML</span>
</div>
<!-- =====================================================
CURSOR — inline SVG arrow with drop-shadow
===================================================== -->
<div class="cursor" id="cursor">
<svg
class="cursor-svg"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<filter id="cursorShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="2" dy="4" stdDeviation="3" flood-opacity="0.3" />
</filter>
</defs>
<path
d="M5.5 3.5L19 10.5L11.5 12.5L15.5 19.5L13.5 20.5L9.5 13.5L5.5 17.5V3.5Z"
fill="#050711"
stroke="#f8fafc"
stroke-width="1.5"
stroke-linejoin="round"
filter="url(#cursorShadow)"
/>
</svg>
</div>
</div>
</div>
<script>
/* ================================================================
TIMING
================================================================ */
const W = 1920,
H = 1080;
const TOTAL_DUR = 5.5;
const TIMING = {
introStart: 0.17, // ~5 frames at 30 fps
introDur: 0.47, // hero enter spring settle
morphAt: 2.17, // ~65 frames
morphExitDur: 0.5,
morphFadeDur: 0.15, // 30% of exit
morphEntDur: 0.45, // CTA pop-in
textRevealAt: 2.33, // morph + 0.17 s
textRevealDur: 0.33,
cursorEnterAt: 2.83,
cursorPathDur: 1.0,
clickDownAt: 3.83,
clickDownDur: 0.15,
clickUpAt: 4.17,
clickUpDur: 0.25,
pressIntensity: 0.1,
};
/* ================================================================
INITIAL STATES (via gsap.set, before the timeline runs)
================================================================ */
// Hero: starts at opacity 0, y +40 (will fade up)
gsap.set("#hero", { opacity: 0, y: 40 });
// CTA: starts invisible (scale 0, opacity 0) — morph pops it in
gsap.set("#cta", { scale: 0, opacity: 0 });
// CTA text: starts at opacity 0 + y 10 — reveals after container is recognizable
gsap.set(".cta-text", { opacity: 0, y: 10 });
// Cursor: starts off-screen bottom-right + invisible + scale 1 baseline
gsap.set("#cursor", {
x: W + 100,
y: H + 200,
scale: 1,
opacity: 0,
});
/* ================================================================
TIMELINE
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
/* ----------------------------------------------------------------
PHASE 1: Hero entrance + breathing rotation on logo
---------------------------------------------------------------- */
tl.to(
"#hero",
{
opacity: 1,
y: 0,
duration: TIMING.introDur,
ease: "power3.out", // spring(stiffness:120, damping:14)
},
TIMING.introStart,
);
// Continuous breathing rotation on the logo. ±4° over ~6.3 s period.
// For a 5.5 s composition this is just under one cycle — subtle but alive.
// Use Form 2 (onUpdate reading tl.time) per sine-wave-loop rule.
const logoEl = document.querySelector("#hero-logo");
tl.to(
{ tick: 0 },
{
tick: 1,
duration: TOTAL_DUR,
ease: "none",
onUpdate: function () {
const t = tl.time();
gsap.set(logoEl, { rotation: Math.sin(t * 1.0) * 4 });
},
},
0,
);
/* ----------------------------------------------------------------
PHASE 2: Scale-swap morph
Three concurrent tween clusters from MORPH_AT.
---------------------------------------------------------------- */
// (1) Hero shrinks
tl.to(
"#hero",
{
scale: 0.6,
duration: TIMING.morphExitDur,
ease: "power3.out", // spring(stiffness:150, damping:18)
},
TIMING.morphAt,
);
// (2) Hero fades fast (30% of shrink dur)
tl.to(
"#hero",
{
opacity: 0,
duration: TIMING.morphFadeDur,
ease: "power2.out",
},
TIMING.morphAt,
);
tl.set(
"#hero",
{
x: -4000,
},
TIMING.morphAt + TIMING.morphFadeDur,
);
// (3) CTA pops in with overshoot
tl.to(
"#cta",
{
scale: 1,
opacity: 1,
duration: TIMING.morphEntDur,
ease: "back.out(2)", // spring(stiffness:200, damping:15, mass:0.6)
},
TIMING.morphAt,
);
// (4) CTA text reveals after container reaches recognizable scale
tl.to(
".cta-text",
{
opacity: 1,
y: 0,
duration: TIMING.textRevealDur,
ease: "power2.out",
},
TIMING.textRevealAt,
);
/* ----------------------------------------------------------------
PHASE 3: Cursor entry + motion path
---------------------------------------------------------------- */
// Hard-cut opacity (cursors don't fade in — they appear).
// 0.001 s tween creates a step change that scrubs correctly.
tl.fromTo(
"#cursor",
{ opacity: 0 },
{ opacity: 1, duration: 0.001, ease: "none" },
TIMING.cursorEnterAt,
);
// Spring-driven approach to a target slightly offset from center
// (where a human would naturally aim — not dead-center).
tl.to(
"#cursor",
{
x: W / 2 + 120,
y: H / 2 + 50,
duration: TIMING.cursorPathDur,
ease: "power2.out", // spring(stiffness:60, damping:20)
},
TIMING.cursorEnterAt,
);
/* ----------------------------------------------------------------
PHASE 4: Physics-based press
Both CTA and cursor compress together — single target array.
---------------------------------------------------------------- */
// Press DOWN — both elements compress to (1 - intensity)
tl.to(
["#cta", "#cursor"],
{
scale: 1 - TIMING.pressIntensity,
duration: TIMING.clickDownDur,
ease: "power3.out", // spring(stiffness:300, damping:20)
},
TIMING.clickDownAt,
);
// RELEASE — back to scale 1.0
tl.to(
["#cta", "#cursor"],
{
scale: 1.0,
duration: TIMING.clickUpDur,
ease: "power2.out", // spring(stiffness:200, damping:15)
},
TIMING.clickUpAt,
);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,759 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 04 — Contextual Product Showcase</title>
<!--
HyperFrames composition.tsx.
Choreography (4 phases, 9 seconds total):
0.00 0.80s 3D-tilted page card scales 0.95 → 1.0
Navbar / title / CTA fade in (staggered)
0.12 2.80s Title keywords glow synced to ASR words:
"1" "long" "video" "10" "viral" "clips"
3.08 4.08s Page content scrolls up 280 px (programmatic scroll feel)
Carousel section fades in + scales from 0.9
3.58 8.84s Main video pops forward in 3D (translateZ 80 px) + scales up
Radial spotlight dims the surroundings; decays at end
(attack 0→1 then decay 1→REST_LEVEL). All visual effects (color,
text-shadow, scale) derive from --glow via CSS calc()
- Pop-target uses the same --glow pattern; translateZ + scale derived
- Spotlight is a separate overlay with simple opacity tween
- Video placeholders are colored divs (no asset files needed)
- Tilt is static CSS, never tweened
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--page-bg: #0a0a0f;
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--accent: #edcb50;
--input-bg: #1a1a1f;
--green: #22c55e;
}
* {
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,
-apple-system,
sans-serif;
color: var(--text-primary);
}
/* ============================================================
BACKGROUND
============================================================ */
.bg {
position: absolute;
inset: 0;
background:
radial-gradient(
ellipse 100% 60% at 50% -10%,
rgba(100, 100, 150, 0.08) 0%,
transparent 60%
),
var(--page-bg);
}
/* ============================================================
3D PERSPECTIVE + TILTED CARD
============================================================ */
.perspective-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
perspective: 1200px;
}
.page-card {
width: 92%;
height: 88%;
background-color: var(--page-bg);
border-radius: 20px;
overflow: hidden;
transform-style: preserve-3d;
/* tilt + initial scale set by gsap.set() below so subsequent tweens preserve them */
box-shadow:
-30px 30px 60px rgba(0, 0, 0, 0.4),
-15px 15px 30px rgba(0, 0, 0, 0.3),
0 0 80px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
/* ============================================================
NAVBAR (sticks to top of card; NOT inside scroll content)
============================================================ */
.page-navbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 72px;
padding: 0 48px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 100;
opacity: 0; /* GSAP fades in */
background: linear-gradient(to bottom, rgba(10, 10, 15, 0.95), rgba(10, 10, 15, 0.6));
}
.nav-left {
display: flex;
align-items: center;
gap: 10px;
}
.nav-logo {
width: 32px;
height: 32px;
color: var(--text-primary);
}
.nav-brand {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
.nav-center {
display: flex;
align-items: center;
gap: 28px;
}
.nav-item {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
display: flex;
align-items: center;
}
.nav-item .chev {
margin-left: 4px;
font-size: 10px;
}
.nav-tag {
font-size: 10px;
background: var(--green);
color: #fff;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
margin-right: 6px;
}
.nav-right {
display: flex;
align-items: center;
gap: 12px;
}
.nav-signin {
padding: 8px 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
}
.nav-signup {
padding: 8px 16px;
background: var(--accent);
border-radius: 8px;
color: var(--page-bg);
font-size: 14px;
font-weight: 600;
}
/* ============================================================
SCROLL CONTENT — GSAP tweens its y
============================================================ */
.scroll-content {
padding-top: 140px;
padding-bottom: 60px;
display: flex;
flex-direction: column;
align-items: center;
will-change: transform;
}
/* ============================================================
HERO SECTION
============================================================ */
.hero {
text-align: center;
max-width: 1200px;
width: 90%;
opacity: 0; /* GSAP fades in */
}
.hero-eyebrow {
font-size: 14px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 24px;
font-weight: 600;
}
.hero-title {
font-size: 64px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -1px;
}
.hero-sub {
font-size: 20px;
color: rgba(255, 255, 255, 0.6);
margin-top: 24px;
font-weight: 400;
line-height: 1.5;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
/* ============================================================
KEYWORD GLOW — all derived from --glow CSS custom property
============================================================ */
.kw {
--glow: 0;
display: inline-block;
position: relative;
z-index: 0;
padding: 0 0.015em;
color: rgb(
calc(255 - var(--glow) * 18) calc(255 - var(--glow) * 30) calc(255 - var(--glow) * 150)
);
text-shadow:
0 0 calc(var(--glow) * 10px) rgba(255, 235, 120, calc(var(--glow) * 0.95)),
0 0 calc(var(--glow) * 24px) rgba(237, 203, 80, calc(var(--glow) * 0.78)),
0 0 calc(var(--glow) * 44px) rgba(237, 203, 80, calc(var(--glow) * 0.38));
transform: scale(calc(1 + var(--glow) * 0.035));
}
.kw::before {
content: "";
position: absolute;
left: -0.025em;
right: -0.025em;
top: 0.08em;
bottom: 0.05em;
z-index: -1;
border-radius: 0.16em;
background: linear-gradient(
90deg,
rgba(237, 203, 80, 0.08),
rgba(255, 222, 80, 0.72),
rgba(237, 203, 80, 0.12)
);
opacity: calc(var(--glow) * 0.78);
transform: scaleX(calc(0.72 + var(--glow) * 0.24)) skewX(-5deg);
transform-origin: left center;
box-shadow:
0 0 calc(var(--glow) * 14px) rgba(237, 203, 80, calc(var(--glow) * 0.7)),
0 0 calc(var(--glow) * 28px) rgba(237, 203, 80, calc(var(--glow) * 0.32));
}
/* ============================================================
CTA INPUT ROW
============================================================ */
.cta-row {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 36px;
opacity: 0; /* GSAP fades in */
}
.cta-input {
display: flex;
align-items: center;
background: var(--input-bg);
border-radius: 28px;
padding: 6px 6px 6px 18px;
gap: 12px;
}
.cta-link-icon {
width: 16px;
height: 16px;
color: rgba(255, 255, 255, 0.4);
}
.cta-placeholder {
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
min-width: 130px;
}
.cta-button {
padding: 12px 24px;
background: #fff;
border-radius: 22px;
color: var(--page-bg);
font-size: 14px;
font-weight: 600;
}
.cta-or {
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
}
.cta-upload {
padding: 12px 24px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 22px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
}
/* ============================================================
VIDEO CAROUSEL (Phase 2 entry, Phase 4 pop-out)
============================================================ */
.carousel-wrap {
opacity: 0; /* GSAP fades in */
transform: scale(0.9); /* GSAP scales to 1 */
transform-origin: center top;
margin-top: 48px;
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
width: 100%;
max-width: 1400px;
padding: 0 20px;
perspective: 800px;
transform-style: preserve-3d;
}
.carousel-arrow {
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(60, 60, 65, 0.8);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
font-size: 24px;
font-weight: 300;
}
/* Main video — Phase 4 pop-out target */
.carousel-main {
--glow: 0;
width: 720px;
height: 420px;
background: var(--input-bg);
border-radius: 16px;
position: relative;
overflow: hidden;
flex-shrink: 0;
transform-style: preserve-3d;
transform: translateZ(calc(var(--glow) * 80px)) scale(calc(1 + var(--glow) * 0.15));
box-shadow:
0 0 calc(var(--glow) * 25px) rgba(237, 203, 80, calc(var(--glow) * 0.7)),
0 0 calc(var(--glow) * 50px) rgba(237, 203, 80, calc(var(--glow) * 0.4)),
0 calc(20px + var(--glow) * 40px) calc(60px + var(--glow) * 40px)
rgba(0, 0, 0, calc(0.6 + var(--glow) * 0.2));
border: 3px solid rgba(237, 203, 80, calc(var(--glow) * 0.8));
z-index: 200;
}
.carousel-main-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
background: linear-gradient(135deg, #1a2540 0%, #3d2a4f 50%, #4f1d2e 100%);
}
.carousel-main-placeholder span {
background: rgba(0, 0, 0, 0.4);
padding: 8px 20px;
border-radius: 8px;
}
/* Side panel */
.carousel-side {
width: 420px;
height: 280px;
background: var(--input-bg);
border-radius: 16px;
position: relative;
overflow: hidden;
flex-shrink: 0;
}
.carousel-side-placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #2a3550 0%, #4a3a5a 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
}
.presets-stack {
position: absolute;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 5;
}
.preset-label {
padding: 8px 14px;
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
font-weight: 500;
}
.preset-tile {
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
align-self: flex-end;
}
/* ============================================================
SPOTLIGHT OVERLAY (above page card, dims surroundings)
============================================================ */
.spotlight {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse 850px 550px at 40% 60%,
transparent 0%,
transparent 50%,
rgba(0, 0, 0, 0.65) 100%
);
pointer-events: none;
opacity: 0; /* GSAP fades in during Phase 4 */
z-index: 150;
}
/* ============================================================
VIGNETTE
============================================================ */
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.3) 100%);
pointer-events: none;
z-index: 400;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="9"
data-width="1920"
data-height="1080"
>
<div class="bg"></div>
<div class="perspective-wrap">
<div
class="page-card clip"
data-start="0"
data-duration="9"
data-track-index="1"
id="page-card"
>
<!-- Navbar (anchored to card top) -->
<nav class="page-navbar" id="navbar">
<div class="nav-left">
<svg
class="nav-logo"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fill="currentColor"
d="M11.954 3.817c-4.49 0-8.132 3.644-8.152 8.146v12H0V12C0 5.373 5.352 0 11.954 0s11.953 5.373 11.953 12-5.351 12-11.953 12a12 12 0 0 1-1.718-.123v-3.876q.831.181 1.718.182c4.502 0 8.152-3.663 8.152-8.183s-3.65-8.183-8.152-8.183"
/>
<path
fill="currentColor"
d="M5.118 24V11.995c0-3.79 3.062-6.857 6.836-6.857 3.773 0 6.836 3.068 6.836 6.857s-3.063 6.857-6.836 6.857c-.594 0-1.17-.076-1.718-.218V14.5c.488.337 1.08.534 1.718.534a3.037 3.037 0 0 0 3.034-3.04 3.037 3.037 0 0 0-3.034-3.04 3.037 3.037 0 0 0-3.034 3.008V24z"
/>
</svg>
<span class="nav-brand">OpusClip</span>
</div>
<div class="nav-center">
<span class="nav-item">Features<span class="chev"></span></span>
<span class="nav-item">Solutions<span class="chev"></span></span>
<span class="nav-item">Resources<span class="chev"></span></span>
<span class="nav-item">Pricing</span>
<span class="nav-item">For business<span class="chev"></span></span>
<span class="nav-item"><span class="nav-tag">New</span>Agent Opus</span>
</div>
<div class="nav-right">
<span class="nav-signin">Sign in</span>
<span class="nav-signup">Sign up - It's FREE</span>
</div>
</nav>
<!-- Scrollable content -->
<div class="scroll-content" id="scroll-content">
<div class="hero" id="hero">
<div class="hero-eyebrow">#1 AI VIDEO CLIPPING TOOL</div>
<h1 class="hero-title">
<span class="kw" data-glow-start="0.12" data-glow-end="0.28">1</span>
<span class="kw" data-glow-start="0.52" data-glow-end="0.72">long</span>
<span class="kw" data-glow-start="0.78" data-glow-end="1.44">video,</span>
<span class="kw" data-glow-start="1.48" data-glow-end="1.80">10</span>
<span class="kw" data-glow-start="1.94" data-glow-end="2.22">viral</span>
<span class="kw" data-glow-start="2.28" data-glow-end="2.80">clips.</span>
<br />Create 10x faster.
</h1>
<p class="hero-sub">
OpusClip turns long videos into shorts, and publishes them to all social platforms
in one click.
</p>
</div>
<div class="cta-row" id="cta-row">
<div class="cta-input">
<svg
class="cta-link-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
<span class="cta-placeholder">Drop a video link</span>
<div class="cta-button">Get free clips</div>
</div>
<span class="cta-or">or</span>
<div class="cta-upload">Upload files</div>
</div>
<!-- Video carousel section -->
<div class="carousel-wrap" id="carousel">
<div class="carousel-arrow"></div>
<div class="carousel-main pop-target" id="pop-target">
<div class="carousel-main-placeholder">
<span>Hero animation</span>
</div>
</div>
<div class="carousel-arrow"></div>
<div class="carousel-side">
<div class="carousel-side-placeholder">Demo · reframe</div>
<div class="presets-stack">
<div class="preset-label">Presets</div>
<div class="preset-tile"></div>
<div class="preset-tile"></div>
<div class="preset-tile"></div>
<div class="preset-tile"></div>
</div>
</div>
</div>
</div>
<!-- /.scroll-content -->
</div>
<!-- /.page-card -->
</div>
<!-- /.perspective-wrap -->
<div class="spotlight" id="spotlight"></div>
<div class="vignette"></div>
</div>
<script>
/* ================================================================
TIMING — all in local seconds (scene starts at 0).
ASR timestamps are direct from the source, with SCENE_START = 16
subtracted to get local time.
================================================================ */
const ASR = {
// Phase 2 title keywords
one: { start: 0.119, end: 0.279 },
long: { start: 0.52, end: 0.719 },
video: { start: 0.779, end: 1.44 },
ten: { start: 1.479, end: 1.799 },
viral: { start: 1.94, end: 2.219 },
clips: { start: 2.279, end: 2.8 },
};
const TIMING = {
// Phase 1: entry
cardEntryStart: 0.0,
cardEntryDur: 0.8,
navbarFadeAt: 0.0,
navbarFadeDur: 0.6,
titleFadeAt: 0.17,
titleFadeDur: 0.7,
ctaFadeAt: 0.5,
ctaFadeDur: 0.7,
// Phase 2: keyword glows handled per-element via data attrs
// Phase 3: scroll + carousel
phase2StartAt: 3.08,
scrollDur: 1.0,
scrollDistance: 280,
carouselFadeDur: 0.5,
// Phase 4: pop-out + spotlight
popStart: 3.58,
popAttackDur: 0.67, // ramp 0 → 1
popDecayDur: 0.33, // ramp 1 → 0.5 near end
popEnd: 8.84,
popRestLevel: 0.5,
};
const KEYWORD_REST_LEVEL = 0.14;
const KEYWORD_SUSTAIN = 0.5; // seconds after ASR end before decay completes
/* ================================================================
TIMELINE
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
// Set the static 3D tilt + initial scale via GSAP so subsequent
// scale tweens preserve the rotation aliases. (If we left
// `transform: rotateY(...) rotateX(...) scale(0.95)` in CSS, a GSAP
// tween touching only `scale` could overwrite the full matrix and
// lose the rotation. Owning the transform state in GSAP avoids the
// problem entirely.)
gsap.set(".page-card", {
rotationY: -8,
rotationX: 3,
scale: 0.95,
});
/* ----------------------------------------------------------------
PHASE 1: Page card entry + navbar/title/CTA fade-in
---------------------------------------------------------------- */
tl.to(
".page-card",
{ scale: 1.0, duration: TIMING.cardEntryDur, ease: "power2.out" },
TIMING.cardEntryStart,
);
tl.fromTo(
"#navbar",
{ opacity: 0 },
{ opacity: 1, duration: TIMING.navbarFadeDur, ease: "power2.out" },
TIMING.navbarFadeAt,
);
tl.fromTo(
"#hero",
{ opacity: 0 },
{ opacity: 1, duration: TIMING.titleFadeDur, ease: "power2.out" },
TIMING.titleFadeAt,
);
tl.fromTo(
"#cta-row",
{ opacity: 0 },
{ opacity: 1, duration: TIMING.ctaFadeDur, ease: "power2.out" },
TIMING.ctaFadeAt,
);
/* ----------------------------------------------------------------
PHASE 2: Keyword glows — two tweens per word.
Attack (0 → 1) + decay (1 → REST_LEVEL). GSAP holds REST forever.
---------------------------------------------------------------- */
document.querySelectorAll(".kw").forEach((kw) => {
const start = Number(kw.dataset.glowStart);
const end = Number(kw.dataset.glowEnd);
const peak = start + (end - start) / 2;
const restAt = end + KEYWORD_SUSTAIN;
tl.fromTo(
kw,
{ "--glow": 0 },
{ "--glow": 1.18, duration: peak - start, ease: "power2.out" },
start,
);
tl.to(
kw,
{ "--glow": KEYWORD_REST_LEVEL, duration: restAt - peak, ease: "power2.out" },
peak,
);
});
/* ----------------------------------------------------------------
PHASE 3: Scroll + carousel entry
---------------------------------------------------------------- */
tl.fromTo(
"#scroll-content",
{ y: 0 },
{ y: -TIMING.scrollDistance, duration: TIMING.scrollDur, ease: "power2.inOut" },
TIMING.phase2StartAt,
);
tl.fromTo(
"#carousel",
{ opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1.0, duration: TIMING.carouselFadeDur, ease: "power2.out" },
TIMING.phase2StartAt,
);
/* ----------------------------------------------------------------
PHASE 4: Pop-out (driven by --glow on .carousel-main) + spotlight
---------------------------------------------------------------- */
// Attack the pop-out glow.
tl.fromTo(
"#pop-target",
{ "--glow": 0 },
{ "--glow": 1, duration: TIMING.popAttackDur, ease: "power2.out" },
TIMING.popStart,
);
// Decay at the end of the scene (mirrors original phase2End-10 / phase2End ramp).
tl.to(
"#pop-target",
{ "--glow": TIMING.popRestLevel, duration: TIMING.popDecayDur, ease: "power2.out" },
TIMING.popEnd - TIMING.popDecayDur,
);
// Spotlight fades in alongside the pop attack, decays with the pop.
tl.fromTo(
"#spotlight",
{ opacity: 0 },
{ opacity: 1, duration: 0.5, ease: "power2.out" },
TIMING.popStart,
);
tl.to(
"#spotlight",
{ opacity: TIMING.popRestLevel, duration: TIMING.popDecayDur, ease: "power2.out" },
TIMING.popEnd - TIMING.popDecayDur,
);
</script>
</body>
</html>
@@ -0,0 +1,729 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 01 — Counting Icon Burst</title>
<!--
HyperFrames composition.tsx.
Choreography (4 phases, 3.5 seconds total):
0.00 0.17s Background visible with dark overlay; nothing else
0.17 0.57s Four enriched icons enter staggered, clustered at startOffset 0.4
(clock 0.17s · scissors 0.30s · video 0.43s · play 0.57s)
0.47 1.47s Counter "0 → 90" with growing font size (0.20W → 0.42W);
icons expand outward from 40% to 100% position
0.50 2.33s Camera focus phase: scale 0.92 → 1.0
2.33 3.50s Camera push phase: scale 1.0 → 1.08
1.27s Percent symbol pops in with own spring
Continuous motion (running from t=0, gated by icon visibility):
Clock minute hand: linear rotation 420° over 3.5s
Scissors: ±15° sine oscillation, period ~1.7s
Cutting line: stroke-dashoffset drifts -200 over 3.5s
Video record dot: opacity + scale phase-offset sine pulses
Play triangle: scale pulse ±8%, period ~2.6s
ease + duration (no shared driver needed, just identical timing)
- Counter text + number scale are emitted as seek-safe timeline keyframes
- Background: local Pexels MP4 plus dark overlay
- Per-icon entry uses GSAP fromTo on a nested .icon-entry wrapper
so it never overwrites the .icon-pos expansion tween
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--bg-dark: #060812;
--text-primary: #ffffff;
--accent-yellow: #edcb50;
--record-red: #ef4444;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg-dark);
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
color: var(--text-primary);
}
/* ============================================================
BACKGROUND (static gradient)
============================================================ */
.bg {
position: absolute;
inset: 0;
transform-origin: center center;
will-change: transform;
background:
radial-gradient(ellipse at 30% 30%, rgba(80, 100, 200, 0.25), transparent 60%),
radial-gradient(ellipse at 70% 70%, rgba(180, 80, 150, 0.2), transparent 60%),
linear-gradient(135deg, #0a0d1f 0%, #1a0d20 100%);
}
.bg-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.66);
}
/* ============================================================
CAMERA WRAPPER (GSAP-managed scale phases)
============================================================ */
.camera {
position: absolute;
inset: 0;
transform-origin: center center;
will-change: transform;
}
/* ============================================================
ICONS — two nested wrappers per icon.
.icon-pos is positioned at TARGET; GSAP x/y shifts toward center.
.icon-entry tweens scale/opacity/rotation.
============================================================ */
.icons-stage {
position: absolute;
inset: 0;
}
.icon-pos {
position: absolute;
width: 180px;
height: 180px;
will-change: transform;
}
.icon-entry {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
will-change: transform, opacity;
/* initial scale(0) + opacity(0) set by GSAP fromTo */
}
.icon-svg {
width: 100%;
height: 100%;
display: block;
}
/* SVG inner elements — class hooks for GSAP tweens */
.clock-hand-min {
transform-box: view-box;
}
.scissor-upper,
.scissor-lower {
transform-origin: 12px 12px;
}
.play-tri {
transform-origin: 12px 12px;
}
.record-dot {
transform-origin: 19px 8px;
}
.play-ring {
transform-origin: 12px 12px;
transform: rotate(-90deg);
}
/* ============================================================
COUNTER (absolute center, 3D entry)
============================================================ */
.counter-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
perspective: 2000px;
perspective-origin: center center;
}
.counter-3d {
display: flex;
align-items: baseline;
justify-content: center;
transform-style: preserve-3d;
will-change: transform, opacity;
opacity: 0; /* GSAP entry fades in */
}
.counter-number {
font-size: 806px; /* W × 0.42 — final size, GSAP scales from 0.20W */
font-weight: 900;
letter-spacing: -0.04em;
line-height: 0.9;
font-variant-numeric: tabular-nums; /* prevents 1→2 digit jitter */
color: var(--text-primary);
display: inline-block;
transform-origin: center bottom;
will-change: transform;
}
.counter-percent {
font-size: 269px; /* W × 0.14 */
font-weight: 700;
letter-spacing: -0.02em;
color: var(--accent-yellow);
margin-left: 16px;
display: inline-block;
will-change: transform, opacity;
opacity: 0; /* GSAP entry tweens in later */
}
/* ============================================================
VIGNETTE
============================================================ */
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
pointer-events: none;
z-index: 400;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="3.5"
data-width="1920"
data-height="1080"
>
<div class="bg"></div>
<div class="bg-overlay"></div>
<div
class="camera clip"
data-start="0"
data-duration="3.5"
data-track-index="1"
id="camera-stage"
>
<div class="icons-stage">
<!-- Clock -->
<div class="icon-pos clock-pos" id="clock-pos">
<div class="icon-entry clock-entry">
<svg
class="icon-svg"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
class="clock-ring"
cx="12"
cy="12"
r="9"
fill="none"
stroke="white"
stroke-width="2"
stroke-dasharray="56.5"
stroke-dashoffset="56.5"
/>
<line
x1="12"
y1="12"
x2="12"
y2="8"
stroke="white"
stroke-width="2"
stroke-linecap="round"
/>
<line
class="clock-hand-min"
x1="12"
y1="12"
x2="12"
y2="6"
stroke="#edcb50"
stroke-width="2"
stroke-linecap="round"
/>
<circle cx="12" cy="12" r="1.5" fill="#edcb50" />
</svg>
</div>
</div>
<!-- Scissors -->
<div class="icon-pos scissors-pos" id="scissors-pos">
<div class="icon-entry scissors-entry">
<svg
class="icon-svg"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g class="scissor-upper">
<path
d="M12 10.5L4.5 3L6.5 3L14 10.5"
stroke="white"
stroke-width="2"
stroke-linecap="round"
fill="none"
/>
<circle cx="6" cy="6" r="2.5" fill="none" stroke="white" stroke-width="1.5" />
</g>
<g class="scissor-lower">
<path
d="M12 13.5L4.5 21L6.5 21L14 13.5"
stroke="white"
stroke-width="2"
stroke-linecap="round"
fill="none"
/>
<circle cx="6" cy="18" r="2.5" fill="none" stroke="white" stroke-width="1.5" />
</g>
<line
class="cutting-line"
x1="14"
y1="12"
x2="20"
y2="12"
stroke="#edcb50"
stroke-width="2"
stroke-linecap="round"
stroke-dasharray="4 2"
/>
</svg>
</div>
</div>
<!-- Video -->
<div class="icon-pos video-pos" id="video-pos">
<div class="icon-entry video-entry">
<svg
class="icon-svg"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect
class="video-frame"
x="3"
y="5"
width="14"
height="14"
rx="2"
stroke="white"
stroke-width="2"
fill="none"
stroke-dasharray="56"
stroke-dashoffset="56"
/>
<path d="M8 9L13 12L8 15V9Z" fill="white" />
<circle class="record-dot" cx="19" cy="8" r="2.5" fill="#ef4444" />
</svg>
</div>
</div>
<!-- Play -->
<div class="icon-pos play-pos" id="play-pos">
<div class="icon-entry play-entry">
<svg
class="icon-svg"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
class="play-ring"
cx="12"
cy="12"
r="9"
fill="none"
stroke="white"
stroke-width="2"
stroke-dasharray="56.5"
stroke-dashoffset="56.5"
/>
<circle cx="12" cy="12" r="7" fill="rgba(255,255,255,0.05)" />
<path class="play-tri" d="M10 8L16 12L10 16V8Z" fill="#edcb50" />
</svg>
</div>
</div>
</div>
<!-- /.icons-stage -->
<div class="counter-stage">
<div class="counter-3d" id="counter-3d">
<span class="counter-number" id="counter-number">0</span
><span class="counter-percent" id="counter-percent">%</span>
</div>
</div>
</div>
<!-- /.camera -->
<div class="vignette"></div>
</div>
<script>
/* ================================================================
CONSTANTS
================================================================ */
const W = 1920,
H = 1080;
const ICON_SIZE = 180;
const CENTER_X = W / 2 - ICON_SIZE / 2; // 870
const CENTER_Y = H / 2 - ICON_SIZE / 2; // 450
const TIMING = {
// Phase 2 — icon entries (staggered)
clockEntryAt: 0.17,
scissorsEntryAt: 0.3,
videoEntryAt: 0.43,
playEntryAt: 0.57,
entryDur: 0.55,
// Phase 3 — count + expansion (shared start, dur, ease)
countAt: 0.47,
countDur: 1.0,
startOffset: 0.4,
// Counter 3D entry (slightly earlier than count starts)
counterEntryAt: 0.27,
counterEntryDur: 0.7,
// Percent symbol entry
percentEntryAt: 1.27,
percentEntryDur: 0.55,
// Phase 4 — camera
cameraFocusAt: 0.5,
cameraFocusDur: 1.83,
cameraPushAt: 2.33,
cameraPushDur: 1.17,
};
const ICONS = [
{
name: "clock",
sel: "#clock-pos",
targetX: W * 0.06,
targetY: H * 0.18,
delay: TIMING.clockEntryAt,
entryRotation: -180,
entryEase: "back.out(1.5)",
},
{
name: "scissors",
sel: "#scissors-pos",
targetX: W * 0.85,
targetY: H * 0.2,
delay: TIMING.scissorsEntryAt,
entryRotation: 0,
entryEase: "back.out(1.5)",
},
{
name: "video",
sel: "#video-pos",
targetX: W * 0.04,
targetY: H * 0.68,
delay: TIMING.videoEntryAt,
entryRotation: 0,
entryEase: "back.out(1.4)",
},
{
name: "play",
sel: "#play-pos",
targetX: W * 0.88,
targetY: H * 0.65,
delay: TIMING.playEntryAt,
entryRotation: 90,
entryEase: "back.out(1.5)",
},
];
/* ================================================================
SET ICON TARGET POSITIONS + INITIAL EXPANSION OFFSETS
================================================================ */
ICONS.forEach(({ sel, targetX, targetY }) => {
const el = document.querySelector(sel);
el.style.left = targetX + "px";
el.style.top = targetY + "px";
// Initial offset for START_OFFSET = 0.4:
// position = target + (center - target) * (1 - 0.4) = target + 0.6 * (center - target)
const offsetX = (CENTER_X - targetX) * (1 - TIMING.startOffset);
const offsetY = (CENTER_Y - targetY) * (1 - TIMING.startOffset);
gsap.set(el, { x: offsetX, y: offsetY });
});
/* Set initial entry state on each .icon-entry */
ICONS.forEach(({ name, entryRotation }) => {
gsap.set(`.${name}-entry`, {
scale: 0,
opacity: 0,
rotation: entryRotation,
});
});
/* Set initial background and camera scale + subtle pan baseline */
gsap.set(".bg", { scale: 1.05 });
gsap.set(".camera", { scale: 0.92, x: 0, y: 2 });
gsap.set("#counter-number", { scale: 0.2 / 0.42 });
/* ================================================================
TIMELINE
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
tl.to(
".bg",
{
scale: 1.07,
x: 6,
y: -4,
duration: 3.5,
ease: "sine.inOut",
},
0,
);
/* ----------------------------------------------------------------
PHASE 2: Icon entries (staggered)
---------------------------------------------------------------- */
ICONS.forEach(({ name, delay, entryEase }) => {
// Scale + opacity + rotation entry
tl.to(
`.${name}-entry`,
{
scale: 1,
opacity: 0.85,
rotation: 0,
duration: TIMING.entryDur,
ease: entryEase,
},
delay,
);
});
// Stroke-draw the outline circles/rect on clock, video, and play during their entries
tl.to(
".clock-ring",
{
attr: { "stroke-dashoffset": 0 },
duration: TIMING.entryDur,
ease: "power2.out",
},
TIMING.clockEntryAt,
);
tl.to(
".video-frame",
{
attr: { "stroke-dashoffset": 0 },
duration: TIMING.entryDur,
ease: "power2.out",
},
TIMING.videoEntryAt,
);
tl.to(
".play-ring",
{
attr: { "stroke-dashoffset": 0 },
duration: TIMING.entryDur,
ease: "power2.out",
},
TIMING.playEntryAt,
);
/* ----------------------------------------------------------------
PHASE 3: Counter (with 3D entry) + expansion
---------------------------------------------------------------- */
// Counter 3D entry — translateZ + rotateY + scale + opacity, in parallel.
tl.fromTo(
"#counter-3d",
{ rotationY: 10, scale: 0.7, z: -300, opacity: 0 },
{
rotationY: 0,
scale: 1.0,
z: 0,
opacity: 1,
duration: TIMING.counterEntryDur,
ease: "power3.out",
},
TIMING.counterEntryAt,
);
// Counter number + apparent size. Use discrete frame keyframes so
// HyperFrames can seek directly to any sampled time.
const counterEl = document.querySelector("#counter-number");
const COUNT_STEPS = 30;
const COUNT_START_SCALE = 0.2 / 0.42;
for (let i = 0; i <= COUNT_STEPS; i += 1) {
const p = i / COUNT_STEPS;
const eased = 1 - Math.pow(1 - p, 2.5);
const number = Math.round(eased * 90);
const scale = COUNT_START_SCALE + eased * (1 - COUNT_START_SCALE);
tl.set(
counterEl,
{
textContent: String(number),
scale,
},
TIMING.countAt + p * TIMING.countDur,
);
}
// Percent symbol enters later with its own spring.
tl.fromTo(
"#counter-percent",
{ x: 60, rotation: 20, scale: 0.4, opacity: 0 },
{
x: 0,
rotation: 0,
scale: 1.0,
opacity: 1,
duration: TIMING.percentEntryDur,
ease: "back.out(1.4)",
},
TIMING.percentEntryAt,
);
// Icon expansion — per-icon, same start/dur/ease as counter for lock-step sync.
ICONS.forEach(({ sel }) => {
tl.to(
sel,
{
x: 0,
y: 0,
duration: TIMING.countDur,
ease: "power2.out",
},
TIMING.countAt,
);
});
/* ----------------------------------------------------------------
PHASE 4: Multi-phase camera
---------------------------------------------------------------- */
tl.to(
".camera",
{
scale: 1.0,
duration: TIMING.cameraFocusDur,
ease: "power2.out",
},
TIMING.cameraFocusAt,
);
tl.to(
".camera",
{
scale: 1.08,
duration: TIMING.cameraPushDur,
ease: "power2.out",
},
TIMING.cameraPushAt,
);
// Subtle deterministic pan from the the source camera micro-movement.
tl.to(
".camera",
{
x: 3,
y: 1.6,
duration: 3.5,
ease: "sine.inOut",
},
0,
);
/* ----------------------------------------------------------------
CONTINUOUS INTERNAL SVG MOTION
All four icons' inner animations are derived from tl.time() in
a single shared onUpdate. Cheaper than 4-5 separate onUpdates,
easier to inspect.
---------------------------------------------------------------- */
const SCISSOR_SPEED = 0.12 * 30; // = 3.6 rad/sec (sin freq)
const SCISSOR_AMP = 15;
const REC_OPACITY_SPEED = 0.15 * 30; // = 4.5 rad/sec
const REC_SCALE_SPEED = 0.1 * 30; // = 3.0 rad/sec
const REC_OPACITY_AMP = 0.3;
const REC_OPACITY_BASE = 0.7;
const REC_SCALE_AMP = 0.15;
const PLAY_PULSE_SPEED = 0.08 * 30; // = 2.4 rad/sec
const PLAY_PULSE_AMP = 0.08;
const CUTTING_FLOW_SPEED = 0.5 * 30; // = 15 units/sec (for stroke-dashoffset)
const scissorUpper = document.querySelector(".scissor-upper");
const scissorLower = document.querySelector(".scissor-lower");
const recordDot = document.querySelector(".record-dot");
const playTri = document.querySelector(".play-tri");
const cuttingLine = document.querySelector(".cutting-line");
tl.to(
{ tick: 0 },
{
tick: 1,
duration: 3.5,
ease: "none",
onUpdate: function () {
const t = tl.time();
// Scissors — symmetric oscillation, anchored to scissors delay
const sciT = t - TIMING.scissorsEntryAt;
const sciAngle = sciT > 0 ? Math.sin(sciT * SCISSOR_SPEED) * SCISSOR_AMP : 0;
gsap.set(scissorUpper, { rotation: sciAngle });
gsap.set(scissorLower, { rotation: -sciAngle });
// Cutting line — linear dash flow (negative = leftward drift)
const cutT = t - TIMING.scissorsEntryAt;
if (cutT > 0) {
gsap.set(cuttingLine, {
attr: { "stroke-dashoffset": -cutT * CUTTING_FLOW_SPEED },
});
}
// Record dot — phase-offset opacity and scale
const recT = t - TIMING.videoEntryAt;
if (recT > 0) {
const recOpacity =
Math.sin(recT * REC_OPACITY_SPEED) * REC_OPACITY_AMP + REC_OPACITY_BASE;
const recScale = 1 + Math.sin(recT * REC_SCALE_SPEED) * REC_SCALE_AMP;
gsap.set(recordDot, { opacity: recOpacity, scale: recScale });
}
// Play triangle — gentle scale pulse
const playT = t - TIMING.playEntryAt;
if (playT > 0) {
const playScale = 1 + Math.sin(playT * PLAY_PULSE_SPEED) * PLAY_PULSE_AMP;
gsap.set(playTri, { scale: playScale });
}
},
},
0,
);
/* Clock minute hand — linear rotation, anchored to clock entry */
tl.fromTo(
".clock-hand-min",
{ rotation: 0, svgOrigin: "12 12" },
{
rotation: 420, // 120 deg/sec × 3.5s, scaled for visible motion
svgOrigin: "12 12",
duration: 3.5 - TIMING.clockEntryAt,
ease: "none",
},
TIMING.clockEntryAt,
);
</script>
</body>
</html>
@@ -0,0 +1,352 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene — HyperFrames Messaging Multi-Phrase</title>
<!--
Blueprint: messaging-multi-phrase (HyperFrames preview)
HyperFrames-native version of the scene-9-multi-phrase concept.
Choreography (3 phrases, ~7.5 seconds total, content-driven duration):
Phrase 1 [0.00 3.86 s]
"With an AI assistant " (21 chars × 0.083 s = 1.75 s)
+ "you can trust" (13 chars × 0.083 s = 1.08 s, accent pink)
+ hold 1.00 s
→ total 1.75 + 1.08 + 1.00 ≈ 3.83 s (computed by JS)
Phrase 2 [3.86 5.27 s]
"Instant " (8 chars × 0.083 s = 0.67 s)
+ "insights" (8 chars × 0.083 s = 0.67 s, accent pink)
+ hold 1.00 s
→ total 0.67 + 0.67 + 1.00 ≈ 2.33 s, but starts at 3.86 → endTime ≈ 6.18 s
Phrase 3 [6.18 9.45 s]
"Endless " (8 chars × 0.083 = 0.67 s)
+ "possibilities" (13 chars × 0.083 = 1.08 s, accent pink)
+ hold 2.00 s (longer — closing beat)
→ total 0.67 + 1.08 + 2.00 ≈ 3.75 s, but starts at 6.18 → endTime ≈ 9.93 s
Continuous: cursor blink (square wave via `tl.time() % 1.0`)
color, cursor blink. No per-phrase tweens.
- No conditional DOM — phrase container exists from t=0 with empty text;
onUpdate overwrites textContent in-place at each frame.
- TIMELINE is a plain const computed once at script load (no useMemo).
- Frames become seconds: charSpeed 2.5 frames → 0.083 s; hold 30 frames → 1.0 s.
- Cursor blink: `(t % BLINK_CYCLE) < BLINK_CYCLE / 2 ? 1 : 0` inside the same
onUpdate, not a separate component with `useCurrentFrame`.
- textContent !== visible guard skips redundant DOM writes during hold windows.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--text-primary: #ffffff;
--text-accent: #32fff6;
--text-accent-2: #a3ff7a;
--bg: linear-gradient(
135deg,
#3a3a3a 0%,
#17211f 30%,
#0b2328 48%,
#1f3518 70%,
#343434 100%
);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg);
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
/* ============================================================
PHRASE STAGE — one shared line with three inline children:
.phrase-main primary-color portion
.phrase-accent accent-color portion
.phrase-cursor block cursor (color overridden per-frame)
`white-space: pre` keeps the trailing space in textMain so the
accent doesn't merge with the lead-in.
============================================================ */
.phrase-stage {
position: absolute;
inset: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 150px;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
white-space: pre;
text-align: center;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.16);
}
.phrase-main {
color: var(--text-primary);
}
.phrase-accent {
color: var(--text-accent);
text-shadow: 0 0 12px rgba(50, 255, 246, 0.28);
}
.phrase-cursor {
display: inline-block;
flex: 0 0 auto;
width: 8px;
height: 162px; /* ≈ 1.08 × fontSize */
background: var(--text-primary);
margin-left: 12px;
vertical-align: middle;
transform: translateY(10px); /* baseline alignment */
will-change: opacity, background-color;
box-shadow: 0 0 12px rgba(50, 255, 246, 0.36);
}
/* ============================================================
BACKGROUND TINT — subtle radial glow so pure black doesn't
feel sterile. Drops to ~rgb(8,8,12) at corners.
============================================================ */
.bg-tint {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 42% 48%, rgba(24, 217, 232, 0.32), transparent 42%),
radial-gradient(ellipse at 58% 52%, rgba(123, 234, 90, 0.26), transparent 44%),
radial-gradient(ellipse at 50% 50%, rgba(8, 16, 18, 0.42), transparent 70%);
pointer-events: none;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="7.5"
data-width="1920"
data-height="1080"
>
<div
id="multi-phrase-scene"
class="stage clip"
data-start="0"
data-duration="7.5"
data-track-index="1"
>
<div class="bg-tint"></div>
<div class="phrase-stage">
<span class="phrase-main"></span><span class="phrase-accent"></span
><span class="phrase-cursor"></span>
</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
/* ============================================================
SCRIPT — content + per-phrase timing config.
Change SCRIPT and the timeline reshapes itself.
============================================================ */
const SCRIPT = [
{
textMain: "Build video with ",
textAccent: "HTML",
charSpeed: 0.083, // seconds per character (= 2.5 frames @ 30 fps)
hold: 1.0,
},
{
textMain: "Seek ",
textAccent: "any frame",
charSpeed: 0.083,
hold: 1.0,
},
{
textMain: "Render to ",
textAccent: "MP4",
charSpeed: 0.083,
hold: 2.0, // longer closing beat
},
];
/* ============================================================
TIMELINE — flat reduce, computed once.
Each entry gains absolute startTime + endTime in seconds.
============================================================ */
let acc = 0;
const TIMELINE = SCRIPT.map((item) => {
const totalChars = item.textMain.length + item.textAccent.length;
const typingDuration = totalChars * item.charSpeed;
const totalDuration = typingDuration + item.hold;
const start = acc;
const end = start + totalDuration;
acc = end;
return Object.assign({}, item, {
startTime: start,
endTime: end,
typingDuration,
});
});
const TOTAL = TIMELINE[TIMELINE.length - 1].endTime;
// TOTAL ≈ 9.93s with the SCRIPT above. The composition root caps at 7.5s
// (data-duration), which is the actual render window — phrases 1+2 fit
// comfortably; phrase 3 holds long enough that any time after ~6.18s is
// already on the closing line. Adjust SCRIPT durations to fit your budget.
/* ============================================================
COLORS & BLINK
============================================================ */
const MAIN_COLOR = "#FFFFFF";
const ACCENT_COLOR = "#32FFF6";
const BLINK_CYCLE = 1.0; // seconds — 0.5s on, 0.5s off (matches 30-frame square wave)
const mainEl = document.querySelector(".phrase-main");
const accentEl = document.querySelector(".phrase-accent");
const cursorEl = document.querySelector(".phrase-cursor");
/* ============================================================
MASTER ENGINE — single onUpdate writes text + cursor every frame.
============================================================ */
// Cache the most-recently-active phrase index for seek-safe scans.
let lastIdx = 0;
function findPhrase(t) {
// Hot path: same phrase still active
if (t >= TIMELINE[lastIdx].startTime && t < TIMELINE[lastIdx].endTime) {
return TIMELINE[lastIdx];
}
// Cold path: linear scan (handles forward + backward seeks)
for (let i = 0; i < TIMELINE.length; i++) {
if (t >= TIMELINE[i].startTime && t < TIMELINE[i].endTime) {
lastIdx = i;
return TIMELINE[i];
}
}
return null;
}
function renderAt(t) {
// ----- Cursor blink (square wave, runs always) -----
cursorEl.style.opacity = t % BLINK_CYCLE < BLINK_CYCLE / 2 ? "1" : "0";
// ----- Find current phrase -----
const phrase = findPhrase(t);
if (!phrase) {
if (mainEl.textContent !== "") mainEl.textContent = "";
if (accentEl.textContent !== "") accentEl.textContent = "";
cursorEl.style.background = MAIN_COLOR;
return;
}
// ----- Compute visible characters -----
const activeT = t - phrase.startTime;
const charIdx = Math.floor(activeT / phrase.charSpeed);
const mainLen = phrase.textMain.length;
const visMain = phrase.textMain.slice(0, Math.min(charIdx, mainLen));
const accentLen = Math.max(0, charIdx - mainLen);
const visAccent = phrase.textAccent.slice(0, accentLen);
// ----- DOM writes (guarded) -----
if (mainEl.textContent !== visMain) mainEl.textContent = visMain;
if (accentEl.textContent !== visAccent) accentEl.textContent = visAccent;
// ----- Cursor color follows active segment -----
const inAccent = visMain.length === mainLen && visAccent.length > 0;
cursorEl.style.background = inAccent ? ACCENT_COLOR : MAIN_COLOR;
}
tl.to(
{ tick: 0 },
{
tick: 1,
duration: 7.5, // matches data-duration; clips the timeline
ease: "none",
onUpdate: () => renderAt(tl.time()),
},
0,
);
const pauseTimeline = tl.pause.bind(tl);
tl.pause = (time, suppressEvents) => {
const result = pauseTimeline(time, suppressEvents);
if (typeof time === "number") renderAt(time);
return result;
};
function wrapSeekOwner(owner) {
if (!owner || owner.__multiPhraseSeekWrapped) return;
let currentSeek = owner.seek;
Object.defineProperty(owner, "seek", {
configurable: true,
get: () => currentSeek,
set: (value) => {
if (typeof value !== "function") {
currentSeek = value;
return;
}
currentSeek = function (time) {
const result = value.apply(this, arguments);
if (typeof time === "number") renderAt(time);
return result;
};
},
});
owner.seek = currentSeek;
owner.__multiPhraseSeekWrapped = true;
}
function installWindowSeekHook(key) {
if (window[key]) {
wrapSeekOwner(window[key]);
return;
}
let pendingOwner;
Object.defineProperty(window, key, {
configurable: true,
get: () => pendingOwner,
set: (value) => {
pendingOwner = value;
wrapSeekOwner(value);
},
});
}
installWindowSeekHook("__player");
installWindowSeekHook("__hf");
renderAt(0);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,779 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 07 — HyperFrames Video Kinetic Text Pivot</title>
<!--
HyperFrames composition.tsx.
Choreography (4 phases, 6.5 seconds total):
0.17 0.87s "HyperFrames" badge fades in (top)
0.17 0.87s Demo screen card enters centered (scale 0.6 → 1)
2.20 3.00s Video slides left to 32% W (scale → 0.85); MP4 pops in on right
with 5-layer green depth stack and bouncy back.out(1.6)
3.00 3.86s MP4 breathes (±2% scale at ~1.9 Hz)
3.86 4.46s Video and MP4 slide off left + fade; typing stage fades in center
4.36 5.13s "HTML pages become video" types char-by-char (23 chars at 30 ch/s)
Accent words "pages" and "video" in brand green
4.83 5.43s Gradient pill (purple → green) scales in behind line 2 with glow halo
5.13 5.63s "frame by frame." types char-by-char inside the pill
0 6.50s Continuous: video float, cursor blink, stat breath
(each gated by time window)
so entry, continuous motion, and static tilt never overwrite each other
- Conditional rendering ({frame > X && ...}) replaced by permanent DOM +
opacity 0 + gates inside the scene-ticker onUpdate
- Cursor blink derived from tl.time() via Math.floor(t * 2) % 2
- Demo screen is a self-contained CSS scene (.video-scene); to showcase real
footage, drop a local mp4 back in as a muted &lt;video class="clip"&gt; (see
the note at the .video-content markup)
- 3D depth layers built once at composition setup with document.createElement
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--bg-dark: #0a0a18;
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.65);
--brand-purple: #7c3aed;
--brand-purple-soft: #a78bfa;
--brand-green: #22c55e;
--brand-purple-glow: rgba(124, 58, 237, 1);
--brand-green-glow: rgba(34, 197, 94, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg-dark);
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
color: var(--text-primary);
}
.stage {
position: absolute;
inset: 0;
overflow: hidden;
}
/* ============================================================
BACKGROUND + AMBIENT GLOW
============================================================ */
.bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 50% 50%, rgba(40, 30, 80, 0.5), transparent 65%),
linear-gradient(135deg, #0a0a18 0%, #160a1f 100%);
}
.ambient-glow {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 1536px;
height: 864px;
background: radial-gradient(
ellipse at center,
rgba(147, 51, 234, 0.13) 0%,
rgba(34, 197, 94, 0.08) 30%,
transparent 70%
);
filter: blur(80px);
pointer-events: none;
}
/* ============================================================
BADGE
============================================================ */
.badge {
position: absolute;
top: 42px;
left: 50%;
transform: translateX(-50%);
padding: 12px 32px;
font-size: 112px;
font-weight: 800;
color: var(--text-primary);
line-height: 1;
white-space: nowrap;
text-shadow:
0 0 32px rgba(248, 250, 252, 0.12),
0 0 72px rgba(24, 217, 232, 0.1);
will-change: transform, opacity;
/* initial opacity 0 + scale 0.9 via gsap.set */
}
.badge .accent {
color: var(--brand-purple-soft);
background: linear-gradient(135deg, var(--brand-purple-soft) 0%, var(--brand-purple) 72%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 28px rgba(124, 58, 237, 0.34));
}
/* ============================================================
VIDEO CARD — three nested wrappers
============================================================ */
.video-pos {
position: absolute;
left: 0;
top: 0;
width: 1040px;
height: 585px; /* 16:9 ratio, intentionally large for the pivot beat */
margin-left: -520px; /* so x: W/2 centers it */
margin-top: -292.5px;
will-change: transform, opacity;
}
.video-float {
width: 100%;
height: 100%;
will-change: transform;
}
.video-tilt {
width: 100%;
height: 100%;
transform-style: preserve-3d;
transform: rotateX(5deg) rotateY(15deg);
}
.video-content {
width: 100%;
height: 100%;
border-radius: 16px;
overflow: hidden;
border: 2px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 30px 60px rgba(0, 0, 0, 0.5),
0 0 80px rgba(147, 51, 234, 0.2),
0 0 120px rgba(34, 197, 94, 0.15);
position: relative;
}
.video-scene {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 30% 30%, rgba(255, 200, 100, 0.4), transparent 50%),
linear-gradient(135deg, #1a3a5f 0%, #2a4570 50%, #3a2a5a 100%);
}
.video-scene::before {
content: "";
position: absolute;
top: 35%;
left: 40%;
width: 220px;
height: 220px;
border-radius: 50%;
background: linear-gradient(135deg, #fbbf24, #f97316);
opacity: 0.6;
filter: blur(8px);
}
.video-caption {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
color: var(--text-primary);
font-size: 34px;
font-weight: 600;
white-space: nowrap;
}
.video-caption .accent {
color: var(--brand-green);
}
.video-reflection {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 100%);
pointer-events: none;
}
/* ============================================================
MP4 STAT — three nested wrappers + depth stack
============================================================ */
.stat-pos {
position: absolute;
left: 0;
top: 0;
will-change: transform, opacity;
}
.stat-breath {
will-change: transform;
}
.stat-tilt {
transform-style: preserve-3d;
transform: rotateX(5deg) rotateY(-15deg);
}
.depth-stack {
position: relative;
display: inline-block;
font-size: 340px;
font-weight: 900;
letter-spacing: -0.03em;
line-height: 1;
}
.depth-stack .depth-layer.front {
position: relative;
color: var(--brand-green);
}
.depth-stack .depth-layer.back {
position: absolute;
top: var(--top);
left: var(--left);
color: rgba(34, 197, 94, var(--alpha));
}
/* ============================================================
TYPING STAGE — center, fades in during pivot
============================================================ */
.typing-stage {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
will-change: transform, opacity;
}
.typing-tilt {
perspective: 1200px;
transform-style: preserve-3d;
transform: rotateY(-15deg) rotateX(5deg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 30px;
}
.line1 {
display: flex;
align-items: center;
white-space: pre;
}
.seg {
font-size: 124px;
font-weight: 700;
color: var(--text-primary);
white-space: pre;
line-height: 1;
}
.seg.accent,
.seg.accent2 {
color: var(--brand-green);
}
.cursor {
display: inline-block;
width: 10px;
height: 124px;
background-color: var(--text-primary);
margin-left: 8px;
vertical-align: middle;
transform: translateY(4px);
opacity: 0;
will-change: opacity;
}
.cursor.green {
background-color: var(--brand-green);
}
.line2-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 64px;
}
.pill-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
width: calc(100% + 80px);
height: calc(100% + 40px);
border-radius: 80px;
background: linear-gradient(90deg, var(--brand-purple) 0%, var(--brand-green) 100%);
opacity: 0;
z-index: -1;
will-change: transform, opacity;
}
.pill-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% + 100px);
height: calc(100% + 60px);
border-radius: 80px;
background: radial-gradient(
ellipse at center,
var(--brand-purple-glow) 0%,
transparent 70%
);
opacity: 0;
filter: blur(30px);
z-index: -2;
will-change: opacity;
}
.line2-content {
display: flex;
align-items: center;
position: relative;
z-index: 1;
opacity: 0;
will-change: opacity;
}
.seg.line2 {
color: var(--text-primary);
}
/* ============================================================
VIGNETTE
============================================================ */
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
pointer-events: none;
z-index: 400;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="6.5"
data-width="1920"
data-height="1080"
>
<div
class="stage clip"
data-start="0"
data-duration="6.5"
data-track-index="1"
id="scene-stage"
>
<div class="bg"></div>
<div class="ambient-glow"></div>
<div class="badge" id="badge">Hyper<span class="accent">Frames</span></div>
<!-- Video card -->
<div class="video-pos" id="video-pos" data-layout-allow-overflow>
<div class="video-float" id="video-float">
<div class="video-tilt">
<div class="video-content">
<div class="video-scene"></div>
<!-- To showcase real footage, add a local mp4 on top of .video-scene:
&lt;video id="demo-video" class="clip" src="assets/demo.mp4"
style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover"
data-start="0.17" data-duration="3.69" data-track-index="2"
data-media-start="0" muted playsinline&gt;&lt;/video&gt; -->
<div class="video-caption">
HTML, CSS and JS become <span class="accent">video</span>
</div>
<div class="video-reflection"></div>
</div>
</div>
</div>
</div>
<!-- MP4 stat -->
<div class="stat-pos" id="stat-pos" data-layout-allow-overflow>
<div class="stat-breath" id="stat-breath">
<div class="stat-tilt">
<div class="depth-stack" id="depth-stack-97" data-text="MP4"></div>
</div>
</div>
</div>
<!-- Typing -->
<div class="typing-stage" id="typing-stage">
<div class="typing-tilt">
<div class="line1">
<span class="seg main">HTML </span><span class="seg accent">pages</span
><span class="seg suffix"> become </span><span class="seg accent2">video</span
><span class="cursor cursor1" id="cursor1"></span>
</div>
<div class="line2-wrap">
<div class="pill-bg" id="pill-bg"></div>
<div class="pill-glow" id="pill-glow"></div>
<div class="line2-content">
<span class="seg line2">frame by frame.</span
><span class="cursor cursor2" id="cursor2"></span>
</div>
</div>
</div>
</div>
</div>
<div class="vignette"></div>
</div>
<script>
/* ================================================================
CONSTANTS
================================================================ */
const W = 1920,
H = 1080;
const TOTAL_DUR = 6.5;
/* ASR-anchored timing (the source source frame counts converted to seconds at 30fps). */
const TIMING = {
// Phase 1: video + badge enter
badgeAt: 0.17,
badgeDur: 0.7,
videoEntryAt: 0.17,
videoEntryDur: 0.7,
// Phase 2: video slides, MP4 appears
slideAt: 2.2,
videoSlideDur: 0.8,
videoSlideX: W * 0.29,
videoSlideScale: 0.92,
statEntryDur: 0.6,
statEntryX: W * 0.6,
statBreathAt: 3.0, // entry settles by here, breath begins
statBreathDur: 0.86, // until exit at 3.86
// Phase 3: pivot — both exit, typing begins
pivotAt: 3.86,
exitDur: 0.6,
videoExitX: -W * 0.5,
statExitX: -W * 0.7,
typingStageDur: 0.5,
// Phase 3 typing
typingStartAt: 4.36, // pivotAt + 0.5 s stage settle
typeRate: 30, // chars/sec
// Phase 4: pill
pillAt: 4.83, // typing reaches line2 at this time
pillDur: 0.6,
pillGlowDur: 0.8,
};
/* Text segments and boundaries */
const SEG = {
main: "HTML ",
accent: "pages",
suffix: " become ",
accent2: "video",
line2: "frame by frame.",
};
const BOUNDS = {
mainEnd: SEG.main.length, // 5
accentEnd: SEG.main.length + SEG.accent.length, // 10
suffixEnd: SEG.main.length + SEG.accent.length + SEG.suffix.length, // 18
accent2End: SEG.main.length + SEG.accent.length + SEG.suffix.length + SEG.accent2.length, // 23
line2End:
SEG.main.length +
SEG.accent.length +
SEG.suffix.length +
SEG.accent2.length +
SEG.line2.length, // 38
};
const TYPING_DUR = (BOUNDS.line2End - 0) / TIMING.typeRate; // ≈ 1.3 s
/* ================================================================
BUILD DOM — 5-layer depth stack for "MP4"
================================================================ */
const LAYER_COUNT = 5;
const OFFSET_X = 1;
const OFFSET_Y = 2;
const depthStack = document.getElementById("depth-stack-97");
const depthText = depthStack.dataset.text;
for (let i = 0; i < LAYER_COUNT; i++) {
const layer = document.createElement("div");
layer.className = "depth-layer " + (i === LAYER_COUNT - 1 ? "front" : "back");
layer.textContent = depthText;
if (i < LAYER_COUNT - 1) {
layer.setAttribute("aria-hidden", "true");
const alpha = 0.62 + 0.08 * (LAYER_COUNT - 2 - i);
layer.style.setProperty("--alpha", alpha);
layer.style.setProperty("--top", i * OFFSET_Y + "px");
layer.style.setProperty("--left", -i * OFFSET_X + "px");
}
depthStack.appendChild(layer);
}
/* ================================================================
INITIAL STATES (via gsap.set, before the timeline runs)
================================================================ */
gsap.set("#badge", { opacity: 0, scale: 0.9 });
// Video starts centered (x = W/2, y = H/2 via the .video-pos margin trick)
gsap.set("#video-pos", { x: W / 2, y: H / 2, scale: 0.6, opacity: 0 });
// Stat starts off-right + below + small + invisible
gsap.set("#stat-pos", { x: TIMING.statEntryX, y: H * 0.42 + 60, scale: 0.3, opacity: 0 });
gsap.set("#typing-stage", { scale: 0.9, opacity: 0 });
/* ================================================================
TIMELINE
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
/* ----------------------------------------------------------------
PHASE 1: Badge + Video entry
---------------------------------------------------------------- */
tl.to(
"#badge",
{
opacity: 1,
scale: 1,
duration: TIMING.badgeDur,
ease: "power3.out",
},
TIMING.badgeAt,
);
tl.to(
"#video-pos",
{
scale: 1,
opacity: 1,
duration: TIMING.videoEntryDur,
ease: "power3.out", // spring(stiffness:80, damping:15)
},
TIMING.videoEntryAt,
);
/* ----------------------------------------------------------------
PHASE 2: Video slides left, MP4 appears on right
---------------------------------------------------------------- */
tl.to(
"#video-pos",
{
x: TIMING.videoSlideX,
scale: TIMING.videoSlideScale,
duration: TIMING.videoSlideDur,
ease: "power3.out", // spring(stiffness:100, damping:18)
},
TIMING.slideAt,
);
tl.to(
"#stat-pos",
{
y: H * 0.42, // rises to final y
scale: 1,
opacity: 1,
duration: TIMING.statEntryDur,
ease: "back.out(1.6)", // spring(stiffness:150, damping:12)
},
TIMING.slideAt,
);
/* ----------------------------------------------------------------
PHASE 3: Pivot — video + stat exit; typing fades in
---------------------------------------------------------------- */
tl.to(
"#video-pos",
{
x: TIMING.videoExitX,
scale: 0.8,
opacity: 0,
duration: TIMING.exitDur,
ease: "power3.out", // spring(stiffness:120, damping:20)
},
TIMING.pivotAt,
);
tl.to(
"#stat-pos",
{
x: TIMING.statExitX,
scale: 0.8,
opacity: 0,
duration: TIMING.exitDur,
ease: "power3.out",
},
TIMING.pivotAt,
);
tl.to(
"#typing-stage",
{
scale: 1,
opacity: 1,
duration: TIMING.typingStageDur,
ease: "power3.out", // spring(stiffness:100, damping:15)
},
TIMING.pivotAt,
);
/* ----------------------------------------------------------------
PHASE 3 (continued): Character-by-character typing
---------------------------------------------------------------- */
const segMain = document.querySelector(".seg.main");
const segAccent = document.querySelector(".seg.accent");
const segSuffix = document.querySelector(".seg.suffix");
const segAccent2 = document.querySelector(".seg.accent2");
const segLine2 = document.querySelector(".seg.line2");
const typeProxy = { idx: 0 };
function typedIndexAt(t) {
if (t < TIMING.typingStartAt) return 0;
return Math.min(BOUNDS.line2End, Math.floor((t - TIMING.typingStartAt) * TIMING.typeRate));
}
function renderTypedText(i) {
const m = SEG.main.slice(0, Math.min(i, BOUNDS.mainEnd));
const a = SEG.accent.slice(0, Math.max(0, Math.min(i - BOUNDS.mainEnd, SEG.accent.length)));
const s = SEG.suffix.slice(
0,
Math.max(0, Math.min(i - BOUNDS.accentEnd, SEG.suffix.length)),
);
const a2 = SEG.accent2.slice(
0,
Math.max(0, Math.min(i - BOUNDS.suffixEnd, SEG.accent2.length)),
);
const l2 = SEG.line2.slice(
0,
Math.max(0, Math.min(i - BOUNDS.accent2End, SEG.line2.length)),
);
if (segMain.textContent !== m) segMain.textContent = m;
if (segAccent.textContent !== a) segAccent.textContent = a;
if (segSuffix.textContent !== s) segSuffix.textContent = s;
if (segAccent2.textContent !== a2) segAccent2.textContent = a2;
if (segLine2.textContent !== l2) segLine2.textContent = l2;
}
tl.to(
typeProxy,
{
idx: BOUNDS.line2End,
duration: TYPING_DUR,
ease: "none",
onUpdate: function () {
renderTypedText(Math.floor(typeProxy.idx));
},
},
TIMING.typingStartAt,
);
/* ----------------------------------------------------------------
PHASE 4: Gradient pill behind line 2
---------------------------------------------------------------- */
gsap.set("#pill-bg", { scaleX: 0, scaleY: 0.5, opacity: 0 });
gsap.set("#pill-glow", { opacity: 0 });
tl.to(
"#pill-bg",
{
scaleX: 1,
scaleY: 1,
opacity: 0.9,
duration: TIMING.pillDur,
ease: "power3.out", // spring(stiffness:80, damping:15)
},
TIMING.pillAt,
);
tl.to(
"#pill-glow",
{
opacity: 0.5,
duration: TIMING.pillGlowDur,
ease: "power2.out",
},
TIMING.pillAt,
);
tl.to(
".line2-content",
{
opacity: 1,
duration: 0.18,
ease: "power2.out",
},
TIMING.pillAt + 0.3,
);
/* ----------------------------------------------------------------
CONTINUOUS SCENE-TICKER — float, breath, cursor blink
All gated by time windows so they only fire when visible.
---------------------------------------------------------------- */
const videoFloat = document.getElementById("video-float");
const statBreathEl = document.getElementById("stat-breath");
const cursor1El = document.getElementById("cursor1");
const cursor2El = document.getElementById("cursor2");
tl.to(
{ tick: 0 },
{
tick: 1,
duration: TOTAL_DUR,
ease: "none",
onUpdate: function () {
const t = tl.time();
const idx = typedIndexAt(t);
renderTypedText(idx);
// Video float — only while video is visible (Phase 1 + 2)
if (t < TIMING.pivotAt) {
const floatY = Math.sin(t * 0.9) * 6;
gsap.set(videoFloat, { y: floatY });
}
// Stat breath — only while MP4 is settled and not yet exiting
if (t > TIMING.statBreathAt && t < TIMING.pivotAt) {
const breath = 1 + Math.sin((t - TIMING.statBreathAt) * 1.2) * 0.02;
gsap.set(statBreathEl, { scale: breath });
}
// Cursor blink — 1 Hz (Math.floor(t * 2) % 2 === 0 → on for 0.5s, off for 0.5s)
const blink = Math.floor(t * 2) % 2 === 0 ? 1 : 0;
// Cursor 1: visible while typing line 1 (idx < accent2End)
// Determine cursor color: green when typing accent segments, white otherwise.
if (t >= TIMING.typingStartAt && idx < BOUNDS.accent2End) {
const inAccent = idx >= BOUNDS.mainEnd && idx < BOUNDS.accentEnd;
const inAccent2 = idx >= BOUNDS.suffixEnd && idx < BOUNDS.accent2End;
const isGreen = inAccent || inAccent2;
cursor1El.style.opacity = blink;
if (cursor1El._lastGreen !== isGreen) {
cursor1El.classList.toggle("green", isGreen);
cursor1El._lastGreen = isGreen;
}
} else {
cursor1El.style.opacity = 0;
}
// Cursor 2: visible while typing line 2 (idx ≥ accent2End && idx < line2End)
if (idx >= BOUNDS.accent2End && idx < BOUNDS.line2End) {
cursor2El.style.opacity = blink;
} else {
cursor2El.style.opacity = 0;
}
},
},
0,
);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,861 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 03 — HyperFrames Anchor Chain Reveal</title>
<!--
HyperFrames-native version of the scene-03-anchor-chain-reveal concept.
Same 5-phase choreography, GSAP-driven, seek-safe.
Audio (assumed accompanying narration):
"Meet HyperFrames, the HTML video composition toolkit for building,
previewing, and rendering programmable motion."
Sub-shots (local time, 8s total):
0.0 1.2s "HyperFrames" Brand reveal (hacker-flip)
1.2 4.0s "HTML VIDEO" Text swap + rolling ticker
4.0 6.3s "60FPS" Counter + capability cloud
6.3 8.0s "Built for Motion" Capability strip
Placeholders (self-contained — no asset files needed):
inline-SVG "HF" mark — brand logo (swap for your image)
CSS initials circles — user avatars (edit CREATOR_INITIALS)
CSS text chips — partner brand logos (edit --brand-name)
Replace with real assets for production.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
/* Palette — direct port of the the source COLORS constants */
--text-primary: #ffffff;
--text-muted: rgba(255, 255, 255, 0.45);
--brand-green: #00e676;
--bg-grad-from: #0a1a2e;
--bg-grad-to: #1a0a2e;
}
* {
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,
-apple-system,
sans-serif;
color: var(--text-primary);
}
/* ============================================================
BACKGROUND (port of <FrostedGlassBackground />)
============================================================ */
.bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 30% 20%, rgba(0, 230, 118, 0.18), transparent 50%),
radial-gradient(ellipse at 70% 80%, rgba(120, 60, 220, 0.18), transparent 50%),
linear-gradient(135deg, var(--bg-grad-from), var(--bg-grad-to));
}
/* Camera wrapper — port of <CameraSystem /> */
.camera {
position: absolute;
inset: 0;
transform-origin: center center;
}
/* ============================================================
PHASES 13 (BrandReveal): logo + decode + swap + recenter
============================================================ */
.brand-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 50; /* above cloud SVG (z:1), below logo (z:100) */
}
.anchor-shift {
display: flex;
align-items: center;
gap: 35px;
/* GSAP tweens .x on this element for Phase-2 recenter */
}
.anchor-logo {
position: relative;
width: 192px;
height: 192px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 100; /* highest — sits above lines + avatars */
opacity: 1;
/* initial scale(0) set by GSAP fromTo — no CSS transform here */
}
.anchor-logo img {
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 4px 24px rgba(0, 0, 0, 0.4));
}
.anchor-text {
position: relative;
display: flex;
align-items: center;
}
.phase1-text {
display: flex;
perspective: 800px; /* required for the per-glyph rotateX */
white-space: nowrap;
}
.flip-glyph {
position: relative;
display: inline-block;
font-size: 163px; /* 1920 × 0.085 — matches the source brandFontSize */
font-weight: 900;
letter-spacing: -0.02em;
}
.flip-glyph .ghost {
opacity: 0;
}
.flip-glyph .anim {
position: absolute;
left: 0;
top: 0;
width: 100%;
color: var(--text-primary);
opacity: 0;
transform: perspective(600px) rotateX(90deg);
transform-origin: bottom;
backface-visibility: hidden;
}
.flip-glyph.space {
min-width: 0.35em;
}
.phase2-claim {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
opacity: 0;
white-space: nowrap;
display: flex;
align-items: center;
gap: 40px;
}
.claim-rank {
color: var(--brand-green);
font-size: 163px;
font-weight: 900;
letter-spacing: -0.02em;
}
.claim-ai,
.claim-video {
color: var(--text-primary);
font-size: 163px;
font-weight: 900;
letter-spacing: -0.02em;
}
/* Vertical ticker — port of <RollingWord /> */
.ticker-window {
height: 204px; /* fontSize × 1.25 = 163 × 1.25 ≈ 204 */
overflow: hidden;
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
}
.ticker-stack {
display: flex;
flex-direction: column;
will-change: transform;
}
.ticker-item {
height: 204px;
display: flex;
align-items: center;
color: var(--text-primary);
font-weight: 900;
font-size: 163px;
letter-spacing: -0.02em;
line-height: 1;
}
/* ============================================================
PHASE 4 (CreatorCloud): counter + avatar cloud + lines
============================================================ */
.cloud-stage {
position: absolute;
inset: 0;
z-index: 10; /* below brand-stage's logo (z:100), below counter */
opacity: 0; /* GSAP fades it in */
}
.counter-pin {
position: absolute;
top: 3%;
left: 50%;
transform: translateX(-50%);
text-align: center;
z-index: 60; /* above brand-stage so it overlays cleanly */
}
.count {
font-variant-numeric: tabular-nums;
font-weight: 900;
font-size: 115px; /* 1920 × 0.06 */
color: var(--brand-green);
display: inline-block;
min-width: 200px;
text-align: right;
}
.count-suffix {
font-weight: 900;
font-size: 115px;
color: var(--brand-green);
opacity: 0; /* GSAP fades in after count */
}
.cloud-lines {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.cloud-avatars {
position: absolute;
inset: 0;
z-index: 10;
}
.cloud-avatar {
position: absolute;
border-radius: 50%;
overflow: hidden;
border: 3px solid rgba(255, 255, 255, 0.2);
opacity: 0;
/* initial scale(0) set by GSAP fromTo — no CSS transform here */
background: linear-gradient(135deg, #6b8cff, #a6b3ff);
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.8);
font-weight: 700;
font-size: 32px;
}
/* ============================================================
PHASE 5 (BrandShowcase): label + scrolling brand strip
============================================================ */
.brand-strip {
position: absolute;
bottom: 8%;
left: 0;
right: 0;
opacity: 0; /* GSAP fades + translateY in via fromTo */
z-index: 70;
}
.brand-label {
text-align: center;
margin-bottom: 25px;
font-size: 27px; /* 1920 × 0.014 */
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.brand-strip-window {
display: flex;
justify-content: center;
align-items: center;
gap: 80px;
overflow: hidden;
}
.brand-strip-track {
display: flex;
gap: 110px;
}
.brand-logo {
width: 170px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
/* initial scale(0.5) set by GSAP fromTo — no CSS transform here */
}
.brand-logo::before {
content: var(--brand-name);
font-family: Inter, system-ui, sans-serif;
font-size: 30px;
font-weight: 800;
letter-spacing: 0.08em;
white-space: nowrap;
color: rgba(255, 255, 255, 0.72);
}
.brand-nvidia {
--brand-name: "NVIDIA";
}
.brand-visa {
--brand-name: "VISA";
}
.brand-zoominfo {
--brand-name: "ZOOMINFO";
}
.brand-github {
--brand-name: "GITHUB";
}
/* Vignette overlay */
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.45) 100%);
pointer-events: none;
z-index: 200;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="8"
data-width="1920"
data-height="1080"
>
<div class="bg"></div>
<div class="camera">
<!-- =====================================================
PHASES 13: BrandReveal (logo + hacker-flip + swap)
===================================================== -->
<div
id="phase-anchor"
class="brand-stage clip"
data-start="0"
data-duration="8"
data-track-index="1"
>
<div class="anchor-shift" data-layout-allow-overflow>
<!-- Anchor logo — persists across all phases -->
<div class="anchor-logo">
<!-- Inline-SVG placeholder mark — swap for your logo image -->
<svg
class="logo-mark"
viewBox="0 0 100 100"
width="100%"
height="100%"
role="img"
aria-label="hyperframes logo"
>
<defs>
<linearGradient id="hfMark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8b7bff" />
<stop offset="1" stop-color="#3ddc97" />
</linearGradient>
</defs>
<rect x="4" y="4" width="92" height="92" rx="22" fill="url(#hfMark)" />
<text
x="50"
y="63"
text-anchor="middle"
font-family="Inter, system-ui, sans-serif"
font-size="40"
font-weight="800"
fill="#fff"
>
HF
</text>
</svg>
</div>
<!-- Swappable text zone -->
<div class="anchor-text">
<!-- Phase 1: 'HyperFrames' decoded character by character -->
<div class="phase1-text" aria-label="HyperFrames">
<!-- Spans generated by the script below -->
</div>
<!-- Phase 2: 'HTML video render/ship' claim -->
<div class="phase2-claim">
<span class="claim-rank">HTML</span>
<span class="claim-ai">Video</span>
<div class="claim-ticker">
<div class="ticker-window" data-layout-allow-overflow>
<div class="ticker-stack">
<div class="ticker-item">render</div>
<div class="ticker-item">ship</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- =====================================================
PHASE 4: CreatorCloud (counter + avatar ring + lines)
===================================================== -->
<div
id="phase-cloud"
class="cloud-stage clip"
data-start="3.6"
data-duration="4.4"
data-track-index="2"
>
<div class="counter-pin">
<span class="count">60</span><span class="count-suffix">FPS</span>
</div>
<svg class="cloud-lines" width="1920" height="1080">
<defs>
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#00e676" stop-opacity="0.8" />
<stop offset="100%" stop-color="#00e676" stop-opacity="0.2" />
</linearGradient>
</defs>
<!-- <line> elements generated by JS -->
</svg>
<div class="cloud-avatars">
<!-- .cloud-avatar elements generated by JS -->
</div>
</div>
<!-- =====================================================
PHASE 5: BrandShowcase (label + scrolling logos)
===================================================== -->
<div
id="phase-brands"
class="brand-strip clip"
data-start="6.3"
data-duration="1.7"
data-track-index="3"
>
<div class="brand-label">Trusted by Leading Brands</div>
<div class="brand-strip-window" data-layout-allow-overflow>
<div class="brand-strip-track">
<div class="brand-logo brand-nvidia" aria-label="NVIDIA"></div>
<div class="brand-logo brand-visa" aria-label="Visa"></div>
<div class="brand-logo brand-zoominfo" aria-label="ZoomInfo"></div>
<div class="brand-logo brand-github" aria-label="GitHub"></div>
<div class="brand-logo brand-nvidia" aria-label="NVIDIA"></div>
<div class="brand-logo brand-visa" aria-label="Visa"></div>
<div class="brand-logo brand-zoominfo" aria-label="ZoomInfo"></div>
<div class="brand-logo brand-github" aria-label="GitHub"></div>
<div class="brand-logo brand-nvidia" aria-label="NVIDIA"></div>
<div class="brand-logo brand-visa" aria-label="Visa"></div>
<div class="brand-logo brand-zoominfo" aria-label="ZoomInfo"></div>
<div class="brand-logo brand-github" aria-label="GitHub"></div>
</div>
</div>
</div>
</div>
<!-- /.camera -->
<div class="vignette"></div>
</div>
<script>
/* ================================================================
CONSTANTS — all derived once, never recomputed at tween time.
Composition is 1920 × 1080, total duration 8.0s.
================================================================ */
const W = 1920,
H = 1080;
const FPS_HASH = 60; // synthetic clock for the hacker-flip flicker hash only
// Self-contained placeholder data — swap for real asset paths in production.
const CREATOR_INITIALS = ["JC", "MK", "AR", "TS", "LP", "DV", "NW", "SB", "KH", "EM"];
const TIMING = {
// Phase 1 — logo pop + HyperFrames decode
logoPop: 0.32,
flipStart: 0.4, // logoPop + ~5/60
flipStagger: 0.033, // 2 frames at 60fps per glyph
flipDuration: 0.55,
// Phase 2 — slide-out + claim slide-in.
swapTrigger: 1.38,
swapDuration: 0.55,
claimFadeIn: 1.65, // swapTrigger + ~8/60
claimFadeDur: 0.27,
// Rolling ticker — render → ship.
tickerTrigger: 2.35,
tickerDuration: 0.45,
// Phase 3 — logo recenters (anchored ~0.25s before "trusted")
recenterTrigger: 3.87,
recenterDuration: 0.9,
// Phase 4 — cloud + counter.
cloudFadeIn: 4.12,
cloudFadeDur: 0.4,
avatarEntryStart: 4.3,
avatarStagger: 0.1,
avatarEntryDur: 0.55,
linesDelay: 0.2, // gap after last avatar
linesDur: 0.45,
lineStagger: 0.033,
counterStart: 4.92,
counterDuration: 0.85,
suffixDelay: 0.05,
// Phase 5 — brand logos
brandStripStart: 6.5,
brandStripFadeDur: 0.35,
brandLogoStagger: 0.1,
brandScrollDur: 1.4, // until end (8.0 - 6.5 - 0.1 buffer)
};
// Layout constants — shared center keeps logo, avatars, and lines aligned.
const RECENTER_OFFSET = -W * 0.12; // = -230.4 px — container shift in Phase 2
const CENTER_OFFSET = 800; // Phase 3: logo center aligns with cloud center.
// Cloud geometry — logo final position and line origins derive from this center.
const CLOUD_CENTER_X = W / 2;
const CLOUD_CENTER_Y = H * 0.47; // = 507.6 px
const VERTICAL_ADJUST = CLOUD_CENTER_Y - H / 2;
const CLOUD_RADIUS_X = W * 0.25; // = 480 px
const CLOUD_RADIUS_Y = H * 0.22; // = 237.6 px
const AVATAR_COUNT = 10;
/* ================================================================
BUILD DOM — flip glyphs, avatars, connection lines.
Runs synchronously before the timeline registration.
================================================================ */
// ---- Hacker-flip glyphs for "HyperFrames" -------------------------
const phase1 = document.querySelector(".phase1-text");
const decodeText = "HyperFrames";
decodeText.split("").forEach((char, index) => {
const span = document.createElement("span");
span.className = "flip-glyph" + (char === " " ? " space" : "");
span.dataset.char = char;
span.dataset.index = String(index);
const ghost = document.createElement("span");
ghost.className = "ghost";
ghost.textContent = char === " " ? " " : char;
const anim = document.createElement("span");
anim.className = "anim";
anim.textContent = char === " " ? " " : char;
span.append(ghost, anim);
phase1.appendChild(span);
});
// ---- Avatar cloud + connection lines ------------------------------
const cloudLines = document.querySelector(".cloud-lines");
const cloudAvatars = document.querySelector(".cloud-avatars");
const avatarPositions = [];
const avatars = CREATOR_INITIALS.slice(0, AVATAR_COUNT);
for (let i = 0; i < avatars.length; i++) {
const angle = (i / avatars.length) * Math.PI * 2 - Math.PI / 2;
const x = CLOUD_CENTER_X + Math.cos(angle) * CLOUD_RADIUS_X;
const y = CLOUD_CENTER_Y + Math.sin(angle) * CLOUD_RADIUS_Y;
avatarPositions.push({ x, y });
const size = 90 + (i % 3) * 15;
const av = document.createElement("div");
av.className = "cloud-avatar";
av.style.left = x - size / 2 + "px";
av.style.top = y - size / 2 + "px";
av.style.width = size + "px";
av.style.height = size + "px";
av.dataset.index = String(i);
// Initials placeholder — deterministic hue from index (no asset files).
av.textContent = avatars[i];
av.style.background = `linear-gradient(135deg, hsl(${(220 + i * 36) % 360}, 70%, 62%), hsl(${(260 + i * 36) % 360}, 70%, 46%))`;
cloudAvatars.appendChild(av);
// SVG line — both endpoints start at center; will tween x2/y2 outward.
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", CLOUD_CENTER_X);
line.setAttribute("y1", CLOUD_CENTER_Y);
line.setAttribute("x2", CLOUD_CENTER_X);
line.setAttribute("y2", CLOUD_CENTER_Y);
line.setAttribute("stroke", "#00e676");
line.setAttribute("stroke-width", "2");
line.setAttribute("stroke-dasharray", "4 4");
line.setAttribute("stroke-opacity", "0");
cloudLines.appendChild(line);
}
/* ================================================================
TIMELINE
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// ----------------------------------------------------------------
// Camera: gentle initial zoom + drift. Finite, computed from duration.
// ----------------------------------------------------------------
tl.fromTo(".camera", { scale: 0.95 }, { scale: 1.0, duration: 0.95, ease: "power2.out" }, 0);
// Subtle drift across the whole 8s. Finite yoyo cycles, not infinite.
// sin(frame * 0.003) cycle = 2π / 0.003 frames = 2094 frames ≈ 34.9s at 60fps.
// Over 8s we see roughly a quarter cycle — model as a single slow yoyo.
tl.fromTo(
".camera",
{ x: 0, y: 0 },
{ x: 3, y: 2, duration: 4, ease: "sine.inOut", yoyo: true, repeat: 1 },
0,
);
// ----------------------------------------------------------------
// PHASE 1: Logo pop + HyperFrames decode
// ----------------------------------------------------------------
tl.fromTo(
".anchor-logo",
{ scale: 0 },
{
scale: 1,
duration: 0.6,
ease: "back.out(1.4)", // spring(stiffness:180, damping:12)
},
TIMING.logoPop,
);
// Hacker-flip per glyph. Each glyph has its own tween with onUpdate
// that derives the visible character from time.
const CHAR_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&";
const FLICKER = 3; // frames between glyph reshuffles
const REVEAL_AT = 0.6; // progress threshold to swap random → real
function pseudoHash(i, t) {
// Cheap deterministic 32-bit mix — no Math.random, no Date.now
return ((i * 374761393 + t * 668265263) >>> 0) % CHAR_POOL.length;
}
document.querySelectorAll(".flip-glyph").forEach((glyph) => {
const index = Number(glyph.dataset.index);
const real = glyph.dataset.char === " " ? " " : glyph.dataset.char;
const anim = glyph.querySelector(".anim");
const start = TIMING.flipStart + index * TIMING.flipStagger;
tl.fromTo(
anim,
{ rotationX: 90, opacity: 0, "--p": 0 },
{
rotationX: 0,
opacity: 1,
"--p": 1,
duration: TIMING.flipDuration,
ease: "back.out(1.6)", // spring(stiffness:150, damping:14)
onUpdate: function () {
const p = Number(gsap.getProperty(anim, "--p"));
if (p >= REVEAL_AT) {
if (anim.textContent !== real) anim.textContent = real;
} else {
const localFrame = Math.floor((tl.time() - start) * FPS_HASH);
const bucket = Math.max(0, Math.floor(localFrame / FLICKER));
anim.textContent = CHAR_POOL[pseudoHash(index, bucket)];
}
},
},
start,
);
});
// ----------------------------------------------------------------
// PHASE 2: Swap — old text slides out, container shifts left,
// claim fades in at the old text's origin.
// ----------------------------------------------------------------
tl.to(
".phase1-text",
{
x: 200,
opacity: 0,
duration: TIMING.swapDuration * 0.5,
ease: "power3.out",
},
TIMING.swapTrigger,
);
tl.set(".phase1-text .anim", { opacity: 0 }, TIMING.swapTrigger + TIMING.swapDuration * 0.5);
tl.to(
".anchor-shift",
{
x: RECENTER_OFFSET,
duration: TIMING.swapDuration,
ease: "power3.out", // spring(stiffness:80, damping:18)
},
TIMING.swapTrigger,
);
tl.to(
".phase2-claim",
{
opacity: 1,
duration: TIMING.claimFadeDur,
ease: "power2.out",
},
TIMING.claimFadeIn,
);
// Rolling ticker — render → ship.
tl.to(
".ticker-stack",
{
y: "-=204", // one itemHeight
duration: TIMING.tickerDuration,
ease: "back.out(1.4)", // spring(stiffness:120, damping:14)
},
TIMING.tickerTrigger,
);
// ----------------------------------------------------------------
// PHASE 3: Logo recenters + text fades out.
// ----------------------------------------------------------------
tl.to(
".anchor-text",
{
opacity: 0,
duration: 0.3,
ease: "power2.out",
},
TIMING.recenterTrigger,
);
tl.to(
".anchor-logo",
{
x: CENTER_OFFSET,
y: VERTICAL_ADJUST,
duration: TIMING.recenterDuration,
ease: "power2.out", // spring(stiffness:45, damping:22) — gentle
},
TIMING.recenterTrigger,
);
// ----------------------------------------------------------------
// PHASE 4: Avatar cloud + counter.
// ----------------------------------------------------------------
tl.to(
".cloud-stage",
{
opacity: 1,
duration: TIMING.cloudFadeDur,
ease: "power2.out",
},
TIMING.cloudFadeIn,
);
// Avatars cascade in.
tl.fromTo(
".cloud-avatar",
{ scale: 0, opacity: 0 },
{
scale: 1,
opacity: 1,
duration: TIMING.avatarEntryDur,
ease: "back.out(1.7)",
stagger: { each: TIMING.avatarStagger, from: "start" },
},
TIMING.avatarEntryStart,
);
// Connection lines draw outward from center after all avatars settle.
const lastAvatarEnd =
TIMING.avatarEntryStart + TIMING.avatarStagger * (AVATAR_COUNT - 1) + TIMING.avatarEntryDur;
const linesStart = lastAvatarEnd + TIMING.linesDelay;
document.querySelectorAll(".cloud-lines line").forEach((line, i) => {
const { x, y } = avatarPositions[i];
tl.to(
line,
{
attr: { x2: x, y2: y },
strokeOpacity: 0.6,
duration: TIMING.linesDur,
ease: "power2.out",
},
linesStart + i * TIMING.lineStagger,
);
});
const countEl = document.querySelector(".count");
const suffixEl = document.querySelector(".count-suffix");
// Counter pulse — text stays deterministic under timeline seeking.
tl.fromTo(
countEl,
{
scale: 0.88,
},
{
scale: 1,
duration: TIMING.counterDuration,
ease: "power2.out",
},
TIMING.counterStart,
);
tl.to(
suffixEl,
{
opacity: 1,
duration: 0.3,
ease: "back.out(1.6)",
},
TIMING.counterStart + TIMING.counterDuration + TIMING.suffixDelay,
);
// ----------------------------------------------------------------
// PHASE 5: Brand strip — label + scrolling partner logos.
// ----------------------------------------------------------------
tl.fromTo(
".brand-strip",
{ opacity: 0, y: 40 },
{
opacity: 1,
y: 0,
duration: TIMING.brandStripFadeDur,
ease: "power2.out",
},
TIMING.brandStripStart,
);
tl.fromTo(
".brand-logo",
{ scale: 0.5, opacity: 0 },
{
scale: 1,
opacity: 0.7,
duration: 0.5,
ease: "back.out(1.4)",
stagger: { each: TIMING.brandLogoStagger, from: "start" },
},
TIMING.brandStripStart,
);
// Finite horizontal scroll. Distance derived from remaining time × speed.
// Speed in original: 0.8 px/frame at 30fps = 24 px/s. Over 1.4s ≈ 33.6 px.
// For a more visible scroll, multiply by 4 → ~135 px.
tl.to(
".brand-strip-track",
{
x: -135,
duration: TIMING.brandScrollDur,
ease: "none",
},
TIMING.brandStripStart,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,347 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 5 — HyperFrames Displace Reveal</title>
<!--
HyperFrames composition.tsx.
Choreography (4 phases, 7.5 seconds total):
0.00 0.67s Typewriter reveals "HyperFrames turns" (3 → 17 chars)
1.67 2.22s Ticker scrolls: HTML → motion
3.33 3.88s Ticker scrolls: motion → video
4.60 5.45s Logo enters from offscreen-right with rotation + scale impact
Text group pushed left + fades (40-50% of hero duration)
6.60 7.50s Logo breathes (dual-frequency sine onUpdate, multiplicative on 1.3 scale)
tuned to 40-50% of hero duration so the impact reads as causal
- Breathing uses Form 2 (onUpdate) so it multiplies onto the 1.3 final scale,
not overwrites it (Form 1 yoyo would undo the impact)
- Two breathing periods (1.0s scale, 1.33s rotation) for organic feel
- Logo uses a local static image asset referenced with a plain URL
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
--bg: linear-gradient(
135deg,
#3a3a3a 0%,
#17211f 30%,
#0b2328 48%,
#1f3518 70%,
#343434 100%
);
--text: #f8fafc;
--accent: #18d9e8;
--accent-2: #7bea5a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg);
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
color: var(--text);
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Text group — typewriter + ticker side by side, displaced as a unit */
.text-group {
position: absolute;
display: flex;
flex-direction: row;
align-items: center;
gap: 20px;
}
.typewriter {
height: 168px; /* = ITEM_HEIGHT (FONT_SIZE × 1.2) */
display: flex;
align-items: center;
color: var(--text);
font-weight: 400;
font-size: 140px;
line-height: 1;
white-space: pre;
text-shadow: 0 0 36px rgba(24, 217, 232, 0.18);
}
/* Vertical ticker */
.ticker-window {
height: 168px;
overflow: hidden;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
}
.ticker-stack {
display: flex;
flex-direction: column;
will-change: transform;
}
.ticker-item {
height: 168px;
display: flex;
align-items: center;
color: var(--accent);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
font-size: 140px;
padding-left: 20px;
line-height: 1;
white-space: pre;
}
/* Hero logo — enters from offscreen right */
.hero {
position: absolute;
width: 440px;
height: 440px;
display: flex;
align-items: center;
justify-content: center;
z-index: 20; /* above text during overlap */
opacity: 0; /* fromTo will animate */
}
.hero .logo-mark {
width: 100%;
height: 100%;
display: block;
border-radius: 52px;
box-shadow:
0 0 70px rgba(24, 217, 232, 0.28),
0 0 120px rgba(123, 234, 90, 0.18);
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="7.5"
data-width="1920"
data-height="1080"
>
<div
class="stage clip"
data-start="0"
data-duration="7.5"
data-track-index="1"
id="displace-stage"
>
<!-- Text group: typewriter + ticker -->
<div class="text-group" id="text-group">
<div class="typewriter">
<span class="typewriter-text">Hyp</span>
</div>
<div class="ticker-window">
<div class="ticker-stack">
<div class="ticker-item">HTML</div>
<div class="ticker-item">motion</div>
<div class="ticker-item">video</div>
</div>
</div>
</div>
<!-- Hero logo asset -->
<div class="hero" id="hero-logo">
<!-- Inline-SVG placeholder mark — swap for your logo image -->
<svg class="logo-mark" viewBox="0 0 100 100" role="img" aria-label="HyperFrames">
<defs>
<linearGradient id="hfMark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8b7bff" />
<stop offset="1" stop-color="#3ddc97" />
</linearGradient>
</defs>
<rect x="4" y="4" width="92" height="92" rx="22" fill="url(#hfMark)" />
<text
x="50"
y="63"
text-anchor="middle"
font-family="Inter, system-ui, sans-serif"
font-size="40"
font-weight="800"
fill="#fff"
>
HF
</text>
</svg>
</div>
</div>
</div>
<script>
/* ================================================================
CONSTANTS
================================================================ */
const TOTAL_DURATION = 7.5;
const FONT_SIZE = 140;
const ITEM_HEIGHT = FONT_SIZE * 1.2; // = 168 px
const TIMING = {
// Phase 1: typewriter
typeStart: 0.0,
typeDur: 0.67,
typeStartLen: 3, // pre-render the first 3 chars
// Phase 2: ticker steps
ticker1At: 1.67, // HTML → motion
ticker2At: 3.33, // motion → video
stepDur: 0.55,
// Phase 3: reactive displacement
displaceAt: 4.6,
heroDur: 0.85, // matches spring(mass:1.5) settle
offscreenX: 800, // hero starts here
pushDist: -150, // text pushed THIS direction
// Phase 4: breathing
idleStart: 6.6, // displaceAt + heroDur + ~1.15s buffer
};
const FULL_TEXT = "HyperFrames turns";
/* ================================================================
TIMELINE
================================================================ */
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
/* ----------------------------------------------------------------
PHASE 1: Typewriter (smooth slice)
---------------------------------------------------------------- */
const typewriterEl = document.querySelector(".typewriter-text");
const typeProxy = { progress: TIMING.typeStartLen };
tl.to(
typeProxy,
{
progress: FULL_TEXT.length,
duration: TIMING.typeDur,
ease: "none",
onUpdate: () => {
const len = Math.floor(typeProxy.progress);
const next = FULL_TEXT.slice(0, len);
if (typewriterEl.textContent !== next) typewriterEl.textContent = next;
},
},
TIMING.typeStart,
);
/* ----------------------------------------------------------------
PHASE 2: Vertical ticker (2 steps: HTML → motion → video)
---------------------------------------------------------------- */
tl.to(
".ticker-stack",
{
y: `-=${ITEM_HEIGHT}`,
duration: TIMING.stepDur,
ease: "back.out(1.4)", // spring(stiffness:120, damping:14)
},
TIMING.ticker1At,
);
tl.to(
".ticker-stack",
{
y: `-=${ITEM_HEIGHT}`,
duration: TIMING.stepDur,
ease: "back.out(1.4)",
},
TIMING.ticker2At,
);
/* ----------------------------------------------------------------
PHASE 3: Reactive displacement — three concurrent tweens.
---------------------------------------------------------------- */
// (1) Hero enters with rotation + scale impact, lands at scale 1.3.
tl.fromTo(
".hero",
{ x: TIMING.offscreenX, scale: 0.5, rotation: -45, opacity: 0 },
{
x: 0,
scale: 1.3,
rotation: 0,
opacity: 1,
duration: TIMING.heroDur,
ease: "power2.out", // spring(stiffness:100, damping:20, mass:1.5)
},
TIMING.displaceAt,
);
// (2) Text pushed left. Completes at 50% of hero duration → immediate impact.
tl.to(
".text-group",
{ x: TIMING.pushDist, duration: TIMING.heroDur * 0.5, ease: "power2.out" },
TIMING.displaceAt,
);
// (3) Text fades. Completes at 40% → fades slightly before push lands.
tl.to(
".text-group",
{ opacity: 0, duration: TIMING.heroDur * 0.4, ease: "power2.out" },
TIMING.displaceAt,
);
/* ----------------------------------------------------------------
PHASE 4: Breathing — onUpdate so it MULTIPLIES onto the 1.3 scale.
Dual frequencies (1.0s scale, 1.33s rotation) for organic motion.
---------------------------------------------------------------- */
const heroEl = document.querySelector(".hero");
const HERO_FINAL_SCALE = 1.3;
const HERO_FINAL_ROTATION = 0;
const SCALE_PERIOD = 1.0; // seconds per scale cycle
const ROTATE_PERIOD = 1.333; // seconds per rotation cycle — not a simple ratio of SCALE_PERIOD
const SCALE_AMP = 0.05;
const ROTATE_AMP = 3;
const breathDur = TOTAL_DURATION - TIMING.idleStart;
tl.to(
{ tick: 0 },
{
tick: 1,
duration: breathDur,
ease: "none",
onUpdate: function () {
const idleTime = Math.max(0, tl.time() - TIMING.idleStart);
const omegaS = (idleTime / SCALE_PERIOD) * Math.PI * 2;
const omegaR = (idleTime / ROTATE_PERIOD) * Math.PI * 2;
gsap.set(heroEl, {
scale: HERO_FINAL_SCALE * (1 + Math.sin(omegaS) * SCALE_AMP),
rotation: HERO_FINAL_ROTATION + Math.sin(omegaR) * ROTATE_AMP,
});
},
},
TIMING.idleStart,
);
</script>
</body>
</html>
@@ -0,0 +1,606 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene — HyperFrames Workflow Approve & Press</title>
<!--
Blueprint: workflow-approve-press
HyperFrames composition.tsx.
Same four-phase choreography, GSAP-driven, seek-safe.
Audio (assumed accompanying narration):
"This is AI that renders with you, not just for you.
Preview the result, render to MP4, then confirm."
Sub-phases (local time, 5.5s total):
0.17 0.72s Headline "AI edits WITH you" slides down
0.50 1.10s Center editor mockup scales in
0.67 2.17s Step indicators stagger-enter on the left (3D-tilted)
2.00 3.33s Step state machine: 1→complete, 2→active … 2→complete, 3→active
3.52 4.02s Confirm MP4 button (3D-tilted right) bouncy entry after render step is active
4.22 4.72s Button depression (linear, no overshoot)
4.72 5.22s Render confirmed: step 3 completes, color shifts to success green + checkmark pop
Asset placeholders:
./assets/editor-demo.mp4 — center product demo video. Replace with a
real export of the AI-editor flow. The
composition falls back to a CSS-mockup of
an editor timeline so it renders without
network or media files.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root {
/* Palette — port of the the source COLORS / TYPOGRAPHY constants */
--text-primary: #ffffff;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--brand-purple: #a78bfa;
--brand-purple-glow: rgba(167, 139, 250, 0.6);
--brand-green: #22c55e;
--brand-green-glow: rgba(34, 197, 94, 0.6);
--panel-stroke: rgba(255, 255, 255, 0.1);
}
* {
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,
-apple-system,
sans-serif;
color: var(--text-primary);
}
/* ============================================================
BACKGROUND — port of <FrostedGlassBackground />
============================================================ */
.bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 25% 30%, rgba(167, 139, 250, 0.2), transparent 55%),
radial-gradient(ellipse at 80% 70%, rgba(34, 197, 94, 0.12), transparent 55%),
linear-gradient(135deg, #1a1530 0%, #0a0815 70%);
}
/* Ambient warm glow on the right (button side) */
.ambient {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at 70% 50%,
var(--brand-purple-glow) 0%,
transparent 40%
);
opacity: 0.15;
pointer-events: none;
}
/* Vignette */
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.4) 100%);
pointer-events: none;
}
/* ============================================================
PHASE 1 — HEADLINE
============================================================ */
.headline-wrap {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
opacity: 0; /* GSAP fades in */
text-align: center;
white-space: nowrap;
}
.headline {
font-size: 96px;
font-weight: 800;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.headline .accent {
color: var(--brand-purple);
text-shadow: 0 0 30px var(--brand-purple-glow);
}
/* ============================================================
PHASE 2 — CENTER DEMO
(Falls back to a CSS-mockup editor when ./assets/editor-demo.mp4
is missing. The mockup is purely decorative — no animation
inside it; the eye reads it as a paused product screenshot.)
============================================================ */
.demo-wrap {
position: absolute;
left: 50%;
top: 60%;
opacity: 0;
}
.demo-frame {
width: 1000px;
height: 600px;
border-radius: 16px;
overflow: hidden;
border: 2px solid var(--panel-stroke);
box-shadow:
0 0 40px var(--brand-purple-glow),
0 20px 60px rgba(0, 0, 0, 0.5);
position: relative;
}
.demo-frame video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* CSS-mockup fallback for the editor demo */
.demo-mockup {
position: absolute;
inset: 0;
display: grid;
grid-template-rows: 56px 1fr 100px;
background: linear-gradient(180deg, #1f1b3a 0%, #15112a 100%);
}
.mockup-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
background: #15112a;
border-bottom: 1px solid var(--panel-stroke);
}
.mockup-toolbar .dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
}
.mockup-toolbar .dot.r {
background: #ef4444;
}
.mockup-toolbar .dot.y {
background: #eab308;
}
.mockup-toolbar .dot.g {
background: #22c55e;
}
.mockup-toolbar .title {
margin-left: 18px;
color: var(--text-secondary);
font-size: 16px;
font-weight: 500;
}
.mockup-canvas {
position: relative;
padding: 30px;
overflow: hidden;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 40px);
}
.mockup-canvas .preview-box {
width: 100%;
height: 100%;
border-radius: 10px;
background: linear-gradient(135deg, rgba(167, 139, 250, 0.35), rgba(34, 197, 94, 0.2));
border: 1px solid var(--panel-stroke);
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.mockup-timeline {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 20px;
background: #15112a;
border-top: 1px solid var(--panel-stroke);
}
.mockup-timeline .track {
height: 24px;
display: flex;
gap: 4px;
}
.mockup-timeline .clip {
height: 100%;
border-radius: 4px;
}
.mockup-timeline .t1 .a {
flex: 3;
background: var(--brand-purple);
opacity: 0.85;
}
.mockup-timeline .t1 .b {
flex: 2;
background: var(--brand-purple);
opacity: 0.55;
}
.mockup-timeline .t1 .c {
flex: 4;
background: var(--brand-purple);
opacity: 0.85;
}
.mockup-timeline .t2 .a {
flex: 5;
background: var(--brand-green);
opacity: 0.75;
}
.mockup-timeline .t2 .b {
flex: 2;
background: var(--brand-green);
opacity: 0.45;
}
.mockup-timeline .t2 .c {
flex: 2;
background: var(--brand-green);
opacity: 0.75;
}
/* ============================================================
PHASE 3 — LEFT FLANK: STEP INDICATORS (3D-TILTED)
============================================================ */
.steps-flank {
position: absolute;
left: 100px;
top: 55%;
transform: translateY(-50%) perspective(800px) rotateY(15deg);
display: flex;
flex-direction: column;
gap: 30px;
}
.step {
display: flex;
align-items: center;
gap: 16px;
opacity: 0; /* GSAP fades in */
}
.step-circle {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--text-muted);
background-color: transparent;
box-shadow: none;
transition: none; /* Steps SNAP between states, no CSS transition */
flex-shrink: 0;
}
.step-num {
font-size: 18px;
font-weight: 700;
color: var(--text-muted);
}
.step-check {
display: none;
}
.step-label {
font-size: 28px;
font-weight: 500;
color: var(--text-secondary);
}
/* State machine — toggled via tl.set({ attr: data-state }) */
.step[data-state="active"] .step-circle {
border-color: var(--brand-purple);
box-shadow: 0 0 15px var(--brand-purple-glow);
}
.step[data-state="active"] .step-num {
color: var(--brand-purple);
}
.step[data-state="active"] .step-label {
font-weight: 700;
color: var(--text-primary);
}
.step[data-state="complete"] .step-circle {
border-color: var(--brand-green);
background-color: var(--brand-green);
}
.step[data-state="complete"] .step-num {
display: none;
}
.step[data-state="complete"] .step-check {
display: inline-flex;
}
/* ============================================================
PHASE 4 — RIGHT FLANK: CONFIRM BUTTON (3D-TILTED)
============================================================ */
.button-flank {
position: absolute;
right: 70px;
top: 55%;
transform: translateY(-50%) perspective(800px) rotateY(-15deg);
}
.btn-press {
opacity: 0;
transform-origin: center center;
}
.btn {
--btn-glow-blur: 20px;
padding: 20px 50px;
border-radius: 12px;
background-color: var(--brand-purple);
box-shadow: 0 0 var(--btn-glow-blur) var(--brand-purple-glow);
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-primary);
font-size: 36px;
font-weight: 700;
white-space: nowrap;
}
.btn-check {
display: inline-flex;
transform-origin: center center;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="interactive-workflow"
data-start="0"
data-duration="5.5"
data-width="1920"
data-height="1080"
style="position: relative; width: 1920px; height: 1080px; overflow: hidden"
>
<!-- Background layers -->
<div class="bg"></div>
<!-- PHASE 1: Headline -->
<div class="headline-wrap">
<div class="headline">
HyperFrames builds
<span class="accent">WITH</span>
you
</div>
</div>
<!-- PHASE 2: Center demo (video with CSS-mockup fallback) -->
<div class="demo-wrap">
<div class="demo-frame">
<!--
Real asset path (replace for production).
If missing, the CSS mockup below shows through the transparent
poster region. The video stays muted per HyperFrames rules.
-->
<!-- Decorative CSS-mockup of an editor — paused screenshot feel. -->
<div class="demo-mockup">
<div class="mockup-toolbar">
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
<span class="title">HyperFrames — Project: Launch Promo</span>
</div>
<div class="mockup-canvas">
<div class="preview-box">Preview · 1920 × 1080</div>
</div>
<div class="mockup-timeline">
<div class="track t1">
<div class="clip a"></div>
<div class="clip b"></div>
<div class="clip c"></div>
</div>
<div class="track t2">
<div class="clip a"></div>
<div class="clip b"></div>
<div class="clip c"></div>
</div>
</div>
</div>
</div>
</div>
<!-- PHASE 3: Left-flank step indicators (3D-tilted +15°) -->
<div class="steps-flank">
<div class="step step-1" data-step="1" data-state="pending">
<div class="step-circle">
<span class="step-num">1</span>
<svg class="step-check" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17L4 12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" />
</svg>
</div>
<span class="step-label">Compose HTML Scene</span>
</div>
<div class="step step-2" data-step="2" data-state="pending">
<div class="step-circle">
<span class="step-num">2</span>
<svg class="step-check" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17L4 12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" />
</svg>
</div>
<span class="step-label">Seek &amp; Preview</span>
</div>
<div class="step step-3" data-step="3" data-state="pending">
<div class="step-circle">
<span class="step-num">3</span>
<svg class="step-check" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17L4 12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" />
</svg>
</div>
<span class="step-label">Render to MP4</span>
</div>
</div>
<!-- PHASE 4: Right-flank confirm button (3D-tilted -15°) -->
<div class="button-flank">
<div class="btn-press">
<div class="btn">
<span class="btn-check">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M20 6L9 17L4 12"
stroke="#fff"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="btn-label">Confirm MP4</span>
</div>
</div>
</div>
<!-- Ambient + vignette overlays (pointer-events: none) -->
<div class="ambient"></div>
<div class="vignette"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// ── Phase boundaries (seconds) — match the blueprint ────────────
const HEADLINE_START = 0.17;
const HEADLINE_END = 0.72;
const VIDEO_START = 0.5;
const STEPS_START = 0.67;
const STEP_STAGGER = 0.5;
const STEP_ACTIVE_T2 = 2.0; // step 1 → complete, step 2 → active
const STEP_ACTIVE_T3 = 3.33; // step 2 → complete, step 3 → active
const BUTTON_ENTER = 3.52; // after "Render to MP4" becomes active
const PRESS_FRAME = 4.22;
const PRESS_DURATION = 0.5;
const CHECK_POP = PRESS_FRAME + PRESS_DURATION;
const SCENE_END = 5.5; // matches data-duration on the root
// ── Phase 1: Headline slides down from top ─────────────────────
gsap.set(".demo-wrap", { xPercent: -50, yPercent: -50, scale: 0.8, opacity: 0 });
gsap.set(".step", { x: -30, opacity: 0 });
gsap.set(".btn-press", { scale: 0, opacity: 0 });
gsap.set(".btn-check", { scale: 0 });
tl.fromTo(
".headline-wrap",
{ y: -40, opacity: 0 },
{
y: 0,
opacity: 1,
duration: HEADLINE_END - HEADLINE_START,
ease: "back.out(1.2)", // ≈ the source SPRING_CONFIGS.entrance
},
HEADLINE_START,
);
// ── Phase 2: Center demo scales in ─────────────────────────────
// HyperFrames syncs the <video> element's currentTime to the seek
// position automatically — no .play() call needed (and one here
// would break deterministic rendering).
tl.to(".demo-wrap", { scale: 1, opacity: 1, duration: 0.6, ease: "power3.out" }, VIDEO_START);
// ── Phase 3a: Stagger entry of the step indicators ─────────────
tl.to(
".step",
{
x: 0,
opacity: 1,
duration: 0.4,
ease: "power3.out",
stagger: { each: STEP_STAGGER, from: "start" },
},
STEPS_START,
);
// ── Phase 3b: Step state machine (snap-toggled here) ──
// Step 1 enters in the "active" state because narration starts on it.
tl.set(".step-1", { attr: { "data-state": "active" } }, STEPS_START);
// First transition — step 1 finishes, step 2 takes over.
tl.set(".step-1", { attr: { "data-state": "complete" } }, STEP_ACTIVE_T2);
tl.set(".step-2", { attr: { "data-state": "active" } }, STEP_ACTIVE_T2);
// Second transition — step 2 finishes, step 3 takes over.
tl.set(".step-2", { attr: { "data-state": "complete" } }, STEP_ACTIVE_T3);
tl.set(".step-3", { attr: { "data-state": "active" } }, STEP_ACTIVE_T3);
// ── Phase 4a: Button bouncy entry ──────────────────────────────
tl.to(
".btn-press",
{ scale: 1, opacity: 1, duration: 0.5, ease: "back.out(1.4)", overwrite: "auto" },
BUTTON_ENTER,
);
// ── Phase 4b: Press (linear depression → linear return) ────────
// Two adjacent tweens on .btn-press — end value of (1) = start value of (2).
tl.to(".btn-press", { scale: 0.95, duration: 0.1, ease: "power1.out" }, PRESS_FRAME);
tl.to(
".btn-press",
{ scale: 1.0, duration: PRESS_DURATION - 0.1, ease: "power1.in" },
PRESS_FRAME + 0.1,
);
// ── Phase 4c: Color shift + label swap (at press end) ──────────
tl.to(
".btn",
{
backgroundColor: "#15803d",
boxShadow: "0 0 25px rgba(21, 128, 61, 0.68)",
duration: 0.3,
ease: "power2.out",
},
CHECK_POP,
);
tl.set(".btn-label", { textContent: "Approved!" }, CHECK_POP);
tl.set(".step-3", { attr: { "data-state": "complete" } }, CHECK_POP);
// ── Phase 4d: Checkmark pops into view ─────────────────────────
// back.out(1.6) ≈ the source spring({ stiffness: 200, damping: 15 }).
tl.to(".btn-check", { scale: 1, duration: 0.5, ease: "back.out(1.6)" }, CHECK_POP);
// ── Ambient pulsing glow on the button (Math.sin) ──
// Source: `glowIntensity = 20 + Math.sin(frame * 0.1) * 10` — i.e. blur
// oscillates 10 ↔ 30 around 20, with angular freq 0.1 rad/frame.
// At 60fps that's 6 rad/s, so full period ≈ 2π / 6 ≈ 1.05s.
// Finite yoyo replaces the continuous sine; `box-shadow` reads the
// tweened CSS variable so the blur is actually animatable.
const PULSE_PERIOD = 1.05;
const PULSE_HALVES = Math.max(2, Math.floor((SCENE_END - BUTTON_ENTER) / (PULSE_PERIOD / 2)));
tl.fromTo(
".btn",
{ "--btn-glow-blur": "10px" }, // source amplitude: 20 ± 10
{
"--btn-glow-blur": "30px",
duration: PULSE_PERIOD / 2,
ease: "sine.inOut",
yoyo: true,
repeat: PULSE_HALVES - 1,
},
BUTTON_ENTER,
);
window.__timelines["interactive-workflow"] = tl;
</script>
</body>
</html>