heygen-com/hyperframes已通过检查
SKILL DETAIL
canopy-part-title
heygen-com/hyperframes/canopy-part-title
Leaves sweep through the frame and part to reveal the headline. HyperFrames block, 1920×1080, 12s, 11 variables.
安装量 · 132查看来源
Installation
npx skills add https://github.com/heygen-com/hyperframes --skill canopy-part-title
技能文件
SKILL.md
最近同步 · 2026年9月20日
canopy-part-title.html›
<!doctype html>
<html
lang="en"
data-composition-variables='[{"id":"headline1","type":"string","label":"Headline 1","default":"Understory"},{"id":"headline2","type":"string","label":"Headline 2","default":"Move slowly"},{"id":"font","type":"string","label":"Headline font","default":"Helvetica"},{"id":"fontWeight","type":"number","label":"Font weight","default":900,"min":100,"max":900,"step":100},{"id":"fontSize","type":"number","label":"Font size (1 = auto-fit)","default":1,"min":0.3,"max":2,"step":0.01},{"id":"letterSpacing","type":"number","label":"Letter spacing (em)","default":0.01,"min":-0.2,"max":1,"step":0.01},{"id":"background","type":"color","label":"Background","default":"#020805"},{"id":"leafCount","type":"number","label":"Leaves per sweep","default":170,"min":40,"max":320,"step":1},{"id":"stayCount","type":"number","label":"Leaves left behind","default":5,"min":0,"max":20,"step":1},{"id":"sweepSpeed","type":"number","label":"Sweep speed","default":1,"min":0.3,"max":3,"step":0.05},{"id":"retainedBreeze","type":"number","label":"Retained leaf breeze","default":1,"min":0,"max":2,"step":0.05}]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>canopy-part-title</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Gelasio:wght@500;600&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #020805;
}
#cpt-root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
#cpt-scene {
position: absolute;
inset: 0;
}
/* Full-frame background paint on a child, never on the composition root.
Plain flat colour — driven by the `background` variable, no gradients. */
#cpt-bg {
position: absolute;
inset: 0;
background: #020805;
}
#cpt-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
#cpt-fontload {
position: absolute;
opacity: 0;
pointer-events: none;
font-family: Gelasio, Georgia, serif;
}
</style>
</head>
<body>
<div
data-hf-id="hf-idkp"
id="cpt-root"
data-composition-id="canopy-part-title"
data-start="0"
data-duration="12"
data-width="1920"
data-height="1080"
>
<section
data-hf-id="hf-wrn4"
id="cpt-scene"
class="clip"
data-start="0"
data-duration="12"
data-track-index="1"
>
<div
style="font-family: "Alan Sans", ui-sans-serif, system-ui, sans-serif"
data-hf-id="hf-bank"
id="cpt-bg"
></div>
</section>
<div data-hf-id="hf-p1fl" id="cpt-fontload" aria-hidden="true">
<span data-hf-id="hf-grhl" style="font-weight: 500">Aa</span
><span data-hf-id="hf-7bxm" style="font-weight: 600">Aa</span>
</div>
</div>
<!-- Seeded layout + master timeline. Runs synchronously before the Three module so
the timeline exists the moment the page is scriptable. Zero Math.random. -->
<script>
(function () {
"use strict";
window.__timelines = window.__timelines || {};
/* Studio's variables parser fails on any composition with a <canvas>
in its MARKUP — created at runtime instead (identical for render). */
(function () {
var c = document.createElement("canvas");
c.id = "cpt-canvas";
c.width = 1920;
c.height = 1080;
document.getElementById("cpt-scene").appendChild(c);
})();
var V = Object.fromEntries(
JSON.parse(
document
.querySelector("[data-composition-variables]")
.getAttribute("data-composition-variables"),
).map((e) => [e.id, e.default]),
);
try {
if (window.__hyperframes && window.__hyperframes.getVariables)
V = Object.assign(V, window.__hyperframes.getVariables() || {});
} catch (e) {
/* corrupt declarations must not blank the composition */
}
var LEAF_COUNT = Math.max(40, Math.min(320, Number(V.leafCount) || 170));
var STAY_COUNT = Math.max(
0,
Math.min(
20,
Math.round(
V.stayCount === undefined || !isFinite(Number(V.stayCount)) ? 5 : Number(V.stayCount),
),
),
);
var SPEED = Math.max(0.3, Math.min(3, Number(V.sweepSpeed) || 1));
var DUR = 12;
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* V1's opening sweep plus a separate depth-lift batch. Each batch has
exactly stayCount leaves reserved for edge rest slots. The lift
carries the opening's remaining edge leaves past the camera. */
var rand = mulberry32(0x6a756e67);
var TEXT_HALF_W = 8.2,
TEXT_HALF_H = 2.0;
function buildBatch(batchIndex) {
var out = [];
for (var i = 0; i < LEAF_COUNT; i++) {
var kindRoll = rand();
var kind =
kindRoll < 0.22
? "monstera"
: kindRoll < 0.42
? "alocasia"
: kindRoll < 0.68
? "lance"
: kindRoll < 0.89
? "calathea"
: "fern";
/* Stayers are chosen AFTER the batch is built (exactly stayCount of
them, scattered) — here every leaf just gets wind-seed rest values
and a LOW flight band: in front of the text plane but pushed away
from the camera so the depth of field leaves most leaves sharp. */
var stayer = false;
var y0 = (rand() * 2 - 1) * 3.6 - 0.5;
var restX = (rand() * 2 - 1) * 9.2;
var restY = y0;
var restRZ = rand() * Math.PI * 2;
out.push({
batch: batchIndex,
kind: kind,
stayer: stayer,
z: -4.6 + rand() * 3.0,
xStart: -12.5 - rand() * 5.5,
xEnd: 12.5 + rand() * 5.0,
restX: restX,
restY: restY,
restRZ: restRZ,
y0: y0,
delay: rand() * 0.5,
delay2: 0, // filled for batch-1 stayers below
bobAmp: 0.3 + rand() * 0.65,
bobN: 1.1 + rand() * 1.6,
spin: (rand() < 0.5 ? -1 : 1) * (1.6 + rand() * 3.0),
phase: rand() * Math.PI * 2,
rotation: rand() * Math.PI * 2,
tiltX: (rand() - 0.5) * 0.42,
tiltY: (rand() - 0.5) * 0.58,
widthMul: 0.83 + rand() * 0.34,
lengthMul: 0.86 + rand() * 0.25,
scale: (0.62 + rand() * 0.85) * 0.56 * 1.9,
});
}
return out;
}
/* Exactly STAY_COUNT leaves per batch stay behind, scattered at seeded
random positions anywhere in frame EXCEPT the headline band — never
lined up along the top and bottom. */
function pickStayers(batch) {
var placed = 0,
guard = 0;
while (placed < STAY_COUNT && guard++ < 600) {
var idx = Math.floor(rand() * batch.length) % batch.length;
var sp = batch[idx];
if (sp.stayer) continue;
var rx,
ry,
tries = 0;
do {
rx = (rand() * 2 - 1) * 8.6;
ry = (rand() * 2 - 1) * 4.3;
tries++;
} while (
tries < 30 &&
Math.abs(ry) < TEXT_HALF_H + 0.7 &&
Math.abs(rx) < TEXT_HALF_W + 0.8
);
sp.stayer = true;
sp.restX = rx;
sp.restY = ry;
if (Math.abs(rx) > TEXT_HALF_W + 0.8 && Math.abs(ry) < TEXT_HALF_H + 1.4) {
sp.restRZ = ((rx > 0 ? -1 : 1) * Math.PI) / 2 + (rand() - 0.5) * 0.7;
} else if (ry >= 0) {
sp.restRZ = (rand() - 0.5) * 0.8;
} else {
sp.restRZ = Math.PI + (rand() - 0.5) * 0.8;
}
placed++;
}
}
var batch1 = buildBatch(1);
var batch2 = buildBatch(2);
pickStayers(batch1);
pickStayers(batch2);
// Sweep-away order for batch-1 stayers: left-most leave first.
batch1.forEach(function (s) {
s.delay2 = ((s.restX + 16) / 32) * 0.5 + rand() * 0.14;
});
// Independent seed: V1's opening geometry/seed sequence is unchanged.
var liftRand = mulberry32(0x6c696674);
batch2.forEach(function (s, i) {
s.liftX = (liftRand() * 2 - 1) * 7.8;
s.liftY = -5.2 + liftRand() * 5.8;
s.liftZ = -7 + liftRand() * 4.9;
s.liftDelay = liftRand() * 0.28;
s.depthResponse = Math.max(0.25, Math.min(1, (s.liftZ + 7) / 14 + 0.55));
// Keep final foliage outside the headline, including its full leaf extent.
if (s.stayer) {
var a = i * 2.399963;
s.restX = Math.sin(a) * 8.5;
s.restY = (i % 2 ? 1 : -1) * 5.5;
s.restRZ = i % 2 ? 0 : Math.PI;
}
});
window.__cptBatches = [batch1, batch2];
var DUST_COUNT = 800;
var dustHome = new Array(DUST_COUNT * 3);
var dustSeeds = new Array(DUST_COUNT);
for (var d = 0; d < DUST_COUNT; d++) {
dustHome[d * 3] = (rand() * 2 - 1) * 10;
dustHome[d * 3 + 1] = (rand() * 2 - 1) * 6;
dustHome[d * 3 + 2] = rand() * 12 - 5;
dustSeeds[d] = rand();
}
window.__cptDust = { home: dustHome, seeds: dustSeeds };
// Separate per-leaf seeds leave the approved sweep/lift layout untouched.
// Different periods and phases avoid a shared rocking or looping beat.
[batch1, batch2].forEach(function (batch, batchIndex) {
batch.forEach(function (s, i) {
if (!s.stayer) return;
var breezeRand = mulberry32(0x62726565 ^ ((batchIndex + 1) * 104729 + i * 7919));
s.breeze = {
phase: breezeRand() * Math.PI * 2,
phase2: breezeRand() * Math.PI * 2,
phase3: breezeRand() * Math.PI * 2,
rate: 0.55 + breezeRand() * 0.45,
driftX: 0.22 + breezeRand() * 0.24,
driftY: 0.28 + breezeRand() * 0.28,
driftZ: 0.35 + breezeRand() * 0.45,
depth: (breezeRand() - 0.5) * 1.3,
tiltX: 0.07 + breezeRand() * 0.1,
tiltY: 0.1 + breezeRand() * 0.14,
roll: 0.12 + breezeRand() * 0.14,
turn: (breezeRand() < 0.5 ? -1 : 1) * (0.012 + breezeRand() * 0.022),
arrival: breezeRand() * 0.16,
};
});
});
/* Masters tweened by the timeline; all per-leaf motion is a closed-form
function of these plus each leaf's seeded constants. */
var state = { sw1: 0, sw2: 0 };
window.__cptState = state;
// Keep the existing speed parameter; finite holds absorb the slack.
var d1 = Math.max(1.2, Math.min(6.0, 3.2 / SPEED));
var s2 = Math.max(0.4 + d1 + 0.9, 5.2);
var d2 = Math.max(1.2, Math.min(3.2 / SPEED, DUR - s2 - 1.8));
window.__cptWindows = { d1: d1, s2: s2, d2: d2 };
var tl = gsap.timeline({ paused: true });
tl.addLabel("opening", 0)
.addLabel("headline-1", 0.4 + d1)
.addLabel("canopy-lift", s2)
.addLabel("headline-2", s2 + d2)
.addLabel("final-hold", 10.5);
tl.fromTo(
"#cpt-scene",
{ opacity: 0 },
{ opacity: 1, duration: 0.35, ease: "power1.out" },
0.05,
);
// Imperceptible 1.5px drift on the bg plate (same colour as the body
// behind it) so the layout sweep sees DOM geometry advance under seek.
tl.fromTo("#cpt-bg", { y: 0 }, { y: 1.5, duration: 12, ease: "none" }, 0);
tl.to(state, { sw1: 1, duration: d1, ease: "none" }, 0.4);
tl.to(state, { sw2: 1, duration: d2, ease: "none" }, s2);
tl.to(state, { sw2: 1, duration: 0.01 }, DUR - 0.01); // pin timeline length to DUR
tl.eventCallback("onUpdate", function () {
if (window.__cptRender) window.__cptRender(tl.time());
});
window.__timelines["canopy-part-title"] = tl;
})();
</script>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from "three";
import { EffectComposer } from "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/postprocessing/RenderPass.js";
import { BokehPass } from "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/postprocessing/BokehPass.js";
const W = 1920,
H = 1080,
DUR = 12;
let V = Object.fromEntries(
JSON.parse(
document
.querySelector("[data-composition-variables]")
.getAttribute("data-composition-variables"),
).map((e) => [e.id, e.default]),
);
try {
if (window.__hyperframes && window.__hyperframes.getVariables)
V = Object.assign(V, window.__hyperframes.getVariables() || {});
} catch (e) {
/* corrupt declarations must not blank the composition */
}
const HEADLINE1 = String(V.headline1 ?? "Understory");
const HEADLINE2 = String(V.headline2 ?? "MOVE SLOWLY");
/* Headline typography is a PARAMETER — never a Design-panel edit. Any
Google Fonts family name works; falls back through Gelasio/serif
while (or if never) loading. */
const FONT = (String(V.font || "Gelasio").trim() || "Gelasio").replace(/["']/g, "");
const FONT_WEIGHT = Math.max(
100,
Math.min(
900,
Math.round(
isFinite(Number(V.fontWeight)) && Number(V.fontWeight) ? Number(V.fontWeight) : 500,
),
),
);
/* fontSize is a multiplier on the auto-fitted size (0.3-2); letterSpacing
is in em (fraction of the font size, may be negative). */
const FONT_SIZE = Math.max(
0.3,
Math.min(
2,
isFinite(Number(V.fontSize)) && Number(V.fontSize) > 0 ? Number(V.fontSize) : 1,
),
);
const LETTER_SP = Math.max(
-0.2,
Math.min(1, isFinite(Number(V.letterSpacing)) ? Number(V.letterSpacing) : 0.1),
);
const FONT_STACK = '"' + FONT + '", Gelasio, Georgia, serif';
if (!/^(Helvetica|Arial|Gelasio|Georgia|serif|sans-serif|monospace)$/i.test(FONT)) {
const fl = document.createElement("link");
fl.rel = "stylesheet";
fl.href =
"https://fonts.googleapis.com/css2?family=" +
encodeURIComponent(FONT).replace(/%20/g, "+") +
":wght@" +
FONT_WEIGHT +
"&display=swap";
document.head.appendChild(fl);
}
const BG = String(V.background || "#020805");
const RETAINED_BREEZE = Math.max(
0,
Math.min(2, Number.isFinite(Number(V.retainedBreeze)) ? Number(V.retainedBreeze) : 1),
);
const state = window.__cptState;
// Fixed experiment parameters (the source's tuned defaults), baked in.
const WIND_SPEED = 1.06;
const WIND_STRENGTH = 2.5;
const TURBULENCE = 0.8;
const TEXT_Z = -5.0;
const BACKDROP_Z = -7.5;
const CAM_DIST = 13.6;
const clamp = THREE.MathUtils.clamp;
const smoothstep = (a, b, x) => {
const t = clamp((x - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
};
function hash2(x, y, seed) {
let n = Math.imul(x + seed * 1013, 374761393) + Math.imul(y - seed * 79, 668265263);
n = n ^ (n >>> 13);
n = Math.imul(n, 1274126177);
return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
}
document.getElementById("cpt-bg").style.background = BG;
document.body.style.background = BG;
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("cpt-canvas"),
antialias: true,
powerPreference: "high-performance",
alpha: false,
});
renderer.setSize(W, H, false);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.8;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
const scene = new THREE.Scene();
const bgColor = new THREE.Color(BG);
scene.background = bgColor.clone();
scene.fog = new THREE.FogExp2(bgColor.clone(), 0.008);
const camera = new THREE.PerspectiveCamera(41, W / H, 0.1, 40);
camera.position.set(0, 0.15, 12.1);
camera.lookAt(0, 0, -1.5);
const composer = new EffectComposer(renderer);
composer.setSize(W, H);
// Copy only colour; retain the scene depth for the sharp headline pass.
composer.copyPass.material.depthTest = false;
composer.copyPass.material.depthWrite = false;
composer.addPass(new RenderPass(scene, camera));
const bokehPass = new BokehPass(scene, camera, {
focus: 16.4,
aperture: 0.0016,
maxblur: 0.0055,
});
composer.addPass(bokehPass);
const hemi = new THREE.HemisphereLight(0x9fc09b, 0x061009, 2);
scene.add(hemi);
const sun = new THREE.DirectionalLight(new THREE.Color("#ece5ff"), 8.5);
sun.position.set(-13.3, 8.5, 9);
sun.target.position.set(0, -1, -3);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -20;
sun.shadow.camera.right = 20;
sun.shadow.camera.top = 20 * 0.73;
sun.shadow.camera.bottom = -20 * 0.73;
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 28;
sun.shadow.bias = 0.00105;
sun.shadow.normalBias = 0.0525;
sun.shadow.radius = 2.5;
scene.add(sun, sun.target);
const fill = new THREE.DirectionalLight(new THREE.Color("#00bd03"), 9.55);
fill.position.set(-11.8, 4.2, 5.2);
fill.target.position.set(0.7, 1, -2);
scene.add(fill, fill.target);
/* Plain flat backdrop in the variable colour (unlit so it stays flat), with a
shadow-only catcher just in front for leaf contact shadows. */
const wall = new THREE.Mesh(
new THREE.PlaneGeometry(68, 44),
new THREE.MeshBasicMaterial({ color: bgColor.clone(), fog: false }),
);
wall.position.z = BACKDROP_Z;
scene.add(wall);
const catcher = new THREE.Mesh(
new THREE.PlaneGeometry(68, 44),
new THREE.ShadowMaterial({ opacity: 0.28 }),
);
catcher.position.z = BACKDROP_Z + 0.05;
catcher.receiveShadow = true;
scene.add(catcher);
// ---- Procedural leaf species textures (ported verbatim from the experiment).
const palettes = {
monstera: [
[22, 77, 46],
[79, 128, 70],
[170, 184, 104],
],
alocasia: [
[18, 55, 43],
[57, 104, 73],
[190, 206, 149],
],
lance: [
[15, 74, 42],
[53, 126, 65],
[145, 172, 84],
],
calathea: [
[32, 58, 39],
[82, 117, 72],
[195, 178, 126],
],
fern: [
[20, 73, 39],
[64, 126, 65],
[158, 176, 88],
],
};
function leafWidth(kind, y) {
const s = Math.max(0, Math.sin(Math.PI * y));
if (kind === "lance" || kind === "fern") return Math.pow(s, 1.28) * (0.66 + y * 0.12);
if (kind === "alocasia") return Math.pow(s, 0.62) * (0.88 + 0.16 * (1 - y));
if (kind === "calathea") return Math.pow(s, 0.82) * 0.78;
return Math.pow(s, 0.55) * (0.82 + 0.15 * y);
}
function makeLeafTexture(kind, seed) {
const w = 384,
h = 768;
const colorCanvas = document.createElement("canvas");
const bumpCanvas = document.createElement("canvas");
colorCanvas.width = bumpCanvas.width = w;
colorCanvas.height = bumpCanvas.height = h;
const colorCtx = colorCanvas.getContext("2d");
const bumpCtx = bumpCanvas.getContext("2d");
const colorImage = colorCtx.createImageData(w, h);
const bumpImage = bumpCtx.createImageData(w, h);
const p = palettes[kind];
for (let py = 0; py < h; py++) {
const y = py / (h - 1);
const width = leafWidth(kind, y);
for (let px = 0; px < w; px++) {
const x = (px / (w - 1) - 0.5) * 2;
const nx = Math.abs(x) / Math.max(0.001, width);
let signed = 1 - nx;
let inside = signed > -0.015 && y > 0.008 && y < 0.996;
if (inside && kind === "monstera") {
const ax = Math.abs(x);
const side = x < 0 ? -1 : 1;
for (let k = 0; k < 6; k++) {
const jitter = (hash2(k, seed, 44) - 0.5) * 0.034;
const root = 0.205 + k * 0.083 + jitter;
const inner = 0.23 + k * 0.022 + (hash2(k, seed, 51) - 0.5) * 0.026;
const curve =
root + (ax - inner) * (0.14 + k * 0.012) + Math.sin((ax + k) * 13.0) * 0.008;
const slotWidth =
(0.009 + k * 0.0018) * smoothstep(inner, Math.max(inner + 0.11, width), ax);
if (ax > inner && Math.abs(y - curve) < slotWidth && ax < width * 1.025)
inside = false;
if (k < 4) {
const cx = side * (0.225 + k * 0.052 + (hash2(k, seed, 61) - 0.5) * 0.025);
const cy = 0.31 + k * 0.105 + (hash2(k, seed, 71) - 0.5) * 0.025;
const rx = 0.026 + k * 0.003;
const ry = 0.038 + k * 0.005;
const skewY = y - cy - (x - cx) * side * 0.26;
const hole = (x - cx) ** 2 / (rx * rx) + skewY ** 2 / (ry * ry) < 1;
if (hole) inside = false;
}
}
}
if (inside && kind === "alocasia" && y < 0.105) {
const notch = Math.abs(x) < 0.15 * (1 - y / 0.105);
if (notch) inside = false;
}
const i = (py * w + px) * 4;
if (!inside) {
colorImage.data[i + 3] = 0;
bumpImage.data[i] = bumpImage.data[i + 1] = bumpImage.data[i + 2] = 112;
bumpImage.data[i + 3] = 255;
continue;
}
const n0 = hash2(px >> 2, py >> 2, seed);
const n1 = hash2(px >> 4, py >> 4, seed + 17);
const middle = Math.exp(-Math.abs(x) * (kind === "lance" ? 28 : 35));
const veinFreq = kind === "lance" ? 12 : 8.5;
const ribWave = Math.abs(((y * veinFreq + Math.abs(x) * 1.85) % 1) - 0.5);
const sideVein = Math.exp(-ribWave * 46) * smoothstep(0.04, 0.82, Math.abs(x));
const edge = smoothstep(0, 0.16, signed);
const stripe =
kind === "calathea" ? 0.5 + 0.5 * Math.cos(y * 58 + Math.abs(x) * 10) : 0;
const mottling = (n0 - 0.5) * 0.16 + (n1 - 0.5) * 0.12 + stripe * 0.08;
const light = clamp(
0.42 + y * 0.12 + mottling + middle * 0.34 + sideVein * 0.16 - (1 - edge) * 0.27,
0,
1,
);
const dry = hash2(px >> 3, py >> 3, seed + 90) > 0.978 ? 0.16 : 0;
const c0 = p[0],
c1 = p[1],
c2 = p[2];
const t = smoothstep(0.08, 0.9, light);
const hi = smoothstep(0.56, 1, t);
for (let ch = 0; ch < 3; ch++) {
const low = c0[ch] + (c1[ch] - c0[ch]) * t;
const val =
low + (c2[ch] - low) * hi * 0.58 + dry * (ch === 0 ? 56 : ch === 1 ? 35 : 10);
colorImage.data[i + ch] = clamp(val, 0, 255);
}
colorImage.data[i + 3] = clamp(edge * 390, 0, 255);
const bump = clamp(
112 + (n0 - 0.5) * 22 + middle * 100 + sideVein * 52 - (1 - edge) * 18,
0,
255,
);
bumpImage.data[i] = bumpImage.data[i + 1] = bumpImage.data[i + 2] = bump;
bumpImage.data[i + 3] = 255;
}
}
colorCtx.putImageData(colorImage, 0, 0);
bumpCtx.putImageData(bumpImage, 0, 0);
const map = new THREE.CanvasTexture(colorCanvas);
map.colorSpace = THREE.SRGBColorSpace;
map.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
const bump = new THREE.CanvasTexture(bumpCanvas);
bump.anisotropy = map.anisotropy;
return { map, bump };
}
const textureLoader = new THREE.TextureLoader();
const [photoSurface, photoNormal] = await Promise.all([
textureLoader.loadAsync("assets/leaf-surface-color.webp"),
textureLoader.loadAsync("assets/leaf-surface-normal.webp"),
]);
photoSurface.colorSpace = THREE.SRGBColorSpace;
photoSurface.wrapS = photoSurface.wrapT = THREE.RepeatWrapping;
photoSurface.repeat.set(2.4, 4.8);
photoNormal.wrapS = photoNormal.wrapT = THREE.RepeatWrapping;
photoNormal.repeat.set(2.4, 4.8);
const kinds = ["monstera", "alocasia", "lance", "calathea", "fern"];
const textureSets = Object.fromEntries(
kinds.map((kind, i) => [kind, makeLeafTexture(kind, i * 31 + 7)]),
);
const materials = {};
const shaderMaterials = [];
for (const [index, kind] of kinds.entries()) {
const tex = textureSets[kind];
const material = new THREE.MeshPhysicalMaterial({
map: tex.map,
bumpMap: tex.bump,
bumpScale: (kind === "monstera" ? 0.065 : 0.045) * 2.5,
normalMap: photoNormal,
normalScale: new THREE.Vector2(0.35, 0.35),
alphaTest: 0.42,
side: THREE.DoubleSide,
roughness: 0.5626,
metalness: 0,
clearcoat: 0.5256,
clearcoatRoughness: 0.4274,
thickness: 0,
transmission: 0.015,
sheen: 0.24,
sheenColor: new THREE.Color(kind === "calathea" ? 0xc6a68d : 0x7da06a),
sheenRoughness: 0.8,
emissive: new THREE.Color(0x06140a),
emissiveIntensity: 0.18,
shadowSide: THREE.DoubleSide,
});
// Transmission and ordinary passes can compile separate shader variants.
// All variants share one time uniform, including a late first compilation.
material.userData.timeUniform = { value: 0 };
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = material.userData.timeUniform;
shader.uniforms.uWindStrength = { value: WIND_STRENGTH };
shader.uniforms.uTurbulence = { value: TURBULENCE };
shader.uniforms.uShapeNoise = { value: 2.5 };
shader.uniforms.uTextureNoise = { value: 2 };
shader.uniforms.uPhotoTexture = { value: 1.5 };
shader.uniforms.uPhotoSurface = { value: photoSurface };
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
`#include <common>
uniform float uTime;
uniform float uWindStrength;
uniform float uTurbulence;
uniform float uShapeNoise;
`,
)
.replace(
"#include <begin_vertex>",
`
vec3 transformed = vec3(position);
float root = pow(uv.y, 1.28);
float worldPhase = dot(modelMatrix[3].xyz, vec3(0.71, 1.13, 0.43)) + ${index.toFixed(1)};
vec2 worldXY = modelMatrix[3].xy;
float gustCell = sin(uTime * 0.23 + worldXY.x * 0.42 + sin(worldXY.y * 0.37 + uTime * 0.11));
float localGust = pow(max(0.0, gustCell), 3.6);
float breath = sin(uTime * 0.46 + worldPhase) * 0.20 + sin(uTime * 1.17 + worldPhase * 1.7) * 0.07 + localGust * 0.62;
float flutter = (sin(uTime * 2.7 + uv.y * 8.0 + worldPhase * 2.2) * 0.012 + localGust * sin(uv.y * 11.0 + worldPhase) * 0.038) * uTurbulence;
float edgeNoise = sin(uv.y * 31.0 + worldPhase * 4.1) * pow(abs(uv.x - 0.5) * 2.0, 2.4) * 0.022 * uShapeNoise;
transformed.x += (breath * 0.075 * uWindStrength + flutter + edgeNoise) * root;
transformed.z += breath * 0.12 * root * uWindStrength + flutter * root * 0.8 + edgeNoise;
transformed.z += (1.0 - pow(abs(uv.x - 0.5) * 2.0, 1.65)) * 0.075;
`,
);
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
`#include <common>
uniform float uTextureNoise;
uniform float uPhotoTexture;
uniform sampler2D uPhotoSurface;`,
)
.replace(
"#include <map_fragment>",
`#include <map_fragment>
float leafNoise = fract(sin(dot(vMapUv * vec2(311.7, 917.3), vec2(12.9898, 78.233))) * 43758.5453);
diffuseColor.rgb *= 1.0 + (leafNoise - 0.5) * 0.18 * uTextureNoise;
vec3 photographedSurface = texture2D(uPhotoSurface, vMapUv * vec2(2.4, 4.8)).rgb;
diffuseColor.rgb *= mix(vec3(1.0), photographedSurface * 1.24, clamp(uPhotoTexture, 0.0, 1.0) * 0.26);
`,
);
material.userData.shader = shader;
};
material.customProgramCacheKey = () => `cpt-leaf-wind-photo-v6-${index}`;
materials[kind] = material;
shaderMaterials.push(material);
}
const geometryCache = new Map();
function leafGeometry(width, length) {
const key = `${width.toFixed(2)}-${length.toFixed(2)}`;
if (geometryCache.has(key)) return geometryCache.get(key);
const geo = new THREE.PlaneGeometry(width, length, 12, 22);
geo.translate(0, length * 0.5, 0);
const position = geo.attributes.position;
const uv = geo.attributes.uv;
for (let i = 0; i < position.count; i++) {
const across = position.getX(i) / (width * 0.5);
const along = uv.getY(i);
const arch = (1 - Math.pow(Math.abs(across), 1.62)) * Math.sin(Math.PI * along);
const twist = across * (along - 0.22);
const edgeRipple =
Math.sin(along * Math.PI * 5.4 + across * 2.1) * Math.pow(Math.abs(across), 2.2);
position.setZ(
i,
arch * length * 0.052 + twist * length * 0.038 + edgeRipple * length * 0.012,
);
}
position.needsUpdate = true;
geo.computeVertexNormals();
geometryCache.set(key, geo);
return geo;
}
const DIMS = {
monstera: [2.05, 2.7],
alocasia: [1.92, 2.85],
lance: [0.68, 3.45],
calathea: [1.36, 3.05],
fern: [0.46, 1.5],
};
const leaves = [];
for (const batch of window.__cptBatches) {
for (const spec of batch) {
const dims = DIMS[spec.kind];
if (spec.batch === 2) {
// Start the entire leaf below the camera frustum, including its
// tip, wind deformation and the small camera orbit. Its identity
// persists: the lift carries it into view rather than spawning it.
const reach =
Math.hypot(dims[0] * spec.widthMul * 0.5, dims[1] * spec.lengthMul) * spec.scale;
spec.liftStartY =
-Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)) * (CAM_DIST - spec.liftZ) -
reach -
2;
}
const group = new THREE.Group();
const mesh = new THREE.Mesh(
leafGeometry(dims[0] * spec.widthMul, dims[1] * spec.lengthMul),
materials[spec.kind],
);
mesh.castShadow = true;
mesh.receiveShadow = spec.z < 4.8;
group.add(mesh);
group.visible = false;
group.position.set(spec.xStart, spec.y0, spec.z);
group.scale.setScalar(spec.scale);
group.rotation.set(spec.tiltX, spec.tiltY, spec.rotation);
scene.add(group);
leaves.push({ group, spec });
}
}
/* ---- The headlines live IN the scene, behind the leaf band, so foliage
genuinely occludes them. Reveal is a wipe uniform that tracks the sweep's
trailing edge — the visible edge always sits under the dense part of the
band, so the type reads as uncovered by the passing leaves, never faded. */
function headlineTexture(text) {
// Enough source detail for the headline at both 1x and 2x output density.
const c = document.createElement("canvas");
c.width = 4096;
c.height = 1024;
const g = c.getContext("2d");
g.clearRect(0, 0, c.width, c.height);
g.textAlign = "center";
g.textBaseline = "middle";
let size = 380;
const applyFont = () => {
g.font = `${FONT_WEIGHT} ${size}px ${FONT_STACK}`;
g.letterSpacing = `${Math.round(size * LETTER_SP)}px`;
};
applyFont();
let tw = g.measureText(text).width;
if (tw > 3760) {
size = Math.floor((size * 3760) / tw);
applyFont();
}
/* User size multiplier rides on top of the auto-fit. */
size = Math.round(size * FONT_SIZE);
applyFont();
g.shadowColor = "rgba(0,0,0,0.5)";
g.shadowBlur = 30;
g.shadowOffsetY = 8;
g.fillStyle = "#e4eedb";
g.fillText(text, c.width / 2 + Math.round(size * LETTER_SP * 0.5), c.height / 2 + 16);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 16;
return t;
}
function headlinePlane(text, second = false) {
const mat = new THREE.MeshBasicMaterial({
map: headlineTexture(text),
transparent: true,
toneMapped: false,
fog: false,
depthWrite: false,
});
const uniforms = { uIn: { value: -0.05 }, uOut: { value: -0.05 }, uLift: { value: -0.05 } };
mat.onBeforeCompile = (shader) => {
shader.uniforms.uIn = uniforms.uIn;
shader.uniforms.uOut = uniforms.uOut;
shader.uniforms.uLift = uniforms.uLift;
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
"#include <common>\nuniform float uIn;\nuniform float uOut;\nuniform float uLift;",
)
.replace(
"#include <map_fragment>",
`#include <map_fragment>
float eIn = uIn + sin(vMapUv.y * 43.0) * 0.012;
float eOut = uOut + sin(vMapUv.y * 37.0 + 2.0) * 0.012;
float vis = smoothstep(eIn + 0.02, eIn - 0.02, vMapUv.x) * smoothstep(eOut - 0.02, eOut + 0.02, vMapUv.x);
float lifted = smoothstep(uLift - 0.08, uLift + 0.08, vMapUv.y);
diffuseColor.a *= vis * ${second ? "(1.0 - lifted)" : "lifted"};`,
);
};
mat.customProgramCacheKey = () => "cpt-headline-lift-" + second;
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(16.4, 4.1), mat);
mesh.position.set(0, 0.15, TEXT_Z);
mesh.renderOrder = 2;
// Exclude type from the bokeh colour/depth passes. Its alpha edges must
// not be blurred using the discontinuous text/backdrop depth samples.
mesh.layers.set(1);
scene.add(mesh);
return {
mesh,
mat,
uniforms,
retex: (t2) => {
const previous = mat.map;
mat.map = headlineTexture(t2);
previous.dispose();
mat.needsUpdate = true;
},
};
}
const plane1 = headlinePlane(HEADLINE1);
const plane2 = headlinePlane(HEADLINE2, true);
// ---- Glow motes (drift is a pure function of seed and t).
const DUST_COUNT = 800;
const dustHome = Float32Array.from(window.__cptDust.home);
const dustSeeds = Float32Array.from(window.__cptDust.seeds);
const dustPositions = dustHome.slice();
const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute("position", new THREE.BufferAttribute(dustPositions, 3));
dustGeo.setAttribute("seed", new THREE.BufferAttribute(dustSeeds, 1));
const moteCanvas = document.createElement("canvas");
moteCanvas.width = moteCanvas.height = 64;
const moteCtx = moteCanvas.getContext("2d");
const moteGradient = moteCtx.createRadialGradient(32, 32, 0, 32, 32, 31);
moteGradient.addColorStop(0, "rgba(255,255,232,1)");
moteGradient.addColorStop(0.16, "rgba(224,255,202,.92)");
moteGradient.addColorStop(0.5, "rgba(180,228,160,.28)");
moteGradient.addColorStop(1, "rgba(140,205,125,0)");
moteCtx.fillStyle = moteGradient;
moteCtx.fillRect(0, 0, 64, 64);
const moteSprite = new THREE.CanvasTexture(moteCanvas);
moteSprite.colorSpace = THREE.SRGBColorSpace;
const dustMat = new THREE.PointsMaterial({
color: new THREE.Color("#daf9b4"),
map: moteSprite,
size: 0.1,
transparent: true,
opacity: 0.55,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const dust = new THREE.Points(dustGeo, dustMat);
scene.add(dust);
// BokehPass depth prepass with alpha-aware materials (ported from the experiment).
const dofDepthMaterials = new Map();
function depthMaterialFor(source) {
const key = source.uuid;
if (dofDepthMaterials.has(key)) return dofDepthMaterials.get(key);
const depth = new THREE.MeshDepthMaterial({
depthPacking: THREE.RGBADepthPacking,
map: source.map || null,
alphaTest: source.alphaTest || 0.5,
side: source.side,
depthTest: true,
depthWrite: true,
});
dofDepthMaterials.set(key, depth);
return depth;
}
bokehPass.render = function (activeRenderer, writeBuffer, readBuffer) {
const swapped = [];
const dustVisible = dust.visible;
dust.visible = false;
this.scene.traverse((object) => {
if (!object.isMesh) return;
swapped.push([object, object.material]);
object.material = depthMaterialFor(object.material);
});
try {
activeRenderer.setRenderTarget(this.renderTargetDepth);
activeRenderer.clear();
activeRenderer.render(this.scene, this.camera);
} finally {
for (const [object, material] of swapped) object.material = material;
dust.visible = dustVisible;
}
this.uniforms.tColor.value = readBuffer.texture;
this.uniforms.tDepth.value = this.renderTargetDepth.texture;
activeRenderer.setRenderTarget(writeBuffer);
if (this.clear) activeRenderer.clear();
this.fsQuad.render(activeRenderer);
// Bring the blurred foliage back without clearing the ORIGINAL colour
// pass's depth. That depth includes the leaves' alpha cutouts and wind
// deformation, so the unblurred headline still sits behind the canopy.
const copy = composer.copyPass;
copy.renderToScreen = false;
const background = scene.background;
const autoClear = activeRenderer.autoClear;
const layers = camera.layers.mask;
const shadowAutoUpdate = activeRenderer.shadowMap.autoUpdate;
try {
scene.background = null;
activeRenderer.autoClear = false;
activeRenderer.shadowMap.autoUpdate = false;
copy.render(activeRenderer, readBuffer, writeBuffer);
camera.layers.set(1);
activeRenderer.render(scene, camera);
} finally {
scene.background = background;
activeRenderer.autoClear = autoClear;
activeRenderer.shadowMap.autoUpdate = shadowAutoUpdate;
camera.layers.mask = layers;
}
// Keep the existing colour pipeline for both foliage and typography.
copy.renderToScreen = this.renderToScreen;
copy.render(activeRenderer, writeBuffer, readBuffer);
};
const cameraTarget = new THREE.Vector3(0, 0, -1.5);
const cameraOffset = new THREE.Vector3();
const cameraOrbit = new THREE.Euler(0, 0, 0, "YXZ");
const degToRad = THREE.MathUtils.degToRad;
const DJ = 0.5;
function motionClock(t) {
const end = window.__cptWindows.s2 + window.__cptWindows.d2;
const duration = Math.max(0.1, 10.5 - end);
const p = clamp((t - end) / duration, 0, 1);
return t <= end ? t : end + duration * (p - (p * p) / 2);
}
function openingWind(spec, t) {
const wt = motionClock(t) * WIND_SPEED;
const gust = Math.sin(wt * 0.23 + spec.restX * 0.42 + Math.sin(spec.y0 * 0.37 + wt * 0.11));
const sway =
(Math.sin(wt * 0.46 + spec.phase) * 0.011 +
Math.sin(wt * 0.58 + spec.y0 * 0.74) * 0.007 +
Math.max(0, gust) ** 3.6 * 0.03) *
WIND_STRENGTH;
return [sway, Math.sin(wt * 0.31 + spec.phase) * 0.02];
}
// Sample the authored incoming path, including its actual rotation/wind.
// Only retained leaves use this path; the passing leaf wall stays intact.
function retainedSweepPose(s, t) {
const w = window.__cptWindows;
if (s.batch === 2) {
const p = clamp(clamp((t - w.s2) / w.d2, 0, 1) * 1.28 - s.liftDelay, 0, 1);
const lift = smoothstep(0, 0.58, p),
seat = smoothstep(0.38, 1, p);
return [
THREE.MathUtils.lerp(s.liftX, s.restX, seat),
THREE.MathUtils.lerp(s.liftY, s.restY, seat) -
(s.liftY - s.liftStartY) * (1 - smoothstep(0, 0.42, p)),
THREE.MathUtils.lerp(s.liftZ, s.z, lift),
s.tiltX + seat * 3.6 * Math.sin(s.phase),
s.tiltY + seat * 3.6 * Math.cos(s.phase * 1.7),
THREE.MathUtils.lerp(s.rotation, s.restRZ, seat),
];
}
const p = clamp(clamp((t - 0.4) / w.d1, 0, 1) * 1.5 - s.delay, 0, 1);
const pe = 1 - Math.pow(1 - p, 3);
const rz = s.rotation + pe * s.spin * 0.55;
const turn =
((((s.restRZ - rz + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
const [wind, roll] = openingWind(s, t);
return [
s.xStart + (s.restX - s.xStart) * pe,
s.y0 +
(s.restY - s.y0) * pe +
Math.sin(pe * Math.PI * 1.4 + s.phase) * s.bobAmp * (1 - pe) * 0.8 +
wind * 0.14,
s.z,
s.tiltX + wind,
s.tiltY + wind * 0.65,
rz + turn * smoothstep(0.55, 1, pe) + roll,
];
}
function retainedIdlePose(spec, t, handoff) {
const b = spec.breeze,
age = t - handoff.start,
a = age * b.rate;
const pose = handoff.center.slice();
if (spec.batch === 1) {
const [wind, roll] = openingWind(spec, t);
pose[1] += wind * 0.14;
pose[3] += wind;
pose[4] += wind * 0.65;
pose[5] += roll;
}
const floatX = Math.sin(a + b.phase) * 0.72 + Math.sin(a * 0.613 + b.phase2) * 0.28;
const floatY = Math.sin(a * 0.79 + b.phase2) * 0.76 + Math.sin(a * 1.137 + b.phase3) * 0.24;
const floatZ = Math.sin(a * 0.53 + b.phase3);
const offsets = [
b.driftX * floatX,
b.driftY * floatY,
b.depth + b.driftZ * floatZ,
b.tiltX * Math.sin(a * 0.67 + b.phase2),
b.tiltY * Math.sin(a * 0.47 + b.phase3),
b.roll * floatX + b.turn * age,
];
return pose.map((v, i) => v + RETAINED_BREEZE * offsets[i]);
}
// Join while each leaf still has momentum, before its old stop point.
// Quintic Hermite correction matches position, velocity AND acceleration
// at both ends. The moving breeze target is present throughout settling;
// there is no idle timer, zero-velocity rest, or later amplitude fade-in.
for (const { spec: s } of leaves) {
if (!s.stayer) continue;
const w = window.__cptWindows,
first = s.batch === 1;
const rate = first ? 1.5 / w.d1 : 1.28 / w.d2;
const progress = (first ? 0.64 : 0.78) + s.breeze.arrival * 0.12;
const start = (first ? 0.4 : w.s2) + (progress + (first ? s.delay : s.liftDelay)) / rate;
const end = first ? 0.4 + w.d1 : w.s2 + w.d2;
const center = retainedSweepPose(s, end);
const incoming = retainedSweepPose(s, start);
if (first) {
const [wind, roll] = openingWind(s, end);
center[1] -= wind * 0.14;
center[3] -= wind;
center[4] -= wind * 0.65;
center[5] -= roll;
}
for (let i = 3; i < 6; i++)
center[i] += Math.round((incoming[i] - center[i]) / (2 * Math.PI)) * 2 * Math.PI;
const h = (s.handoff = { start, duration: (first ? 0.52 : 0.48) / rate, center });
const dt = 0.001;
const delta = (time) =>
retainedSweepPose(s, time).map((v, i) => v - retainedIdlePose(s, time, h)[i]);
const a = delta(start - dt),
b = delta(start),
c = delta(start + dt);
h.correction = b.map((v, i) => [
v,
(c[i] - a[i]) / (2 * dt),
(c[i] - 2 * v + a[i]) / (dt * dt),
]);
}
function applyRetainedMotion(group, spec, t) {
const h = spec.handoff;
if (!h || !group.visible) return;
if (t < h.start) {
// Use the same unrounded clock on both sides of the handoff; GSAP's
// rounded scalar writes must not introduce a tiny boundary jump.
const incoming = retainedSweepPose(spec, t);
group.position.set(incoming[0], incoming[1], incoming[2]);
group.rotation.set(incoming[3], incoming[4], incoming[5]);
return;
}
const pose = retainedIdlePose(spec, t, h);
const u = clamp((t - h.start) / h.duration, 0, 1);
const u2 = u * u,
u3 = u2 * u,
u4 = u3 * u,
u5 = u4 * u;
const p = 1 - 10 * u3 + 15 * u4 - 6 * u5;
const v = u - 6 * u3 + 8 * u4 - 3 * u5;
const a = (u2 - 3 * u3 + 3 * u4 - u5) / 2;
for (let i = 0; i < 6; i++) {
const c = h.correction[i];
pose[i] += c[0] * p + c[1] * h.duration * v + c[2] * h.duration * h.duration * a;
}
if (spec.batch === 1) {
// Carry the current floating pose into takeoff, never pull it back
// to a rest slot. Quintic lift starts with zero added speed/acceleration.
const m = clamp((t - window.__cptWindows.s2) / window.__cptWindows.d2, 0, 1);
const q = clamp(m * 1.5 - spec.delay2 * 0.4, 0, 1);
const lift = q * q * q * (10 + q * (-15 + 6 * q));
pose[1] += lift * 6;
pose[2] += lift * 34;
pose[5] += lift * spec.spin * 0.55;
}
group.position.set(pose[0], pose[1], pose[2]);
group.rotation.set(pose[3], pose[4], pose[5]);
}
let disposed = false;
function renderAt(t) {
if (disposed) return;
t = clamp(t, 0, DUR);
const settleTime = window.__cptWindows.s2 + window.__cptWindows.d2;
const settleDuration = Math.max(0.1, 10.5 - settleTime);
const settleProgress = clamp((t - settleTime) / settleDuration, 0, 1);
const motionTime =
t <= settleTime
? t
: settleTime +
settleDuration * (settleProgress - (settleProgress * settleProgress) / 2);
const wt = motionTime * WIND_SPEED;
const m1 = state.sw1;
const m2 = state.sw2;
for (const { group, spec } of leaves) {
// Depth lift: emerge from the backdrop, cross the type plane,
// rise through the focal range, then pass the camera (source exitDistance=34).
// No lateral sweep, frame history, pointer force, or accumulated physics.
if (spec.batch === 2) {
const p = clamp(m2 * 1.28 - spec.liftDelay, 0, 1);
// At p=0 it is already below frame; at p=1 passers are behind
// the camera. No visibility/opacity switch at either boundary.
group.visible = true;
const entry = smoothstep(0, 0.42, p);
const lift = smoothstep(0, 0.58, p);
const exit = smoothstep(0.38, 1, p);
const seat = smoothstep(0.38, 1, p);
const z = spec.stayer
? THREE.MathUtils.lerp(spec.liftZ, spec.z, lift)
: spec.liftZ + lift * 8 * spec.depthResponse + exit * 34;
const orbit = exit * 1.15;
const x = spec.stayer
? THREE.MathUtils.lerp(spec.liftX, spec.restX, seat)
: spec.liftX + Math.cos(spec.phase + p * 1.3) * orbit;
const liftedY = spec.stayer
? THREE.MathUtils.lerp(spec.liftY, spec.restY, seat)
: spec.liftY + lift * 2.2 + exit * 3.8;
const y = liftedY - (spec.liftY - spec.liftStartY) * (1 - entry);
group.position.set(x, y, z);
group.rotation.set(
spec.tiltX + exit * 3.6 * Math.sin(spec.phase),
spec.tiltY + exit * 3.6 * Math.cos(spec.phase * 1.7),
spec.stayer
? THREE.MathUtils.lerp(spec.rotation, spec.restRZ, seat)
: spec.rotation + exit * 3.6 * 0.55,
);
continue;
}
const m = spec.batch === 1 ? m1 : m2;
const p = clamp(m * (1 + DJ) - spec.delay, 0, 1);
if (p <= 0.0005) {
group.visible = false;
continue;
}
group.visible = true;
// Analytic wind sway, always applied (scaled up once settled).
const gustCell = Math.sin(
wt * 0.23 + spec.restX * 0.42 + Math.sin(spec.y0 * 0.37 + wt * 0.11),
);
const localGust = Math.max(0, gustCell) ** 3.6;
const slowWind =
(Math.sin(wt * 0.46 + spec.phase) * 0.011 +
Math.sin(wt * 0.58 + spec.y0 * 0.74) * 0.007 +
localGust * 0.03) *
WIND_STRENGTH;
let x,
y,
rz,
z = spec.z;
if (!spec.stayer) {
const pe = p * p * (3 - 2 * p);
x = spec.xStart + (spec.xEnd - spec.xStart) * pe;
y = spec.y0 + Math.sin(pe * Math.PI * spec.bobN + spec.phase) * spec.bobAmp;
rz = spec.rotation + pe * spec.spin;
} else {
const pe = 1 - Math.pow(1 - p, 3);
x = spec.xStart + (spec.restX - spec.xStart) * pe;
y =
spec.y0 +
(spec.restY - spec.y0) * pe +
Math.sin(pe * Math.PI * 1.4 + spec.phase) * spec.bobAmp * (1 - pe) * 0.8;
const travelRz = spec.rotation + pe * spec.spin * 0.55;
const seat = smoothstep(0.55, 1, pe);
const dRz =
((((spec.restRZ - travelRz + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) %
(2 * Math.PI)) -
Math.PI;
rz = travelRz + dRz * seat;
if (spec.batch === 1 && m2 > 0) {
// Existing edge leaves join the same upward depth current.
const q = clamp(m2 * 1.5 - spec.delay2 * 0.4, 0, 1);
const qe = smoothstep(0, 1, q);
z += qe * 34;
y += qe * 6;
rz += qe * spec.spin * 0.55;
}
}
group.position.set(x, y + slowWind * 0.14, z);
group.rotation.set(
spec.tiltX + slowWind,
spec.tiltY + slowWind * 0.65,
rz + Math.sin(wt * 0.31 + spec.phase) * 0.02,
);
}
// The background/camera can settle while retained leaves keep breathing
// through the final hold. Use absolute t, never accumulated frame deltas.
for (const { group, spec } of leaves) applyRetainedMotion(group, spec, t);
/* Reveal wipes, derived from the sweep masters. Tuned so the visible wipe
edge stays inside the dense part of the leaf band (verified visually). */
plane1.uniforms.uIn.value = clamp((m1 - 0.3) / 0.52, 0, 1) * 1.1 - 0.05;
plane1.uniforms.uOut.value = -0.05;
plane2.uniforms.uIn.value = 1.05;
plane1.uniforms.uLift.value = smoothstep(0.25, 0.43, m2) * 1.3 - 0.15;
plane2.uniforms.uLift.value = smoothstep(0.4, 0.62, m2) * 1.3 - 0.15;
// Source experiment DOF at lift peak; return to V1's crisp hold.
const lens = smoothstep(0.05, 0.28, m2) * (1 - smoothstep(0.72, 1, m2));
bokehPass.uniforms.aperture.value = 0.0016 + lens * 0.0005;
bokehPass.uniforms.maxblur.value = 0.0055 + lens * 0.0035;
plane2.uniforms.uOut.value = -0.05;
for (const material of shaderMaterials) {
material.userData.timeUniform.value = wt;
}
for (let i = 0; i < DUST_COUNT; i++) {
const o = i * 3;
const s = dustSeeds[i];
dustPositions[o] = dustHome[o] + Math.sin(wt * 0.19 + s * 18) * 0.36;
dustPositions[o + 1] = dustHome[o + 1] + Math.cos(wt * 0.14 + s * 27) * 0.38;
dustPositions[o + 2] = dustHome[o + 2] + Math.sin(wt * 0.11 + s * 41) * 0.4;
}
dustGeo.attributes.position.needsUpdate = true;
const swayX = Math.sin(motionTime * 0.31) * 0.6 + Math.sin(motionTime * 0.127 + 2.1) * 0.5;
const swayY = Math.sin(motionTime * 0.083 + 1.7) * 0.7;
cameraOrbit.set(degToRad(-swayY), degToRad(swayX), 0, "YXZ");
cameraOffset.set(0, 0.15, CAM_DIST).applyEuler(cameraOrbit);
camera.position.copy(cameraTarget).add(cameraOffset);
camera.lookAt(cameraTarget);
dust.rotation.x = degToRad(-swayY) * 0.6;
dust.rotation.y = degToRad(swayX) * 0.6;
composer.render();
}
function onSeek(event) {
const time = clamp(Number(event.detail?.time) || 0, 0, DUR);
const timeline = window.__timelines["canopy-part-title"];
timeline.totalTime(time, true);
renderAt(time);
}
window.addEventListener("hf-seek", onSeek);
window.__cptInstance = {
dispose() {
if (disposed) return;
disposed = true;
window.removeEventListener("hf-seek", onSeek);
const resources = new Set();
scene.traverse((object) => {
if (object.geometry) resources.add(object.geometry);
for (const mat of [object.material].flat().filter(Boolean)) {
resources.add(mat);
for (const value of Object.values(mat)) if (value?.isTexture) resources.add(value);
}
});
for (const mat of dofDepthMaterials.values()) resources.add(mat);
for (const resource of resources) resource.dispose();
bokehPass.dispose();
composer.dispose();
renderer.dispose();
renderer.forceContextLoss();
window.__cptRender = null;
},
};
window.__cptRender = renderAt;
const tl = window.__timelines["canopy-part-title"];
// Compile every leaf material before the first time sample. Hidden leaves
// otherwise compile on their first visible seek with uTime=0, making that
// frame differ from a later seek to the same time.
for (const { group } of leaves) group.visible = true;
renderer.compile(scene, camera);
renderAt(0);
renderAt(window.__hfThreeTime ?? (tl ? tl.time() : 0));
/* Draw across the timeline and flush the GPU before ready, so shader links
and texture uploads land in the readiness gate, not the first visible frames. */
async function warmGpu() {
const dur = tl ? tl.duration() : 0;
for (const f of [0.25, 0.5, 0.75]) {
await new Promise((resolve) => setTimeout(resolve, 0));
renderAt(dur * f);
}
renderAt(tl ? tl.time() : 0);
renderer.getContext().finish();
}
window.__hf = window.__hf || {};
window.__hf.buildReady = window.__hf.buildReady || {};
if (document.fonts && document.fonts.ready) {
window.__cptReady = Promise.all([
document.fonts.load(`${FONT_WEIGHT} 100px "${FONT}"`).catch(() => {}),
document.fonts.ready,
])
.then(() => {
if (disposed) return;
plane1.retex(HEADLINE1);
plane2.retex(HEADLINE2);
renderAt(tl ? tl.time() : 0);
return warmGpu();
})
.catch(() => {});
window.__hf.buildReady["canopy-part-title"] = window.__cptReady;
} else {
window.__hf.buildReady["canopy-part-title"] = warmGpu();
}
</script>
</body>
</html>
registry-item.json›
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "canopy-part-title",
"type": "hyperframes:block",
"title": "Canopy Part Title",
"description": "Leaves sweep through the frame and part to reveal the headline.",
"tags": [
"3d-motion",
"title-card",
"leaves",
"organic",
"depth-of-field",
"two-headlines",
"sweep"
],
"dimensions": {
"width": 1920,
"height": 1080
},
"duration": 12,
"files": [
{
"path": "canopy-part-title.html",
"target": "compositions/canopy-part-title/canopy-part-title.html",
"type": "hyperframes:composition"
},
{
"path": "assets/leaf-surface-color.webp",
"target": "compositions/canopy-part-title/assets/leaf-surface-color.webp",
"type": "hyperframes:asset"
},
{
"path": "assets/leaf-surface-normal.webp",
"target": "compositions/canopy-part-title/assets/leaf-surface-normal.webp",
"type": "hyperframes:asset"
},
{
"path": "SKILL.md",
"target": "compositions/canopy-part-title/SKILL.md",
"type": "hyperframes:asset"
}
],
"preview": {
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/canopy-part-title.mp4",
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/canopy-part-title.png"
}
}
SKILL.md›
---
name: canopy-part-title
description: Leaves sweep through the frame and part to reveal the headline. HyperFrames block, 1920×1080, 12s, 11 variables.
---
# Canopy Part Title
A dense canopy of textured leaves sweeps across the frame with shallow depth of field, then parts to uncover the first headline; a second, depth-lifted batch carries the second headline past the camera. A handful of leaves stay behind on the type and keep a light breeze. Font, weight, size, letter spacing, leaf counts, sweep speed and the retained-leaf breeze are variables.
Composition id: `canopy-part-title`. Duration 12 s at 30 fps, 1920×1080.
## Files
- `canopy-part-title.html` (57 KB)
- `assets/leaf-surface-color.webp` (64 KB)
- `assets/leaf-surface-normal.webp` (136 KB)
## Install
Install with `npx hyperframes add canopy-part-title`; by default the files above land under `compositions/canopy-part-title/`. Then mount the block from the host `index.html`:
```html
<div
data-composition-id="canopy-part-title"
data-composition-src="compositions/canopy-part-title/canopy-part-title.html"
data-start="0"
data-duration="12"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
```
Render with custom values by targeting the composition file directly:
```sh
npx --yes [email protected] render 'compositions/canopy-part-title/canopy-part-title.html' --variables '{"headline1":"Understory","headline2":"Move slowly"}'
```
## Variables
Read at runtime via `window.__hyperframes.getVariables()`; declared on the composition root as `data-composition-variables` (single-quoted attribute, plain JSON).
| id | type | default | label / range |
| ---------------- | ------ | --------------- | ---------------------------------------- |
| `headline1` | string | `"Understory"` | Headline 1 |
| `headline2` | string | `"Move slowly"` | Headline 2 |
| `font` | string | `"Helvetica"` | Headline font |
| `fontWeight` | number | `900` | Font weight 100–900 step 100 |
| `fontSize` | number | `1` | Font size (1 = auto-fit) 0.3–2 step 0.01 |
| `letterSpacing` | number | `0.01` | Letter spacing (em) -0.2–1 step 0.01 |
| `background` | color | `"#020805"` | Background |
| `leafCount` | number | `170` | Leaves per sweep 40–320 step 1 |
| `stayCount` | number | `5` | Leaves left behind 0–20 step 1 |
| `sweepSpeed` | number | `1` | Sweep speed 0.3–3 step 0.05 |
| `retainedBreeze` | number | `1` | Retained leaf breeze 0–2 step 0.05 |
## Runtime contract
- One paused GSAP timeline registered as `window.__timelines["canopy-part-title"]`.
- Re-syncs on the `hf-seek` CustomEvent; every frame is a closed-form function of time (seeded PRNG only, no rAF loops, no Date.now).
- Renderer: three.js 0.170.0, GSAP 3.14.2, Canvas 2D, Post-processing, Seeded PRNG, Shadow maps. Budget roughly 350 MB per live instance; run one at a time.
- External runtime dependencies: `https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js`, `https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js`, `https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/postprocessing/EffectComposer.js`, `https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/postprocessing/RenderPass.js`, `https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/postprocessing/BokehPass.js`.
- Web fonts from Google Fonts: Gelasio.
## Editing rules (from the source project)
1. Keep `data-composition-variables` a single-quoted attribute with plain `"` JSON. Never save it through Studio's Design panel.
2. Do not put `<canvas>` in static markup; create it at runtime.
3. Keep every visual state a function of t; seek-safety is what makes the block renderable.