Add HyperFrames skills
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011h6NB8JQtfPc6eRCefEzBB
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>
|
||||
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -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>
|
||||