mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 07:40:06 +00:00
* fix(skills): clear Snyk findings and harden supply-chain surface
Address the security-audit findings on the published skills with no change to
any skill's behaviour.
- media-use: resolve.test.mjs runs resolve.mjs via execFileSync with an argv
array instead of execSync(`node … "${tmp}" …`), removing the command-injection
(CWE-78) sink that drove the Snyk Fail.
- music-to-video: replace dynamic `element.innerHTML = <var>` with a setSvg()
helper (DOMParser image/svg+xml + importNode, text fallback) in the
intro-kinetic-cascade and logo-split-lockup-pulse frame templates, clearing the
DOM-XSS (CWE-79) Snyk Fail. Renders identical SVG.
- pr-to-video: fetch-people-avatars.mjs refuses any avatar URL that is not https
on a GitHub avatar host (SSRF guard) and only writes under the project dir
(path-traversal guard); best-effort, always-exit-0 behaviour is unchanged.
- embedded-captions: pin `uvx --from whisperx==3.8.6` (overridable via
$WHISPERX_VERSION) so transcription no longer resolves "latest" at runtime.
- gsap: add Subresource Integrity (integrity + crossorigin) to the 8 render-time
CDN GSAP <script> tags across embedded-captions, music-to-video,
faceless-explainer, pr-to-video and product-launch-video.
- hyperframes-animation / hyperframes-creative: document package-loader's
defense-in-depth and note that the installLine strings are display-only.
Verified: media-use resolve (12/12), probe injection (1/1) and manifest (19/19)
tests pass; avatar host-allowlist checks pass; all changed JS passes node --check
and oxfmt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(skills): clarify product-launch-video vs website-to-video routing
Sharpen the router's product-vs-site decision in hyperframes/SKILL.md: the
split is now "is the site selling a product?" — yes (SaaS / app / product /
company site) → /product-launch-video (a promo; the default for any commercial
URL, even if the site is only named); no, or the user just wants the site shown
as-is (portfolio / blog / docs / personal / event) → /website-to-video (a tour).
Updates the workflow table, the disambiguation bullet, and both workflows'
Input/Output blurbs to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(skills): satisfy oxfmt in the two music-to-video templates
The CI Format job runs `oxfmt --check .`, which also formats embedded <script> in .html. Reflow the setSvg() blocks added for the DOM-XSS fix to oxfmt's wrapping — no logic change. Regenerate the music-to-video manifest hash to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(skills): sanitize SVG in music-to-video templates (real CWE-79 fix)
Addresses @Magi's review: the previous setSvg() only swapped the sink
(innerHTML → DOMParser + importNode) but did NOT sanitize, so active SVG
content still executed on insertion into the live document. Verified in
headless Chrome that the old shape fired both an svg `onload` handler and an
inline `<script>`.
setSvg() now runs a default-deny cleanSvg() over the parsed tree before it ever
enters the document: only an allow-list of inert drawing elements
(svg/g/path/line/rect/circle/… ) and presentation attributes
(d/fill/stroke/viewBox/…) survives. Every other element (`<script>`, `<image>`,
`<use>`, `<foreignObject>`, `<a>`, `<animate>`, …), every `on*` handler, and
href/xlink:href/style are stripped — on the root node too. Non-SVG or malformed
input still falls back to textContent.
Trusted content (the bundled icon library + the default spark/cloud marks)
renders byte-identically; only hostile markup in vars.icon / leftMark / rightMark
is neutralized.
Browser-verified (headless Chrome, both templates' helper):
old setSvg → fired ["script","onload"]
new setSvg → fired [] · trusted icon still renders · 0 danger nodes · 0 on* attrs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
611 lines
22 KiB
HTML
611 lines
22 KiB
HTML
<!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>
|