Files
hyperframes/skills/music-to-video/references/templates/intro-kinetic-cascade/index.html
T
WaterrrForeverandClaude Opus 4.8 535297280a fix(skills): clear two Snyk Fails and harden the network + supply-chain surface (#1804)
* 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>
2026-07-01 12:39:42 +08:00

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>