@babylonjs-toolkit/agent 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +344 -0
- package/bin/bt-agent.js +266 -0
- package/lib/doctor.js +59 -0
- package/lib/install.js +145 -0
- package/lib/manifest.js +46 -0
- package/lib/paths.js +66 -0
- package/lib/payload.js +56 -0
- package/lib/persona.js +177 -0
- package/lib/targets.js +105 -0
- package/package.json +43 -0
- package/persona.md +5 -0
- package/scripts/postinstall.js +49 -0
- package/skills/bt-atlas/SKILL.md +192 -0
- package/skills/bt-atlas/scripts/composite_skin.py +58 -0
- package/skills/bt-atlas/scripts/preview.py +70 -0
- package/skills/bt-atlas/scripts/requirements.txt +2 -0
- package/skills/bt-atlas/scripts/uv_island_mask.py +87 -0
- package/skills/bt-convert/SKILL.md +32 -0
- package/skills/bt-copycat/SKILL.md +184 -0
- package/skills/bt-design/SKILL.md +187 -0
- package/skills/bt-design/references/3d-hero-docs.md +976 -0
- package/skills/bt-design/references/3d-hero-scroll.md +269 -0
- package/skills/bt-design/templates/3d-hero-scroll/HeroScroll.tsx +167 -0
- package/skills/bt-design/templates/3d-hero-scroll/hero-scroll.css +268 -0
- package/skills/bt-design/templates/3d-hero-scroll/hero-scroll.d.ts +67 -0
- package/skills/bt-design/templates/3d-hero-scroll/hero-scroll.html +78 -0
- package/skills/bt-design/templates/3d-hero-scroll/hero-scroll.js +559 -0
- package/skills/bt-execute/SKILL.md +130 -0
- package/skills/bt-gauntlet/SKILL.md +335 -0
- package/skills/bt-hero/SKILL.md +158 -0
- package/skills/bt-landing/SKILL.md +126 -0
- package/skills/bt-plan/SKILL.md +172 -0
- package/skills/bt-prototype/SKILL.md +161 -0
- package/skills/bt-spec/SKILL.md +328 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
3D-HERO-SCROLL — scroll-scrubbed cinematic hero engine
|
|
3
|
+
Generic, brand-agnostic, config-driven. Pairs with hero-scroll.css.
|
|
4
|
+
|
|
5
|
+
FRAMEWORK-AGNOSTIC ESM. One engine, two hosts:
|
|
6
|
+
|
|
7
|
+
• Plain HTML — load as a module; it auto-boots against `document` when
|
|
8
|
+
`window.HS_CONFIG` is set and a `#hs-journey` exists:
|
|
9
|
+
<script type="module" src="hero-scroll.js"></script>
|
|
10
|
+
• React / Vue / any bundler — import and mount against your own root, then
|
|
11
|
+
tear down on unmount (see HeroScroll.tsx):
|
|
12
|
+
import { initHeroScroll } from "./hero-scroll";
|
|
13
|
+
const hs = initHeroScroll(rootEl, config); // rootEl contains the markup
|
|
14
|
+
// ...later: hs.destroy();
|
|
15
|
+
|
|
16
|
+
Config (all keys optional; pass as `window.HS_CONFIG` or the 2nd arg):
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
video: "media/journey-scrub.mp4", // ALL-INTRA encoded (-g 1) mp4
|
|
20
|
+
smoothing: 0.16, // scrub low-pass factor (1 = none)
|
|
21
|
+
motionBlur: true, // scroll-velocity blur on footage
|
|
22
|
+
fallbackClass: "hs-no-video", // body class when footage missing
|
|
23
|
+
telemetry: { // omit (null) to disable HUD math
|
|
24
|
+
max: 250, startP: 0.286, endP: 0.95, exponent: 0.45,
|
|
25
|
+
segments: [[0.0,"SEG 01"], [0.25,"SEG 02"]],
|
|
26
|
+
},
|
|
27
|
+
sweep: "page", // "page" (default): PLAY glides on to
|
|
28
|
+
// the document bottom and END jumps
|
|
29
|
+
// there (full cinematic ride);
|
|
30
|
+
// "hero": PLAY and END stop at the
|
|
31
|
+
// journey's end — you land on the
|
|
32
|
+
// regular section right after the
|
|
33
|
+
// hero, same as a normal scroll.
|
|
34
|
+
// (Names where autoplay/jumps end;
|
|
35
|
+
// deliberately NOT called "reach" so
|
|
36
|
+
// it never collides with route/DOM
|
|
37
|
+
// scope in a spec/plan.)
|
|
38
|
+
tailRate: 0.5, // autoplay px/s after journey,
|
|
39
|
+
// as a fraction of the viewport
|
|
40
|
+
// (sweep: "page" only)
|
|
41
|
+
veilFadeMs: 480, // veiled-cut fade duration
|
|
42
|
+
veilHoldMs: 450, // hold on black (sweep plays here)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Required markup (inside `root`; see hero-scroll.html / HeroScroll.tsx):
|
|
46
|
+
#hs-journey > .hs-stage > #hs-video the scrub stage
|
|
47
|
+
Optional markup — each feature activates only if its element exists:
|
|
48
|
+
#hs-loader (+ #hs-loader-fill, #hs-loader-pct) preload screen
|
|
49
|
+
#hs-hud (+ #hs-hud-value, #hs-hud-progress,
|
|
50
|
+
#hs-hud-seg, #hs-hud-pct) telemetry HUD
|
|
51
|
+
#hs-play (+ .hs-play-label) film-speed autoplay
|
|
52
|
+
#hs-top / #hs-end veiled jump nav
|
|
53
|
+
.hs-ovl[data-from][data-to] choreographed overlays
|
|
54
|
+
[data-hs-depth] children parallax drift
|
|
55
|
+
[data-hs-count][data-target][data-decimals] count-up stats
|
|
56
|
+
The #hs-veil element is injected automatically (and removed on destroy).
|
|
57
|
+
|
|
58
|
+
initHeroScroll(root, config) → { play, stop, veilTo, progress, destroy }
|
|
59
|
+
═══════════════════════════════════════════════════════════════════ */
|
|
60
|
+
|
|
61
|
+
"use strict";
|
|
62
|
+
|
|
63
|
+
const DEFAULTS = {
|
|
64
|
+
video: "media/journey-scrub.mp4",
|
|
65
|
+
smoothing: 0.16,
|
|
66
|
+
motionBlur: true,
|
|
67
|
+
fallbackClass: "hs-no-video",
|
|
68
|
+
telemetry: null,
|
|
69
|
+
sweep: "page",
|
|
70
|
+
tailRate: 0.5,
|
|
71
|
+
veilFadeMs: 480,
|
|
72
|
+
veilHoldMs: 450,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mount the hero-scroll engine against a root element (or `document`).
|
|
77
|
+
* Returns a controller; call `.destroy()` to fully tear down (React unmount).
|
|
78
|
+
*/
|
|
79
|
+
export function initHeroScroll(root = document, userConfig = {}) {
|
|
80
|
+
const CFG = { ...DEFAULTS, ...userConfig };
|
|
81
|
+
|
|
82
|
+
const scope = root === document ? document : root;
|
|
83
|
+
const $ = (s) => scope.querySelector(s);
|
|
84
|
+
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
85
|
+
const linear = (t) => t;
|
|
86
|
+
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
|
|
87
|
+
const easeInOutCubic = (t) =>
|
|
88
|
+
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
89
|
+
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
90
|
+
|
|
91
|
+
const journey = $("#hs-journey");
|
|
92
|
+
const video = $("#hs-video");
|
|
93
|
+
if (!journey || !video) return { destroy() {} }; // nothing to drive
|
|
94
|
+
|
|
95
|
+
// optional chrome — every feature is presence-gated
|
|
96
|
+
const loaderFill = $("#hs-loader-fill");
|
|
97
|
+
const loaderPct = $("#hs-loader-pct");
|
|
98
|
+
const hud = $("#hs-hud");
|
|
99
|
+
const hudValue = $("#hs-hud-value");
|
|
100
|
+
const hudProgress = $("#hs-hud-progress");
|
|
101
|
+
const hudSeg = $("#hs-hud-seg");
|
|
102
|
+
const hudPct = $("#hs-hud-pct");
|
|
103
|
+
const playBtn = $("#hs-play");
|
|
104
|
+
const playLabel = playBtn && playBtn.querySelector(".hs-play-label");
|
|
105
|
+
const gotoTop = $("#hs-top");
|
|
106
|
+
const gotoEnd = $("#hs-end");
|
|
107
|
+
|
|
108
|
+
// the veil is engine-owned — inject it
|
|
109
|
+
const veil = document.createElement("div");
|
|
110
|
+
veil.id = "hs-veil";
|
|
111
|
+
veil.setAttribute("aria-hidden", "true");
|
|
112
|
+
document.body.appendChild(veil);
|
|
113
|
+
|
|
114
|
+
document.body.dataset.hsState = "loading";
|
|
115
|
+
|
|
116
|
+
/* ── scroll container resolution ───────────────────────────────────
|
|
117
|
+
CRITICAL for framework hosts: the page may scroll on `document.body`
|
|
118
|
+
or an overflow ancestor rather than the window (e.g. apps that set
|
|
119
|
+
`html, body { height: 100%; overflow-y: auto }`). Assuming the window
|
|
120
|
+
silently breaks scrubbing. Resolve the element that actually scrolls,
|
|
121
|
+
and read/write scroll through it. */
|
|
122
|
+
|
|
123
|
+
function resolveScroller(node) {
|
|
124
|
+
let el = node && node.parentElement;
|
|
125
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
126
|
+
const oy = getComputedStyle(el).overflowY;
|
|
127
|
+
if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight)
|
|
128
|
+
return el;
|
|
129
|
+
el = el.parentElement;
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
document.body.scrollHeight > document.body.clientHeight + 1 &&
|
|
133
|
+
getComputedStyle(document.body).overflowY !== "visible"
|
|
134
|
+
)
|
|
135
|
+
return document.body;
|
|
136
|
+
return document.scrollingElement || document.documentElement;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const scroller = resolveScroller(journey);
|
|
140
|
+
const isDocScroller =
|
|
141
|
+
scroller === document.scrollingElement ||
|
|
142
|
+
scroller === document.documentElement ||
|
|
143
|
+
scroller === document.body;
|
|
144
|
+
|
|
145
|
+
const getY = () => scroller.scrollTop;
|
|
146
|
+
const setY = (y) => {
|
|
147
|
+
scroller.scrollTop = y;
|
|
148
|
+
};
|
|
149
|
+
const viewportH = () => (isDocScroller ? innerHeight : scroller.clientHeight);
|
|
150
|
+
const maxY = () => scroller.scrollHeight - viewportH();
|
|
151
|
+
|
|
152
|
+
/* ── preload the footage as a blob so seeking is instant ──────── */
|
|
153
|
+
|
|
154
|
+
let videoReady = false;
|
|
155
|
+
let duration = 0;
|
|
156
|
+
let objectUrl = null;
|
|
157
|
+
let alive = true;
|
|
158
|
+
|
|
159
|
+
function setLoader(p) {
|
|
160
|
+
const pct = Math.round(clamp(p, 0, 1) * 100);
|
|
161
|
+
if (loaderFill) loaderFill.style.width = pct + "%";
|
|
162
|
+
if (loaderPct) loaderPct.textContent = String(pct).padStart(3, "0");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function preload() {
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(CFG.video);
|
|
168
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
169
|
+
const total = +res.headers.get("content-length") || 0;
|
|
170
|
+
const reader = res.body.getReader();
|
|
171
|
+
const chunks = [];
|
|
172
|
+
let received = 0;
|
|
173
|
+
for (;;) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) break;
|
|
176
|
+
if (!alive) return;
|
|
177
|
+
chunks.push(value);
|
|
178
|
+
received += value.length;
|
|
179
|
+
setLoader(total ? received / total : 0.5);
|
|
180
|
+
}
|
|
181
|
+
setLoader(1);
|
|
182
|
+
objectUrl = URL.createObjectURL(new Blob(chunks, { type: "video/mp4" }));
|
|
183
|
+
video.src = objectUrl;
|
|
184
|
+
await new Promise((ok, err) => {
|
|
185
|
+
video.addEventListener("loadedmetadata", ok, { once: true });
|
|
186
|
+
video.addEventListener("error", err, { once: true });
|
|
187
|
+
});
|
|
188
|
+
if (!alive) return;
|
|
189
|
+
duration = video.duration;
|
|
190
|
+
video.currentTime = 0;
|
|
191
|
+
videoReady = true;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
document.body.classList.add(CFG.fallbackClass); // poster still shows
|
|
194
|
+
setLoader(1);
|
|
195
|
+
}
|
|
196
|
+
if (alive) document.body.dataset.hsState = "ready";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ── overlay windows ───────────────────────────────────────────── */
|
|
200
|
+
|
|
201
|
+
const overlays = [...scope.querySelectorAll(".hs-ovl")].map((el) => ({
|
|
202
|
+
el,
|
|
203
|
+
from: parseFloat(el.dataset.from),
|
|
204
|
+
to: parseFloat(el.dataset.to),
|
|
205
|
+
active: false,
|
|
206
|
+
parallax: [...el.querySelectorAll("[data-hs-depth]")],
|
|
207
|
+
counters: [...el.querySelectorAll("[data-hs-count]")],
|
|
208
|
+
counting: false,
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
const FADE = 0.035; // progress-units to fade an overlay in/out
|
|
212
|
+
|
|
213
|
+
function runCounters(o) {
|
|
214
|
+
if (o.counting || !o.counters.length) return;
|
|
215
|
+
o.counting = true;
|
|
216
|
+
const t0 = performance.now();
|
|
217
|
+
const DUR = reduced ? 0 : 1400;
|
|
218
|
+
const tick = (now) => {
|
|
219
|
+
if (!alive || !o.counting) return;
|
|
220
|
+
const k = DUR ? easeInOutCubic(clamp((now - t0) / DUR, 0, 1)) : 1;
|
|
221
|
+
for (const c of o.counters) {
|
|
222
|
+
const target = parseFloat(c.dataset.target);
|
|
223
|
+
const dec = +(c.dataset.decimals || 0);
|
|
224
|
+
let v = (target * k).toFixed(dec);
|
|
225
|
+
if (c.dataset.group) v = (+v).toLocaleString("en-US");
|
|
226
|
+
c.textContent = v;
|
|
227
|
+
}
|
|
228
|
+
if (k < 1) requestAnimationFrame(tick);
|
|
229
|
+
};
|
|
230
|
+
requestAnimationFrame(tick);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function armCounters(o) {
|
|
234
|
+
o.counting = false;
|
|
235
|
+
for (const c of o.counters)
|
|
236
|
+
c.textContent = (0).toFixed(+(c.dataset.decimals || 0));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function styleOverlay(o, p) {
|
|
240
|
+
if (p < o.from - 0.01 || p > o.to + 0.01) {
|
|
241
|
+
if (o.el.style.visibility !== "hidden") {
|
|
242
|
+
o.el.style.visibility = "hidden";
|
|
243
|
+
o.el.style.opacity = "0";
|
|
244
|
+
}
|
|
245
|
+
if (o.active) {
|
|
246
|
+
o.active = false;
|
|
247
|
+
o.el.classList.remove("hs-active");
|
|
248
|
+
armCounters(o);
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// an overlay whose window starts at 0 must be fully risen at p = 0
|
|
253
|
+
const rise = o.from <= 0 ? 1 : clamp((p - o.from) / FADE, 0, 1);
|
|
254
|
+
const fall = clamp((o.to - p) / FADE, 0, 1);
|
|
255
|
+
const vis = Math.min(rise, fall);
|
|
256
|
+
const drift = (1 - rise) * 34 - (1 - fall) * 34;
|
|
257
|
+
const blur = reduced ? 0 : (1 - vis) * 14;
|
|
258
|
+
|
|
259
|
+
o.el.style.visibility = "visible";
|
|
260
|
+
o.el.style.opacity = vis.toFixed(3);
|
|
261
|
+
o.el.style.transform = `translateY(${drift.toFixed(1)}px)`;
|
|
262
|
+
o.el.style.filter = blur > 0.4 ? `blur(${blur.toFixed(1)}px)` : "none";
|
|
263
|
+
|
|
264
|
+
if (o.parallax.length && !reduced) {
|
|
265
|
+
const local = (p - o.from) / (o.to - o.from) - 0.5;
|
|
266
|
+
for (const m of o.parallax) {
|
|
267
|
+
const depth = parseFloat(m.dataset.hsDepth || "0.5");
|
|
268
|
+
m.style.transform = `translateY(${(-local * 60 * depth).toFixed(1)}px)`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (vis > 0.5 && !o.active) {
|
|
273
|
+
o.active = true;
|
|
274
|
+
o.el.classList.add("hs-active");
|
|
275
|
+
runCounters(o);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ── telemetry value curve ─────────────────────────────────────── */
|
|
280
|
+
|
|
281
|
+
const TEL = CFG.telemetry;
|
|
282
|
+
|
|
283
|
+
function valueAt(p) {
|
|
284
|
+
if (!TEL || p <= TEL.startP) return 0;
|
|
285
|
+
const span = Math.max(0.001, (TEL.endP ?? 0.95) - TEL.startP);
|
|
286
|
+
return (
|
|
287
|
+
TEL.max *
|
|
288
|
+
Math.pow(clamp((p - TEL.startP) / span, 0, 1), TEL.exponent ?? 0.45)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* ── frame loop ────────────────────────────────────────────────── */
|
|
293
|
+
|
|
294
|
+
let smoothP = 0;
|
|
295
|
+
let lastP = 0;
|
|
296
|
+
let journeyTop = 0;
|
|
297
|
+
let journeyScroll = 1;
|
|
298
|
+
let frameRaf = null;
|
|
299
|
+
|
|
300
|
+
function measure() {
|
|
301
|
+
const sTop = isDocScroller ? 0 : scroller.getBoundingClientRect().top;
|
|
302
|
+
journeyTop = journey.getBoundingClientRect().top - sTop + getY();
|
|
303
|
+
journeyScroll = Math.max(1, journey.offsetHeight - viewportH());
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// where PLAY/END consider "the end": journey end (sweep:"hero") or
|
|
307
|
+
// document bottom (sweep:"page")
|
|
308
|
+
const endY = () =>
|
|
309
|
+
CFG.sweep === "hero"
|
|
310
|
+
? journeyTop + journey.offsetHeight - viewportH()
|
|
311
|
+
: maxY();
|
|
312
|
+
|
|
313
|
+
function frame() {
|
|
314
|
+
const y = clamp(getY() - journeyTop, 0, journeyScroll);
|
|
315
|
+
const p = y / journeyScroll;
|
|
316
|
+
|
|
317
|
+
smoothP += (p - smoothP) * (reduced ? 1 : CFG.smoothing);
|
|
318
|
+
if (Math.abs(p - smoothP) < 0.0004) smoothP = p;
|
|
319
|
+
|
|
320
|
+
if (videoReady && duration) {
|
|
321
|
+
const t = smoothP * Math.max(0, duration - 0.05);
|
|
322
|
+
if (Math.abs(t - video.currentTime) > 0.02) video.currentTime = t;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (CFG.motionBlur && !reduced && videoReady) {
|
|
326
|
+
const blur = Math.min(5, Math.abs(smoothP - lastP) * 620);
|
|
327
|
+
video.style.filter = blur > 0.35 ? `blur(${blur.toFixed(2)}px)` : "none";
|
|
328
|
+
}
|
|
329
|
+
lastP = smoothP;
|
|
330
|
+
|
|
331
|
+
if (hud) {
|
|
332
|
+
if (hudValue) hudValue.textContent = Math.round(valueAt(smoothP));
|
|
333
|
+
if (hudProgress)
|
|
334
|
+
hudProgress.style.transform = `scaleX(${smoothP.toFixed(4)})`;
|
|
335
|
+
if (hudPct)
|
|
336
|
+
hudPct.textContent =
|
|
337
|
+
String(Math.round(smoothP * 100)).padStart(3, "0") + "%";
|
|
338
|
+
if (hudSeg && TEL && TEL.segments) {
|
|
339
|
+
let seg = TEL.segments[0][1];
|
|
340
|
+
for (const [at, label] of TEL.segments) if (smoothP >= at) seg = label;
|
|
341
|
+
hudSeg.textContent = seg;
|
|
342
|
+
}
|
|
343
|
+
hud.classList.toggle(
|
|
344
|
+
"hs-hud-off",
|
|
345
|
+
getY() > journeyTop + journey.offsetHeight - viewportH() * 0.55
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (gotoTop) gotoTop.classList.toggle("hs-nav-dim", getY() < 4);
|
|
350
|
+
if (gotoEnd) gotoEnd.classList.toggle("hs-nav-dim", getY() >= endY() - 4);
|
|
351
|
+
|
|
352
|
+
for (const o of overlays) styleOverlay(o, smoothP);
|
|
353
|
+
|
|
354
|
+
if (alive) frameRaf = requestAnimationFrame(frame);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ── veiled cut + film-speed autoplay ──────────────────────────── */
|
|
358
|
+
|
|
359
|
+
let autoActive = false;
|
|
360
|
+
let autoRaf = null;
|
|
361
|
+
let autoToken = 0; // invalidates in-flight timelines/veil callbacks
|
|
362
|
+
const timers = new Set();
|
|
363
|
+
const later = (fn, ms) => {
|
|
364
|
+
const id = setTimeout(() => {
|
|
365
|
+
timers.delete(id);
|
|
366
|
+
fn();
|
|
367
|
+
}, ms);
|
|
368
|
+
timers.add(id);
|
|
369
|
+
return id;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
function stopAuto() {
|
|
373
|
+
autoToken += 1;
|
|
374
|
+
autoActive = false;
|
|
375
|
+
if (autoRaf !== null) cancelAnimationFrame(autoRaf);
|
|
376
|
+
autoRaf = null;
|
|
377
|
+
veil.classList.remove("hs-on");
|
|
378
|
+
if (playBtn) {
|
|
379
|
+
playBtn.classList.remove("hs-playing");
|
|
380
|
+
playBtn.setAttribute("aria-pressed", "false");
|
|
381
|
+
if (playLabel) playLabel.textContent = playLabel.dataset.idle || "PLAY";
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Fade to black, reposition + re-sync the scrub under the veil
|
|
386
|
+
// (no visible fast-scroll), lift, then optionally continue.
|
|
387
|
+
function veilTo(targetY, done) {
|
|
388
|
+
const token = ++autoToken;
|
|
389
|
+
veil.classList.add("hs-on");
|
|
390
|
+
later(() => {
|
|
391
|
+
if (token !== autoToken) return;
|
|
392
|
+
setY(targetY);
|
|
393
|
+
const p = clamp((targetY - journeyTop) / journeyScroll, 0, 1);
|
|
394
|
+
smoothP = p; // snap the smoothed scrub — no reverse/fast-forward flash
|
|
395
|
+
lastP = p;
|
|
396
|
+
if (videoReady && duration)
|
|
397
|
+
video.currentTime = p * Math.max(0, duration - 0.05);
|
|
398
|
+
later(() => {
|
|
399
|
+
if (token !== autoToken) return;
|
|
400
|
+
veil.classList.remove("hs-on");
|
|
401
|
+
if (done)
|
|
402
|
+
later(() => {
|
|
403
|
+
if (token === autoToken) done(token);
|
|
404
|
+
}, 200); // continue as the veil lifts
|
|
405
|
+
}, CFG.veilHoldMs);
|
|
406
|
+
}, CFG.veilFadeMs);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function runFrom(token) {
|
|
410
|
+
if (token !== autoToken) return;
|
|
411
|
+
const max = maxY();
|
|
412
|
+
const journeyEnd = journeyTop + journey.offsetHeight - viewportH();
|
|
413
|
+
const filmRate = journeyScroll / (videoReady && duration ? duration : 30);
|
|
414
|
+
const tailRate = Math.max(420, viewportH() * CFG.tailRate);
|
|
415
|
+
|
|
416
|
+
const segs = [];
|
|
417
|
+
let from = getY();
|
|
418
|
+
if (from < journeyEnd)
|
|
419
|
+
segs.push({
|
|
420
|
+
from,
|
|
421
|
+
to: journeyEnd,
|
|
422
|
+
dur: ((journeyEnd - from) / filmRate) * 1000, // real-time film speed
|
|
423
|
+
ease: linear,
|
|
424
|
+
});
|
|
425
|
+
if (CFG.sweep !== "hero" && Math.max(from, journeyEnd) < max)
|
|
426
|
+
segs.push({
|
|
427
|
+
from: Math.max(from, journeyEnd),
|
|
428
|
+
to: max,
|
|
429
|
+
dur: ((max - Math.max(from, journeyEnd)) / tailRate) * 1000,
|
|
430
|
+
ease: easeOutCubic,
|
|
431
|
+
});
|
|
432
|
+
if (!segs.length) {
|
|
433
|
+
stopAuto();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let i = 0;
|
|
438
|
+
let t0 = performance.now();
|
|
439
|
+
const step = (now) => {
|
|
440
|
+
if (token !== autoToken) return;
|
|
441
|
+
const s = segs[i];
|
|
442
|
+
const k = s.dur > 0 ? clamp((now - t0) / s.dur, 0, 1) : 1;
|
|
443
|
+
setY(s.from + (s.to - s.from) * s.ease(k));
|
|
444
|
+
if (k >= 1) {
|
|
445
|
+
i += 1;
|
|
446
|
+
t0 = now;
|
|
447
|
+
if (i >= segs.length) {
|
|
448
|
+
stopAuto();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
autoRaf = requestAnimationFrame(step);
|
|
453
|
+
};
|
|
454
|
+
autoRaf = requestAnimationFrame(step);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function startAuto() {
|
|
458
|
+
autoActive = true;
|
|
459
|
+
if (playBtn) {
|
|
460
|
+
playBtn.classList.add("hs-playing");
|
|
461
|
+
playBtn.setAttribute("aria-pressed", "true");
|
|
462
|
+
if (playLabel) playLabel.textContent = playLabel.dataset.busy || "STOP";
|
|
463
|
+
}
|
|
464
|
+
if (getY() >= endY() - 8) {
|
|
465
|
+
veilTo(0, (token) => runFrom(token)); // replay: cut to black, re-arm
|
|
466
|
+
} else {
|
|
467
|
+
runFrom(++autoToken);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* ── listeners (all tracked for teardown) ──────────────────────── */
|
|
472
|
+
|
|
473
|
+
const bound = [];
|
|
474
|
+
const on = (target, ev, fn, opts) => {
|
|
475
|
+
target.addEventListener(ev, fn, opts);
|
|
476
|
+
bound.push([target, ev, fn, opts]);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
if (playBtn)
|
|
480
|
+
on(playBtn, "click", () => (autoActive ? stopAuto() : startAuto()));
|
|
481
|
+
if (gotoTop)
|
|
482
|
+
on(gotoTop, "click", () => {
|
|
483
|
+
stopAuto();
|
|
484
|
+
veilTo(0);
|
|
485
|
+
});
|
|
486
|
+
if (gotoEnd)
|
|
487
|
+
on(gotoEnd, "click", () => {
|
|
488
|
+
stopAuto();
|
|
489
|
+
veilTo(endY());
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// any manual input hands control back to the viewer
|
|
493
|
+
const cancelInput = (e) => {
|
|
494
|
+
if (!autoActive) return;
|
|
495
|
+
const t = e.target; // guard: target may not be a Node (window)
|
|
496
|
+
if (
|
|
497
|
+
e.type === "keydown" &&
|
|
498
|
+
playBtn &&
|
|
499
|
+
t instanceof Node &&
|
|
500
|
+
playBtn.contains(t)
|
|
501
|
+
)
|
|
502
|
+
return; // Enter/Space on the button toggles via its click handler
|
|
503
|
+
stopAuto();
|
|
504
|
+
};
|
|
505
|
+
for (const ev of ["wheel", "touchstart", "keydown"])
|
|
506
|
+
on(window, ev, cancelInput, { passive: true });
|
|
507
|
+
|
|
508
|
+
// scroll can happen on window or an overflow element — listen on both
|
|
509
|
+
// (element scroll doesn't bubble to window; capture on document catches it).
|
|
510
|
+
const onResize = () => measure();
|
|
511
|
+
on(window, "resize", onResize);
|
|
512
|
+
|
|
513
|
+
/* ── go ────────────────────────────────────────────────────────── */
|
|
514
|
+
|
|
515
|
+
measure();
|
|
516
|
+
preload();
|
|
517
|
+
frameRaf = requestAnimationFrame(frame);
|
|
518
|
+
|
|
519
|
+
/* ── teardown ──────────────────────────────────────────────────── */
|
|
520
|
+
|
|
521
|
+
function destroy() {
|
|
522
|
+
alive = false;
|
|
523
|
+
autoToken += 1;
|
|
524
|
+
if (frameRaf !== null) cancelAnimationFrame(frameRaf);
|
|
525
|
+
if (autoRaf !== null) cancelAnimationFrame(autoRaf);
|
|
526
|
+
for (const id of timers) clearTimeout(id);
|
|
527
|
+
timers.clear();
|
|
528
|
+
for (const [target, ev, fn, opts] of bound)
|
|
529
|
+
target.removeEventListener(ev, fn, opts);
|
|
530
|
+
if (veil.parentNode) veil.parentNode.removeChild(veil);
|
|
531
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
532
|
+
delete document.body.dataset.hsState;
|
|
533
|
+
document.body.classList.remove(CFG.fallbackClass);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return { play: startAuto, stop: stopAuto, veilTo, progress: () => smoothP, destroy };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export default initHeroScroll;
|
|
540
|
+
|
|
541
|
+
/* ── plain-HTML auto-boot ───────────────────────────────────────────
|
|
542
|
+
When loaded as a module in a plain HTML page that sets window.HS_CONFIG
|
|
543
|
+
and has a #hs-journey, boot automatically against the document. Bundler/
|
|
544
|
+
React hosts import initHeroScroll directly and never set HS_CONFIG, so
|
|
545
|
+
this stays dormant there. */
|
|
546
|
+
if (
|
|
547
|
+
typeof window !== "undefined" &&
|
|
548
|
+
window.HS_CONFIG &&
|
|
549
|
+
typeof document !== "undefined" &&
|
|
550
|
+
document.getElementById &&
|
|
551
|
+
document.getElementById("hs-journey")
|
|
552
|
+
) {
|
|
553
|
+
const boot = () => {
|
|
554
|
+
window.HS = initHeroScroll(document, window.HS_CONFIG);
|
|
555
|
+
};
|
|
556
|
+
if (document.readyState === "loading")
|
|
557
|
+
document.addEventListener("DOMContentLoaded", boot, { once: true });
|
|
558
|
+
else boot();
|
|
559
|
+
}
|