Oil Motion运行时控制器源码剖析:frame-scrub与分段播放的阻尼、环形距离和反向撤回
【免费下载链接】oil-motion设计并实现随滚动、拖动、指针或状态变化响应的网页动画,覆盖素材、时间轴和运行时。项目地址: https://gitcode.com/gh_mirrors/oi/oil-motion
Oil Motion 是一个把 AI 视频、序列帧变成网页交互动画的开源 Skill,它负责生成关键帧、编译时间轴,并通过运行时控制器让画面跟随滚动、指针、拖动或状态变化。本文剖析它运行时层的核心源码 assets/interactive-motion.ts:frame-scrub 控制器如何用阻尼让画面平滑随动、如何用最短环形距离处理 360° 旋转输入,以及 segment-playback 控制器如何实现分段播放与反向撤回。
先搞清楚:控制器是怎么选出来的?
Oil Motion 规定“格式选择”和“播放方式”是两件独立的事。预算脚本 scripts/motion_budget.py 根据 Motion Brief 中的time_control直接决定runtime.controller,映射关系只有三种(见 references/runtime.md):
| time_control | runtime.controller | 行为 |
|---|---|---|
scrub | frame-scrub | 输入值持续映射到帧位置,停手画面停在原地 |
segment-play | segment-playback | 输入只选择下一状态,片段随后按时间自行播放 |
autonomous | autonomous-playback | 时间自行推进,交互只负责开始/暂停 |
所有控制器都只读取编译生成的build/timeline.json,页面里不允许维护第二份时间常量。本文重点拆解前两个控制器。
frame-scrub:输入停在哪,画面就停在哪
createFrameAnimator(interactive-motion.ts#L66-L158)是 frame-scrub 的完整实现,对外只暴露四个方法:
setTarget(frame):直接指定目标帧;setProgress(0~1):把一维进度映射到progress × (frameCount - 1);setDirection(x, y):把指针相对主体的方向角映射到帧环上;getCurrentFrame()/destroy():读取当前位置、释放动画帧。
阻尼:为什么快速甩动指针不会闪烁
核心在smoothDamp函数(interactive-motion.ts#L36-L64),它移植了 Unity 经典的 SmoothDamp 算法:
- 用
omega = 2 / smoothTime把平滑时间换算成角频率,smoothTime默认0.11秒——值越小跟随越快,值越大“拖尾”越明显; - 用
maxSpeed限制每帧最大位移,默认frameCount × 2,即最快约半秒扫完整个时间轴,防止指针急甩时帧数瞬间跳变; - 每帧按指数衰减更新速度和位置,并在越过目标点时强制落位归零(L56-L62),保证不会过冲后回弹。
渲染全部集中在requestAnimationFrame循环里(L95-L124):输入事件只更新target,不直接碰 DOM;当位置与目标差、速度都小于0.002时主动停表,符合项目“每个动画帧最多一次 DOM 写入、状态稳定时停止 rAF”的性能验收标准。若系统开启了prefers-reduced-motion,则直接跳到目标帧,不做连续 scrub。
最短环形距离:360° 展台自转的关键
当素材是闭环动画(circular: true,如角色圆周注视、产品展台自转),不能把帧号当直线处理。wrap与shortestCircularDelta(interactive-motion.ts#L25-L34)负责这件事:
delta = wrap(to) - wrap(from) 若 delta > 帧数/2 → delta -= 帧数 若 delta < -帧数/2 → delta += 帧数也就是说,从第 239 帧转到第 1 帧(240 帧图集),走的是+2 帧的短路径,而不是倒退 238 帧。setTarget在环形模式下会把目标改写成position + 最短距离(L133-L139),阻尼系统看到的就是一条连续前进的轨迹。
setDirection(x, y, startAngle)(L140-L145)则把指针极角归一化到0~1再乘帧数,默认起始角-0.75π,正好对应“指针在下方时角色正视镜头”。一个 240 帧、16 列 15 行的环形图集配置示例可参考 assets/motion-manifest.example.json。
segment-playback:分段播放与反向撤回
“点击下一章节播放转场”这类交互用的是createSegmentPlayer(interactive-motion.ts#L257-L512)。它初始化时就严格校验timeline.json:start <= hold < endExclusive、各段不重叠、states数量必须等于段数加一、状态 ID 唯一且与from → to顺序一致——任何一项不满足直接抛错,不进入“运行时才崩”的状态。
正向播放:速度曲线属于时间轴
每段可声明播放速率曲线SegmentRateCurve(L233-L243):
constant:恒定速率;edge-mid-edge:两端快、中间慢,按cos²权重在 edgeRate 与 midRate 之间插值,适合“起势快、结尾快”的镜头感。
正向播放直接调用video.play(),并用requestVideoFrameCallback逐帧监听进度(L322-L331),不支持时才回退 rAF;文档明确禁止用低频的timeupdate判断停帧。
反向撤回:不依赖负 playbackRate 的手动倒放
这是本文件最有巧思的部分。浏览器普遍不支持负playbackRate,所以反向时控制器暂停视频、手动逐帧回退currentTime(L428-L438):
video.pause() video.currentTime = max(targetTime, currentTime - rate × delta)围绕这条倒放链路,源码做了三层防护:
- 边界间隙清除:
removeBoundaryGap(L349-L376)在起播和每帧 tick 时检查当前时间是否落在“上一段 hold 与下一段 start 之间”的剪辑缝隙里,若是则直接跳过缝隙,避免倒放时画面卡在半透明接缝帧上; - 精确落位:接近目标
frameDuration/2内就调用settle(L378-L386)——先pause()再把currentTime精确设到目标状态的hold,杜绝“越过目标再回跳”; - 快速连续输入可取消:每次
startRun递增runToken(L388-L390),旧的 tick 回调发现 token 不匹配立即退出;cancel()则直接作废当前任务并暂停视频。因此用户连点五次“上一步”,只有最后一个目标生效,这正是 runtime 规范中“快速连续输入只保留最新目标”的落地。
step(-1)/step(1)(L485-L487)把方向增量转成goTo(targetState ± 1),边界状态自动 clamp,不会越界出空状态。
分步手势:把惯性滚轮合并成一步
分段播放与分页导航组合时,输入侧由 assets/step-gesture.ts 的createStepGestureAdapter统一处理(L44-L67):
- 累计增量达到
threshold(默认 40px)才触发一次onStep,把一次滚轮/触控板惯性序列合并成一个方向意图,而不是每个 wheel 事件跳一个状态; - 反向输入立刻清零累计值并解锁方向,天然支持“上滚两格再下滚一格”的撤回;
idleMilliseconds(默认 140ms)无新输入后自动复位;setProgrammaticNavigation(true)可屏蔽页面自身平滑滚动引起的误触发。
手势阈值、惯性结束判定只写在这一处,媒体控制器和分页组件不得各写一份。
接入与验收要点
结合 SKILL.md 第 8 步与 references/runtime.md,接入两个控制器时记住这几条硬性要求:
- 一个持续存在的媒体实例:切换状态不替换
src、不重建视频节点,分段播放的反向永远是“从当前画面撤回”,不得换源硬切; - 时间值只来自编译结果:
hold、start、endExclusive全部取自最终编码帧,禁止手工推算; - 反向必须倒放同一段:推进变拉远、溶解变复原;爆炸、泼洒这类倒放违反直觉的动作要单独生成反向片段;
- 降级路径:
prefers-reduced-motion使用合同指定的静态状态,资源失败时回退静态画面并解锁页面。
从smoothDamp的过冲防护、到shortestCircularDelta的环形取短、再到反向撤回的 token 取消机制,两个控制器共同体现了 Oil Motion 的一条设计原则:输入事件只表达意图,平滑、落位和取消都交给共享控制器——这也是它的动画在快速反向、连续输入下不粘滞、不抽动、不回跳的原因。
更多运行时细节可查阅 references/runtime.md,交付格式的选择逻辑见 references/delivery-selection.md。
【免费下载链接】oil-motion设计并实现随滚动、拖动、指针或状态变化响应的网页动画,覆盖素材、时间轴和运行时。项目地址: https://gitcode.com/gh_mirrors/oi/oil-motion
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考