从HTML5视频到帧序列:OPTIKKA流畅滚动动画技术解析

本文详细介绍了Zajno团队如何为OPTIKKA平台实现流畅的滚动同步动画,从最初的HTML5视频方案转向帧序列技术,涵盖帧提取、设备适配、智能加载系统和Canvas渲染等核心实现细节。

创建流畅的滚动同步动画:从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或消耗过多带宽是技术难点。

分步加载解决方案

实现分阶段加载系统:

  1. 立即开始:即时加载前10帧
  2. 首帧显示:用户立即看到动画
  3. 后台加载:剩余帧在后台无缝加载
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从视频切换到帧序列证明了为任务选择正确技术的重要性。尽管增加了复杂性,但新方法提供了:

  • 跨设备的可靠性能
  • 一致、流畅的动画
  • 各种场景的精细控制

有时候,如果能够提供更好的用户体验,技术上更复杂的解决方案是合理的。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计