OpenMontage × HyperFrames:gsap-effects 即插即用动效配方——确定性渲染下的打字机与音频可视化
本文基于 OpenMontage 仓库中 HyperFrames 动画技能包的规则文档 gsap-effects.md 展开,系统讲解两类"即插即用"的 GSAP 动效配方:逐字符打字机(含光标状态机、退格、单词轮换、多行光标交接)与音频可视化(预提取频谱数据驱动 Canvas/DOM 渲染)。读完本文,你将在 HyperFrames 的 seek 驱动渲染模型下掌握:如何构建确定性、可重复播放的文本动画时间轴,以及如何绕过 Web Audio API 的限制,用预提取的每帧音频数据实现可 seek 的频谱可视化,并能对照仓库中真实的提取脚本源码理解数据生成原理。
一、文档定位:HyperFrames 规则体系与 GSAP 默认运行时
HyperFrames 是 OpenMontage 中"用 HTML 渲染视频"的核心框架:一个 composition 是声明时序(data-* 属性)、动画运行时可 seek、媒体播放由框架接管的 HTML 文件(见 hyperframes/SKILL.md)。在动画领域技能 hyperframes-animation 的分层结构里,动效知识被拆为五类:rules(原子配方)、blueprints(多阶段场景模板)、transitions(场景间转场)、techniques(更宽泛的动效设计技法)与 adapters(各运行时 API 参考)。
gsap-effects.md 正是 rules 目录下"Effect Recipes"分类中的一员。在 rules-index.md 的索引中,它的定位是:
gsap-effects — Drop-in GSAP timeline patterns — typewriter, audio visualizer, and other reusable choreography blocks. Tags: gsap, recipe, drop-in, typewriter, audio-visualizer
文档开头对其设计约束的表述是:每个效果都是自包含的(HTML + CSS + JS),并遵循 HyperFrames 的 seek 驱动契约——确定性、无随机性、时间轴注册到 window.__timelines。
这一点在 GSAP 运行时适配器文档 adapters/gsap.md 中有完整契约说明:
- 同步创建一个 paused(暂停) 的时间轴,并以与 composition 根节点
data-composition-id完全一致的键注册到window.__timelines,由 HyperFrames 负责 seek; - 渲染关键动画不得调用
tl.play(); - 不得在 async 代码、定时器或事件处理器内构建时间轴;
- 循环必须有限(HyperFrames 渲染的是有限时长视频,禁止
repeat: -1); - 视频时长由 composition 根节点的
data-duration决定,而不是时间轴长度。
注册的标准样板如下(摘自 GSAP 适配器文档):
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(".title", { y: 48, opacity: 0, duration: 0.6, ease: "power3.out" }, 0);
tl.to(".accent", { scaleX: 1, duration: 0.5, ease: "power2.out" }, 0.25);
window.__timelines["main"] = tl; // key must equal data-composition-id on the composition root
</script>
由于 GSAP 是该技能默认的动画运行时(官方表述为"95% 的动效工作"),全部原子 rules 都基于 GSAP,因此 gsap-effects.md 中的两个配方天然贴合框架的确定性渲染契约——这正是"即插即用"的含义:整段配方复制进 composition 即可工作,无需适配。
二、Typewriter:基于 TextPlugin 的逐字符文本揭示
2.1 必需的插件
打字机效果使用 GSAP 的 TextPlugin,需要按文档要求加载并注册:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/TextPlugin.min.js"></script>
<script>
gsap.registerPlugin(TextPlugin);
</script>
版本锁定在 3.14.2,与适配器文档中的 CDN 引用保持一致,保证时间轴行为在不同环境下可复现。
2.2 基础打字机
核心是一个把 text 属性作为补间目标值的 tl.to(...):
const text = "Hello, world!";
const cps = 10; // chars per second: 3-5 dramatic, 8-12 conversational, 15-20 energetic
tl.to(
"#typed-text",
{ text: { value: text }, duration: text.length / cps, ease: "none" },
startTime,
);
参数要点:
cps(每秒字符数) 是节奏的总开关,注释直接给出了三档语义:3–5 戏剧化、8–12 口语化、15–20 有活力;duration = text.length / cps—— 时长完全由内容长度与速度决定,无需手调;ease: "none"—— 匀速揭示,避免缓动造成字符间忽快忽慢的观感。这也符合框架对"内容驱动时长"的一贯主张(如 rules-index 中dynamic-content-sequencing条目的"no hand-tuned offsets"思路)。
2.3 带闪烁光标:三条铁律与 CSS 状态机
文档将光标处理归纳为三条规则:
- 同一时刻只能有一个光标可见 —— 显示下一个之前先隐藏前一个;
- 空闲时光标必须闪烁 —— 打字结束后的停顿、等待期间都要闪;
- 文本与光标之间不能有空隙 —— 两个元素在 HTML 中必须紧密相邻。
HTML 结构遵循规则 3,两个 span 紧贴:
<span id="typed-text"></span><span id="cursor" class="cursor-blink">|</span>
光标用纯 CSS 动画表达三种状态。这里有一个值得注意的设计:闪烁是无限循环的 CSS 动画(animation: blink 0.8s step-end infinite),而不是 GSAP 的 repeat: -1。HyperFrames 禁止时间轴内的无限 repeat,但渲染时页面本身是静态的,CSS 循环动画不依赖时间轴 seek,因此成为规避该限制的标准做法——GSAP 只负责在正确的时间点切换 class:
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.cursor-blink {
animation: blink 0.8s step-end infinite;
}
.cursor-solid {
animation: none;
opacity: 1;
}
.cursor-hide {
animation: none;
opacity: 0;
}
状态机转移模式为:blink → solid(开始打字)→ type → solid → blink(打字结束)。对应的 GSAP 片段用 tl.call 在精确时间点切换 class:
tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], startTime);
tl.to("#typed-text", { text: { value: text }, duration: dur, ease: "none" }, startTime);
tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], startTime + dur);
tl.call(callback, params, position) 是 seek 安全的时间点回调:seek 到该时间时回调即被触发,不依赖实际播放经过,因此完全满足确定性契约。
2.4 退格(Backspacing):为什么 TextPlugin 不够
文档指出了一个容易踩的坑:TextPlugin 的删除是从文本前端进行的,对退格来说是错误方向。退格必须用手工 substring 移除,逐字符通过 tl.call 排入时间轴:
function backspace(tl, selector, word, startTime, cps) {
const el = document.querySelector(selector);
const interval = 1 / cps;
for (let i = word.length - 1; i >= 0; i--) {
tl.call(
() => {
el.textContent = word.slice(0, i);
},
[],
startTime + (word.length - i) * interval,
);
}
return word.length * interval;
}
实现细节:interval = 1 / cps 是单字符间隔;循环从 word.length - 1 递减到 0,每次调用在 startTime + (word.length - i) * interval 处把 textContent 截短一个字符;函数返回总耗时,调用方据此继续排布后续时间轴——这让"打字→退格"这类多阶段编排可以像函数式组合一样串接。
2.5 与静态文本的间距处理
当打字机单词紧邻静态文本时,文档给出明确的排错方案:用外层 wrapper span 的 margin-left 制造间距,不要用 flex gap(它会把光标与文本也拉开),也不要在静态文本末尾放空字符(当动态 span 为空时该空格会被折叠):
<div style="display:flex; align-items:baseline;">
<span style="font-size:40px; color:#555;">Ship something</span>
<span style="margin-left:14px;"><span id="word"></span><span id="cursor">|</span></span>
</div>
这类"细节坑位"正是即插即用配方的价值所在:它们不是 API 文档能覆盖的,而是反复验证后沉淀下来的排版经验。
2.6 单词轮换(Word Rotation)
完整的轮换循环是:type → hold → backspace → 下一个词,且每一段空闲时刻(hold、退格后)光标都要回到闪烁状态:
let offset = 0;
words.forEach((word, i) => {
const typeDur = word.length / 10;
tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], offset);
tl.to("#typed-text", { text: { value: word }, duration: typeDur, ease: "none" }, offset);
tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], offset + typeDur);
offset += typeDur + 1.5; // hold
if (i < words.length - 1) {
tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], offset);
const clearDur = backspace(tl, "#typed-text", word, offset, 20);
tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], offset + clearDur);
offset += clearDur + 0.3;
}
});
从源码结构看,这段编排有三个可复用的节奏参数:打字速度 10 cps、hold 时长 1.5s、退格速度 20 cps(比打字快一倍,符合"删除比输入快"的直觉)以及退格后 0.3s 的呼吸停顿。offset 累加器的写法保证了所有阶段严格串行、无重叠,且每个时间点都是可推导的绝对值。
2.7 逐词追加成句(Appending Words)
与"覆盖式轮换"不同,追加模式把句子逐词累积进同一个元素。技巧在于每次都补间到"已累积前缀 + 新词"的完整目标串,只按新增字符数计算时长,避免重打已有内容:
let accumulated = "";
let offset = 0;
words.forEach((word) => {
const target = accumulated + (accumulated ? " " : "") + word;
const newChars = target.length - accumulated.length;
tl.to("#typed-text", { text: { value: target }, duration: newChars / 10, ease: "none" }, offset);
accumulated = target;
offset += newChars / 10 + 0.3;
});
注意 TextPlugin 对相同前缀字符串的差异处理:目标串以已显示文本为前缀时,视觉表现就是"只打了新增部分"。每个词之间保留 0.3s 的自然停顿。
2.8 多行光标交接(Multi-Line Cursor Handoff)
跨行交接的规范序列是:隐藏旧光标 → 新光标闪烁 → 短暂停顿 → 开始打字时变 solid。文档特别警告绝不能 hidden → solid 直跳——那样会跳过空闲闪烁,违反 2.3 的铁律 2:
tl.call(
() => {
prevCursor.classList.replace("cursor-blink", "cursor-hide");
nextCursor.classList.replace("cursor-hide", "cursor-blink");
},
[],
handoffTime,
);
const typeStart = handoffTime + 0.5; // brief blink pause
tl.call(() => nextCursor.classList.replace("cursor-blink", "cursor-solid"), [], typeStart);
tl.to("#next-text", { text: { value: text }, duration: dur, ease: "none" }, typeStart);
tl.call(() => nextCursor.classList.replace("cursor-solid", "cursor-blink"), [], typeStart + dur);
注意交接瞬间的两个 class 切换打包在同一个 tl.call 里同步执行,保证"任意时刻至多一个可见光标"在 seek 到交接帧时依然成立——这正是确定性渲染对原子性的要求。
2.9 节奏速查表(Timing Guide)
| CPS | 观感 | 适用场景 |
|---|---|---|
| 3–5 | 缓慢、有仪式感 | 戏剧化揭示、悬念 |
| 8–12 | 自然打字感 | 对话、旁白 |
| 15–20 | 快速、有活力 | 技术演示、代码 |
| 30+ | 接近瞬时 | 填充长文本块 |
三、Audio Visualizer:预提取音频数据驱动的确定性可视化
3.1 核心原则:为什么不能在渲染时读音频
文档对该配方的第一条就是硬约束:预提取音频数据,每帧用一个 tl.call(...) 驱动 Canvas/DOM 渲染;不要在渲染时使用 Web Audio API——seek 过程中没有音频播放。
这与 HyperFrames 的渲染模型直接相关:渲染器是逐帧 seek 的,adapters/gsap-transforms-and-perf.md 明确指出"渲染模式没有输入事件",同理也没有实时播放上下文。Web Audio 的 AnalyserNode 依赖实际解码与播放,seek 到任意时间点时拿不到对应时刻的频谱。唯一正确的路径是把"音频 → 每帧数值"这一非确定性的实时计算离线固化为 JSON 数据文件,运行时只做"时间 → 帧索引 → 数值"的确定性查表。
3.2 数据提取:仓库中真实的提取脚本
文档给出的提取命令:
python skills/hyperframes-creative/scripts/extract-audio-data.py audio.mp3 -o audio-data.json
python skills/hyperframes-creative/scripts/extract-audio-data.py video.mp4 --fps 30 --bands 16 -o audio-data.json
该脚本在本仓库中的实际路径是 extract-audio-data.py(.agents/skills/ 前缀为仓库内技能目录布局),依赖 ffmpeg 与 Python numpy(Python 3.9+)。阅读其源码可以补全文档未展开的关键实现细节:
信号链与 FFT 参数(源码 L37-L40):
- 采样率固定 44100 Hz,先经 ffmpeg 解码为单声道
s16le再转 float32([-1, 1]); - FFT 窗口 4096 样本(约 93ms),在 44100 Hz 下每 bin 约 10.8 Hz——源码注释解释:按 30fps 每帧仅 1470 样本,窗太小会导致低频各 band 落到相同 bin,因此用远大于帧长的窗口;
- 频带覆盖 30 Hz–16 kHz:低于 30 Hz 的次低频多数扬声器不可复现,高于 16 kHz 多为噪声与谐波,对节奏/旋律感知无贡献;
- 帧窗口以帧中心对齐,取不到时零填充(edge zero-pad),加 Hann 窗抑制频谱泄漏。
频带划分(compute_band_edges):band 边界是对数间隔(30→16000 Hz 的几何级数),这与人耳对频率的感知一致,也让"band 0 = 低音、高索引 = 高音"的文档表述有实现依据。每个 band 取该频率区间内 FFT 幅度的峰值(np.max)。
两遍归一化(L138-L146):
rms除以全曲峰值 → 得到文档所说的"整轨归一化的 0–1 总响度";bands逐 band 独立归一化(除以该 band 全曲峰值)——源码注释说明目的:"让高音与更响的低音并排可见"。这解释了文档数据格式中"Each band normalized independently"的措辞。
命令行参数(main):input(音频或视频文件,视频会自动抽取音轨)、-o/--output(默认 audio-data.json)、--fps(默认 30,最小 1)、--bands(默认 16,最小 1)。
3.3 数据格式
{
"fps": 30,
"totalFrames": 5415,
"frames": [{ "time": 0.0, "rms": 0.42, "bands": [0.8, 0.6, 0.3] }]
}
rms(0–1) — 整轨归一化后的整体响度;bands[](0–1) — 频率幅度。索引 0 = 低音(bass),索引越大越接近高音(treble)。每个 band 独立归一化。
对照源码,实际输出还额外包含 duration 与 bands(band 总数)两个顶层字段,以及每帧精确到 4 位小数的 time 值,可供时间轴精确对齐。
3.4 同步加载数据(关键约束)
// Option A — inline(小文件,约 500 KB 以下)
var AUDIO_DATA = {
/* paste audio-data.json contents */
};
// Option B — 同步 XHR(大文件;时间轴构建必须是同步的才确定)
var xhr = new XMLHttpRequest();
xhr.open("GET", "audio-data.json", false);
xhr.send();
var AUDIO_DATA = JSON.parse(xhr.responseText);
文档用全大写强调:不要用异步 fetch()。原因是 HyperFrames 在页面加载后同步读取 window.__timelines——如果把时间轴构建放进 .then(),采集开始时时间轴尚未就绪,整段动效会丢失。这与 GSAP 适配器"不得在 async 代码内构建时间轴"的总约束是同一件事在数据层的投影。
3.5 驱动时间轴
Canvas 2D(最常见:柱状条、波形、圆环、渐变)——对每一帧注册一个 tl.call,在 f / fps 的绝对时间点绘制对应帧数据:
const canvas = document.getElementById("viz");
const ctx = canvas.getContext("2d");
for (let f = 0; f < AUDIO_DATA.totalFrames; f++) {
tl.call(
() => {
const frame = AUDIO_DATA.frames[f];
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw using frame.rms and frame.bands
},
[],
f / AUDIO_DATA.fps,
);
}
这个"每帧一个回调"的模式本质上是把音频数据当作时间轴的查找表:渲染器 seek 到 t 秒,GSAP 触发 floor(t * fps) 对应的回调,绘制结果完全由 t 决定——seek 任意次、任意顺序,同一时刻画面一致。
WebGL / Three.js — HyperFrames 为确定性时间补丁了 THREE.Clock,每帧从音频数据更新 uniforms 即可(对应 adapters/three.md 的 Three.js 运行时约定)。
DOM 元素 — 元素数少于约 20 个时可行,数量多时性能不如 Canvas。
3.6 平滑(Smoothing)
原始 FFT 数值帧间跳变明显,文档给出的轻量解法是一阶指数平滑,维护 prev 状态、按帧递推:
let prev = null;
const smoothing = 0.25; // 0.1-0.2 snappy, 0.3-0.5 flowing
function smooth(f) {
const raw = AUDIO_DATA.frames[f];
if (!prev) {
prev = { rms: raw.rms, bands: [...raw.bands] };
return prev;
}
prev = {
rms: prev.rms * smoothing + raw.rms * (1 - smoothing),
bands: raw.bands.map((b, i) => prev.bands[i] * smoothing + b * (1 - smoothing)),
};
return prev;
}
参数语义:0.1–0.2 偏"跟手/利落",0.3–0.5 偏"流动"。需要注意实现上 prev 是按时间轴顺序构建时逐帧推进的状态——在 seek 渲染模型下,平滑必须发生在时间轴构建期(构建回调内顺序访问帧)而非播放期,这也再次印证"构建期做一切计算"的确定性原则。
3.7 空间映射(Spatial Mapping)
- 水平布局:低音在左、高音在右(bands 从左到右迭代);
- 垂直布局:低音在下、高音在上;
- 环形布局:低音在 12 点方向,顺时针环绕;要完整圆环则做镜像。
映射依据来自 3.2 的频带划分:band 索引与频率对数坐标单调对应,因此"索引序 = 低→高"的布局直觉直接成立。
3.8 运动原则(Motion Principles)
- 低音驱动大动作 — scale、glow、位置位移;
- 高音驱动细节 — shimmer、flicker、边缘效果;
- RMS 驱动全局量 — 背景亮度、整体能量;
- 只挑 2–3 个属性做动画,更多会显得嘈杂;
- 保持最小值大于零 — 安静段落也需要"活着"。
这套原则把音频信号的物理分层(低频能量大/变化慢,高频细节多/变化快)直接映射到视觉层次,是频谱类动效"听起来对"的关键。
3.9 频带数量选择
| Bands | 细节量 | 适用 |
|---|---|---|
| 4 | 低 | 背景辉光、脉动 |
| 8 | 中 | 柱状图、基础频谱 |
| 16 | 高 | 精细 EQ(默认值) |
| 32 | 很高 | 高密度径向布局 |
16 bands 是提取脚本的默认值(--bands 16),与表格一致。
3.10 分层(Layering)
用多个 canvas 叠加 z-index 制造纵深:背景层由 bass/rms 驱动,前景层由各个 band 驱动——不需要对每个元素做复杂计算就能获得深度:
<canvas id="bg-layer" style="position:absolute;top:0;left:0;z-index:1;"></canvas>
<canvas id="main-layer" style="position:absolute;top:0;left:0;z-index:2;"></canvas>
四、落地清单:把配方放进 HyperFrames Composition
把以上两个配方用于实际渲染时,完整的核对清单如下(综合 gsap-effects.md 与 adapters/gsap.md):
- 时间轴注册:
gsap.timeline({ paused: true })同步构建,键与data-composition-id一致地挂到window.__timelines; - 时长治理:视频时长写在根节点
data-duration上,不用空 tween 拉长轴; - 打字机:TextPlugin 匀速打字 +
tl.call切换光标 class 的三段式状态机;退格用手工 substring 函数并回收总时长; - 音频可视化:离线跑提取脚本固化数据 → 内联或同步 XHR 载入 → 每帧一个
tl.call查表绘制 → 构建期做指数平滑; - 禁用项:
fetch()异步构建时间轴、Math.random()/Date.now()、repeat: -1、对display/visibility直接补间、布局属性(width/height/top/left)补间——空间运动只用 GSAP transform 别名(x/y/scale/rotation); - 可审计性:技能包还提供 scripts/animation-map.mjs 用于在制作后审计已注册时间轴(枚举 tween、采样 bbox、标记死区与 stagger 一致性问题),可与本文配方配合做回归检查。
五、相关文档与仓库路径
| 资源 | 路径 |
|---|---|
| 本文主体(即插即用效果配方) | gsap-effects.md |
| 原子规则总索引(36 条 rules) | rules-index.md |
| HyperFrames 动画技能入口(rules/blueprints/transitions/adapters 路由) | SKILL.md |
GSAP 运行时适配器(window.__timelines 契约与属性白名单) |
adapters/gsap.md |
| 音频数据提取脚本(FFMPEG + NumPy 实现) | extract-audio-data.py |
| HyperFrames 框架入口技能(渲染模型与意图路由) | hyperframes/SKILL.md |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00