Add HyperFrames skills

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6NB8JQtfPc6eRCefEzBB
This commit is contained in:
Claude committed 2026-10-14 21:51:46 +00:00
1 parent 6bd8165e6d
commit 1b2c8439f0
964 files changed
+142321

No files matched your search

@@ -0,0 +1,527 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"theme", "type":"string", "label":"Palette theme — aurora | ember | mono | slate", "default":"aurora"},
{"id":"bgColor", "type":"color", "label":"Stage background override (empty = theme bg)", "default":""},
{"id":"cards", "type":"string", "label":"Deck JSON — array of {tag,title,meta,color?}", "default":""},
{"id":"landings", "type":"string", "label":"Landing beats JSON — abs seconds, one per card", "default":""},
{"id":"yaw", "type":"number", "label":"Scene yaw (deg, side tilt of the whole deck)", "default":-12}
]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Card Flyby · accelerating 3D deck cascade — reference impl</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
background: #06070d;
overflow: hidden;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
background: radial-gradient(1300px 820px at 50% 52%, #0d1018 0%, #06070d 70%, #04050a 100%);
overflow: hidden;
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
}
/* .stage carries the perspective; .scene carries the static yaw. The cards' own
rotateX drives the tumble-forward — the scene only holds the side tilt. */
.stage {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 1080px;
perspective: 2400px;
perspective-origin: 50% 55%;
}
.scene {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 1080px;
transform-style: preserve-3d;
transform: rotateY(-12deg);
}
.card {
position: absolute;
left: calc(50% - 700px);
top: calc(50% - 395px);
width: 1400px;
height: 790px;
border-radius: 32px;
will-change: transform, opacity;
--wipe: 0;
--color: #444;
transform-origin: 50% 50%;
}
/* Wireframe (always present, opacity tracks the slot). */
.wire {
position: absolute;
inset: 0;
border-radius: 28px;
border: 1.5px solid rgba(255, 255, 255, 0.42);
box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.012);
overflow: hidden;
}
.wire .wire-label {
position: absolute;
left: 68px;
top: 60px;
font-size: 26px;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
}
.wire .wire-corner {
position: absolute;
right: 68px;
top: 60px;
width: 34px;
height: 34px;
border: 1.5px solid rgba(255, 255, 255, 0.32);
border-radius: 50%;
}
.wire .wire-bar {
position: absolute;
left: 68px;
right: 68px;
bottom: 68px;
height: 1.5px;
background: rgba(255, 255, 255, 0.22);
}
/* Solid colored face — revealed via an angled clip-path wipe driven by --wipe.
Polygon sweeps upper-left → lower-right at ~72°. wipe=0 collapsed off-element,
wipe=1 covers the full card. */
.solid {
position: absolute;
inset: 0;
border-radius: 28px;
/* gentle top→bottom tonal gradient (theme-derived shades) for depth */
background: linear-gradient(
158deg,
var(--c1, var(--color)) 0%,
var(--c2, var(--color)) 100%
);
box-shadow:
0 34px 100px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.14);
overflow: hidden;
/* the title is the hero — centered in the card */
display: flex;
align-items: center;
justify-content: center;
padding: 90px 104px;
clip-path: polygon(
0% 0%,
calc(var(--wipe) * 200% - 30%) 0%,
calc(var(--wipe) * 200% - 80%) 100%,
0% 100%
);
}
.solid::before {
content: "";
position: absolute;
inset: 0;
border-radius: 28px;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.13) 0%,
rgba(255, 255, 255, 0.03) 40%,
rgba(0, 0, 0, 0.14) 100%
);
pointer-events: none;
}
.solid .title {
font-size: 184px;
font-weight: 800;
letter-spacing: -0.035em;
line-height: 0.92;
color: var(--ink, #ffffff);
text-align: center;
max-width: 100%;
overflow-wrap: break-word;
text-wrap: balance;
}
.solid .play {
position: absolute;
right: 68px;
bottom: 70px;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.solid .play::after {
content: "";
width: 0;
height: 0;
border-left: 22px solid #ffffff;
border-top: 14px solid transparent;
border-bottom: 14px solid transparent;
margin-left: 5px;
}
</style>
</head>
<body>
<!-- duration 6.0s = a 6-card accelerating cascade (1 card lands per beat, dwells shrinking) -->
<div
data-hf-id="hf-cf-root"
id="root"
data-composition-id="card-flyby"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="6.0"
data-root="true"
>
<div data-hf-id="hf-cf-stage" class="stage">
<div data-hf-id="hf-cf-scene" class="scene" id="scene"></div>
</div>
<script>
(function () {
// ════════════════════════════════════════════════════════════════════
// CARD FLYBY · accelerating 3D deck cascade
// ────────────────────────────────────────────────────────────────────
// A depth column of N cards rolls FORWARD. On each landing beat the next
// card tumbles up into the front slot (s0) and WIPES in its solid colored
// face (angled clip-path), the previous front card tumbles forward + falls
// down toward the camera on exit, and every card behind advances one slot.
// Dwells shrink card-to-card → the deck reads as an accelerating rush into
// a held final card. Asset-free: wireframe + solid CSS cards, system fonts.
//
// COMPOSE BY DATA — `cards` (the deck) and `landings` (one beat per card)
// are the two data surfaces. Card count is data: 3/6/9 cards all "just
// work"; landings default to an accelerating schedule derived from count.
// ════════════════════════════════════════════════════════════════════
window.__timelines = window.__timelines || {};
// force3D is set per card tween/set below (NOT a global default — a global
// would leak onto the plain-object padding tween and warn). It keeps a 3D
// matrix on every transform so cards never collapse to 2D: a card whose
// rotX/Y/Z all hit 0 (the front s0 pose) would otherwise drop out of the 3D
// stacking context and paint in DOM order, letting back cards render on top.
// ── 1. Variables ──────────────────────────────────────────────────────
var defaults = { theme: "aurora", bgColor: "", cards: "", landings: "", yaw: -12 };
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
// ── Palette themes — each is a COHESIVE ramp (one temperature/saturation
// family) so any card count reads as a designed set. card.color still
// overrides per card. White-on-card stays AA at the card type sizes. ──
var THEMES = {
// cool jewel arc: teal → ocean → indigo → violet → plum
aurora: {
bg: "#06060e",
ink: "#ffffff",
colors: ["#0e5f63", "#15517e", "#283f86", "#4a3a83", "#6a3380", "#8a2e63"],
},
// warm sunset arc: wine → brick → rust → amber → ochre → bronze
ember: {
bg: "#0e0805",
ink: "#fff6ee",
colors: ["#5c2030", "#7e2828", "#9c3b1c", "#a9551a", "#8a5a1c", "#5f4626"],
},
// sleek monochrome blue staircase (dark → mid)
mono: {
bg: "#060912",
ink: "#ffffff",
colors: ["#11243f", "#16314f", "#1b3d63", "#214a78", "#27568d", "#2d63a2"],
},
// muted cool slate — understated / editorial
slate: {
bg: "#0b0e12",
ink: "#eef2f6",
colors: ["#232a33", "#2b3540", "#33414e", "#3b4c5c", "#43576a", "#4b6278"],
},
};
var TH = THEMES[String(vars.theme || "").trim()] || THEMES.aurora;
// tiny hex helpers (deterministic, no deps) — derive shades + soft ink.
function parseHex(h) {
h = String(h || "")
.trim()
.replace(/^#/, "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length !== 6) return null;
var n = parseInt(h, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function clamp8(v) {
return Math.max(0, Math.min(255, Math.round(v)));
}
function toHex(c) {
return (
"#" +
[c.r, c.g, c.b]
.map(function (v) {
return clamp8(v).toString(16).padStart(2, "0");
})
.join("")
);
}
function mix(hex, t, amt) {
var c = parseHex(hex);
if (!c) return hex;
return toHex({
r: c.r + (t.r - c.r) * amt,
g: c.g + (t.g - c.g) * amt,
b: c.b + (t.b - c.b) * amt,
});
}
var BLACK = { r: 0, g: 0, b: 0 },
WHITE = { r: 255, g: 255, b: 255 };
function darken(hex, amt) {
return mix(hex, BLACK, amt);
}
function lighten(hex, amt) {
return mix(hex, WHITE, amt);
}
var DEFAULT_CARDS = [
{ title: "FADEGLOW" },
{ title: "SAUL BASS" },
{ title: "EULER" },
{ title: "HERMÈS" },
{ title: "DOSSIER" },
{ title: "BLACK HOLES" },
];
// landings tuned by hand on the sandbox: accelerating dwells, final card holds ~1.6s.
var DEFAULT_LANDINGS = [0.4, 1.7, 2.7, 3.45, 4.0, 4.4];
var DUR = 6.0; // matches data-duration
var YAW = Number(vars.yaw);
if (!isFinite(YAW)) YAW = -12;
function parseJSON(v) {
if (!v) return null;
if (typeof v !== "string") return v;
try {
return JSON.parse(v);
} catch (e) {
return null;
}
}
var CARDS = parseJSON(vars.cards);
if (!CARDS || !CARDS.length) CARDS = DEFAULT_CARDS;
var N = CARDS.length;
// ── 2. Landing schedule (accelerating cascade) ─────────────────────────
// gaps between landings shrink geometrically; the final card lands early
// enough to hold a beat before the cut.
function genLandings(n, total) {
var start = 0.4;
var hold = Math.min(1.6, total * 0.27);
var end = Math.max(start + 0.3, total - hold);
if (n <= 1) return [start];
var ratio = 0.78,
weights = [],
sum = 0,
i;
for (i = 0; i < n - 1; i++) {
var w = Math.pow(ratio, i);
weights.push(w);
sum += w;
}
var span = end - start,
acc = start,
times = [start];
for (i = 0; i < n - 1; i++) {
acc += (span * weights[i]) / sum;
times.push(acc);
}
return times;
}
var LANDED = parseJSON(vars.landings);
if (!LANDED || LANDED.length !== N) {
LANDED = N === DEFAULT_CARDS.length ? DEFAULT_LANDINGS.slice() : genLandings(N, DUR);
}
// per-card transition duration derived from the gap into it (faster as we accelerate).
function transDur(i) {
var gap = i === 0 ? (N > 1 ? LANDED[1] - LANDED[0] : 0.4) : LANDED[i] - LANDED[i - 1];
return Math.max(0.12, Math.min(0.42, gap * 0.32));
}
// ── 3. Build the deck DOM from data ────────────────────────────────────
var scene = document.getElementById("scene");
scene.style.transform = "rotateY(" + YAW + "deg)";
// bg: explicit bgColor override wins; else the theme's field color. The radial
// lifts the centre slightly and sinks the edges, both derived from the bg.
var BG = vars.bgColor && String(vars.bgColor).trim() ? vars.bgColor : TH.bg;
document.documentElement.style.background = BG;
document.body.style.background = BG;
var rootEl = document.getElementById("root");
rootEl.style.background =
"radial-gradient(1300px 820px at 50% 52%, " +
lighten(BG, 0.1) +
" 0%, " +
BG +
" 70%, " +
darken(BG, 0.4) +
" 100%)";
function el(cls, parent) {
var d = document.createElement("div");
d.className = cls;
if (parent) parent.appendChild(d);
return d;
}
var cardEls = [];
for (var i = 0; i < N; i++) {
var c = CARDS[i] || {};
var card = el("card", scene);
card.id = "card-" + i;
// color: per-card override, else cycle the cohesive theme ramp. A gentle
// top→bottom shade pair (--c1/--c2) gives each face tonal depth.
var col = c.color || TH.colors[i % TH.colors.length];
card.style.setProperty("--color", col);
card.style.setProperty("--c1", lighten(col, 0.05));
card.style.setProperty("--c2", darken(col, 0.22));
card.style.setProperty("--ink", TH.ink);
var wire = el("wire", card);
var label = el("wire-label", wire);
label.textContent = String(i + 1).padStart(3, "0");
el("wire-corner", wire);
el("wire-bar", wire);
var solid = el("solid", card);
var title = el("title", solid);
title.textContent = c.title || "";
el("play", solid);
cardEls.push(card);
}
// ── 4. SLOT poses ───────────────────────────────────────────────────────
// rotX is the key. Cards behind tilt BACK (top edge away, +rotX); the front
// is upright (rotX 0); on exit a card continues forward into a face-down
// tumble (-rotX) while dropping down + toward camera. Depth from perspective
// (Z) + the cards' rotateX, not a camera move.
var SLOT = {
far: { x: 360, y: 380, z: -1080, rotX: 84, scale: 0.74, opacity: 0, zi: 60 },
s3: { x: 280, y: 290, z: -820, rotX: 66, scale: 0.8, opacity: 0.32, zi: 70 },
s2: { x: 195, y: 200, z: -560, rotX: 46, scale: 0.86, opacity: 0.58, zi: 80 },
s1: { x: 105, y: 105, z: -290, rotX: 22, scale: 0.93, opacity: 0.85, zi: 90 },
s0: { x: 0, y: 0, z: 0, rotX: 0, scale: 1.0, opacity: 1.0, zi: 100 },
exit: { x: -90, y: 620, z: 420, rotX: -88, scale: 1.1, opacity: 0, zi: 110 },
};
// pose for a card whose index is `rel` slots behind the current front card
// (rel 0 = front, +ve = behind, -ve = already exiting / gone).
function poseForRel(rel) {
if (rel <= -1) return SLOT.exit;
if (rel === 0) return SLOT.s0;
if (rel === 1) return SLOT.s1;
if (rel === 2) return SLOT.s2;
if (rel === 3) return SLOT.s3;
return SLOT.far;
}
var tl = gsap.timeline({ paused: true });
function poseTween(idx, slot, t, dur, ease) {
// z-index is set instantly at the swap start so paint order matches the
// slot — CSS can't reliably depth-sort non-intersecting planes.
tl.set("#card-" + idx, { zIndex: slot.zi }, t);
tl.to(
"#card-" + idx,
{
x: slot.x,
y: slot.y,
z: slot.z,
rotationX: slot.rotX,
scale: slot.scale,
opacity: slot.opacity,
force3D: true,
duration: dur,
ease: ease || "power2.inOut",
},
t,
);
}
// ── 5. Initial pose (front index = -1: nothing landed yet) ─────────────
// card j sits at rel = j + 1, so card0 starts at s1, card1 at s2, etc. Apply
// via gsap.set (so the paused render at t=0 shows the staged stack) AND via
// tl.set at 0 (so scrubbing back to 0 restores it).
for (var j = 0; j < N; j++) {
var s = poseForRel(j + 1);
var props = {
x: s.x,
y: s.y,
z: s.z,
rotationX: s.rotX,
scale: s.scale,
opacity: s.opacity,
zIndex: s.zi,
force3D: true,
"--wipe": 0,
};
gsap.set("#card-" + j, props);
tl.set("#card-" + j, props, 0);
}
// ── 6. Per-landing orchestration ───────────────────────────────────────
// On landing i, card i becomes front. Tween the live window [i-1 .. i+4]:
// i-1 tumbles to exit, i rolls into s0 (+ wipe), the rest advance toward
// the front. Cards outside the window are already at rest (far / gone).
for (var L = 0; L < N; L++) {
var dur = transDur(L);
var t = Math.max(0, LANDED[L] - dur);
for (var k = L - 1; k <= L + 4; k++) {
if (k < 0 || k >= N) continue;
var rel = k - L;
var ease = rel === 0 ? "power3.out" : rel < 0 ? "power2.in" : "power2.inOut";
poseTween(k, poseForRel(rel), t, dur, ease);
}
// the landing card wipes in its solid face as it rolls up.
tl.to(
"#card-" + L,
{
"--wipe": 1,
duration: dur * 0.85,
ease: "power2.inOut",
},
t + dur * 0.15,
);
}
// pad the timeline so duration() >= data-duration.
tl.to({}, { duration: DUR }, 0);
tl.seek(0);
window.__timelines["card-flyby"] = tl;
})();
</script>
</div>
</body>
</html>
@@ -0,0 +1,13 @@
{
"_": "Default card-flyby program. `theme` picks a cohesive palette; `cards` is the deck (each card = a big centered title; `color` is OPTIONAL — omit it and the card takes the next color from the theme ramp); `landings` is one onset per card (accelerating cadence; omit to auto-derive). Pass these as the matching composition-variables; empty vars fall back to these values.",
"theme": "aurora",
"cards": [
{ "title": "FADEGLOW" },
{ "title": "SAUL BASS" },
{ "title": "EULER" },
{ "title": "HERMÈS" },
{ "title": "DOSSIER" },
{ "title": "BLACK HOLES" }
],
"landings": [0.4, 1.7, 2.7, 3.45, 4.0, 4.4]
}
@@ -0,0 +1,485 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"bgColor", "type":"color", "label":"Page background", "default":"#050507"},
{"id":"colorLow", "type":"color", "label":"Flow — shadow tone", "default":"#0a1430"},
{"id":"colorMid", "type":"color", "label":"Flow — mid tone", "default":"#6a6f9e"},
{"id":"colorHigh", "type":"color", "label":"Flow — highlight tone", "default":"#e8f0ff"},
{"id":"accentColor","type":"color", "label":"Core / glow accent", "default":"#aee4ff"},
{"id":"markText", "type":"string", "label":"Monogram", "default":"HF"},
{"id":"titleText", "type":"string", "label":"Title", "default":"HYPERFRAMES"},
{"id":"tagText", "type":"string", "label":"Tagline", "default":"HTML · SHADERS · MP4"},
{"id":"flowSpeed", "type":"number", "label":"Flow speed", "default":1, "min":0.1, "max":3, "step":0.1}
]'
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>WebGL Textures Playground — Template</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/three@0.147.0/build/three.min.js"></script>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* --bg / --mark-top / --mark-bottom / --line are set from variables in JS */
html,
body {
background: var(--bg, #050507);
overflow: hidden;
font-family: "Inter", system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg, #050507);
}
.sf-stage {
position: absolute;
inset: 0;
}
.sf-canvas {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
display: block;
}
.sf-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
color: #fff;
text-align: center;
mix-blend-mode: screen;
pointer-events: none;
}
.sf-mark {
font-size: 280px;
font-weight: 800;
letter-spacing: -18px;
line-height: 1;
display: flex;
gap: 0;
opacity: 0;
filter: drop-shadow(0 0 60px rgba(180, 220, 255, 0.4));
}
.sf-mark-letter {
display: inline-block;
background: linear-gradient(
180deg,
var(--mark-top, #ffffff) 0%,
var(--mark-bottom, #8aa9ff) 100%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
transform: translateY(0);
}
.sf-rule {
width: 0;
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
var(--line, rgba(255, 255, 255, 0.9)) 50%,
transparent 100%
);
opacity: 0;
}
.sf-title {
font-size: 64px;
font-weight: 700;
letter-spacing: 18px;
opacity: 0;
clip-path: inset(0 100% 0 0);
text-transform: uppercase;
}
.sf-tag {
font-size: 18px;
font-weight: 500;
letter-spacing: 8px;
color: rgba(255, 255, 255, 0.65);
opacity: 0;
text-transform: uppercase;
}
.sf-grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.06;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
</style>
</head>
<body>
<div
data-hf-id="hf-a0d4"
id="root"
data-composition-id="webgl-textures-playground"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="12"
data-root="true"
>
<div data-hf-id="hf-clsq" class="sf-stage">
<canvas data-hf-id="hf-yk51" class="sf-canvas" width="1920" height="1080"></canvas>
<div data-hf-id="hf-7z0g" class="sf-overlay">
<div data-hf-id="hf-yo0z" class="sf-mark"><!-- letters injected from markText --></div>
<div data-hf-id="hf-nig2" class="sf-rule"></div>
<div data-hf-id="hf-gjx6" class="sf-title"></div>
<div data-hf-id="hf-e7y8" class="sf-tag"></div>
</div>
<div data-hf-id="hf-ku55" class="sf-grain"></div>
</div>
</div>
<script>
(function () {
// ── 1. Resolve variables (read ONCE during init) ──────────────────
var defaults = {
bgColor: "#050507",
colorLow: "#0a1430",
colorMid: "#6a6f9e",
colorHigh: "#e8f0ff",
accentColor: "#aee4ff",
markText: "HF",
titleText: "HYPERFRAMES",
tagText: "HTML · SHADERS · MP4",
flowSpeed: 1,
};
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
// sRGB hex -> linear-light vec3 (the shader works in linear space and
// gamma-encodes at the end, so converting here makes the picked color
// land close to what a color picker shows).
function hexToLinear(hex) {
var h = String(hex).replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var r = parseInt(h.substr(0, 2), 16) / 255;
var g = parseInt(h.substr(2, 2), 16) / 255;
var b = parseInt(h.substr(4, 2), 16) / 255;
var lin = function (c) {
return Math.pow(c, 2.2);
};
return new THREE.Vector3(lin(r), lin(g), lin(b));
}
var root = document.getElementById("root");
var canvas = root.querySelector(".sf-canvas");
var W = 1920;
var H = 1080;
// ── 2. Apply text + CSS-driven colors ─────────────────────────────
var docEl = document.documentElement;
docEl.style.setProperty("--bg", vars.bgColor);
docEl.style.setProperty("--mark-top", vars.colorHigh);
docEl.style.setProperty("--mark-bottom", vars.colorMid);
docEl.style.setProperty("--line", vars.colorHigh);
var markEl = root.querySelector(".sf-mark");
markEl.innerHTML = "";
String(vars.markText)
.split("")
.forEach(function (ch) {
var span = document.createElement("span");
span.className = "sf-mark-letter";
span.textContent = ch;
markEl.appendChild(span);
});
root.querySelector(".sf-title").textContent = vars.titleText;
root.querySelector(".sf-tag").textContent = vars.tagText;
// ── 3. WebGL flow field ───────────────────────────────────────────
var renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: false,
alpha: false,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
var scene = new THREE.Scene();
var camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
var uniforms = {
uTime: { value: 0.0 },
uPhase: { value: 0.0 },
uReveal: { value: 0.0 },
uChromAb: { value: 0.0 },
uVignette: { value: 0.0 },
uIntensity: { value: 0.0 },
uResolution: { value: new THREE.Vector2(W, H) },
uSpeed: { value: Number(vars.flowSpeed) || 1.0 },
// ── COLOR CONTROLS (driven by the composition variables) ──
uColLow: { value: hexToLinear(vars.colorLow) },
uColMid: { value: hexToLinear(vars.colorMid) },
uColHigh: { value: hexToLinear(vars.colorHigh) },
uAccent: { value: hexToLinear(vars.accentColor) },
};
var vert = [
"varying vec2 vUv;",
"void main(){",
" vUv = uv;",
" gl_Position = vec4(position.xy, 0.0, 1.0);",
"}",
].join("\n");
var frag = [
"precision highp float;",
"varying vec2 vUv;",
"uniform float uTime;",
"uniform float uPhase;",
"uniform float uReveal;",
"uniform float uChromAb;",
"uniform float uVignette;",
"uniform float uIntensity;",
"uniform float uSpeed;",
"uniform vec2 uResolution;",
"uniform vec3 uColLow;",
"uniform vec3 uColMid;",
"uniform vec3 uColHigh;",
"uniform vec3 uAccent;",
"float hash21(vec2 p){",
" p = fract(p * vec2(123.34, 456.21));",
" p += dot(p, p + 45.32);",
" return fract(p.x * p.y);",
"}",
"float noise2(vec2 p){",
" vec2 i = floor(p);",
" vec2 f = fract(p);",
" vec2 u = f * f * (3.0 - 2.0 * f);",
" float a = hash21(i);",
" float b = hash21(i + vec2(1.0, 0.0));",
" float c = hash21(i + vec2(0.0, 1.0));",
" float d = hash21(i + vec2(1.0, 1.0));",
" return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);",
"}",
"float fbm(vec2 p){",
" float v = 0.0;",
" float a = 0.5;",
" for(int i=0;i<5;i++){",
" v += a * noise2(p);",
" p *= 2.02;",
" a *= 0.5;",
" }",
" return v;",
"}",
"vec2 curl(vec2 p, float t){",
" float e = 0.6;",
" float n1 = fbm(p + vec2(0.0, e) + t*0.18);",
" float n2 = fbm(p + vec2(0.0, -e) + t*0.18);",
" float n3 = fbm(p + vec2( e, 0.0) + t*0.18);",
" float n4 = fbm(p + vec2(-e, 0.0) + t*0.18);",
" return vec2(n1 - n2, -(n3 - n4));",
"}",
// 3-stop gradient palette built from the color variables.
// t≈0 -> shadow, t≈0.5 -> mid, t≈1 -> highlight.
"vec3 palette(float t){",
" t = clamp(t, 0.0, 1.0);",
" vec3 lowMid = mix(uColLow, uColMid, smoothstep(0.0, 0.5, t));",
" return mix(lowMid, uColHigh, smoothstep(0.5, 1.0, t));",
"}",
"vec3 sampleField(vec2 uv, float t, float energy){",
" vec2 p = (uv - 0.5) * vec2(uResolution.x/uResolution.y, 1.0) * 2.4;",
" vec2 c = curl(p * 0.9, t);",
" vec2 q = p + c * (0.45 + 0.55 * energy);",
" float flow = fbm(q * 1.6 + vec2(t * 0.11, -t * 0.07));",
" float bands = sin(flow * 14.0 + t * 1.1);",
" bands = smoothstep(0.0, 1.0, 0.5 + 0.5 * bands);",
" float r = length(p);",
" float core = smoothstep(1.5, 0.05, r) * (0.6 + 0.4 * energy);",
" float glow = exp(-r * 1.6) * (0.55 + 0.45 * energy);",
" float k = clamp(flow * 0.85 + 0.15 * bands + 0.25 * core + 0.6 * glow, 0.0, 1.4);",
" vec3 col = palette(k * 0.9 + 0.1 * sin(t * 0.4));",
" col *= 0.7 + 0.6 * energy;",
" col += uAccent * core * 0.55;",
" return col;",
"}",
"void main(){",
" vec2 uv = vUv;",
" float t = uTime * uSpeed;",
" float e = uIntensity;",
" vec2 dir = normalize(uv - 0.5 + 1e-6);",
" float ca = uChromAb * (0.004 + 0.012 * length(uv - 0.5));",
" vec3 cR = sampleField(uv + dir * ca, t, e);",
" vec3 cG = sampleField(uv, t, e);",
" vec3 cB = sampleField(uv - dir * ca, t, e);",
" vec3 col = vec3(cR.r, cG.g, cB.b);",
" float lum = dot(col, vec3(0.299, 0.587, 0.114));",
" col = mix(vec3(lum), col, 1.05);",
" float rev = uReveal;",
" float sweep = smoothstep(0.0, 0.6, rev) * (1.0 - smoothstep(0.6, 1.0, rev));",
" col += uColHigh * sweep * 0.35;",
" vec3 phaseTint = mix(vec3(0.85, 0.92, 1.05), vec3(1.05, 0.95, 0.85), uPhase);",
" col *= phaseTint;",
" float v = length(uv - 0.5);",
" col *= 1.0 - smoothstep(0.45, 0.95, v) * uVignette;",
" col = col / (col + 1.0);",
" col = pow(col, vec3(1.0/2.2));",
" gl_FragColor = vec4(col, 1.0);",
"}",
].join("\n");
var mat = new THREE.ShaderMaterial({
uniforms: uniforms,
vertexShader: vert,
fragmentShader: frag,
});
var geo = new THREE.PlaneGeometry(2, 2);
var mesh = new THREE.Mesh(geo, mat);
scene.add(mesh);
function renderFrame() {
renderer.render(scene, camera);
}
renderFrame();
// Continuous loop: ensures the WebGL backbuffer always has fresh
// pixels when puppeteer takes a screenshot. All visible state comes
// from uniforms driven by the GSAP timeline, so output is fully
// determined by the timeline position (deterministic across renders).
function loop() {
renderFrame();
window.requestAnimationFrame(loop);
}
window.requestAnimationFrame(loop);
// ── 4. Deterministic timeline (unchanged motion design) ───────────
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({
paused: true,
onUpdate: renderFrame,
});
tl.to(uniforms.uTime, { value: 12.0, duration: 12, ease: "none" }, 0);
tl.to(uniforms.uIntensity, { value: 0.55, duration: 3.0, ease: "sine.inOut" }, 0);
tl.to(uniforms.uIntensity, { value: 1.0, duration: 1.2, ease: "power2.in" }, 3.0);
tl.to(uniforms.uIntensity, { value: 0.55, duration: 3.0, ease: "power2.out" }, 4.2);
tl.to(uniforms.uIntensity, { value: 0.3, duration: 4.8, ease: "sine.inOut" }, 7.2);
tl.fromTo(
uniforms.uChromAb,
{ value: 0.0 },
{ value: 0.4, duration: 3.0, ease: "sine.in" },
0,
);
tl.to(uniforms.uChromAb, { value: 1.6, duration: 1.0, ease: "power3.in" }, 3.0);
tl.to(uniforms.uChromAb, { value: 0.25, duration: 1.5, ease: "power2.out" }, 4.0);
tl.to(uniforms.uChromAb, { value: 0.18, duration: 6.5, ease: "sine.inOut" }, 5.5);
tl.fromTo(
uniforms.uReveal,
{ value: 0.0 },
{ value: 1.0, duration: 1.2, ease: "none" },
3.4,
);
tl.set(uniforms.uReveal, { value: 0.0 }, 4.6);
tl.to(uniforms.uPhase, { value: 0.6, duration: 5.0, ease: "sine.inOut" }, 1.5);
tl.to(uniforms.uPhase, { value: 0.15, duration: 5.5, ease: "sine.inOut" }, 6.5);
tl.to(uniforms.uVignette, { value: 0.85, duration: 3.0, ease: "power2.out" }, 4.2);
var mark = root.querySelector(".sf-mark");
var letters = root.querySelectorAll(".sf-mark-letter");
var rule = root.querySelector(".sf-rule");
var title = root.querySelector(".sf-title");
var tag = root.querySelector(".sf-tag");
tl.fromTo(
mark,
{
opacity: 0,
scale: 1.18,
filter: "blur(24px) drop-shadow(0 0 60px rgba(180,220,255,0.4))",
},
{
opacity: 1,
scale: 1.0,
filter: "blur(0px) drop-shadow(0 0 80px rgba(180,220,255,0.55))",
duration: 0.9,
ease: "power3.out",
},
3.6,
);
tl.fromTo(
letters,
{ y: 80 },
{ y: 0, duration: 1.0, ease: "expo.out", stagger: 0.08 },
3.6,
);
tl.fromTo(
rule,
{ width: 0, opacity: 0 },
{ width: 360, opacity: 0.85, duration: 0.8, ease: "power2.out" },
5.1,
);
tl.fromTo(
title,
{ opacity: 0, clipPath: "inset(0 100% 0 0)" },
{ opacity: 1, clipPath: "inset(0 0% 0 0)", duration: 0.95, ease: "power3.out" },
5.3,
);
tl.fromTo(
tag,
{ opacity: 0, y: 12 },
{ opacity: 0.7, y: 0, duration: 0.8, ease: "sine.out" },
6.0,
);
tl.to(mark, { scale: 1.04, duration: 5.0, ease: "sine.inOut" }, 7.0);
tl.to([mark, rule, title, tag], { opacity: 0, duration: 0.6, ease: "power1.in" }, 11.4);
window.__timelines["webgl-textures-playground"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,302 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"markText", "type":"string", "label":"Held word", "default":"NAMSHI"},
{"id":"fontStyle", "type":"select", "label":"Font style", "options":["display","serif"], "default":"display"},
{"id":"markScale", "type":"number", "label":"Word size scale", "default":1, "min":0.3, "max":2, "step":0.05},
{"id":"idleColor", "type":"color", "label":"Idle background (between bursts)", "default":"#000000"},
{"id":"idleInk", "type":"color", "label":"Idle word color", "default":"#ffffff"},
{"id":"frames", "type":"string", "label":"Texture frames JSON (empty=built-in)", "default":""},
{"id":"strobePlan", "type":"string", "label":"Strobe plan JSON bursts (empty=built-in)", "default":""},
{"id":"decor", "type":"select", "label":"Blueprint grid + crop border", "options":["no","yes"], "default":"no"}
]'
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Held Text / Strobe Burst (Texture Flip) — Template</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
font-family: Arial, sans-serif;
}
/* Persistent base: the held word on the idle plate. Always visible; the
texture looks flip ON TOP of it during bursts, so the word reads as
continuously held (anchor) and falls back to a solid color on black
between bursts. */
.base {
position: absolute;
inset: 0;
background: var(--idle-bg, #000);
}
/* One full-frame layer per texture frame. Only opacity is toggled — a
seek-safe flip-book (deterministic under frame-by-frame render). */
.look {
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0;
background: var(--bg);
}
/* optional blueprint grid */
.look.decor::before,
.base.decor::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
background-size: 72px 72px;
opacity: 0.18;
mix-blend-mode: soft-light;
}
/* optional inner crop border */
.look.decor::after,
.base.decor::after {
content: "";
position: absolute;
inset: 44px;
border: 5px solid rgba(255, 255, 255, 0.22);
border-radius: 4px;
}
.word-wrap {
position: absolute;
inset: 0;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 18px 26px rgba(0, 0, 0, 0.28));
}
/* THE technique: the word's letterforms are a window — a solid tint
(multiply-blended) over a texture image, clipped to the text. */
.word {
color: transparent;
background-color: transparent;
background-image: var(--solid-fill), var(--texture-url);
background-blend-mode: multiply;
background-size:
100% 100%,
var(--texture-size, 150% 150%);
background-position: center, var(--texture-position, center);
background-repeat: repeat;
-webkit-background-clip: text;
background-clip: text;
text-align: center;
white-space: nowrap;
}
/* solid-fill variant for the idle base word (no texture) */
.word.solid {
background-image: none;
color: var(--ink, #fff);
-webkit-text-fill-color: var(--ink, #fff);
}
.word.display {
font-family: Impact, "Arial Black", Arial, sans-serif;
font-size: 470px;
font-weight: 900;
/* line-height ≥ 1 so the background-paint box encloses the full glyph;
with line-height < 1 the cap tops/bottoms overflow the box and the
texture (clipped to text) leaves untextured slivers there. */
line-height: 1.05;
text-transform: uppercase;
transform: scaleX(0.78);
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.22);
}
.word.serif {
font-family: Georgia, "Times New Roman", serif;
font-size: 360px;
font-weight: 500;
font-style: italic;
line-height: 1.05;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="held-text-strobe-burst"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="3"
data-root="true"
>
<!-- .base (held word on idle plate) and .look layers are injected by JS -->
</div>
<script>
(function () {
// Built-in texture frames (texture-flip palette). texture = file name
// (no .png) under assets/texture-mask-text/masks/.
var BUILTIN_FRAMES =
'[{"texture":"tiles-138","bg":"#ff70b0","fill":"#f0a020","ink":"#2a0f1d"},' +
'{"texture":"paving-stones-150","bg":"#f0d020","fill":"#d07838","ink":"#2a2410"},' +
'{"texture":"metal-041-a","bg":"#1050e0","fill":"#9fd0ff","ink":"#eaf2ff"},' +
'{"texture":"lava","bg":"#101127","fill":"#ff5a2c","ink":"#ffd9c2"},' +
'{"texture":"ground-080","bg":"#0f7a5a","fill":"#ffd27a","ink":"#04241a"},' +
'{"texture":"onyx","bg":"#e8e2d6","fill":"#3a3a44","ink":"#1a1a22"},' +
'{"texture":"bricks-075-a","bg":"#ff9000","fill":"#7fb0c0","ink":"#241400"}]';
// Built-in plan: as a BASE template the default is one continuous burst
// across the whole composition — flips every 0.125s (≈3f@24fps), never
// stops, never cuts to idle. (For the gruop_4-faithful two-burst look,
// pass strobePlan = [{start:1.125,end:1.542,step:.125},{start:2.042,end:2.708,step:.125}].)
var BUILTIN_PLAN = '[{"start":0,"end":3,"step":0.125}]';
var defaults = {
markText: "NAMSHI",
fontStyle: "display",
markScale: 1,
idleColor: "#000000",
idleInk: "#ffffff",
frames: "",
strobePlan: "",
decor: "no",
};
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
var root = document.getElementById("root");
var masksDir = "assets/texture-mask-text/masks";
var style = vars.fontStyle === "serif" ? "serif" : "display";
var decor = vars.decor === "yes";
var scale = Number(vars.markScale) || 1;
function parse(str, fallback) {
var v = str && String(str).trim() ? str : fallback;
try {
var p = JSON.parse(v);
return Array.isArray(p) ? p : [];
} catch (e) {
return [];
}
}
var frames = parse(vars.frames, BUILTIN_FRAMES);
var plan = parse(vars.strobePlan, BUILTIN_PLAN);
// Apply word size scale (relative to the per-style base size).
var baseSize = style === "serif" ? 360 : 470;
function makeWord(extraClass) {
var word = document.createElement("div");
word.className = "word " + style + (extraClass ? " " + extraClass : "");
word.textContent = String(vars.markText);
word.style.fontSize = baseSize * scale + "px";
return word;
}
function wrap(word) {
var w = document.createElement("div");
w.className = "word-wrap";
w.appendChild(word);
return w;
}
// ── Persistent base: held word on the idle plate ──────────────────
var base = document.createElement("div");
base.className = "base" + (decor ? " decor" : "");
base.style.setProperty("--idle-bg", vars.idleColor);
var baseWord = makeWord("solid");
baseWord.style.setProperty("--ink", vars.idleInk);
base.appendChild(wrap(baseWord));
root.appendChild(base);
// ── One stacked .look per texture frame ───────────────────────────
var looks = frames.map(function (f) {
var look = document.createElement("div");
look.className = "look" + (decor ? " decor" : "");
look.style.setProperty("--bg", f.bg || "#000");
var word = makeWord("");
word.style.setProperty("--texture-url", 'url("' + masksDir + "/" + f.texture + '.png")');
word.style.setProperty(
"--solid-fill",
"linear-gradient(" + (f.fill || "#fff") + "," + (f.fill || "#fff") + ")",
);
if (f.pos) word.style.setProperty("--texture-position", f.pos);
look.appendChild(wrap(word));
root.appendChild(look);
return look;
});
// ── Deterministic, beat-quantized timeline ────────────────────────
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
gsap.set(looks, { opacity: 0 });
var compDur = Number(root.getAttribute("data-duration")) || 3;
function gridJumps(start, end, stp) {
var s = Number(start) || 0,
e = Number(end) || s,
d = Number(stp) || 0.125;
var out = [],
n = Math.max(0, Math.floor((e - s) / d + 1e-6) + 1);
for (var i = 0; i < n; i++) out.push(+(s + i * d).toFixed(4));
return out;
}
// For each burst: flip a fresh texture look on every jump (0ms), cycling
// frames by a global step index; clear back to the idle base at end.
var step = 0;
var prev = null;
plan.forEach(function (burst) {
var jumps =
Array.isArray(burst.jumps) && burst.jumps.length
? burst.jumps.slice()
: gridJumps(burst.start, burst.end, burst.step);
jumps.forEach(function (t) {
if (!looks.length) return;
var cur = looks[step % looks.length];
if (prev && prev !== cur) tl.set(prev, { opacity: 0 }, t);
tl.set(cur, { opacity: 1 }, t);
prev = cur;
step += 1;
});
// clean cut back to idle base (held word on black) at burst end —
// but NOT when the burst runs to the composition end, so the strobe
// never blacks out / stops on the last frame.
if (prev && burst.end < compDur - 1e-6) {
tl.set(prev, { opacity: 0 }, burst.end);
prev = null;
}
});
// pin total duration
tl.set({}, {}, compDur);
window.__timelines["held-text-strobe-burst"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,610 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"theme", "type":"string", "label":"Palette — light | dark | bold", "default":"light"},
{"id":"icon", "type":"string", "label":"Climax icon — bell|cursor|sparkle|bolt|play|heart|check|star, an emoji, <svg>, or none", "default":"bell"},
{"id":"phrases", "type":"string", "label":"Phrases JSON (empty = built-in intro)", "default":""},
{"id":"climax", "type":"string", "label":"Climax JSON {text,in,iconAt,hold,size} (empty = default)","default":""}
]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Intro Kinetic Cascade · beat-synced kinetic typography — reference impl</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #f5f5f7;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #f5f5f7;
color: #1d1d1f;
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
}
#stage {
position: absolute;
inset: 0;
}
/* A PHRASE = a stacked, left-aligned editorial poster, vertically centered.
Word-by-word reveal, then a hard cut-out. */
.phrase {
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: flex-start;
opacity: 0;
will-change: opacity;
}
.line {
font-weight: 900;
letter-spacing: -0.045em;
line-height: 0.88;
white-space: nowrap;
}
.line + .line {
margin-top: -0.04em;
}
/* the HERO line's elegant serif-italic contrast (offline system fallback) */
.line.serif {
font-family: "Playfair Display", Georgia, "Times New Roman", serif;
font-style: italic;
letter-spacing: -0.02em;
}
.w {
display: inline-block;
opacity: 0;
will-change: opacity;
}
.w.grad {
background: var(--grad);
background-size: 200% 100%;
background-position: 0% 50%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
/* The CLIMAX phrase SLIDES in, holds to the end, and may carry an inline
animated icon (the {icon} token). */
.climax {
position: absolute;
left: 50%;
top: 50%;
display: flex;
align-items: center;
gap: 0.32em;
white-space: nowrap;
opacity: 0;
font-weight: 900;
letter-spacing: -0.035em;
line-height: 1;
will-change: transform, opacity;
}
.cword {
flex-shrink: 0;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
opacity: 0;
overflow: visible;
line-height: 1;
}
.icon-glyph {
position: relative;
display: flex;
align-items: center;
justify-content: center;
transform-origin: 50% 14%;
}
.icon-glyph svg {
display: block;
width: 100%;
height: 100%;
}
.ring {
position: absolute;
left: 50%;
top: 50%;
border-radius: 50%;
border: 3px solid var(--ring1);
opacity: 0;
transform: translate(-50%, -50%) scale(0.4);
}
.ring.ring2 {
border-color: var(--ring2);
}
</style>
</head>
<body>
<div
data-hf-id="hf-ikc-root"
id="root"
data-composition-id="intro-kinetic-cascade"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="7"
data-root="true"
>
<div data-hf-id="hf-ikc-stage" id="stage"></div>
<script>
(function () {
// ════════════════════════════════════════════════════════════════════
// INTRO KINETIC CASCADE · beat-synced editorial kinetic typography
// ────────────────────────────────────────────────────────────────────
// A spoken/anchored line laid out as a sequence of big editorial PHRASES,
// each revealed word-by-word on its anchors (VO word-times OR audio onsets),
// hard cut-out between them, climaxing on a phrase that SLIDES in with a
// swappable, ringing ICON. Asset-free: system type, inline-SVG icon library,
// bundled GSAP. Reversed from the VO intro `act0-intro-bell`.
//
// COMPOSE BY DATA — `phrases` (the cascade) + `climax` (the finale) + `icon`
// (which glyph rings) + `theme` (palette). The icon is its own knob so it can
// match the scene: bell for timing, cursor for a click, sparkle for AI, …
// ════════════════════════════════════════════════════════════════════
window.__timelines = window.__timelines || {};
// ── 1. Variables ──
var defaults = { theme: "light", icon: "bell", phrases: "", climax: "" };
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
// ── 2. Palette themes (accents are AA-tuned on each bg) ──
var THEMES = {
light: {
bg: "#f5f5f7",
ink: "#1d1d1f",
accents: ["#0b8a3e", "#0a78b4", "gradient"],
gradFrom: "#0a78b4",
gradTo: "#0b8a3e",
},
dark: {
bg: "#0d0e12",
ink: "#f2f3f5",
accents: ["#3be06b", "#37c6ff", "gradient"],
gradFrom: "#37c6ff",
gradTo: "#3be06b",
},
bold: {
bg: "#0a1228",
ink: "#ffffff",
accents: ["#ffd23f", "#ff5da2", "gradient"],
gradFrom: "#ff5da2",
gradTo: "#ffd23f",
},
};
var TH = THEMES[String(vars.theme || "").trim()] || THEMES.light;
function hexRgba(hex, a) {
var h = String(hex).replace(/^#/, "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var n = parseInt(h, 16);
return (
"rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")"
);
}
// ── 3. Inline-SVG icon library (asset-free; fill follows the text color) ──
var ICONS = {
bell: '<path d="M12 2.2a1.9 1.9 0 0 1 1.9 1.9v.7a6.2 6.2 0 0 1 4.3 5.9v3.5l1.9 2.1v1.1H3.9v-1.1l1.9-2.1V10.7a6.2 6.2 0 0 1 4.3-5.9v-.7A1.9 1.9 0 0 1 12 2.2zM9.6 19.6h4.8a2.4 2.4 0 0 1-4.8 0z"/>',
cursor: '<path d="M5.5 2.6l13.4 7.9-5.9 1.2-1.1 5.9-6.4-15z"/>',
sparkle: '<path d="M12 1.8l2.1 6.9 6.9 2.1-6.9 2.1L12 19.8l-2.1-6.9L3 10.8l6.9-2.1z"/>',
bolt: '<path d="M13.2 1.8L4.4 13.6h5.6l-1.2 8.6 9.2-12.4h-5.8z"/>',
play: '<path d="M12 2.4a9.6 9.6 0 1 0 0 19.2 9.6 9.6 0 0 0 0-19.2zm-2.1 5.1l6 4.5-6 4.5z"/>',
heart:
'<path d="M12 21.2S2.8 15.4 2.8 8.9C2.8 5.8 5.1 4 7.6 4c1.9 0 3.4 1.1 4.4 2.6C13 5.1 14.5 4 16.4 4c2.5 0 4.8 1.8 4.8 4.9 0 6.5-9.2 12.3-9.2 12.3z"/>',
check: '<path d="M9.6 16.7L4.8 11.9l1.7-1.7 3.1 3.1 7.9-7.9 1.7 1.7z"/>',
star: '<path d="M12 2.2l2.9 6.4 7 .6-5.3 4.6 1.6 6.8L12 17.4 5.8 20.6l1.6-6.8L2.1 9.2l7-.6z"/>',
};
function iconSVG(name) {
return (
'<svg viewBox="0 0 24 24" fill="currentColor" width="100%" height="100%">' +
ICONS[name] +
"</svg>"
);
}
// Insert SVG markup as SANITIZED parsed nodes — never innerHTML, and never
// a raw appendChild. DOMParser builds the tree, then cleanSvg() hard-strips
// everything that isn't inert drawing: only allow-listed shape elements +
// presentation attributes survive, so <script>, <image>/<use>/<foreignObject>,
// javascript: hrefs and every on* handler are removed before the nodes ever
// enter the live document. A custom icon SVG therefore can't smuggle active
// content (CWE-79 DOM-XSS). Non-SVG / malformed input falls back to text.
var SVG_OK_TAGS = {
svg: 1,
g: 1,
path: 1,
line: 1,
polyline: 1,
polygon: 1,
rect: 1,
circle: 1,
ellipse: 1,
defs: 1,
lineargradient: 1,
radialgradient: 1,
stop: 1,
clippath: 1,
title: 1,
desc: 1,
text: 1,
tspan: 1,
};
var SVG_OK_ATTRS = {
viewbox: 1,
xmlns: 1,
width: 1,
height: 1,
fill: 1,
"fill-rule": 1,
"fill-opacity": 1,
stroke: 1,
"stroke-width": 1,
"stroke-linecap": 1,
"stroke-linejoin": 1,
"stroke-dasharray": 1,
"stroke-dashoffset": 1,
"stroke-opacity": 1,
opacity: 1,
d: 1,
x: 1,
y: 1,
x1: 1,
y1: 1,
x2: 1,
y2: 1,
cx: 1,
cy: 1,
r: 1,
rx: 1,
ry: 1,
points: 1,
transform: 1,
offset: 1,
"stop-color": 1,
"stop-opacity": 1,
gradientunits: 1,
gradienttransform: 1,
"clip-path": 1,
"clip-rule": 1,
class: 1,
id: 1,
};
function cleanSvg(node) {
var attrs = Array.prototype.slice.call(node.attributes || []);
for (var a = 0; a < attrs.length; a++) {
if (!SVG_OK_ATTRS[attrs[a].name.toLowerCase()]) node.removeAttribute(attrs[a].name);
}
var kids = Array.prototype.slice.call(node.childNodes);
for (var k = 0; k < kids.length; k++) {
var c = kids[k];
if (c.nodeType === 1) {
if (SVG_OK_TAGS[(c.localName || c.nodeName).toLowerCase()]) cleanSvg(c);
else node.removeChild(c);
} else if (c.nodeType !== 3) {
node.removeChild(c);
}
}
}
function setSvg(el, markup) {
el.textContent = "";
var doc = new DOMParser().parseFromString(String(markup), "image/svg+xml");
var root = doc.documentElement;
if (
!root ||
root.nodeName.toLowerCase() !== "svg" ||
doc.getElementsByTagName("parsererror").length
) {
el.textContent = String(markup);
return;
}
cleanSvg(root);
el.appendChild(document.importNode(root, true));
}
// ── 4. Default program (reversed from act0-intro-bell) ──
var DEFAULT_PHRASES = [
{
out: 1.36,
lines: [
{ text: "If you've", size: 220, x: 140 },
{ text: "ever", size: 520, x: 240, hero: true, font: "serif" },
{ text: "used AI", size: 280, x: 780 },
],
times: [0.14, 0.3, 0.55, 0.87, 1.06],
},
{
out: 3.06,
lines: [
{ text: "for", size: 220, x: 160 },
{ text: "video", size: 500, x: 220, hero: true },
{ text: "editing before,", size: 200, x: 220 },
],
times: [1.36, 1.58, 1.92, 2.48],
},
{
out: 3.79,
lines: [
{ text: "you", size: 240, x: 200 },
{ text: "know", size: 500, x: 260, hero: true, accent: "gradient" },
{ text: "that", size: 340, x: 820 },
],
times: [3.06, 3.3, 3.54],
},
];
var DEFAULT_CLIMAX = {
text: "timing is {icon} tricky.",
in: 3.79,
iconAt: 4.9,
hold: 1.3,
size: 150,
};
var WORD_FADE = 0.18,
CUT = 0.1,
SLIDE = 0.42,
OFFSCREEN_R = 1400;
function parseJSON(v, fallback) {
if (!v) return fallback;
if (typeof v !== "string") return v;
try {
var p = JSON.parse(v);
return p || fallback;
} catch (e) {
return fallback;
}
}
var PHRASES = parseJSON(vars.phrases, DEFAULT_PHRASES);
if (!PHRASES.length) PHRASES = DEFAULT_PHRASES;
var C = Object.assign({}, DEFAULT_CLIMAX, parseJSON(vars.climax, {}));
var words = function (s) {
return String(s).trim().split(/\s+/).filter(Boolean);
};
// ── 5. Theme the field ──
var root = document.getElementById("root");
var stage = document.getElementById("stage");
root.style.background = TH.bg;
root.style.color = TH.ink;
document.documentElement.style.background = TH.bg;
document.body.style.background = TH.bg;
var GRAD =
"linear-gradient(90deg," +
TH.gradFrom +
" 0%," +
TH.gradTo +
" 50%," +
TH.gradFrom +
" 100%)";
// ── 6. Build phrase DOM, collect word spans in reading order ──
PHRASES.forEach(function (p, pi) {
var pEl = document.createElement("div");
pEl.className = "phrase";
var accent = p.accent || TH.accents[pi % TH.accents.length];
p._spans = [];
p._grad = [];
p.lines.forEach(function (ln) {
var lEl = document.createElement("div");
lEl.className = "line" + (ln.font === "serif" ? " serif" : "");
lEl.style.fontSize = ln.size + "px";
lEl.style.marginLeft = (ln.x != null ? ln.x : 0) + "px";
var heroColor = ln.hero ? ln.accent || accent : null;
words(ln.text).forEach(function (wtext) {
var w = document.createElement("span");
w.className = "w";
w.textContent = wtext;
if (heroColor === "gradient") {
w.classList.add("grad");
w.style.setProperty("--grad", GRAD);
p._grad.push(w);
} else if (heroColor) w.style.color = heroColor;
lEl.appendChild(w);
lEl.appendChild(document.createTextNode(" "));
p._spans.push(w);
});
pEl.appendChild(lEl);
});
stage.appendChild(pEl);
p._el = pEl;
});
// ── 7. Build the climax (slide-in + swappable icon) ──
var cEl = document.createElement("div");
cEl.className = "climax";
cEl.style.fontSize = C.size + "px";
cEl.style.setProperty("--ring1", hexRgba(TH.ink, 0.25));
cEl.style.setProperty("--ring2", hexRgba(TH.ink, 0.1));
var iconPx = Math.round(C.size * 1.7);
var iconEl = null,
rings = [];
var iconSpec = String(vars.icon == null ? "bell" : vars.icon).trim();
words(C.text).forEach(function (tok) {
if (tok === "{icon}") {
if (!iconSpec || iconSpec === "none") return; // drop the slot → pure-text climax
iconEl = document.createElement("span");
iconEl.className = "icon";
iconEl.style.width = iconPx + "px";
iconEl.style.height = iconPx + "px";
var r1 = document.createElement("span");
r1.className = "ring";
var r2 = document.createElement("span");
r2.className = "ring ring2";
[r1, r2].forEach(function (r) {
var rs = Math.round(iconPx * 1.3);
r.style.width = rs + "px";
r.style.height = rs + "px";
iconEl.appendChild(r);
});
rings = [r1, r2];
var glyph = document.createElement("span");
glyph.className = "icon-glyph";
glyph.style.width = iconPx + "px";
glyph.style.height = iconPx + "px";
glyph.style.color = TH.ink;
if (ICONS[iconSpec]) {
setSvg(glyph, iconSVG(iconSpec)); // library icon
} else if (iconSpec.slice(0, 4).toLowerCase() === "<svg") {
setSvg(glyph, iconSpec); // custom inline SVG
} else {
glyph.style.fontSize = Math.round(iconPx * 0.86) + "px"; // emoji / text glyph
glyph.textContent = iconSpec;
}
iconEl.appendChild(glyph);
cEl.appendChild(iconEl);
} else {
var w = document.createElement("span");
w.className = "cword";
w.textContent = tok;
cEl.appendChild(w);
}
});
stage.appendChild(cEl);
// ── 8. Timeline ──
var tl = gsap.timeline({ paused: true });
PHRASES.forEach(function (p) {
gsap.set(p._el, { opacity: 0 });
gsap.set(p._spans, { opacity: 0 });
});
gsap.set(cEl, { xPercent: -50, yPercent: -50, x: OFFSCREEN_R, opacity: 0 });
if (iconEl) {
gsap.set(iconEl, { opacity: 0 });
gsap.set(iconEl.querySelector(".icon-glyph"), { scale: 0.4, rotation: -8, opacity: 0 });
gsap.set(rings, { opacity: 0, scale: 0.4 });
}
PHRASES.forEach(function (p, pi) {
var n = p._spans.length;
var prevOut = pi === 0 ? 0.15 : PHRASES[pi - 1].out;
var times = p.times && p.times.length === n ? p.times : null;
if (!times) {
var start = p.start != null ? p.start : prevOut;
var end = p.out - Math.max(0.25, (p.out - start) * 0.18);
times = p._spans.map(function (_, i) {
return n <= 1 ? start : start + ((end - start) * i) / (n - 1);
});
}
tl.to(p._el, { opacity: 1, duration: 0.01, overwrite: "auto" }, times[0]);
p._spans.forEach(function (w, i) {
tl.to(
w,
{ opacity: 1, duration: WORD_FADE, ease: "power2.out", overwrite: "auto" },
times[i],
);
});
if (p._grad.length) {
tl.fromTo(
p._grad,
{ backgroundPosition: "0% 50%" },
{
backgroundPosition: "-200% 50%",
duration: Math.max(0.6, p.out - times[0]),
ease: "none",
},
times[0],
);
}
tl.to(
p._el,
{ opacity: 0, duration: CUT, ease: "power2.in", overwrite: "auto" },
p.out - CUT,
);
});
// climax slide-in
tl.to(
cEl,
{ x: 0, opacity: 1, duration: SLIDE, ease: "power3.out", overwrite: "auto" },
C.in,
);
// icon pop + ring
if (iconEl) {
var glyph = iconEl.querySelector(".icon-glyph");
tl.to(
iconEl,
{ opacity: 1, duration: 0.3, ease: "power3.out", overwrite: "auto" },
C.iconAt,
);
tl.fromTo(
glyph,
{ scale: 0.4, rotation: -8, opacity: 0 },
{
scale: 1,
rotation: 0,
opacity: 1,
duration: 0.35,
ease: "back.out(2.4)",
overwrite: "auto",
},
C.iconAt + 0.05,
);
tl.to(
glyph,
{ rotation: 13, duration: 0.1, ease: "power2.out", overwrite: "auto" },
C.iconAt + 0.4,
);
tl.to(
glyph,
{ rotation: 0, duration: 0.85, ease: "elastic.out(1, 0.25)", overwrite: "auto" },
C.iconAt + 0.5,
);
[0, 0.28].forEach(function (off) {
tl.fromTo(
rings,
{ scale: 0.4, opacity: 0.55 },
{ scale: 1.5, opacity: 0, duration: 0.8, ease: "power2.out", stagger: 0.08 },
C.iconAt + 0.42 + off,
);
});
}
var TOTAL = Math.round((C.iconAt + C.hold) * 100) / 100;
tl.to({}, { duration: TOTAL }, 0);
root.setAttribute("data-duration", String(TOTAL));
tl.seek(0);
window.__timelines["intro-kinetic-cascade"] = tl;
})();
</script>
</div>
</body>
</html>
@@ -0,0 +1,41 @@
{
"_": "Default intro-kinetic-cascade program (reversed from act0-intro-bell). `theme` = palette; `icon` = the ringing climax glyph (MATCH TO SCENE — bell|cursor|sparkle|bolt|play|heart|check|star, an emoji, a raw <svg>, or 'none'); `phrases` = the word-by-word cascade (each `times` = VO word onsets / audio onsets, omit to auto-spread); `climax` = the slide-in finale ({icon} marks the glyph). Pass these as the matching composition-variables; empty vars fall back to these values.",
"theme": "light",
"icon": "bell",
"phrases": [
{
"out": 1.36,
"lines": [
{ "text": "If you've", "size": 220, "x": 140 },
{ "text": "ever", "size": 520, "x": 240, "hero": true, "font": "serif" },
{ "text": "used AI", "size": 280, "x": 780 }
],
"times": [0.14, 0.3, 0.55, 0.87, 1.06]
},
{
"out": 3.06,
"lines": [
{ "text": "for", "size": 220, "x": 160 },
{ "text": "video", "size": 500, "x": 220, "hero": true },
{ "text": "editing before,", "size": 200, "x": 220 }
],
"times": [1.36, 1.58, 1.92, 2.48]
},
{
"out": 3.79,
"lines": [
{ "text": "you", "size": 240, "x": 200 },
{ "text": "know", "size": 500, "x": 260, "hero": true, "accent": "gradient" },
{ "text": "that", "size": 340, "x": 820 }
],
"times": [3.06, 3.3, 3.54]
}
],
"climax": {
"text": "timing is {icon} tricky.",
"in": 3.79,
"iconAt": 4.9,
"hold": 1.3,
"size": 150
}
}
@@ -0,0 +1,596 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"bgColor", "type":"color", "label":"Background", "default":"#249b5f"},
{"id":"markColor", "type":"color", "label":"Logo mark color", "default":"#e7e3dd"},
{"id":"textColor", "type":"color", "label":"Word color", "default":"#e7e3dd"},
{"id":"leftMark", "type":"string", "label":"Left mark SVG (empty = spark)", "default":""},
{"id":"rightMark", "type":"string", "label":"Right mark SVG (empty = cloud)", "default":""},
{"id":"word1", "type":"string", "label":"Word 1 (opens the gap)", "default":"lille"},
{"id":"word2", "type":"string", "label":"Word 2 (lands on the surge)", "default":"2020"},
{"id":"word3", "type":"string", "label":"Word 3 (optional)", "default":""},
{"id":"word4", "type":"string", "label":"Word 4 (optional)", "default":""}
]'
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Logo Split / Lockup Pulse — group_7 reference impl</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<!-- Velocity-driven directional motion blur (registry: motion-blur). Samples each
target's GSAP x/y per seek and applies a one-sided ghost trail + top blur
proportional to speed; clears at rest. This is what makes the fast mark moves
(split / snap) read SILKY instead of stiff. Self-contained (SVG filters). -->
<script>
(function () {
if (!window._hfMbUid) window._hfMbUid = 0;
window.attachMotionBlur = function (selector, tl, opts) {
opts = opts || {};
var blurScale = opts.blurScale !== undefined ? opts.blurScale : 0.008;
var blurMax = opts.blurMax !== undefined ? opts.blurMax : 20;
var stretchScale = opts.stretchScale !== undefined ? opts.stretchScale : 0.0002;
var stretchMax = opts.stretchMax !== undefined ? opts.stretchMax : 0;
var axis = opts.axis || "both";
var items = Array.isArray(selector) ? selector : [selector];
var targets = items.reduce(function (acc, s) {
if (typeof s === "string") {
document.querySelectorAll(s).forEach(function (el) {
acc.push(el);
});
} else if (s instanceof Element) {
acc.push(s);
}
return acc;
}, []);
var ns = "http://www.w3.org/2000/svg";
var GHOST_CFG = [
{ frac: 0.25, slope: 0.55 },
{ frac: 0.55, slope: 0.28 },
{ frac: 1.0, slope: 0.1 },
];
var state = targets.map(function (el) {
var uid = "hf-mb-" + window._hfMbUid++;
var svg = document.createElementNS(ns, "svg");
svg.setAttribute("style", "position:absolute;width:0;height:0;overflow:hidden;");
var filter = document.createElementNS(ns, "filter");
filter.id = uid;
filter.setAttribute("x", "-110%");
filter.setAttribute("y", "-25%");
filter.setAttribute("width", "260%");
filter.setAttribute("height", "150%");
var ghosts = GHOST_CFG.map(function (cfg, gi) {
var feOff = document.createElementNS(ns, "feOffset");
feOff.setAttribute("in", "SourceGraphic");
feOff.setAttribute("dx", "0");
feOff.setAttribute("dy", "0");
feOff.setAttribute("result", "go" + gi);
var feGB = document.createElementNS(ns, "feGaussianBlur");
feGB.setAttribute("in", "go" + gi);
feGB.setAttribute("stdDeviation", "0 0");
feGB.setAttribute("result", "gb" + gi);
var feCT = document.createElementNS(ns, "feComponentTransfer");
feCT.setAttribute("in", "gb" + gi);
feCT.setAttribute("result", "gf" + gi);
var feFuncA = document.createElementNS(ns, "feFuncA");
feFuncA.setAttribute("type", "linear");
feFuncA.setAttribute("slope", String(cfg.slope));
feCT.appendChild(feFuncA);
filter.appendChild(feOff);
filter.appendChild(feGB);
filter.appendChild(feCT);
return { feOff: feOff, feGB: feGB, frac: cfg.frac };
});
var feTopBlur = document.createElementNS(ns, "feGaussianBlur");
feTopBlur.setAttribute("in", "SourceGraphic");
feTopBlur.setAttribute("stdDeviation", "0 0");
feTopBlur.setAttribute("result", "top");
filter.appendChild(feTopBlur);
var feMerge = document.createElementNS(ns, "feMerge");
for (var mi = GHOST_CFG.length - 1; mi >= 0; mi--) {
var mn = document.createElementNS(ns, "feMergeNode");
mn.setAttribute("in", "gf" + mi);
feMerge.appendChild(mn);
}
var mnTop = document.createElementNS(ns, "feMergeNode");
mnTop.setAttribute("in", "top");
feMerge.appendChild(mnTop);
filter.appendChild(feMerge);
svg.appendChild(filter);
document.body.appendChild(svg);
return {
el: el,
ghosts: ghosts,
feTopBlur: feTopBlur,
filterId: uid,
prevX: parseFloat(gsap.getProperty(el, "x")) || 0,
prevY: parseFloat(gsap.getProperty(el, "y")) || 0,
prevTime: tl.time(),
};
});
var _proxy = { t: 0 };
tl.to(
_proxy,
{
t: 1,
duration: Math.max(tl.duration(), 0.1),
ease: "none",
onUpdate: function () {
var time = tl.time();
state.forEach(function (s) {
var x = parseFloat(gsap.getProperty(s.el, "x")) || 0;
var y = parseFloat(gsap.getProperty(s.el, "y")) || 0;
var dt = time - s.prevTime;
if (dt > 0.0005) {
var vx = axis !== "y" ? (x - s.prevX) / dt : 0;
var vy = axis !== "x" ? (y - s.prevY) / dt : 0;
var bx = Math.min(Math.abs(vx) * blurScale, blurMax);
var by = Math.min(Math.abs(vy) * blurScale, blurMax);
var bxFinal = axis !== "y" ? bx : Math.max(by * 0.08, 0.4);
var byFinal = axis !== "x" ? by : Math.max(bx * 0.08, 0.4);
var active = bx > 0.3 || by > 0.3;
if (active) {
s.el.style.filter = "url(#" + s.filterId + ")";
s.ghosts.forEach(function (g) {
var dx =
axis !== "y" ? (vx >= 0 ? -bxFinal * g.frac : bxFinal * g.frac) : 0;
var dy =
axis !== "x" ? (vy >= 0 ? -byFinal * g.frac : byFinal * g.frac) : 0;
g.feOff.setAttribute("dx", dx.toFixed(2));
g.feOff.setAttribute("dy", dy.toFixed(2));
var gbx =
axis !== "y"
? (bxFinal * g.frac * 0.5).toFixed(2)
: Math.max(byFinal * g.frac * 0.04, 0.4).toFixed(2);
var gby =
axis !== "x"
? (byFinal * g.frac * 0.5).toFixed(2)
: Math.max(bxFinal * g.frac * 0.04, 0.4).toFixed(2);
g.feGB.setAttribute("stdDeviation", gbx + " " + gby);
});
s.feTopBlur.setAttribute(
"stdDeviation",
(bxFinal * 0.15).toFixed(2) + " " + (byFinal * 0.15).toFixed(2),
);
} else {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
} else if (dt < -0.0005) {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
s.prevX = x;
s.prevY = y;
s.prevTime = time;
}
});
},
},
0,
);
};
})();
</script>
<style>
@font-face {
font-family: "Lockup";
src: url("./assets/fredoka-700.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: block;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: var(--bg, #249b5f);
overflow: hidden;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg, #249b5f);
display: flex;
align-items: center;
justify-content: center;
}
/* a 0-size anchor at screen center; marks + words are positioned absolutely
around it, and the whole group scales/translates for the pulse. */
#lockup {
position: relative;
width: 0;
height: 0;
}
.words {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 34px;
white-space: nowrap;
color: var(--text, #e7e3dd);
font-family: "Lockup", system-ui, sans-serif;
font-size: 200px;
font-weight: 700;
letter-spacing: -0.045em;
line-height: 0.8;
}
.word {
display: none; /* revealed one per onset on the timeline */
}
.mark {
position: absolute;
top: 0;
left: 0;
width: 300px;
height: 340px;
color: var(--mark, #e7e3dd);
}
.mark svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
/* edge/vertical centering is set in JS via GSAP xPercent/yPercent so it
composes with the animated x (a CSS % transform would be clobbered). */
</style>
</head>
<body>
<!-- duration = 2.875s = the green logo lifecycle (seed -> split -> lockup ->
snap -> pulse); the source then HARD-CUTS to the next group at f70 (2.917s). -->
<div
data-hf-id="hf-g7rt"
id="root"
data-composition-id="group-seven-logo"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="2.875"
data-root="true"
>
<div data-hf-id="hf-g7lk" id="lockup">
<div data-hf-id="hf-g7ml" class="mark left" id="markLeft"></div>
<div data-hf-id="hf-g7wd" class="words" id="words"></div>
<div data-hf-id="hf-g7mr" class="mark right" id="markRight"></div>
</div>
</div>
<script>
(function () {
// ════════════════════════════════════════════════════════════════
// LOGO SPLIT / LOCKUP PULSE · structural_type: per_onset_sequential
// A two-part mark sits joined at center, SPLITS left<->right to open a
// gap, GROWS a center word-lockup one word per onset (key word on the
// surge), SNAP-CLOSES on a hit, then PULSES with the roll/hihat bed.
// Reversed from group_7 (Lille — World Design Capital 2020 sting).
// ════════════════════════════════════════════════════════════════
// ── 1. Content variables (what a user changes) ─────────────────────
var defaults = {
bgColor: "#249b5f",
markColor: "#e7e3dd",
textColor: "#e7e3dd",
leftMark: "",
rightMark: "",
word1: "lille",
word2: "2020",
word3: "",
word4: "",
};
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
// ── 2. DEFAULT LOGO HALVES (line-art; swap via leftMark / rightMark) ─
// Either two related SVGs (a left + a right graphic) OR the two halves of
// one logo. currentColor inherits --mark. Thick rounded line-art so they
// read as a bold pair bracketing the words (matched to the Lille mark:
// a left radial SPARK with a hollow hub, a right CLOUD/SWIRL).
var DEFAULT_LEFT = [
// radial SPARK: 5 rounded strokes, a tight fan up-and-left from a hollow
// hub near the text (NOT converging to a needle — reads as a spark/shine).
// hub at the INNER (right) edge so the spark hugs the text; 5 long strokes
// fanning ~140 up-and-left — a big radial spark that helps wrap the lockup.
'<svg viewBox="0 0 150 150" fill="none" stroke="currentColor"',
' stroke-width="16" stroke-linecap="round">',
'<line x1="134" y1="66" x2="112" y2="6"/>',
'<line x1="130" y1="72" x2="66" y2="16"/>',
'<line x1="126" y1="80" x2="24" y2="42"/>',
'<line x1="124" y1="88" x2="10" y2="84"/>',
'<line x1="126" y1="96" x2="36" y2="136"/>',
"</svg>",
].join("");
var DEFAULT_RIGHT = [
// CLOUD / backwards-3 SWIRL: two bold stacked lobes bulging right with a
// left-pointing middle notch, opening to the left (the Lille cloud).
// bold backwards-3 swirl: opening tips at the INNER (left) edge so it hugs the
// text, bulging right (mass OUTER). Fills the box height to help wrap the lockup.
'<svg viewBox="0 0 150 150" fill="none" stroke="currentColor"',
' stroke-width="16" stroke-linecap="round" stroke-linejoin="round">',
'<path d="M14 40',
" C50 6 128 14 122 58",
" C120 86 80 92 54 78",
" C86 86 128 96 122 128",
' C116 154 40 158 14 120"/>',
"</svg>",
].join("");
// ── 3. BEAT MAP (filled from <group>.audiomap.json — see README) ────
// group_7.audiomap.json: BPM 92.3. ABSOLUTE audio seconds.
// The marks move on only these DISCRETE anchors — NOT on the sustained hi-hat
// fill (f17-f86), which is rhythmic texture, not a motion driver. Left + right
// always move together (mirrored). After the snap the weld HOLDS dead still.
var ENTRY = 0.083; // f2 — joined bracket pair appears at center (perc onset)
var SPLIT = 0.25; // f6 — brackets fly apart to full width (perc onset)
var WORD_ONSETS = [0.25, 1.0]; // one per word; 1.0 = SURGE@1s (f24) carries the key word
var CLOSE = 2.083; // f50 — snap shut: words vanish + brackets weld back together
var DUR = 2.875; // f69 — hold, then the source hard-cuts to the next group (f70)
var MARK_PAD = 14; // gap between a mark's inner edge and the nearest word
var WORD_GAP = 34; // must match .words { gap }
// ── 4. Build content ───────────────────────────────────────────────
document.documentElement.style.setProperty("--bg", vars.bgColor);
document.documentElement.style.setProperty("--mark", vars.markColor);
document.documentElement.style.setProperty("--text", vars.textColor);
// Insert SVG markup as SANITIZED parsed nodes — never innerHTML, and never
// a raw appendChild. DOMParser builds the tree, then cleanSvg() hard-strips
// everything that isn't inert drawing: only allow-listed shape elements +
// presentation attributes survive, so <script>, <image>/<use>/<foreignObject>,
// javascript: hrefs and every on* handler are removed before the nodes ever
// enter the live document. A custom mark SVG therefore can't smuggle active
// content (CWE-79 DOM-XSS). Non-SVG / malformed input falls back to text.
var SVG_OK_TAGS = {
svg: 1,
g: 1,
path: 1,
line: 1,
polyline: 1,
polygon: 1,
rect: 1,
circle: 1,
ellipse: 1,
defs: 1,
lineargradient: 1,
radialgradient: 1,
stop: 1,
clippath: 1,
title: 1,
desc: 1,
text: 1,
tspan: 1,
};
var SVG_OK_ATTRS = {
viewbox: 1,
xmlns: 1,
width: 1,
height: 1,
fill: 1,
"fill-rule": 1,
"fill-opacity": 1,
stroke: 1,
"stroke-width": 1,
"stroke-linecap": 1,
"stroke-linejoin": 1,
"stroke-dasharray": 1,
"stroke-dashoffset": 1,
"stroke-opacity": 1,
opacity: 1,
d: 1,
x: 1,
y: 1,
x1: 1,
y1: 1,
x2: 1,
y2: 1,
cx: 1,
cy: 1,
r: 1,
rx: 1,
ry: 1,
points: 1,
transform: 1,
offset: 1,
"stop-color": 1,
"stop-opacity": 1,
gradientunits: 1,
gradienttransform: 1,
"clip-path": 1,
"clip-rule": 1,
class: 1,
id: 1,
};
function cleanSvg(node) {
var attrs = Array.prototype.slice.call(node.attributes || []);
for (var a = 0; a < attrs.length; a++) {
if (!SVG_OK_ATTRS[attrs[a].name.toLowerCase()]) node.removeAttribute(attrs[a].name);
}
var kids = Array.prototype.slice.call(node.childNodes);
for (var k = 0; k < kids.length; k++) {
var c = kids[k];
if (c.nodeType === 1) {
if (SVG_OK_TAGS[(c.localName || c.nodeName).toLowerCase()]) cleanSvg(c);
else node.removeChild(c);
} else if (c.nodeType !== 3) {
node.removeChild(c);
}
}
}
function setSvg(el, markup) {
el.textContent = "";
var doc = new DOMParser().parseFromString(String(markup), "image/svg+xml");
var root = doc.documentElement;
if (
!root ||
root.nodeName.toLowerCase() !== "svg" ||
doc.getElementsByTagName("parsererror").length
) {
el.textContent = String(markup);
return;
}
cleanSvg(root);
el.appendChild(document.importNode(root, true));
}
var markLeft = document.getElementById("markLeft");
var markRight = document.getElementById("markRight");
setSvg(markLeft, (vars.leftMark && vars.leftMark.trim()) || DEFAULT_LEFT);
setSvg(markRight, (vars.rightMark && vars.rightMark.trim()) || DEFAULT_RIGHT);
var wordsEl = document.getElementById("words");
var wordList = [vars.word1, vars.word2, vars.word3, vars.word4].filter(function (w) {
return w && String(w).trim() !== "";
});
var wordEls = wordList.map(function (w) {
var s = document.createElement("span");
s.className = "word";
s.textContent = w;
wordsEl.appendChild(s);
return s;
});
// ── 5. Build the timeline once the lockup font is ready ─────────────
// The word widths drive how far the marks open, so they MUST be measured
// in the real (rounded) face, not a fallback. Gate the whole build on
// document.fonts so the measurement is correct; the render engine also
// waits for fonts before capturing.
function build() {
// measure each word in the loaded face, then hide them all so a hidden
// word occupies NO layout width (the lockup truly grows as words enter).
var widths = wordEls.map(function (el) {
el.style.display = "inline-block";
var w = el.offsetWidth;
el.style.display = "none";
return w;
});
function markOffsetFor(k) {
if (k <= 0) return 0;
var blockW = 0;
for (var i = 0; i < k; i++) blockW += widths[i];
blockW += WORD_GAP * (k - 1);
return blockW / 2 + MARK_PAD;
}
var OFF_FULL = markOffsetFor(wordEls.length); // open width (brackets the full lockup)
var OVL = 14; // joined position: welded brackets sit close (small center gap)
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// start state: the two brackets JOINED at center (full-outside / empty-middle),
// invisible; words hidden. xPercent/yPercent center each mark on the anchor and
// COMPOSE with the animated x (a CSS % transform would be clobbered by GSAP x).
gsap.set(markLeft, { autoAlpha: 0, xPercent: -100, yPercent: -50, x: OVL, scale: 0.72 });
gsap.set(markRight, { autoAlpha: 0, xPercent: 0, yPercent: -50, x: -OVL, scale: 0.72 });
gsap.set("#lockup", { scale: 1, transformOrigin: "50% 50%" });
// ── MOTION = the few DISCRETE anchors only (the hi-hat bed is texture, not
// a motion driver). Left + right always move TOGETHER (mirrored). ──
// f2 ENTRY — the joined bracket pair BLOOMS in at center (smooth scale-in,
// not a 1-frame pop — the soft grow is what makes the start feel silky).
tl.to(
[markLeft, markRight],
{ autoAlpha: 1, scale: 1, duration: 0.16, ease: "power2.out" },
ENTRY,
);
// f6 SPLIT — both brackets GLIDE apart together to the FULL lockup width in ONE
// move: a fast launch + long settle (expo.out), and the velocity motion blur
// (attached below) smooths the travel so it reads silky, not stiff. Then HOLD
// — words fill the reserved center space; the marks don't track each word.
tl.to(markLeft, { x: -OFF_FULL, duration: 0.3, ease: "expo.out" }, SPLIT);
tl.to(markRight, { x: OFF_FULL, duration: 0.3, ease: "expo.out" }, SPLIT);
// word reveals — each word eases in with a soft stretch on its onset (the key
// word on the SURGE). Smooth deceleration (power3.out), NO poppy overshoot.
wordEls.forEach(function (el, i) {
var t =
i < WORD_ONSETS.length
? WORD_ONSETS[i]
: WORD_ONSETS[WORD_ONSETS.length - 1] + 0.3 * (i - WORD_ONSETS.length + 1);
tl.set(el, { display: "inline-block" }, t);
tl.fromTo(
el,
{ autoAlpha: 0, scaleX: 0.58, scaleY: 1.09, transformOrigin: "50% 50%" },
{ autoAlpha: 1, scaleX: 1, scaleY: 1, duration: 0.24, ease: "power3.out" },
t,
);
});
// f50 SNAP-CLOSE — 1 frame: words vanish + both brackets teleport back together
// (the teleport reads as an impact streak through the motion blur).
tl.set(wordEls, { autoAlpha: 0, display: "none" }, CLOSE);
tl.set(markLeft, { x: OVL }, CLOSE);
tl.set(markRight, { x: -OVL }, CLOSE);
tl.fromTo(
"#lockup",
{ scale: 0.9 },
{ scale: 1, duration: 0.22, ease: "power2.out" },
CLOSE,
);
// SEE-SAW BOUNCE — the welded unit does a damped vertical spring + a building
// rigid tilt (rotation: +deg = left-spark UP / right-cloud DOWN), beat-synced to
// the hi-hat hits after the snap: f51 slam-down (lowest), f55 up-rebound (highest),
// f58 smaller dip, f63 final flick — amplitude decays, ending tilted to the f70 cut.
var B1 = 2.125,
B2 = 2.292,
B3 = 2.417,
B4 = 2.625; // f51 f55 f58 f63
tl.set("#lockup", { y: 0, rotation: 0 }, CLOSE);
tl.to("#lockup", { y: 34, rotation: -3, duration: B1 - CLOSE, ease: "power2.in" }, CLOSE);
tl.to("#lockup", { y: -64, rotation: 10, duration: B2 - B1, ease: "power3.out" }, B1);
tl.to("#lockup", { y: -38, rotation: 6, duration: B3 - B2, ease: "power2.inOut" }, B2);
tl.to("#lockup", { y: -60, rotation: 10.5, duration: B4 - B3, ease: "power2.inOut" }, B3);
tl.to("#lockup", { y: -56, rotation: 10.5, duration: DUR - B4, ease: "sine.out" }, B4);
// pin duration, then attach velocity motion blur: the brackets (x: split + snap)
// and the welded group (y: the ending bounce). MUST be after all tweens.
tl.set(document.body, {}, DUR);
attachMotionBlur([markLeft, markRight], tl, { axis: "x", blurMax: 26, blurScale: 0.011 });
attachMotionBlur("#lockup", tl, { axis: "y", blurMax: 20, blurScale: 0.012 });
tl.seek(0);
window.__timelines["group-seven-logo"] = tl;
}
if (document.fonts && document.fonts.ready) {
document.fonts.load('700 200px "Lockup"').then(function () {
document.fonts.ready.then(build);
});
} else {
build();
}
})();
</script>
</body>
</html>
@@ -0,0 +1,589 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"bgColor", "type":"color", "label":"Stage background", "default":"#e9e9e9"},
{"id":"tiles", "type":"number", "label":"Tile count (mosaic)", "default":12},
{"id":"bands", "type":"number", "label":"Snake-fill row bands", "default":3},
{"id":"gap", "type":"number", "label":"Seam / gap (px)", "default":10},
{"id":"showText","type":"boolean", "label":"Show tile labels", "default":true},
{"id":"labels", "type":"string", "label":"Tile labels (comma list)", "default":"DARE TO BE BOLD,NEW DROP,WALK THE TALK,STAND OUT,PUCKER UP,SUPER SONIC,IN TECHNICOLOUR,BOLD PRINTS,NEW SCENTS,TIME TO MOVE,CRAZY FUNKY COOL,BEAUTY INSIDE"},
{"id":"program", "type":"string", "label":"Program JSON (empty = built-in group_5 score)", "default":""}
]'
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Poster-Tile Wall · 3 beat-synced operations — group_5 reference impl</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: var(--bg, #e9e9e9);
overflow: hidden;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg, #e9e9e9);
}
/* the wall = the BASE TEMPLATE. all three operations act on this one grid. */
#wall {
position: absolute;
inset: 0;
}
.gf-tile {
position: absolute;
display: flex;
align-items: flex-end;
overflow: hidden;
will-change: transform, opacity;
backface-visibility: hidden;
}
.gf-label {
font-weight: 800;
line-height: 0.95;
letter-spacing: -0.01em;
padding: 0.5em 0.55em;
text-transform: uppercase;
word-break: break-word;
}
/* overlay "cutouts" (phase C) — drawn, asset-free silhouette + label */
.gf-ov {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
border-radius: 6px;
will-change: transform, opacity;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
}
.gf-ov .sil {
position: relative;
width: 60%;
flex: 1 1 auto;
margin-top: 14%;
display: flex;
align-items: flex-end;
justify-content: center;
}
.gf-ov .sil::before {
/* head */
content: "";
position: absolute;
top: 0;
width: 34%;
padding-top: 34%;
border-radius: 50%;
background: currentColor;
}
.gf-ov .sil::after {
/* shoulders / body */
content: "";
width: 100%;
height: 56%;
border-radius: 48% 48% 14% 14% / 60% 60% 14% 14%;
background: currentColor;
}
.gf-ov .cap {
align-self: stretch;
text-align: center;
font-weight: 800;
font-size: 26px;
letter-spacing: 0.02em;
padding: 10px 6px;
text-transform: uppercase;
}
</style>
</head>
<body>
<!-- duration 5.75s = the full group_5 span (3 operations back to back, ends frozen) -->
<div
data-hf-id="hf-g5rt"
id="root"
data-composition-id="group-five-recreate"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="5.75"
data-root="true"
>
<div data-hf-id="hf-g5wall" id="wall"></div>
</div>
<script>
(function () {
// ════════════════════════════════════════════════════════════════════
// POSTER-TILE MOSAIC · ONE base template, THREE beat-synced operations
// ────────────────────────────────────────────────────────────────────
// BASE : a packed MOSAIC of DIFFERENT-SIZED colored tiles (asset-free:
// color + label). The tiles are NOT a uniform grid — they vary in
// size (tall / wide / small) and tessellate to fill the frame with
// no overlap (thin seams only), exactly like the source's 错落 wall.
// "adding a box" = a new image; "changing a box color" = a new
// asset/recolor. Everything below just schedules these tiles on
// the beat. Reverse-engineered from group_5 of the golden sample.
//
// OP A staggerInOut : tiles enter staggered (top-right→bottom-left waterfall)
// → hold on a downbeat → exit staggered. each in/out
// lands on one onset. mixed sizes packing to full frame.
// OP B holdRecolor : all tiles snap on at once, positions LOCKED, the
// whole palette recolors as a unit on each beat.
// OP C snakeFill+overlay : tiles reveal in a snake path (band by band,
// L→R then R→L) → a full-grid recolor on the hard-stop
// → extra "cutout" tiles pop on top, one per beat.
//
// ARCHITECTURE: the operators (§6) are pure, parameterized, order-independent
// functions. A data PROGRAM (§3) lists which operators run, in what order,
// on which beats — and §7 just dispatches it. So you compose by editing DATA
// (the PROGRAM array, or the `program` render variable / program.json), never
// the operator code. Combine freely, repeat, or run one solo.
// ════════════════════════════════════════════════════════════════════
// ── 1. Content variables (what a user changes; everything else is timing)
var defaults = {
bgColor: "#e9e9e9",
tiles: 12,
bands: 3,
gap: 10,
showText: true,
labels:
"DARE TO BE BOLD,NEW DROP,WALK THE TALK,STAND OUT,PUCKER UP," +
"SUPER SONIC,IN TECHNICOLOUR,BOLD PRINTS,NEW SCENTS,TIME TO MOVE," +
"CRAZY FUNKY COOL,BEAUTY INSIDE",
program: "", // empty = built-in group_5 score (DEFAULT_PROGRAM, §3)
};
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
var N = Math.max(2, parseInt(vars.tiles, 10) || 12);
var BANDS = Math.max(1, parseInt(vars.bands, 10) || 3);
var GAP = Math.max(0, parseFloat(vars.gap) || 0);
var SHOW_TEXT = vars.showText !== false && vars.showText !== "false";
var LABELS = String(vars.labels)
.split(",")
.map(function (s) {
return s.trim();
});
// ── 2. PALETTE LIBRARY — each THEME is a "look" the whole wall can wear.
// A recolor (OP B) / a swap (OP C) just points the wall at another theme.
// Tile i takes theme[i % theme.length], so one theme dresses the grid in a
// coordinated multi-color scheme. Add themes freely — more beats, more looks.
var THEMES = [
["#c8f53a", "#101010", "#ffffff", "#1d1d1d"], // 0 lime / ink (default + OP A)
["#b6f23c", "#0c2a14", "#ffffff", "#0c2a14"], // 1 green
["#2f5cff", "#dce8ff", "#0a1748", "#9fc0ff"], // 2 blue
["#ff5ab3", "#23d6a3", "#1a0a14", "#ffd1ec"], // 3 pink / mint
["#ff3b30", "#ffd60a", "#0a84ff", "#30d158"], // 4 saturated DROP look
];
var FONTS = [
// typographic variety per tile (the "字体" inside each box)
'"Helvetica Neue", Arial, sans-serif',
'Georgia, "Times New Roman", serif',
'"Arial Narrow", "Helvetica Neue", sans-serif',
'"Courier New", Courier, monospace',
];
function contrast(hex) {
// readable text color for a given fill
var h = hex.replace("#", "");
var r = parseInt(h.substr(0, 2), 16),
g = parseInt(h.substr(2, 2), 16),
b = parseInt(h.substr(4, 2), 16);
return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#0b0b0b" : "#ffffff";
}
function themeColor(t, slot) {
var pal = THEMES[((t % THEMES.length) + THEMES.length) % THEMES.length];
return pal[slot % pal.length];
}
// ── 3. THE PROGRAM (data) — the composable surface ──────────────────────
// An ordered list of operator steps {op, ...beats/opts}. THIS is what you
// recompose: reorder, drop, repeat, or swap a step's beats — no operator code
// changes. An agent can override the whole list at render time via the
// `program` variable (a JSON string), so it composes by emitting DATA, not by
// editing JS. Beats are absolute audio seconds from <group>.audiomap.json.
// Mirrors program.json. group_5: 129.2 BPM · downbeats 1.324/3.181/5.039 ·
// hard_stop 4.0s. (See program.json for each op's fields.)
var DEFAULT_PROGRAM = [
{
op: "staggerInOut", // build → hold (downbeat) → exit
theme: 0,
enter: [0.23, 0.37, 0.46, 0.6, 0.7, 0.84, 0.91, 1.07, 1.14, 1.28],
hold: 1.324,
exit: [1.37, 1.51, 1.62, 1.74, 1.83, 1.97, 2.07],
},
{
op: "holdRecolor", // snap on, lock, recolor per snare
on: 2.35,
onTheme: 1,
recolor: [2.42, 2.53, 2.81, 2.93],
themes: [2, 3, 1, 2],
off: 2.98,
},
{
op: "snakeFillOverlay", // snake fill → swap → overlay pops
baseTheme: 0,
fill: [3.04, 3.181, 3.44, 3.65, 3.79, 3.9], // 3.181 = downbeat f76 (accel)
swap: 4.0,
swapTheme: 4, // hard_stop "一键换装"
overlay: [4.11, 4.34, 4.48, 4.81, 4.97, 5.25, 5.48],
}, // 7 onsets, 1 cutout each
];
var DUR = 5.75; // = root data-duration; re-timing a new track edits both + the beats
// Resolve the active program: a non-empty `program` variable (JSON) overrides.
var PROGRAM = DEFAULT_PROGRAM;
if (vars.program && String(vars.program).trim()) {
try {
var parsed = typeof vars.program === "string" ? JSON.parse(vars.program) : vars.program;
if (parsed && parsed.length) PROGRAM = parsed;
} catch (e) {
PROGRAM = DEFAULT_PROGRAM;
} // malformed → fall back, render stays valid
}
// Pre-build enough overlay "cutout" elements for the hungriest step.
var maxOverlays = 0;
PROGRAM.forEach(function (s) {
if (s.overlay && s.overlay.length > maxOverlays) maxOverlays = s.overlay.length;
});
// ── 4. Build the MOSAIC (the base template — DIFFERENT-sized packed tiles) ─
var root = document.getElementById("root");
document.documentElement.style.setProperty("--bg", vars.bgColor);
var wall = document.getElementById("wall");
var MARGIN = 56;
var innerW = 1920 - MARGIN * 2,
innerH = 1080 - MARGIN * 2;
// Pack the frame into N DIFFERENT-sized rects that tessellate with NO overlap:
// recursively split the largest region along its longer side. Deterministic —
// the split ratio is a fixed function of the step index, so it renders the same
// every time (no Math.random). This is the "错落 but gap-filling" look of OP A.
function packMosaic(n, W, H) {
var rects = [{ x: 0, y: 0, w: W, h: H }];
while (rects.length < n) {
var bi = 0;
for (var i = 1; i < rects.length; i++) {
if (rects[i].w * rects[i].h > rects[bi].w * rects[bi].h) bi = i;
}
var r = rects.splice(bi, 1)[0];
var ratio = 0.4 + ((rects.length * 37) % 21) / 100; // 0.40..0.60, deterministic
if (r.w >= r.h) {
var w1 = Math.round(r.w * ratio);
rects.push({ x: r.x, y: r.y, w: w1, h: r.h });
rects.push({ x: r.x + w1, y: r.y, w: r.w - w1, h: r.h });
} else {
var h1 = Math.round(r.h * ratio);
rects.push({ x: r.x, y: r.y, w: r.w, h: h1 });
rects.push({ x: r.x, y: r.y + h1, w: r.w, h: r.h - h1 });
}
}
return rects;
}
var rects = packMosaic(N, innerW, innerH);
var tiles = [];
for (var idx = 0; idx < N; idx++) {
var R = rects[idx];
var el = document.createElement("div");
el.className = "gf-tile";
el.style.left = MARGIN + R.x + GAP / 2 + "px";
el.style.top = MARGIN + R.y + GAP / 2 + "px";
el.style.width = Math.max(1, R.w - GAP) + "px";
el.style.height = Math.max(1, R.h - GAP) + "px";
el.style.backgroundColor = themeColor(0, idx);
var lab = document.createElement("span");
lab.className = "gf-label";
lab.textContent = SHOW_TEXT ? LABELS[idx % LABELS.length] || "" : "";
lab.style.color = contrast(themeColor(0, idx));
lab.style.fontFamily = FONTS[idx % FONTS.length];
lab.style.fontSize = Math.round(Math.min(R.w, R.h) * 0.16) + "px";
el.appendChild(lab);
wall.appendChild(el);
tiles.push({
el: el,
label: lab,
idx: idx,
cx: R.x + R.w / 2,
cy: R.y + R.h / 2,
rect: R,
});
}
// enter/exit order (OP A): top-right → bottom-left waterfall (matches source)
var enterOrder = tiles
.map(function (t) {
return t.idx;
})
.sort(function (a, b) {
return tiles[b].cx - tiles[b].cy - (tiles[a].cx - tiles[a].cy);
});
// snake order (OP C): band by band top→bottom, alternating L→R / R→L
var bandH = innerH / BANDS;
var snakeOrder = tiles
.map(function (t) {
return t.idx;
})
.sort(function (a, b) {
var ba = Math.min(BANDS - 1, Math.floor(tiles[a].cy / bandH));
var bb = Math.min(BANDS - 1, Math.floor(tiles[b].cy / bandH));
if (ba !== bb) return ba - bb;
return (ba % 2 === 0 ? 1 : -1) * (tiles[a].cx - tiles[b].cx);
});
// overlay "cutouts" (OP C) — drawn silhouettes at scattered spots
var OV_POS = [
// % of the inner stage
{ l: 60, t: 50 },
{ l: 14, t: 56 },
{ l: 78, t: 12 },
{ l: 38, t: 26 },
{ l: 50, t: 58 },
{ l: 70, t: 66 },
{ l: 26, t: 40 },
];
var overlays = [];
for (var k = 0; k < maxOverlays; k++) {
var pos = OV_POS[k % OV_POS.length];
var ov = document.createElement("div");
ov.className = "gf-ov";
var ow = innerW * 0.2,
oh = innerH * 0.62;
ov.style.width = ow + "px";
ov.style.height = oh + "px";
ov.style.left = MARGIN + innerW * (pos.l / 100) - ow / 2 + "px";
ov.style.top = MARGIN + innerH * (pos.t / 100) - oh / 2 + "px";
var fill = themeColor(4, k);
ov.style.backgroundColor = fill;
var sil = document.createElement("div");
sil.className = "sil";
sil.style.color = contrast(fill);
var cap = document.createElement("div");
cap.className = "cap";
cap.textContent = SHOW_TEXT ? "MODEL " + (k + 1) : "";
cap.style.color = contrast(fill);
ov.appendChild(sil);
ov.appendChild(cap);
wall.appendChild(ov);
overlays.push(ov);
}
// ── 5. Timeline + small helpers ─────────────────────────────────────────
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// overwrite:"auto" lets a later move on a tile cleanly supersede an earlier
// one (and keeps the static linter from cross-flagging dynamically-targeted tweens).
// immediateRender:false — a from/fromTo MUST NOT bake its from-state at creation,
// or an operator scheduled LATER (e.g. staggerInOut's jitter) would park the tiles
// off-grid during an EARLIER operator (e.g. holdRecolor). Keeps operators order-independent.
var POP = {
duration: 0.12,
ease: "back.out(1.7)",
overwrite: "auto",
immediateRender: false,
};
function setTheme(t, idx, themeIdx) {
// 0ms recolor on the beat (a hit, not a fade)
var col = themeColor(themeIdx, idx);
tl.set(tiles[idx].el, { backgroundColor: col }, t);
tl.set(tiles[idx].label, { color: contrast(col) }, t);
}
function jitter(i) {
// deterministic scatter offset (no Math.random — must render identically)
return { x: (((i * 37) % 7) - 3) * 16, y: (((i * 53) % 7) - 3) * 16 };
}
function beatAt(i, n, beats) {
// distribute n items across a beat list, monotonic
if (!beats || !beats.length) return 0;
return beats[Math.min(beats.length - 1, Math.floor((i * beats.length) / n))];
}
function orderOf(name) {
// resolve a reveal order (string preset or explicit array)
if (Array.isArray(name)) return name;
if (name === "snake") return snakeOrder;
if (name === "index")
return tiles.map(function (t) {
return t.idx;
});
return enterOrder; // default = top-right→bottom-left waterfall
}
gsap.set(
tiles.map(function (t) {
return t.el;
}),
{ autoAlpha: 0 },
);
if (overlays.length) gsap.set(overlays, { autoAlpha: 0 });
// ── 6. THE THREE OPERATORS — pure, parameterized, order-independent ──────
// Each takes ONE program step P and schedules onto `tl`. Each "shows" tiles
// via fromTo(from hidden) and sets its own theme, so operators compose in ANY
// order, repeat safely, and can run solo. Add a new operator = add a function
// here + a key in OPS; the PROGRAM (§3 / the `program` var) can then call it.
// A — staggered enter (waterfall) → hold → staggered exit. one move per beat.
// P: { enter:[s], exit?:[s], theme?:i, order?:"waterfall"|"snake"|"index"|[idx] }
function staggerInOut(P) {
var order = orderOf(P.order),
theme = P.theme || 0;
order.forEach(function (i, k) {
setTheme(beatAt(k, N, P.enter), i, theme);
var d = jitter(i);
tl.fromTo(
tiles[i].el,
{ autoAlpha: 0, scale: 0.78, x: d.x, y: d.y },
Object.assign({ autoAlpha: 1, scale: 1, x: 0, y: 0 }, POP),
beatAt(k, N, P.enter),
);
});
// hold = the wall simply stays put until the exit begins (no extra tween)
if (!P.exit || !P.exit.length) return; // no exit → leave the wall standing
order.forEach(function (i, k) {
tl.to(
tiles[i].el,
{
autoAlpha: 0,
scale: 0.9,
y: 46,
duration: 0.12,
ease: "power2.in",
overwrite: "auto",
},
beatAt(k, N, P.exit),
);
});
}
// B — snap whole wall on, LOCK positions, recolor as a unit on each beat.
// P: { on:s, recolor?:[s], themes?:[i], onTheme?:i, off?:s }
function holdRecolor(P) {
tiles.forEach(function (_, i) {
setTheme(P.on, i, P.onTheme || 0);
});
// x:0,y:0 resets any transform a prior operator left behind → clean LOCKED grid
tl.fromTo(
tiles.map(function (t) {
return t.el;
}),
{ autoAlpha: 0, scale: 0.96, x: 0, y: 0 },
{
autoAlpha: 1,
scale: 1,
x: 0,
y: 0,
duration: 0.1,
ease: "power2.out",
overwrite: "auto",
immediateRender: false,
},
P.on,
);
(P.recolor || []).forEach(function (t, ri) {
var th = (P.themes || [0])[ri % (P.themes || [0]).length];
tiles.forEach(function (_, i) {
setTheme(t, i, th);
});
});
if (P.off != null) {
tl.to(
tiles.map(function (t) {
return t.el;
}),
{ autoAlpha: 0, scale: 0.97, duration: 0.1, ease: "power2.in", overwrite: "auto" },
P.off,
);
}
}
// C — snake reveal → optional full-grid swap → overlay cutouts pop on top.
// P: { fill:[s], baseTheme?:i, swap?:s, swapTheme?:i, overlay?:[s], order?:… }
function snakeFillOverlay(P) {
var order = orderOf(P.order || "snake"),
base = P.baseTheme || 0;
var f0 = P.fill && P.fill.length ? P.fill[0] : 0;
order.forEach(function (i) {
setTheme(Math.max(0, f0 - 0.01), i, base);
});
order.forEach(function (i, k) {
tl.fromTo(
tiles[i].el,
{ autoAlpha: 0, scale: 0.85, x: 0, y: 0 }, // x/y reset → fills on the clean grid
{
autoAlpha: 1,
scale: 1,
x: 0,
y: 0,
duration: 0.1,
ease: "back.out(1.6)",
overwrite: "auto",
immediateRender: false,
},
beatAt(k, N, P.fill),
);
});
if (P.swap != null) {
// hard-stop "一键换装" — recolor the whole grid at once
tiles.forEach(function (_, i) {
setTheme(P.swap, i, P.swapTheme != null ? P.swapTheme : 4);
});
}
(P.overlay || []).forEach(function (t, i) {
// one cutout per beat; final holds
if (!overlays[i]) return;
tl.fromTo(
overlays[i],
{ autoAlpha: 0, scale: 0.6, y: 30 },
{
autoAlpha: 1,
scale: 1,
y: 0,
duration: 0.16,
ease: "back.out(2)",
overwrite: "auto",
immediateRender: false,
},
t,
);
});
}
var OPS = {
staggerInOut: staggerInOut,
holdRecolor: holdRecolor,
snakeFillOverlay: snakeFillOverlay,
};
// ── 7. RUN THE PROGRAM — dispatch each step to its operator, in order ────
PROGRAM.forEach(function (step) {
var fn = step && OPS[step.op];
if (fn) fn(step);
});
tl.to({}, { duration: 0.001 }, DUR); // pin the timeline length to the full span
tl.seek(0);
window.__timelines["group-five-recreate"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,25 @@
[
{
"op": "staggerInOut",
"theme": 0,
"enter": [0.23, 0.37, 0.46, 0.6, 0.7, 0.84, 0.91, 1.07, 1.14, 1.28],
"hold": 1.324,
"exit": [1.37, 1.51, 1.62, 1.74, 1.83, 1.97, 2.07]
},
{
"op": "holdRecolor",
"on": 2.35,
"onTheme": 1,
"recolor": [2.42, 2.53, 2.81, 2.93],
"themes": [2, 3, 1, 2],
"off": 2.98
},
{
"op": "snakeFillOverlay",
"baseTheme": 0,
"fill": [3.04, 3.181, 3.44, 3.65, 3.79, 3.9],
"swap": 4.0,
"swapTheme": 4,
"overlay": [4.11, 4.34, 4.48, 4.81, 4.97, 5.25, 5.48]
}
]
@@ -0,0 +1,267 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"bgColor", "type":"color", "label":"Background", "default":"#000000"},
{"id":"textColor", "type":"color", "label":"Text color", "default":"#ffffff"},
{"id":"accentColor","type":"color", "label":"Period accent", "default":"#ff6a32"},
{"id":"flipWords", "type":"string", "label":"Flipbook words (comma-separated)", "default":"Design,Strategy,Branding,Marketing,Production,Campaigns,Experiential,Communication,Event Management"},
{"id":"resolveText","type":"string", "label":"Lock phrase (empty = pure flicker, no lock)", "default":"everything you need"},
{"id":"periodChar", "type":"string", "label":"Trailing accent (empty = none)", "default":"."}
]'
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Roll-Driven Flipbook (Word Cycle → Optional Lock) — gruop_3 reference impl</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: var(--bg, #000000);
overflow: hidden;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg, #000000);
}
.g3-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
/* grid stack — every layer occupies the same centred cell, so scaling a
layer transforms around its own centre (no translate(-50%) to clobber). */
.g3-stack {
display: grid;
place-items: center;
}
.g3-stack > * {
grid-area: 1 / 1;
}
.g3-layer {
white-space: nowrap;
color: var(--text, #ffffff);
text-shadow:
0 0 18px rgba(255, 255, 255, 0.45),
0 0 46px rgba(255, 255, 255, 0.22);
}
.g3-flip {
font-size: 120px;
font-weight: 800;
letter-spacing: -0.01em;
}
.g3-resolve {
font-size: 116px;
font-weight: 800;
font-style: italic;
letter-spacing: -0.01em;
}
.g3-period {
color: var(--accent, #ff6a32);
font-weight: 800;
font-style: italic;
}
</style>
</head>
<body>
<!-- data-duration = the scene span. Set this to YOUR scene length; the script
reads it back so duration is single-sourced (no hard-coded value in JS).
Rebased to scene-relative time — content starts at ~0, no dead pre-roll. -->
<div
data-hf-id="hf-g3rt"
id="root"
data-composition-id="group-three-recreate"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="3.8"
data-root="true"
>
<div data-hf-id="hf-g3ov" class="g3-overlay">
<div data-hf-id="hf-g3sk" class="g3-stack" id="stack"></div>
</div>
</div>
<script>
(function () {
// ════════════════════════════════════════════════════════════════
// ROLL-DRIVEN FLIPBOOK (Word Cycle → Optional Lock) · dense_multiply
// A hi-hat ROLL drives a centred word that flips every 16th-note (flipbook),
// cycling a word list to FILL the roll. Optionally, when a SURGE/phrase
// follows, the flicker resolves (scramble-decode) into a locked phrase.
// Background is pure black. Every move is a 0ms autoAlpha set on a pre-built
// node (no GSAP textContent-sets, no onUpdate — both fail under seek render).
// ════════════════════════════════════════════════════════════════
var root = document.getElementById("root");
// ── 1. BEAT MAP — the ONLY block you edit per track ─────────────────
// SCENE-RELATIVE time (starts at ~0 — no dead pre-roll). When reusing, map
// your audiomap roll [rollStart,rollEnd] onto [flipStart, flipStart+rollLen],
// i.e. subtract the roll's start offset. bpm stays your track's tempo.
var BEAT = {
bpm: 129.2, // tempo.bpm → 16th-note flip step = 60/bpm/4
flipStart: 0.1, // tiny entrance lead-in — NO dead air before the flicker
flipEnd: 2.19, // flicker length ≈ the source roll (~2.1s); auto-fills with words
resolveStart: 2.33, // lock phrase enters after a short clear (only if a phrase is set)
resolveLock: 3.33, // locks ~1s later (place just before a downbeat in your track)
};
// Scene length is single-sourced from data-duration (edit it in the HTML above).
var DUR = parseFloat(root.getAttribute("data-duration")) || 7.34;
// ── 2. Content variables ────────────────────────────────────────────
var defaults = {
bgColor: "#000000",
textColor: "#ffffff",
accentColor: "#ff6a32",
flipWords:
"Design,Strategy,Branding,Marketing,Production,Campaigns,Experiential,Communication,Event Management",
resolveText: "everything you need",
periodChar: ".",
};
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
document.documentElement.style.setProperty("--bg", vars.bgColor);
document.documentElement.style.setProperty("--text", vars.textColor);
document.documentElement.style.setProperty("--accent", vars.accentColor);
var stack = root.querySelector("#stack");
var WORDS = String(vars.flipWords)
.split(",")
.map(function (w) {
return w.trim();
})
.filter(function (w) {
return w !== "";
});
var RESOLVE = String(vars.resolveText || "").trim(); // empty → pure flicker, no lock
var HAS_RESOLVE = RESOLVE !== "";
var PERIOD =
vars.periodChar && String(vars.periodChar).trim() !== "" ? vars.periodChar : "";
// ── 3. Derived timing (auto-fills the roll — adapts to any length / bpm) ─
var FLIP_STEP = 60 / BEAT.bpm / 4; // one word per 16th-note
var N_SWAPS = Math.max(1, Math.round((BEAT.flipEnd - BEAT.flipStart) / FLIP_STEP));
// ── 4. Build nodes ──────────────────────────────────────────────────
var flipNodes = {};
WORDS.forEach(function (w) {
if (!flipNodes[w]) {
var s = document.createElement("span");
s.className = "g3-layer g3-flip";
s.textContent = w;
stack.appendChild(s);
flipNodes[w] = s;
gsap.set(s, { autoAlpha: 0 });
}
});
var resolveNodes = [];
if (HAS_RESOLVE) {
var GLYPHS = "!<>-_/[]{}=+*^?#$%&01ABXYZ";
var decodeAt = function (str, p) {
var n = str.length,
out = "",
step = Math.floor(p * 48);
for (var k = 0; k < n; k++) {
var c = str.charAt(k);
if (c === " ") {
out += " ";
continue;
}
var lock = 0.4 + 0.6 * ((k + 1) / n); // lock left→right, in the back 60%
out += p >= lock ? c : GLYPHS.charAt((k * 7 + step * 3) % GLYPHS.length);
}
return out;
};
var DECODE_STEP = FLIP_STEP / 2; // scramble twice as fast as the flip
var N_STEPS = Math.max(
1,
Math.round((BEAT.resolveLock - BEAT.resolveStart) / DECODE_STEP),
);
for (var s2 = 0; s2 <= N_STEPS; s2++) {
var node = document.createElement("span");
node.className = "g3-layer g3-resolve";
if (s2 === N_STEPS) {
node.appendChild(document.createTextNode(RESOLVE));
if (PERIOD) {
var pd = document.createElement("span");
pd.className = "g3-period";
pd.textContent = PERIOD;
node.appendChild(pd);
}
} else {
node.textContent = decodeAt(RESOLVE, s2 / N_STEPS);
}
stack.appendChild(node);
gsap.set(node, { autoAlpha: 0 });
resolveNodes.push({ node: node, t: BEAT.resolveStart + s2 * DECODE_STEP });
}
}
// ── 5. Timeline — one paused GSAP timeline, absolute audio seconds ──
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// PHASE A — flipbook word-cycle on the 16th grid, FILLING the roll window
var prev = null;
for (var i = 0; i < N_SWAPS; i++) {
var t = BEAT.flipStart + i * FLIP_STEP;
var node = flipNodes[WORDS[i % WORDS.length]];
if (prev && prev !== node) tl.set(prev, { autoAlpha: 0 }, t);
tl.set(node, { autoAlpha: 1 }, t);
prev = node;
}
// glitchy reveal of the first word as the roll kicks in (scale is safe here)
tl.fromTo(
flipNodes[WORDS[0]],
{ scale: 0.8, filter: "blur(10px)" },
{ scale: 1, filter: "blur(0px)", duration: 0.12, ease: "power2.out" },
BEAT.flipStart,
);
// PHASE B — resolve (OPTIONAL). With a lock phrase: clear, then scramble-decode
// and lock, holding to the end. Without one: the last word just holds (locks).
if (HAS_RESOLVE) {
if (prev) tl.set(prev, { autoAlpha: 0 }, BEAT.flipEnd); // clear the flicker
var prevR = null;
for (var r = 0; r < resolveNodes.length; r++) {
if (prevR) tl.set(prevR, { autoAlpha: 0 }, resolveNodes[r].t);
tl.set(resolveNodes[r].node, { autoAlpha: 1 }, resolveNodes[r].t);
prevR = resolveNodes[r].node;
}
}
// (if no resolve, `prev` — the last flip word — stays visible to the end)
// Extend the timeline to the full duration with a no-op so the held final
// state renders on every frame to DUR (a set AT the timeline's last instant
// gets dropped by the seek renderer).
tl.set(stack, { opacity: 1 }, DUR);
tl.seek(0);
window.__timelines["group-three-recreate"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,594 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"bgColor", "type":"color", "label":"Stage background (theme 0)", "default":"#0a84ff"},
{"id":"anchors", "type":"string", "label":"Left anchor column (comma list = rows)","default":"studio,contre,courant"},
{"id":"theme", "type":"number", "label":"Base theme index", "default":0},
{"id":"showText","type":"boolean", "label":"Show text (false = blocks only)", "default":true},
{"id":"program", "type":"string", "label":"Program JSON (empty = built-in group_6 score)", "default":""}
]'
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Split-Anchor Word-Slot · beat-synced kinetic typography — group_6 reference impl</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: #0a84ff;
overflow: hidden;
font-family: "Helvetica Neue", "Arial Black", Arial, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0a84ff;
}
/* #frame = the CENTERING wrapper. CSS positions it slightly ABOVE center (the source
sits high-center); GSAP never touches #frame, so the CSS transform here is safe. */
#frame {
position: absolute;
left: 50%;
top: 43%;
transform: translate(-50%, -50%);
}
/* #lockup = the thing GSAP jitters/tilts (shake) — NO CSS transform, so GSAP owns
x/y/rotation cleanly. Holds the base tilt the whole brutalist lockup leans at. */
#lockup {
position: relative;
display: flex;
align-items: center;
gap: 0;
will-change: transform;
}
/* anchors LEFT-aligned (ragged right) so the slot box clears the shorter words and
never hides the held text. */
#anchors {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.anchor {
font-weight: 900;
line-height: 0.96;
letter-spacing: -0.045em;
text-transform: lowercase;
white-space: nowrap;
will-change: transform, opacity;
}
/* the inner .t span carries the faux-EXTENDED width (scaleX) for the ANCHORS — they
have no background, so nothing overflows. GSAP animates the outer element; the
span's scaleX is never touched by GSAP, so the two never fight (asset-free
Druk-Wide / Extended-Black look from a system font). */
.t {
display: inline-block;
transform: scaleX(1.12);
transform-origin: left center;
}
/* slot TAPES carry their extend via the LINE's GSAP scaleX instead, so the tape
BACKGROUND stretches WITH the text and the word never overflows the strip. */
.sline .t {
transform: scaleX(1);
}
/* #slotbox = a TRANSPARENT container sitting just RIGHT of the anchors (small positive
margin → it never covers the held words). Holds the 3 stacked cards + the word tapes. */
/* small NEGATIVE margin = the source's tight collage TUCK: the tape kisses / lightly
presses over the tail of the longest anchor without hiding the glyph (the tape's own
text starts after its left padding, so words never collide). Tune this for press amount. */
#slotbox {
position: relative;
margin-left: -0.12em;
will-change: transform, opacity;
backface-visibility: hidden;
transform-origin: 50% 42%;
}
/* THREE stacked, offset, torn CARDS = the brutalist collage. Transparent during word
phases; `bg` turns them black for the empty box + the expand zoom (they scale as one
with #slotbox, staying staggered). Siblings — NOT parent-clipped — so the offsets show. */
.card {
position: absolute;
inset: 0;
background: transparent;
will-change: transform;
}
#card1 {
z-index: -1;
clip-path: polygon(0% 4%, 97% 0%, 100% 92%, 3% 100%);
}
#card2 {
z-index: -2;
transform: translate(22px, 17px) rotate(-2.8deg);
clip-path: polygon(0% 2%, 100% 0%, 98% 100%, 2% 96%);
}
#card3 {
z-index: -3;
transform: translate(-15px, 10px) rotate(2.1deg);
clip-path: polygon(0% 6%, 96% 3%, 100% 95%, 4% 100%);
}
.lines {
position: relative;
z-index: 1;
display: grid;
}
.sgroup {
grid-area: 1 / 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
/* each .sline is its OWN torn TAPE — sized to its word, its GSAP scaleX stretches
bg+text TOGETHER (no overflow), its own micro-rotation + stair offset + shadow. */
.sline {
font-weight: 900;
line-height: 0.96;
letter-spacing: -0.045em;
white-space: nowrap;
text-align: left;
background: #f4f4f4;
padding: 0.03em 0.22em 0.08em 0.16em;
clip-path: polygon(0% 6%, 98% 0%, 100% 90%, 3% 100%);
box-shadow: 5px 6px 0 rgba(0, 0, 0, 0.16);
transform-origin: 0% 50%; /* GSAP scaleX stretches RIGHTward only — never back over the anchors */
will-change: transform, opacity;
}
</style>
</head>
<body>
<!-- duration 5.41s = the full group_6 span (lockup → 2 slot cycles → bg flip + shake → expand wipe) -->
<div
data-hf-id="hf-g6rt"
id="root"
data-composition-id="group-six-recreate"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="5.41"
data-root="true"
>
<div data-hf-id="hf-g6frame" id="frame">
<div data-hf-id="hf-g6lock" id="lockup">
<div data-hf-id="hf-g6anch" id="anchors"></div>
<div data-hf-id="hf-g6box" id="slotbox">
<div data-hf-id="hf-g6c1" id="card1" class="card"></div>
<div data-hf-id="hf-g6c2" id="card2" class="card"></div>
<div data-hf-id="hf-g6c3" id="card3" class="card"></div>
<div class="lines" id="lines"></div>
</div>
</div>
</div>
</div>
<script>
(function () {
// ════════════════════════════════════════════════════════════════════
// SPLIT-ANCHOR WORD-SLOT · ONE base lockup, FIVE beat-synced operators
// ────────────────────────────────────────────────────────────────────
// BASE : a kinetic-typography LOCKUP — a HELD left "anchor" column of R rows
// (fixed words) beside a torn-paper word-SLOT box on the right. The
// whole lockup LEANS at a base tilt (brutalist/acid attitude). R is
// data (the `anchors` comma-list). Asset-free: text + colored boxes.
// Reversed from group_6 + tuned against a Gemini side-by-side pass.
//
// OP lockup : pop the R anchor words in (punchy scale, faux motion-blur squash)
// + reveal the empty slot box. The held stage. (source f3–f12)
// OP slot : one word-GROUP cycle — lines WHIP in from the right (expo.out +
// scaleX stretch = fake motion blur), hold, then arc out down-left.
// own color per line. repeatable. (source f24–f44, f49–f66)
// OP bg : HARD-CUT flip the whole scene to another theme on a beat (bg +
// anchor ink + box fill + slot ink invert, 0ms). 0/1/2× = changes
// none / once / twice. (source f83 blue→green)
// OP shake : global per-beat JOLT of the lockup (x/y + rotation around the base
// tilt) with a back-ease recoil — one punch per beat. (source f52–f100)
// OP expand : EXIT wipe — fade text, then the slot box ZOOMS (expo.in, blur) from
// its own centre until it swallows the frame. (source f106–f117)
//
// ARCHITECTURE: every operator writes GSAP tweens at ABSOLUTE audio seconds onto
// ONE paused timeline → order-independent, may overlap (shake runs under slot/bg).
// A data PROGRAM lists which ops run on which beats; §7 dispatches it. Compose by
// editing DATA (the PROGRAM / `program` var / program.json), never operator code.
// ════════════════════════════════════════════════════════════════════
// ── 1. Content variables ──
var defaults = {
bgColor: "#0a84ff",
anchors: "studio,contre,courant",
theme: 0,
showText: true,
program: "",
};
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
var ANCHORS = String(vars.anchors)
.split(",")
.map(function (s) {
return s.trim();
})
.filter(function (s) {
return s.length;
});
if (!ANCHORS.length) ANCHORS = ["studio", "contre", "courant"];
var BASE_THEME = Math.max(0, parseInt(vars.theme, 10) || 0);
var SHOW_TEXT = vars.showText !== false && vars.showText !== "false";
// base attitude: the whole lockup leans; the box leans extra the other way.
var BASE_TILT = -3.5; // deg, the lockup's resting lean
var BOX_TILT = 2.6; // deg, the slot box's extra lean vs the anchors
// ── 2. THEME LIBRARY — brighter electric/fluoro to match the source ──
var THEMES = [
{ bg: "#0a84ff", ink: "#ffffff", box: "#f4f4f4", boxInk: "#0a84ff" }, // 0 electric blue
{ bg: "#5cf03a", ink: "#0a0a0a", box: "#0a0a0a", boxInk: "#5cf03a" }, // 1 lime/acid green (invert)
{ bg: "#0a0a0a", ink: "#ffffff", box: "#ffffff", boxInk: "#0a0a0a" }, // 2 ink
{ bg: "#ffe400", ink: "#0a0a0a", box: "#0a0a0a", boxInk: "#ffe400" }, // 3 amber
];
var ACCENTS = ["#0a84ff", "#39e024", "#ff3bd0", "#ffe400", "#ffffff", "#0a0a0a"];
function theme(i) {
return THEMES[((i % THEMES.length) + THEMES.length) % THEMES.length];
}
function accent(i) {
return ACCENTS[((i % ACCENTS.length) + ACCENTS.length) % ACCENTS.length];
}
// ── 3. THE PROGRAM (data) — see program.json ──
// group_6: ~92 BPM · downbeats 2.322(f56)/4.923(f118) · hihat fill 2.16–4.18s.
var DEFAULT_PROGRAM = [
{ op: "lockup", enter: [0.125, 0.333, 0.5], box: 0.292, theme: 0 },
{
op: "slot",
words: ["I bring", "ideas", "to life"],
enter: [1.0, 1.167, 1.292],
exit: [1.667, 1.75, 1.833],
},
{
op: "slot",
words: ["you", "wanna", "see?"],
enter: [2.042, 2.208, 2.375],
exit: [2.75, 2.75, 2.75],
colors: [0, 1, 1],
},
{
op: "shake",
amp: 26,
rot: 2.4,
settle: 0.16,
beats: [
2.16, 2.3, 2.44, 2.62, 2.76, 2.88, 3.06, 3.27, 3.41, 3.53, 3.71, 3.85, 4.09, 4.18,
],
},
{ op: "bg", at: 3.458, theme: 1 },
{ op: "expand", fade: 4.18, at: 4.4, to: 4.82 },
];
var DUR = 5.41;
var PROGRAM = DEFAULT_PROGRAM;
if (vars.program && String(vars.program).trim()) {
try {
var parsed = typeof vars.program === "string" ? JSON.parse(vars.program) : vars.program;
if (parsed && parsed.length) PROGRAM = parsed;
} catch (e) {
PROGRAM = DEFAULT_PROGRAM;
}
}
// ── 4. Build the LOCKUP ──────────────────────────────────────────────────
var R = ANCHORS.length;
var slotSteps = PROGRAM.filter(function (s) {
return s.op === "slot";
});
var maxSlot = slotSteps.reduce(function (m, s) {
return Math.max(m, (s.words || []).length);
}, 0);
var maxRows = Math.max(R, maxSlot, 1);
var fontPx = maxRows <= 3 ? 116 : maxRows === 4 ? 92 : 74;
var rowH = Math.round(fontPx * 0.99);
var root = document.getElementById("root");
var lockupEl = document.getElementById("lockup");
var anchorsBox = document.getElementById("anchors");
var slotbox = document.getElementById("slotbox");
var card1 = document.getElementById("card1");
var card2 = document.getElementById("card2");
var card3 = document.getElementById("card3");
var cards = [card1, card2, card3];
var linesBox = document.getElementById("lines");
var TH0 = theme(BASE_THEME);
document.documentElement.style.background = vars.bgColor || TH0.bg;
document.body.style.background = vars.bgColor || TH0.bg;
root.style.background = vars.bgColor || TH0.bg;
function mkWord(text, px, h) {
// word node with an inner faux-extended span
var d = document.createElement("div");
d.style.fontSize = px + "px";
d.style.height = h + "px";
var sp = document.createElement("span");
sp.className = "t";
sp.textContent = SHOW_TEXT ? text : "";
d.appendChild(sp);
return { row: d, span: sp };
}
// left anchor column (held words). The faux-extend scaleX renders the glyphs WIDER
// than their layout box (origin-left → overflow to the right); pin each row's width
// to the SCALED width so the column — and the slot box to its right — accounts for
// it and never overlaps the held words.
var anchorEls = [];
for (var a = 0; a < R; a++) {
var w = mkWord(ANCHORS[a], fontPx, rowH);
w.row.className = "anchor";
w.span.style.color = TH0.ink;
anchorsBox.appendChild(w.row);
w.row.style.width = Math.ceil(w.span.getBoundingClientRect().width + 2) + "px";
anchorEls.push(w.row);
}
// the slot box is a TRANSPARENT backing during word phases (so only the per-line
// tapes show on the bg — no dead space). `bg` turns it into a solid torn card for
// the empty state + the expand zoom. ONE .sgroup per slot step, stacked in one grid
// cell. Each .sline is its own TAPE (own width/rotation/offset). No runtime text edit.
slotbox.style.background = "transparent";
cards.forEach(function (c) {
c.style.background = "transparent";
});
var groups = []; // [{ lines:[rowEls], spans:[spanEls] }]
slotSteps.forEach(function (step) {
var g = document.createElement("div");
g.className = "sgroup";
var lines = [],
spans = [];
(step.words || []).forEach(function (word, li) {
var lw = mkWord(word, fontPx, rowH);
lw.row.className = "sline";
lw.row.style.background = TH0.box; // each line = its own tape
lw.row.style.marginLeft = [0, 16, 8, 24][li % 4] + "px"; // rightward stair offset (collage)
lw.span.style.color = TH0.boxInk;
g.appendChild(lw.row);
lines.push(lw.row);
spans.push(lw.span);
});
linesBox.appendChild(g);
groups.push({ lines: lines, spans: spans });
step.__g = groups.length - 1; // remember which group this step drives
});
var allSlotLines = groups.reduce(function (acc, g) {
return acc.concat(g.lines);
}, []);
var allSlotSpans = groups.reduce(function (acc, g) {
return acc.concat(g.spans);
}, []);
// ── 5. Timeline + helpers ────────────────────────────────────────────────
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
function beatAt(i, n, beats) {
if (!beats || !beats.length) return 0;
return beats[Math.min(beats.length - 1, Math.floor((i * beats.length) / n))];
}
function jolt(k) {
// deterministic per-beat shake offset (no Math.random)
return { x: ((k * 37) % 11) - 5, y: ((k * 53) % 9) - 4, r: ((k * 29) % 7) - 3 };
}
function lineRot(i) {
return [-2.0, 1.6, -1.0, 2.2][i % 4];
} // per-line lean
// rest pose: lockup leans at BASE_TILT, box leans extra, all content hidden.
gsap.set(lockupEl, { rotation: BASE_TILT });
gsap.set(slotbox, { rotation: BOX_TILT });
gsap.set(anchorEls, { autoAlpha: 0, y: 18, scale: 0.9, scaleX: 0.9 });
gsap.set(slotbox, { autoAlpha: 0, scale: 0.86 });
allSlotLines.forEach(function (el, i) {
gsap.set(el, { autoAlpha: 0, rotation: lineRot(i) });
});
// ── 6. THE FIVE OPERATORS ────────────────────────────────────────────────
// lockup — punchy anchor pop-in (scale + faux-blur squash) + box reveal.
function lockup(P) {
var th = theme(P.theme != null ? P.theme : BASE_THEME);
tl.set(lockupEl, { rotation: BASE_TILT }, 0);
anchorEls.forEach(function (el, i) {
tl.set(el.firstChild, { color: th.ink }, 0);
tl.fromTo(
el,
{ autoAlpha: 0, y: 26, scale: 0.84, scaleX: 0.7 },
{
autoAlpha: 1,
y: 0,
scale: 1,
scaleX: 1,
duration: 0.2,
ease: "expo.out",
overwrite: "auto",
immediateRender: false,
},
beatAt(i, R, P.enter),
);
});
var boxAt = P.box != null ? P.box : beatAt(R - 1, R, P.enter);
tl.fromTo(
slotbox,
{ autoAlpha: 0, scale: 0.6, rotation: BOX_TILT - 6 },
{
autoAlpha: 1,
scale: 1,
rotation: BOX_TILT,
duration: 0.22,
ease: "back.out(2.2)",
overwrite: "auto",
immediateRender: false,
},
boxAt,
);
}
// slot — word-GROUP cycle: WHIP in from the right (expo.out + scaleX stretch),
// hold, arc out down-left. each line its own colour + lean.
function slot(P) {
var g = groups[P.__g != null ? P.__g : 0];
if (!g) return;
var th = theme(P.theme != null ? P.theme : BASE_THEME);
var n = g.lines.length;
g.lines.forEach(function (el, i) {
var col = P.colors && P.colors[i] != null ? accent(P.colors[i]) : th.boxInk;
var tIn = beatAt(i, n, P.enter);
tl.set(g.spans[i], { color: col }, tIn);
tl.fromTo(
el,
{ autoAlpha: 0, x: 150, scaleX: 1.7, rotation: lineRot(i) },
{
autoAlpha: 1,
x: 0,
scaleX: 1.12,
rotation: lineRot(i),
duration: 0.24,
ease: "expo.out",
overwrite: "auto",
immediateRender: false,
}, // rest scaleX 1.12 = faux-extend (bg stretches with text)
tIn,
);
});
if (!P.exit || !P.exit.length) return;
g.lines.forEach(function (el, i) {
tl.to(
el,
{
autoAlpha: 0,
x: -120,
y: 46,
scaleX: 1.4,
duration: 0.16,
ease: "power3.in",
overwrite: "auto",
},
beatAt(i, n, P.exit),
);
});
}
// bg — hard-cut scene flip (0ms invert). 0/1/2 of these = bg changes 0/1/2×.
function bg(P) {
var th = theme(P.theme != null ? P.theme : 0);
tl.set(root, { backgroundColor: th.bg }, P.at);
tl.set(
anchorEls.map(function (e) {
return e.firstChild;
}),
{ color: th.ink },
P.at,
);
tl.set(cards, { backgroundColor: th.box }, P.at); // 3 stacked torn cards = the EMPTY box + zoom target
tl.set(allSlotLines, { backgroundColor: th.box }, P.at); // recolor any visible tapes
tl.set(allSlotSpans, { color: th.boxInk }, P.at);
}
// shake — per-beat JOLT around the base tilt, with a back-ease recoil.
function shake(P) {
var amp = P.amp != null ? P.amp : 22;
var rot = P.rot != null ? P.rot : 2.2;
var settle = P.settle != null ? P.settle : 0.15;
(P.beats || []).forEach(function (t, k) {
var j = jolt(k);
tl.fromTo(
lockupEl,
{ x: (j.x * amp) / 5, y: (j.y * amp) / 4, rotation: BASE_TILT + (j.r * rot) / 3 },
{
x: 0,
y: 0,
rotation: BASE_TILT,
duration: settle,
ease: "back.out(3)",
overwrite: "auto",
immediateRender: false,
},
t,
);
});
}
// expand — EXIT wipe: fade text, then the box ZOOMS (expo.in + blur) to swallow
// the frame. keeps the lockup lean; clip-path off so the scaled box fully covers.
function expand(P) {
var fadeAt = P.fade != null ? P.fade : P.at;
tl.to(
anchorEls,
{ autoAlpha: 0, duration: 0.16, ease: "power2.in", overwrite: "auto" },
fadeAt,
);
tl.to(
allSlotLines,
{ autoAlpha: 0, duration: 0.14, ease: "power2.in", overwrite: "auto" },
fadeAt,
);
var startAt = P.at,
endAt = P.to != null ? P.to : P.at + 0.46;
tl.set(lockupEl, { x: 0, y: 0 }, startAt); // clear shake residual (keep the lean)
if (P.theme != null) tl.set(cards, { backgroundColor: theme(P.theme).box }, startAt);
var mid = Math.max(startAt + 0.04, endAt - 0.12);
// stage A — the TORN mass grows but stays SUB-frame (≈half-screen, decelerating),
// so the eye reads "the box is swelling" across most of the window (matches the
// source, whose mass is only ~half-frame as late as f113). A big rest box covers
// the frame at a low scale, so cap stage A low and let stage B do the swallow.
tl.fromTo(
slotbox,
{ scale: 1, filter: "blur(0px)" },
{
scale: 2.2,
filter: "blur(4px)",
duration: mid - startAt,
ease: "power2.out",
overwrite: "auto",
immediateRender: false,
},
startAt,
);
// stage B — SWALLOW the frame in the last beats (accelerating), then flatten to solid.
tl.to(
slotbox,
{
scale: 34,
filter: "blur(12px)",
duration: endAt - mid,
ease: "power2.in",
overwrite: "auto",
},
mid,
);
tl.set(card1, { clipPath: "none" }, endAt - 0.03); // card1 (inset 0) flattens to a solid full-frame black-out
}
var OPS = { lockup: lockup, slot: slot, bg: bg, shake: shake, expand: expand };
// ── 7. RUN THE PROGRAM ───────────────────────────────────────────────────
PROGRAM.forEach(function (step) {
var fn = step && OPS[step.op];
if (fn) fn(step);
});
tl.to({}, { duration: 0.001 }, DUR);
tl.seek(0);
window.__timelines["group-six-recreate"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,39 @@
[
{
"op": "lockup",
"enter": [0.125, 0.333, 0.5],
"box": 0.292,
"theme": 0
},
{
"op": "slot",
"words": ["I bring", "ideas", "to life"],
"enter": [1.0, 1.167, 1.292],
"exit": [1.667, 1.75, 1.833]
},
{
"op": "slot",
"words": ["you", "wanna", "see?"],
"enter": [2.042, 2.208, 2.375],
"exit": [2.75, 2.75, 2.75],
"colors": [0, 1, 1]
},
{
"op": "shake",
"amp": 26,
"rot": 2.4,
"settle": 0.16,
"beats": [2.16, 2.3, 2.44, 2.62, 2.76, 2.88, 3.06, 3.27, 3.41, 3.53, 3.71, 3.85, 4.09, 4.18]
},
{
"op": "bg",
"at": 3.458,
"theme": 1
},
{
"op": "expand",
"fade": 4.18,
"at": 4.4,
"to": 4.82
}
]
@@ -0,0 +1,238 @@
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"bgColor", "type":"color", "label":"Background", "default":"#000000"},
{"id":"textColor", "type":"color", "label":"Text color", "default":"#ffffff"},
{"id":"accentColor","type":"color", "label":"Period / keyword accent","default":"#ff6a32"},
{"id":"lead1", "type":"string", "label":"Lead word 1 (optional)", "default":"growth"},
{"id":"lead2", "type":"string", "label":"Lead word 2 (optional)", "default":"through"},
{"id":"lead3", "type":"string", "label":"Lead word 3 (optional)", "default":""},
{"id":"keyword", "type":"string", "label":"Keyword (shuffles font)","default":"creativity"},
{"id":"periodChar", "type":"string", "label":"Trailing accent char (empty = none)","default":"."}
]'
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Typewriter Phrase / Keyword Font-Shuffle — group_2 reference impl</title>
<script src="../../motion-primitives/assets/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: var(--bg, #000000);
overflow: hidden;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg, #000000);
}
.g2-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
/* one centered line; when the keyword changes typeface the line re-centers,
exactly as the source does (the lead words shift slightly, that's faithful). */
.g2-line {
display: inline-flex;
align-items: baseline;
gap: 0.28em;
white-space: nowrap;
color: var(--text, #ffffff);
font-size: 112px;
font-weight: 800;
letter-spacing: -0.01em;
text-shadow:
0 0 18px rgba(255, 255, 255, 0.45),
0 0 44px rgba(255, 255, 255, 0.22);
}
.g2-word {
display: inline-block;
}
/* keyword + its trailing accent travel together so the dot hugs the word */
.g2-cword {
display: inline-flex;
align-items: baseline;
}
.g2-period {
display: inline-block;
color: var(--accent, #ff6a32);
font-weight: 800;
}
</style>
</head>
<body>
<!-- duration = 3.85s = the spell-out + shuffle scene (0 → just before the 3.855s drop) -->
<div
data-hf-id="hf-g2rt"
id="root"
data-composition-id="group-two-recreate"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="3.85"
data-root="true"
>
<div data-hf-id="hf-g2ov" class="g2-overlay">
<div data-hf-id="hf-g2ln" class="g2-line" id="line"></div>
</div>
</div>
<script>
(function () {
// ════════════════════════════════════════════════════════════════
// TYPEWRITER PHRASE / KEYWORD FONT-SHUFFLE · structural_type: per_onset_sequential
// Words type in one-per-onset to spell a phrase; then ONE keyword cycles
// typefaces on the beat while everything else holds dead still.
// ════════════════════════════════════════════════════════════════
// ── 1. Content variables (what a user changes) ─────────────────────
var defaults = {
bgColor: "#000000",
textColor: "#ffffff",
accentColor: "#ff6a32",
lead1: "growth",
lead2: "through",
lead3: "",
keyword: "creativity",
periodChar: ".",
};
var vars = Object.assign(
{},
defaults,
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {},
);
// ── 2. FONT PRESET LIBRARY (preset many — the keyword cycles these) ─
// FONTS[0] is the BASE face, shared with the lead words. The keyword flips
// to FONTS[i] on each SHUFFLE step. Offline-safe generic stacks only (no
// remote fonts). Extend this list freely — more beats → more presets to
// cycle through. Each entry: { family, weight, style }.
var FONTS = [
{
family: '"Helvetica Neue", Helvetica, Arial, sans-serif',
weight: 800,
style: "normal",
}, // 0 base bold sans
{ family: 'Georgia, "Times New Roman", Times, serif', weight: 500, style: "normal" }, // 1 upright serif
{ family: "Georgia, serif", weight: 800, style: "normal" }, // 2 heavy serif
{ family: 'Georgia, "Times New Roman", serif', weight: 500, style: "italic" }, // 3 italic serif
{ family: '"Courier New", Courier, monospace', weight: 700, style: "normal" }, // 4 monospace
{ family: '"Arial Narrow", "Helvetica Neue", sans-serif', weight: 700, style: "normal" }, // 5 condensed sans
{ family: '"Helvetica Neue", Arial, sans-serif', weight: 300, style: "italic" }, // 6 light sans italic
{ family: "Georgia, serif", weight: 700, style: "italic" }, // 7 bold serif italic
];
function fontProps(i) {
var f = FONTS[((i % FONTS.length) + FONTS.length) % FONTS.length];
return { fontFamily: f.family, fontWeight: f.weight, fontStyle: f.style };
}
// ── 3. BEAT MAP (filled from <group>.audiomap.json — see README) ────
// group_2.audiomap.json: BPM 129.2, hi-hat onsets drive every move.
// one ENTRY onset per lead word (in order), then KEYWORD, then PERIOD.
var LEAD_ONSETS = [0.557, 0.906]; // grid.events[].t — one per non-empty lead word
var KEY_ONSET = 1.231; // keyword types in here
var PERIOD_ONSET = 1.44; // accent char punches in here
// keyword font-shuffle: each {t,font} flips the keyword on a beat/onset.
// t = a grid.beats_sec[] / events[].t after the phrase is set.
// font = index into FONTS[]. Add as many rows as the beat has flips — and
// re-use indices to repeat faces, or walk new ones for a longer shuffle.
var SHUFFLE = [
{ t: 1.904, font: 1 }, // → upright serif
{ t: 2.392, font: 2 }, // → heavy serif
{ t: 2.624, font: 3 }, // → italic serif
{ t: 3.088, font: 0 }, // → back to base, then HOLD to the end
];
var DUR = 3.85; // scene end (just before the 3.855 drop/kick)
// ── 4. Build the line (1–3 lead words + keyword + optional accent) ──
var root = document.getElementById("root");
document.documentElement.style.setProperty("--bg", vars.bgColor);
document.documentElement.style.setProperty("--text", vars.textColor);
document.documentElement.style.setProperty("--accent", vars.accentColor);
var line = root.querySelector("#line");
var leadWords = [vars.lead1, vars.lead2, vars.lead3].filter(function (w) {
return w && String(w).trim() !== "";
});
var leadEls = leadWords.map(function (w) {
var s = document.createElement("span");
s.className = "g2-word";
s.textContent = w;
line.appendChild(s);
return s;
});
var cword = document.createElement("span");
cword.className = "g2-cword";
var keyEl = document.createElement("span");
keyEl.className = "g2-word";
keyEl.textContent = vars.keyword;
cword.appendChild(keyEl);
var periodEl = null;
if (vars.periodChar && String(vars.periodChar).trim() !== "") {
periodEl = document.createElement("span");
periodEl.className = "g2-period";
periodEl.textContent = vars.periodChar;
cword.appendChild(periodEl);
}
line.appendChild(cword);
// ── 5. Timeline — one paused GSAP timeline, absolute audio seconds ──
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var allSpans = leadEls.concat([keyEl]);
if (periodEl) allSpans.push(periodEl);
gsap.set(allSpans, { autoAlpha: 0 });
gsap.set(keyEl, fontProps(0)); // keyword starts in the base face
var POP_IN = { autoAlpha: 0, scale: 0.92, y: 5 };
var POP_OUT = { autoAlpha: 1, scale: 1, y: 0, duration: 0.1, ease: "back.out(2)" };
// typewriter accumulation — each lead word pops on its onset (words stay)
leadEls.forEach(function (el, i) {
var t = i < LEAD_ONSETS.length ? LEAD_ONSETS[i] : KEY_ONSET;
tl.fromTo(el, POP_IN, POP_OUT, t);
});
// keyword types in (in the base face)
tl.fromTo(keyEl, POP_IN, POP_OUT, KEY_ONSET);
// trailing accent char punches in on its own onset
if (periodEl) {
tl.fromTo(
periodEl,
{ autoAlpha: 0, scale: 0.3 },
{ autoAlpha: 1, scale: 1, duration: 0.09, ease: "back.out(3)" },
PERIOD_ONSET,
);
}
// keyword font-shuffle — 0ms hard sets on the beat (the percussion IS the swap)
SHUFFLE.forEach(function (s) {
tl.set(keyEl, fontProps(s.font), s.t);
});
tl.seek(0);
window.__timelines["group-two-recreate"] = tl;
})();
</script>
</body>
</html>