一句话:把「镜头语言」变成时间的纯函数。每个动作都是
t(绝对秒)→ 一组数字。 库不碰 DOM、不碰 canvas,无状态、无构建步骤,因此可以被逐帧 seek:同样的t永远给出同样的数。
讲解视频的动效默认只有两种写法:CSS @keyframes(做不了承接和物理)和 GSAP 手写时间轴
(每期重写一遍,且非确定性——实时播放和逐帧渲染结果不一致)。
本库把 23 个模板里反复用到的动作抽象成动作词汇,每期只需挑选、组合、调参:
- 确定性:所有随机数走
seededRng,渲染路径上不出现Math.random()。同一t同一样式,逐帧渲染与实时预览一致。 - 可组合:动作是纯函数,可以叠加(弹簧 + 镜头 + 环境光 = 三层各自算,最后一个
transform相乘)。 - 可测:库里没有任何副作用,所以
tests/test_motion.mjs能对它做 148 条断言。
<!-- 浏览器(file:// 下不能用 ES module):挂到 window.HXM -->
<script src="../assets/motion.js"></script>
<script>
const HXM = window.HXM;
function __seek(t) { /* 用 HXM.* 算出本帧样式 */ }
</script>// Node(跑自测 / 预计算):
const HXM = require('./assets/motion.js');| 钩子 | 作用 |
|---|---|
window.__seek(t) |
设置 t 时刻的全部样式。渲染器每帧调一次,必须在同一帧内把所有元素都定到 t 上 |
window.__ready |
字体就绪后 measure/simulate,再 seek(0)。渲染器等到它为真才开始抓帧 |
window.__meta |
{ dur, fps, w, h, inFrame? } |
可选(但强烈建议):
| 钩子 | 作用 |
|---|---|
window.__motion(t0, t1) |
返回 t0→t1 区间内屏幕最远位移(px,含运镜)。渲染器据此决定这一帧要不要开快门、开几次 |
时序的基础层。所有 tween / 弹簧都从这里取。
| 键 | 特征(t80 = 走完 80% 的进度) | 用在 |
|---|---|---|
linear |
匀速 | 进度条、无限滚动 |
smooth / smoother |
两端慢 | 通用位移 |
sineInOut / cubicInOut / quintInOut |
由软到硬 | 运镜、卡片位移 |
quadOut cubicOut quartOut quintOut |
由软到硬(出场) | 元素入场 |
expoOut |
极快起、长尾收(t80 ≈ .23) | 默认入场曲线 |
expoIn / expoInOut |
极快收 | 退场、运镜加速 |
circOut |
圆滑刹车 | 弹出 |
backOut |
轻微过冲 | 强调 |
back(s) |
工厂函数:back(2.2) 返回一条过冲更猛的曲线 |
需要自定义过冲量时 |
back是唯一需要"调用两次"的曲线:ease.backOut(u)直接用,ease.back(2.2)(u)才是自定义版。
| 函数 | 签名 | 说明 |
|---|---|---|
cubicBezier(x1,y1,x2,y2) |
→ curve |
把 CSS cubic-bezier(...) 号直接落地成 JS 曲线 |
tween(t, t0, t1, curve?) |
→ 0..1 | 最常用的入口:ease.expoOut 默认 |
t80(curve, frac?, steps?) |
→ 数值 | 量「这条曲线走完 frac 用了多少归一化时间」,用于对齐时长 |
spring(tau, zeta?, omega?) |
→ 0..1 | 阻尼弹簧的位移,tau 是自起点起的秒数 |
springVel(tau, zeta?, omega?) |
→ 速度 | 弹簧的导数,用于挤压拉伸、判断是否还在动 |
ring(tau, decay?, omega?) |
→ 幅度 | 衰减振荡,做"叮"的余振 |
settle(zeta?, omega?, eps?) |
→ 秒 | 弹簧多久停(算 hold 长度、决定该帧是否为静止帧) |
hermite(knots, t) |
→ {x,y} | 过控制点的平滑插值,做手绘路径 / 自定义运镜轨迹 |
seededRng(seed) |
→ () => 0..1 |
确定性随机源。渲染路径上唯一允许的随机 |
一共 5 组,每一组对应"画面上正在发生什么"。
| 动作 | 签名要点 |
|---|---|
riseWord(t, t0, opts) |
词从下方"落下"并微弱淡入。慢动作别用纯 opacity 淡入,会读成 PPT |
dropLetters(t, t0, i, opts) |
逐字落下 + 落地挤压。dt 控制错峰间隔 |
springIn(t, t0, opts) |
元素整体弹簧进场(rise 抬升高度、s0/ds 起始缩放) |
blurAway(t, t1, opts) |
退场:位移 + 模糊 + 微放大。dir 决定往哪边糊掉 |
riseFromMask(t, t0, i, opts) |
从蒙版后升起(配合 clip-path),比淡入"有实感" |
typeChars(t, t0, text, opts) |
打字机。cps 字/秒、blink 光标闪烁 |
checkOff(t, tl, opts) |
勾选:过冲 + 回弹,落笔果断 |
flyPlane(t, t0, hold, opts) |
平面飞入 → 停留 → 飞出(from/to/exit 三态),做"穿屏" |
纪律:一个 beat 不许"替换"上一个,必须让某个元素跨过边界继续动。
| 动作 | 说明 |
|---|---|
morphBox(t, t0, t1, A, B, opts) |
矩形 A 变形成矩形 B(位置+宽高+转角)——最基础的承接 |
irisOpen(t, t0, t1, cx, cy, opts) |
以某点为心开光圈(虹膜展开),把上一镜"揭"开 |
diveInto(t, t0, t1, target, opts) |
镜头扎进某个矩形(fit:'cover'/'contain'),做"放大进入细节" |
arcHop(t, t0, t1, from, to, opts) |
抛物弧线跳(height 弧高),比直线位移有生命感 |
gatherTo(t, t0, t1, i, n, from, to, opts) |
n 个元素错峰汇聚到一点(arc 弧、spin 自旋) |
railShift(t, switches, opts) |
轨道换挡:切换瞬间整车抖动(amp 幅度、tilt 倾斜) |
sealDisc(t, t0, t1, opts) |
圆形封印/合拢(turns 圈数) |
burstWord(t, list) |
爆发式短语序列:[{t, text, ...}],做标题弹幕 |
| 动作 | 说明 |
|---|---|
landHit(t, tHit, opts) |
落地撞击:decay/omega 衰减振荡,sqx/sqy 挤压量 |
splitOnHit(t, tHit, x, cx, opts) |
撞击瞬间把一条线/字炸开(spread 散开、rot 旋转、falloff 衰减) |
tapPress(t, tc, opts) |
点击反馈:depth 下压、flash 高光闪。配 UI 音 |
pointer(t, knots, clicks, opts) |
光标沿路径移动 + 沿路点击(down 按下深度) |
stretch2(vx, vy, opts) |
速度 → 拉伸(橡皮感)。k/max 控制强度上限 |
sim.* |
物理仿真工具集:magnet(磁吸)/ follow(跟随+边界)/ jelly(软体抖动)/ verlet(绳索、布料) |
| 动作 | 说明 |
|---|---|
camTrack(t, keys, opts) |
关键帧运镜(keys = [{t,x,y,zoom}]),出 camera 状态 |
layerMatrix(cam, opts) |
相机状态 → 某深度层的 transform 矩阵(depth 视差) |
depthBlur(depth, focus?, opts) |
按深度算虚化量(合焦处为 0) |
whipPan(t, t0, dur, dist, opts) |
甩镜:快速横向撕裂,必须开快门才不跳 |
camShake(t, tHit, opts) |
撞击相机抖动(确定性 seed) |
slowPush(t, t0, t1, amount?) |
极缓推镜(默认 +7%),长镜头不静止 |
gridDots(cam, opts) |
背景网格点随相机视差移动,给运镜"刻度感" |
toScreen/toWorld/boxOnScreen/screenTravel |
世界↔屏幕坐标互转、把一个盒子的屏幕位置/移动量算出来 |
screenTravel(c0, c1, cMid?, opts)是给__motion()用的:它直接返回这段运镜在屏幕上走了多少 px,渲染器据此决定快门样本数。
| 动作 | 说明 |
|---|---|
swiftSpring(tau, kind?, opts) |
位移弹簧的可控封装(kind:'smooth',bounce 覆盖) |
glowField(level, opts) |
顶部光晕强度(rest 静息、top 高度、r0/r1 半径) |
floodRings(t, t0, opts) |
涟漪环扩散(n 环数、dt 错峰、rMax 最大半径) |
noiseField(tau, seed?, stops?, opts) |
流动噪声场(返回 RGBA 像素,gx×gy 网格) |
noiseField.ramps |
5 套色带:ember / peach / dusk / deep / sage |
beltLoop(opts) |
无缝循环的传送带 / 走马灯(speed、rampDur 起停) |
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
html,body{margin:0;background:#0e1116;overflow:hidden}
#stage{position:relative;width:1920px;height:1080px;font-family:system-ui}
.word{position:absolute;left:160px;color:#f2f4f8;font-size:96px;font-weight:700;
opacity:0;will-change:transform,opacity}
#disc{position:absolute;left:760px;top:380px;width:400px;height:400px;border-radius:50%;
background:radial-gradient(circle at 35% 30%, #7c5cff, #2a1d5e)}
</style></head>
<body>
<div id="stage">
<h1 class="word" id="w1" style="top:300px">一款药</h1>
<h1 class="word" id="w2" style="top:430px">要烧掉 26 亿美元</h1>
<div id="disc"></div>
</div>
<script src="../assets/motion.js"></script>
<script>
const HXM = window.HXM, DUR = 6.0;
const w1 = HXM.riseWord, disc = document.getElementById('disc');
function __seek(t) {
// 1) 错峰起字(riseWord → { k, op, y, s })
const a = HXM.riseWord(t, 0.20, { dist: 26 });
const b = HXM.riseWord(t, 1.10, { dist: 26 });
Object.assign(document.getElementById('w1').style,
{ transform: `translateY(${a.y}px) scale(${a.s})`, opacity: a.op });
Object.assign(document.getElementById('w2').style,
{ transform: `translateY(${b.y}px) scale(${b.s})`, opacity: b.op });
// 2) 圆盘砸入 + 落地挤压(contact 接到 enter 上;landHit → { r, sx, sy })
const drop = HXM.spring(t - 1.10, 0.62, 18);
const hit = HXM.landHit(t, 1.10, { sqx: 0.13, sqy: 0.16 });
disc.style.transform =
`translateY(${(1 - drop) * -260}px) scale(${hit.sx}, ${hit.sy})`;
// 3) 缓推镜,让整场不静止
const push = HXM.slowPush(t, 0, DUR, 0.06);
document.getElementById('stage').style.transform = `scale(${push})`;
}
// 告诉渲染器:这段哪些区间有大位移(决定快门样本数)
window.__motion = (t0, t1) => {
const a = HXM.spring(t1 - 1.10, 0.62, 18) - HXM.spring(t0 - 1.10, 0.62, 18);
return Math.abs(a) * 260;
};
window.__meta = { dur: DUR, fps: 30, w: 1920, h: 1080 };
document.fonts.ready.then(() => { __seek(0); window.__ready = true; });
</script>
</body></html>- 慢动作别用纯 opacity 淡入 —— 用带位移的弹簧"落下"(
riseWord),否则读起来像 PPT。 - 一个 beat 不许替换上一个 —— 必须让某个元素跨过边界继续动(
morphBox/irisOpen/camTrack)。 - 快动作必须开快门 —— 超过 80 px/帧 不开运动模糊就会重影(
render_video.mjs --shutter)。 - 所有随机数从固定种子出(
seededRng),渲染路径上不许出现Math.random()。 __seek(t)必须覆盖全部元素 —— 漏掉的元素会保留上一帧的状态,逐帧渲染下表现为"拖影"。- 长镜头用
slowPush—— 超过 3 秒画面完全静止,观众会以为卡了。
node tests/test_motion.mjs # 148 条断言:曲线端点、弹簧收敛、确定性、坐标互转测试覆盖:ease.* 全部曲线端点 0→1、back 工厂语义、spring 收敛与过冲、seededRng 可重复、
springVel 与数值微分一致、toScreen/toWorld 往返一致、screenTravel 单调。