首页
/ OpenMontage × HyperFrames:gsap-effects 即插即用动效配方——确定性渲染下的打字机与音频可视化

OpenMontage × HyperFrames:gsap-effects 即插即用动效配方——确定性渲染下的打字机与音频可视化

2026-09-07 16:33:51作者:温玫谨Lighthearted

本文基于 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 状态机

文档将光标处理归纳为三条规则:

  1. 同一时刻只能有一个光标可见 —— 显示下一个之前先隐藏前一个;
  2. 空闲时光标必须闪烁 —— 打字结束后的停顿、等待期间都要闪;
  3. 文本与光标之间不能有空隙 —— 两个元素在 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):

  1. rms 除以全曲峰值 → 得到文档所说的"整轨归一化的 0–1 总响度";
  2. 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 独立归一化。

对照源码,实际输出还额外包含 durationbands(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.mdadapters/gsap.md):

  1. 时间轴注册gsap.timeline({ paused: true }) 同步构建,键与 data-composition-id 一致地挂到 window.__timelines
  2. 时长治理:视频时长写在根节点 data-duration 上,不用空 tween 拉长轴;
  3. 打字机:TextPlugin 匀速打字 + tl.call 切换光标 class 的三段式状态机;退格用手工 substring 函数并回收总时长;
  4. 音频可视化:离线跑提取脚本固化数据 → 内联或同步 XHR 载入 → 每帧一个 tl.call 查表绘制 → 构建期做指数平滑;
  5. 禁用项fetch() 异步构建时间轴、Math.random() / Date.now()repeat: -1、对 display/visibility 直接补间、布局属性(width/height/top/left)补间——空间运动只用 GSAP transform 别名(x/y/scale/rotation);
  6. 可审计性:技能包还提供 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
登录后查看全文
热门项目推荐
相关项目推荐