创建流畅的滚动同步动画:从HTML5视频到帧序列
初始方案:HTML5视频
为何选择视频方案
我们最初考虑使用HTML5视频配合GSAP的ScrollTrigger插件实现滚动触发动画:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
export default class VideoScene extends Section {
private video: HTMLVideoElement;
private scrollTrigger: ScrollTrigger;
setupVideoScroll() {
this.scrollTrigger = ScrollTrigger.create({
trigger: '.video-container',
start: 'top top',
end: 'bottom bottom',
scrub: true,
onUpdate: (self) => {
const duration = this.video.duration;
this.video.currentTime = self.progress * duration;
},
});
}
}
|
优势:
- 简单性:浏览器原生支持视频播放
- 紧凑性:单个视频文件替代数百张图片
- 压缩:视频编解码器有效减小文件大小
实际缺陷:
- 移动设备卡顿和延迟
- 浏览器自动播放限制
- 压缩导致的画质损失
转向帧序列方案
什么是帧序列?
帧序列由快速播放的独立图像组成,类似于每秒24帧的电影,这种方法可以精确控制动画时间和质量。
从视频提取帧
使用FFmpeg将视频转换为独立帧并优化为Web格式:
1
2
3
4
5
6
7
|
// 提取PNG帧序列
console.log('🎬 Extracting PNG frames...');
await execPromise(`ffmpeg -i "video/${videoFile}" -vf "fps=30" "png/frame_%03d.png"`);
// 转换为WebP序列
console.log('🔄 Converting to WebP sequence...');
await execPromise(`ffmpeg -i "png/frame_%03d.png" -c:v libwebp -quality 80 "webp/frame_%03d.webp"`);
|
设备特定序列
为优化不同设备性能,创建至少两套序列:
1
2
3
4
5
6
7
8
9
10
11
|
export default abstract class Scene extends Section {
// 设备特定的帧配置
private static readonly totalFrames: Record<BreakpointType, number> = {
[BreakpointType.Desktop]: 1182,
[BreakpointType.Tablet]: 880,
[BreakpointType.Mobile]: 880,
};
// 动态路径基于当前断点
img.src = `/${this._currentBreakpointType.toLowerCase()}/frame_${paddedNumber}.webp`;
}
|
智能帧加载系统
挑战
加载1000+图像而不阻塞UI或消耗过多带宽是技术难点。
分步加载解决方案
实现分阶段加载系统:
- 立即开始:即时加载前10帧
- 首帧显示:用户立即看到动画
- 后台加载:剩余帧在后台无缝加载
1
2
3
|
await this.preloadFrames(1, countPreloadFrames);
this.renderFrame(1);
this.loadFramesToHash();
|
并行后台加载
使用ParallelQueue系统高效加载剩余帧:
1
2
3
4
5
6
7
8
9
10
11
12
|
private loadFramesToHash() {
const queue = new ParallelQueue();
for (let i = countPreloadFrames; i <= totalFrames[this._currentBreakpointType]; i++) {
queue.enqueue(async () => {
const img = await this.loadFrame(i);
this._frameImages.set(i, img);
});
}
queue.start();
}
|
Canvas渲染
为什么选择Canvas
在HTML <canvas> 元素中渲染帧的多个优势:
- 即时渲染:帧加载到内存中立即显示
- 无DOM重排:避免页面重绘
- 优化动画:与requestAnimationFrame配合流畅工作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
private renderFrame(frameNumber: number) {
const img = this._frameImages.get(frameNumber);
if (img && this._ctx) {
// 清除前一帧
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
// 处理高DPI显示
const pixelRatio = window.devicePixelRatio || 1;
const canvasRatio = this._canvas.width / this._canvas.height;
const imageRatio = img.width / img.height;
// 计算object-fit: cover行为的尺寸
let drawWidth = this._canvas.width;
let drawHeight = this._canvas.height;
let offsetX = 0;
let offsetY = 0;
if (canvasRatio > imageRatio) {
drawWidth = this._canvas.width;
drawHeight = this._canvas.width / imageRatio;
} else {
drawHeight = this._canvas.height;
drawWidth = this._canvas.height * imageRatio;
offsetX = (this._canvas.width - drawWidth) / 2;
}
this._ctx.drawImage(img, offsetX, offsetY, drawWidth / pixelRatio, drawHeight / pixelRatio);
}
}
|
页面起始循环动画
Canvas还允许我们在页面起始实现循环动画,并使用GSAP无缝过渡到滚动触发帧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
private async playLoop() {
if (!this.isLooping) return;
const startTime = Date.now();
const animate = () => {
if (!this.isLooping) return;
const elapsed = (Date.now() - startTime) % (this.loopDuration * 1000);
const progress = elapsed / (this.loopDuration * 1000);
const frame = Math.round(this.loopStartFrame + progress * this.framesPerLoop);
if (frame !== this._currentFrame.contents) {
this._currentFrame.contents = frame;
this.renderFrame(this._currentFrame.contents);
}
requestAnimationFrame(animate);
};
await this.preloadFrames(this.loopStartFrame, this.loopEndFrame);
animate();
}
|
性能优化
基于滚动方向的动态预加载
通过根据滚动运动动态预加载帧来增强流畅性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
_containerSequenceUpdate = async (self: ScrollTrigger) => {
const currentScroll = window.scrollY;
const isScrollingUp = currentScroll < this.lastScrollPosition;
this.lastScrollPosition = currentScroll;
const totalHeight = document.documentElement.scrollHeight - window.innerHeight;
const adjustedProgress = Math.min(1, currentScroll / (totalHeight - offsetVideoEnd[this._currentBreakpointType]));
this.handleScrollTransition(self.progress);
if (!this.isLooping) {
const frame = Math.round(adjustedProgress * totalFrames[this._currentBreakpointType]);
if (frame !== this._currentFrame.contents) {
this._currentFrame.contents = frame;
// 按滚动方向预加载帧
const preloadAmount = 5;
await this.preloadFrames(
frame + (isScrollingUp ? -preloadAmount : 1),
frame + (isScrollingUp ? -1 : preloadAmount)
);
this.renderFrame(frame);
}
}
};
|
转换结果
优势
- 跨设备稳定性能
- 可预测的内存使用
- 无播放卡顿
- 跨平台一致性
- 自动播放灵活性
- 对每帧的精确控制
技术权衡
- 由于多个请求导致带宽增加
- 总体数据大小更大
- 缓存和预加载逻辑的实现复杂度更高
结论
为OPTIKKA从视频切换到帧序列证明了为任务选择正确技术的重要性。尽管增加了复杂性,但新方法提供了:
- 跨设备的可靠性能
- 一致、流畅的动画
- 各种场景的精细控制
有时候,如果能够提供更好的用户体验,技术上更复杂的解决方案是合理的。