feat(registry): bring back the video-primitive moves (#3169)

Restores the 208 catalog items reverted after their previews 404'd in
production, this time on the payload mechanism rather than the .html files
that caused the outage.

The generator no longer writes a preview document to docs/public. That writer,
and the machinery under it, existed only to produce files the docs host
discards, so it is gone rather than bypassed. Items now embed the composition
itself via a payload, which is what the previous change already does for the
items that were already in the catalog.

The variables explorer is parked, not restored: it drove its preview through
the same unpublished .html path, so it would have shown an empty frame. Items
that declare variables get the live player plus the static variables table, and
reconnecting the explorer to payloads is a follow-up.
This commit is contained in:
Miguel Ángel
2026-08-10 18:46:03 -04:00
committed by GitHub
parent 0f76305191
commit 9734578e60
1383 changed files with 255573 additions and 5760 deletions
@@ -0,0 +1,37 @@
# kinetic-type-swap
A held sentence keeps its prefix and suffix fixed while one masked word slot rolls vertically through comma-separated options. The slot pre-sizes to the widest option, so the sentence never reflows during a swap. The final word locks with a soft pulse and holds until the frame cuts.
4s authored, elastic HOLD, exit `none` by default.
## Variables
| id | type | default | notes |
| --------- | ------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `prefix` | string | `Ship` | Fixed text before the rolling slot. |
| `options` | string | `faster,smarter,together` | Comma-separated words, shown in order; the last one is the sentence's final state. |
| `suffix` | string | `` (empty) | Fixed text after the rolling slot. |
| `cues` | string | `` (empty) | Comma-separated seconds (from mount start) for each swap, e.g. `0.8,1.6`. Sorted ascending; swaps beyond the list extrapolate at the list's own gap. Empty keeps the authored even spread. |
| `accent` | enum | `green` | Slot color: green rides `--brand`, blue rides `--accent`, violet rides `--accent-2`. |
| `exit` | enum | `none` | `none` holds until the cut; `fade` is the opacity release (the pre-Wave-J default ending); `up` adds a small rise to the fade. |
## Mount
```html
<div
class="clip"
data-composition-id="kinetic-type-swap"
data-composition-src="./kinetic-type-swap.html"
data-variable-values='{"prefix":"Render","options":"anything,everywhere,tonight","cues":"1.0,2.2"}'
data-start="0"
data-duration="4"
data-track-index="0"
></div>
```
Elastic root: no `data-width`/`data-height`; it fills whatever box the host clip gives it. Timeline registers under the literal `kinetic-type-swap` key.
## Notes
- Cue N fires the swap from option N to option N+1. Swaps clamp so the roll and settle finish before any exit; the roll shortens automatically when cues sit close together.
- Deterministic and seek-safe; the hold pulse uses a finite odd repeat, never an open loop.
+101
View File
@@ -0,0 +1,101 @@
<!doctype html>
<!--
kinetic-type-swap demo. This standalone composition mounts the primitive
through data-composition-src. Non-default prefix, options, suffix, and
accent values prove the variable path and the widest-option slot contract.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Kinetic Type Swap Demo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0b0c0e;
}
#root {
--bg: #0b0c0e;
--fg: #f8fafc;
--muted: #94a3b8;
--surface: #15181e;
--border: #343a46;
--brand: #71f5a7;
--accent: #61a8ff;
--accent-2: #c5a3ff;
--font-display: Inter, system-ui, sans-serif;
--font-body: Inter, system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--radius: 2.4cqmin;
--space-1: 1.2cqmin;
--space-2: 2.4cqmin;
--space-3: 4cqmin;
--dur-beat: 0.5s;
--ease-standard: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-emphasis: cubic-bezier(0.19, 1, 0.22, 1);
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: radial-gradient(
ellipse at 50% 44%,
color-mix(in srgb, var(--surface) 72%, var(--accent-2)) 0%,
var(--bg) 70%
);
}
.mount-card {
width: 82%;
height: 42%;
left: 9%;
top: 29%;
container-type: size;
overflow: hidden;
border: 0.12cqmin solid color-mix(in srgb, var(--border) 74%, transparent);
border-radius: calc(var(--radius) * 1.3);
background: color-mix(in srgb, var(--surface) 76%, transparent);
box-shadow: 0 4cqh 10cqh color-mix(in srgb, var(--bg) 60%, transparent);
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="kinetic-type-swap-demo"
data-start="0"
data-width="1920"
data-height="1080"
data-duration="4"
data-fps="30"
>
<div
id="kinetic-type-swap-mount"
class="mount-card clip"
data-composition-id="kinetic-type-swap"
data-composition-src="./kinetic-type-swap.html"
data-variable-values='{"prefix":"Make","options":"motion,meaning,momentum","suffix":"visible","accent":"violet"}'
data-start="0"
data-duration="4"
data-track-index="0"
data-width="1574"
data-height="454"
></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["kinetic-type-swap-demo"] = gsap.timeline({ paused: true });
</script>
</body>
</html>
@@ -0,0 +1,373 @@
<!doctype html>
<!--
kinetic-type-swap: HyperFrames video primitive (text effects / swap)
A held sentence keeps its prefix and suffix fixed while one masked word
slot rolls through comma-separated options. Every option remains in the
same CSS grid cell, so the slot takes the widest option's intrinsic width
before motion starts and the sentence never reflows during a swap.
Variables:
- prefix (string, default "Ship"): fixed text before the rolling slot.
- options (string, default "faster,smarter,together"): comma-separated
words shown in order.
- suffix (string, default ""): fixed text after the rolling slot.
- cues (string, default ""): comma-separated seconds (from mount start)
for each word swap. Empty keeps the authored even spread.
- accent (green | blue | violet, default "green"): rolling word color.
- exit (none | fade | up, default "none"): optional departure.
Envelope, fixed IN and OUT with elastic HOLD only:
IN_BASE = 2.25s, fade in followed by every vertical word swap (cues can
stretch the swap phase past IN_BASE when they land later)
HOLD = max(0, D - IN - OUT), final word stays locked with a soft pulse
OUT_BASE = 0.45s departure when exit is fade or up, 0s when exit is
none (the default: the sentence holds until the cut)
If D is shorter than IN_BASE + OUT_BASE, IN and OUT compress together.
The timeline is never time-scaled.
Determinism: options are parsed once before timeline construction. GSAP
owns every visible change, uses finite repeats, and registers one paused
timeline under the literal kinetic-type-swap key.
Mount contract: the runtime clones only this template. #root fills the host
box, establishes the container query basis, and has no data-width or
data-height. Only word transforms change position. The held sentence never
moves, and the grid-owned slot width never changes.
-->
<html
lang="en"
data-composition-id="kinetic-type-swap"
data-composition-duration="4"
data-composition-variables='[
{ "id": "prefix", "type": "string", "role": "content", "label": "Prefix", "description": "Fixed sentence text before the rolling word slot.", "default": "Ship" },
{ "id": "options", "type": "string", "role": "content", "label": "Options", "description": "Comma-separated words shown in the rolling slot.", "default": "faster,smarter,together" },
{ "id": "suffix", "type": "string", "role": "content", "label": "Suffix", "description": "Fixed sentence text after the rolling word slot.", "default": "" },
{ "id": "cues", "type": "string", "role": "timing", "label": "Cues", "description": "Comma-separated seconds (from mount start) for each word swap. Empty keeps the authored even spread.", "default": "" },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Color used by the rolling word slot.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional departure. Default none: the sentence holds until the frame cuts.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Up" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Kinetic Type Swap</title>
</head>
<body>
<template>
<div id="root" data-composition-id="kinetic-type-swap" data-duration="4" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: var(--fg, #f8fafc);
font-family: var(--font-display, Inter, system-ui, sans-serif);
pointer-events: none;
}
.kts-clip {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
background: var(--bg, transparent);
}
.kts-stage {
display: grid;
place-items: center;
width: 94cqw;
height: 70cqh;
opacity: 0;
}
.kts-sentence {
display: flex;
align-items: baseline;
justify-content: center;
/* Word gap must scale with the display type, never a host layout
spacing token: --space-* is chrome-scale and jams the sentence
("Makemomentumvisible") when the type is auto-fitted large. */
gap: 0.26em;
max-width: 94cqw;
color: var(--fg, #f8fafc);
font-size: var(--kts-font-size);
font-weight: 760;
line-height: 1.08;
letter-spacing: -0.045em;
white-space: nowrap;
}
.kts-prefix,
.kts-suffix {
flex: 0 0 auto;
}
.kts-prefix:empty,
.kts-suffix:empty {
display: none;
}
/* Every word occupies one grid cell and contributes its intrinsic
width. Transforms do not affect layout, so this masked slot stays
as wide as the widest option for the entire composition. */
.kts-slot {
display: inline-grid;
flex: 0 0 auto;
overflow: hidden;
color: var(--kts-accent);
text-shadow: 0 0 2.8cqh color-mix(in srgb, var(--kts-accent) 28%, transparent);
vertical-align: baseline;
}
.kts-word {
grid-area: 1 / 1;
width: max-content;
justify-self: center;
color: var(--kts-accent);
will-change: transform, opacity;
}
</style>
<div
id="kinetic-type-swap-clip"
class="kts-clip clip"
data-start="0"
data-duration="4"
data-track-index="0"
>
<div class="kts-stage" role="img">
<div class="kts-sentence">
<span class="kts-prefix"></span>
<span class="kts-slot" aria-hidden="true"></span>
<span class="kts-suffix"></span>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
var stage = root.querySelector(".kts-stage");
var prefixElement = root.querySelector(".kts-prefix");
var slotElement = root.querySelector(".kts-slot");
var suffixElement = root.querySelector(".kts-suffix");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var defaultOptions = ["faster", "smarter", "together"];
var prefix = vars.prefix == null ? "Ship" : String(vars.prefix).trim();
var suffix = vars.suffix == null ? "" : String(vars.suffix).trim();
var rawOptions = vars.options == null ? defaultOptions.join(",") : String(vars.options);
var options = rawOptions
.split(",")
.map(function (option) {
return option.trim();
})
.filter(function (option) {
return option.length > 0;
});
if (options.length === 0) options = defaultOptions.slice();
var accentColors = {
green: "var(--brand, #71f5a7)",
blue: "var(--accent, #61a8ff)",
violet: "var(--accent-2, #c5a3ff)",
};
var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
? vars.accent
: "green";
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
var cuesValue = vars.cues == null ? "" : String(vars.cues);
var cues = cuesValue
.split(",")
.reduce(function (result, token) {
var trimmed = token.trim();
if (trimmed === "") return result;
var seconds = Number(trimmed);
if (!Number.isFinite(seconds) || seconds < 0) return result;
result.push(seconds);
return result;
}, [])
.sort(function (a, b) {
return a - b;
});
root.style.setProperty("--kts-accent", accentColors[accent]);
prefixElement.textContent = prefix;
suffixElement.textContent = suffix;
var words = options.map(function (option, index) {
var word = document.createElement("span");
word.className = "kts-word";
word.id = "kinetic-type-swap-word-" + index;
word.textContent = option;
slotElement.appendChild(word);
return word;
});
var finalSentence = [prefix, options[options.length - 1], suffix]
.filter(function (part) {
return part.length > 0;
})
.join(" ");
stage.setAttribute("aria-label", finalSentence);
var widestOptionLength = options.reduce(function (widest, option) {
return Math.max(widest, Array.from(option).length);
}, 1);
var sentenceLength =
Array.from(prefix).length + widestOptionLength + Array.from(suffix).length + 2;
var fittedCqw = Math.min(12, 122 / Math.max(1, sentenceLength));
root.style.setProperty(
"--kts-font-size",
"min(" + fittedCqw.toFixed(3) + "cqw, 22cqh)",
);
var IN_BASE = 2.25;
var OUT_BASE = exit === "none" ? 0 : 0.45;
var ARRIVAL_BASE = 0.3;
var SWAP_START_BASE = 0.48;
var ROLL_BASE = 0.46;
var SETTLE_TAIL_BASE = 0.18;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
var totalBase = Math.max(0.001, IN_BASE + OUT_BASE);
var scale = duration < totalBase ? duration / totalBase : 1;
var IN = IN_BASE * scale;
var OUT = OUT_BASE * scale;
var ARRIVAL = ARRIVAL_BASE * scale;
var SWAP_START = SWAP_START_BASE * scale;
var ROLL = ROLL_BASE * scale;
var SETTLE_TAIL = SETTLE_TAIL_BASE * scale;
// Swap schedule: cues own the swap moments when provided; the
// authored even spread otherwise. Swaps beyond the cue list
// extrapolate at the cue list's own gap, and every swap clamps
// so its roll and settle finish before any exit.
var swapCount = words.length - 1;
var swapTimes = [];
var rollDuration = ROLL;
if (swapCount > 0) {
if (cues.length > 0) {
var lastCue = cues[cues.length - 1];
var cueGap =
cues.length >= 2 ? Math.max(0.05, (lastCue - cues[0]) / (cues.length - 1)) : ROLL;
var latestSwap = Math.max(0, duration - OUT - ROLL - SETTLE_TAIL);
for (var cueIndex = 0; cueIndex < swapCount; cueIndex += 1) {
var cueAt =
cueIndex < cues.length
? cues[cueIndex]
: lastCue + (cueIndex - (cues.length - 1)) * cueGap;
swapTimes.push(Math.min(latestSwap, Math.max(0, cueAt)));
}
var minGap = Infinity;
for (var gapIndex = 1; gapIndex < swapTimes.length; gapIndex += 1) {
minGap = Math.min(minGap, swapTimes[gapIndex] - swapTimes[gapIndex - 1]);
}
if (Number.isFinite(minGap)) {
rollDuration = Math.min(ROLL, Math.max(0.04, minGap * 0.72));
}
} else {
var lastSwapStart = Math.max(SWAP_START, IN - SETTLE_TAIL - ROLL);
var swapStep = swapCount === 1 ? 0 : (lastSwapStart - SWAP_START) / (swapCount - 1);
rollDuration =
swapCount <= 2 ? ROLL : Math.min(ROLL, Math.max(0.04, swapStep * 0.72));
for (var index = 1; index <= swapCount; index += 1) {
swapTimes.push(
swapCount === 1 ? lastSwapStart : SWAP_START + (index - 1) * swapStep,
);
}
}
}
var swapsEnd =
swapTimes.length > 0
? swapTimes[swapTimes.length - 1] + rollDuration + SETTLE_TAIL
: IN;
var HOLD_START = Math.min(Math.max(0, duration - OUT), Math.max(IN, swapsEnd));
var HOLD = Math.max(0, duration - (HOLD_START + OUT));
var OUT_START = HOLD_START + HOLD;
gsap.set(stage, { opacity: 0 });
gsap.set(words, { yPercent: 112, opacity: 0 });
gsap.set(words[0], { yPercent: 0, opacity: 1 });
var tl = gsap.timeline({ paused: true });
tl.to(stage, { opacity: 1, duration: ARRIVAL, ease: "power2.out" }, 0);
// Masked exchange: the outgoing word whips fully past the slot
// mask (power4.in) before the incoming word crosses center. The
// two phases overlap only for ~10% of the roll, while both words
// are opposite-edge slivers, so every frame shows at most one
// legible word at full ink and never a double exposure.
swapTimes.forEach(function (swapAt, swapIndex) {
var wordIndex = swapIndex + 1;
var incomingEase = wordIndex === words.length - 1 ? "back.out(1.7)" : "power4.out";
var outDuration = rollDuration * 0.55;
var inDuration = rollDuration * 0.55;
var inStart = swapAt + rollDuration - inDuration;
tl.to(
words[wordIndex - 1],
{ yPercent: -112, duration: outDuration, ease: "power4.in" },
swapAt,
);
tl.set(words[wordIndex - 1], { opacity: 0 }, swapAt + outDuration);
tl.set(words[wordIndex], { opacity: 1 }, inStart);
tl.to(
words[wordIndex],
{ yPercent: 0, duration: inDuration, ease: incomingEase },
inStart,
);
});
var finalWord = words[words.length - 1];
var beat = parseFloat(getComputedStyle(root).getPropertyValue("--dur-beat")) || 0.5;
var pulseHalf = Math.min(beat, HOLD / 2);
if (pulseHalf > 0) {
var maxRepeat = Math.floor(HOLD / pulseHalf) - 1;
var pulseRepeat = maxRepeat % 2 === 1 ? maxRepeat : maxRepeat - 1;
if (pulseRepeat >= 1) {
tl.to(
finalWord,
{
opacity: 0.9,
duration: pulseHalf,
ease: "sine.inOut",
yoyo: true,
repeat: pulseRepeat,
},
HOLD_START,
);
}
}
// OUT: optional departure; exit none holds until the frame cuts.
if (exit === "fade") {
tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
} else if (exit === "up") {
tl.to(stage, { opacity: 0, y: "-3cqh", duration: OUT, ease: "power2.in" }, OUT_START);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["kinetic-type-swap"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
@@ -0,0 +1,80 @@
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "kinetic-type-swap",
"type": "hyperframes:component",
"title": "Kinetic Type Swap",
"description": "A held sentence keeps its fixed text in place while one widest-word masked slot rolls through alternatives and settles on the final option.",
"tags": ["motion-primitive", "text-effects", "kinetic-type", "word-swap", "reveal"],
"jobs": ["reveal"],
"family": "text-effects",
"profile": "swap",
"evidence": "Wave I I2 true-spec contract: one widest-option masked word slot rolls while the surrounding sentence remains fixed",
"syncPoints": [{ "id": "final-lock", "phase": "in", "offset": 2.07 }],
"files": [
{
"path": "kinetic-type-swap.html",
"target": "compositions/components/kinetic-type-swap.html",
"type": "hyperframes:snippet"
}
],
"variables": [
{
"id": "prefix",
"type": "string",
"role": "content",
"label": "Prefix",
"description": "Fixed sentence text before the rolling word slot.",
"default": "Ship"
},
{
"id": "options",
"type": "string",
"role": "content",
"label": "Options",
"description": "Comma-separated words shown in the rolling slot.",
"default": "faster,smarter,together"
},
{
"id": "suffix",
"type": "string",
"role": "content",
"label": "Suffix",
"description": "Fixed sentence text after the rolling word slot.",
"default": ""
},
{
"id": "cues",
"type": "string",
"role": "timing",
"label": "Cues",
"description": "Comma-separated seconds (from mount start) for each word swap. Empty keeps the authored even spread.",
"default": ""
},
{
"id": "accent",
"type": "enum",
"role": "style",
"label": "Accent",
"description": "Color used by the rolling word slot.",
"default": "green",
"options": [
{ "value": "green", "label": "Green" },
{ "value": "blue", "label": "Blue" },
{ "value": "violet", "label": "Violet" }
]
},
{
"id": "exit",
"type": "enum",
"role": "timing",
"label": "Exit",
"description": "Optional departure. Default none: the sentence holds until the frame cuts.",
"default": "none",
"options": [
{ "value": "none", "label": "None" },
{ "value": "fade", "label": "Fade" },
{ "value": "up", "label": "Up" }
]
}
]
}