从HTML5视频到帧序列:OPTIKKA平滑滚动同步动画实现指南

本文详细介绍了如何为OPTIKKA平台实现平滑的滚动同步动画,从最初的HTML5视频方案转向帧序列方法,包含技术架构、性能优化和具体实现代码,解决了移动端卡顿和浏览器自动播放限制等问题。

创建OPTIKKA的平滑滚动同步动画:从HTML5视频到帧序列

初始方案:HTML5视频

为何看似可行

我们最初的想法是使用HTML5视频进行滚动触发动画,配合GSAP的ScrollTrigger插件进行滚动跟踪。这种方法有明显优势:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 使用视频元素的初始方法
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. 将其拆分为单独的PNG帧
  3. 将PNG转换为WebP以减少文件大小
1
2
3
4
5
6
7
8
// 将帧提取为PNG序列
console.log('🎬 提取PNG帧...');
await execPromise(`ffmpeg -i "video/${videoFile}" -vf "fps=30" "png/frame_%03d.png"`);

// 将PNG序列转换为WebP序列
console.log('🔄 转换为WebP序列...');
await execPromise(`ffmpeg -i "png/frame_%03d.png" -c:v libwebp -quality 80 "webp/frame_%03d.webp"`);
console.log('✅ 处理完成!');

设备特定序列

为了优化跨设备性能,我们为不同的宽高比创建了至少两组序列:

  • 桌面:更高的帧数以实现更流畅的动画
  • 移动设备:较低的帧数以实现更快的加载和效率
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 基于图像序列的新架构
export default abstract class Scene extends Section {
  private _canvas: HTMLCanvasElement;
  private _ctx: CanvasRenderingContext2D;
  private _frameImages: Map<number, HTMLImageElement> = new Map();
  private _currentFrame: { contents: number } = { contents: 1 };

  // 设备特定的帧配置
  private static readonly totalFrames: Record<BreakpointType, number> = {
    [BreakpointType.Desktop]: 1182,
    [BreakpointType.Tablet]: 880,
    [BreakpointType.Mobile]: 880,
  };

  // 基于设备类型的视频结束偏移量
  private static readonly offsetVideoEnd: Record<BreakpointType, number> = {
    [BreakpointType.Desktop]: 1500,
    [BreakpointType.Tablet]: 1500,
    [BreakpointType.Mobile]: 1800,
  };
}

我们还实现了动态路径解析,以根据用户设备类型加载正确的图像序列。

1
2
// 基于当前断点的动态路径
img.src = `/${this._currentBreakpointType.toLowerCase()}/frame_${paddedNumber}.webp`;

智能帧加载系统

挑战

在不阻塞UI或消耗过多带宽的情况下加载1000多张图像是很棘手的。用户期望即时动画,但繁重的图像序列会减慢网站速度。

分步加载解决方案

我们实现了一个分阶段加载系统:

  1. 立即开始:立即加载前10帧
  2. 首帧显示:用户立即看到动画
  3. 后台加载:剩余帧在后台无缝加载
1
2
3
await this.preloadFrames(1, countPreloadFrames);
this.renderFrame(1);
this.loadFramesToHash();

并行后台加载

使用ParallelQueue系统,我们:

  • 高效加载剩余帧而不阻塞UI
  • 从定义的countPreloadFrames开始以避免冗余
  • 自动缓存每个加载的帧以提高性能
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 使用并行队列后台加载所有帧
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
30
31
32
33
// 具有适当缩放和定位的Canvas渲染
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) {
      // Canvas比图像宽
      drawWidth = this._canvas.width;
      drawHeight = this._canvas.width / imageRatio;
    } else {
      // Canvas比图像高
      drawHeight = this._canvas.height;
      drawWidth = this._canvas.height * imageRatio;
      offsetX = (this._canvas.width - drawWidth) / 2;
    }

    // 使用适当的缩放绘制图像以适应高DPI
    this._ctx.drawImage(img, offsetX, offsetY, drawWidth / pixelRatio, drawHeight / pixelRatio);
  }
}

<img>元素的限制

虽然可能,但使用<img>进行帧序列存在以下问题:

  • 缩放控制有限
  • 快速帧变化期间的同步问题
  • 闪烁和跨浏览器渲染不一致
 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
// 页面顶部的自动播放循环动画
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();
}

页面起始的循环动画

Canvas还允许我们在页面起始处实现循环动画,并使用GSAP无缝过渡到滚动触发的帧。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 循环和基于滚动的动画之间的平滑过渡
private handleScrollTransition(scrollProgress: number) {
  if (this.isLooping && scrollProgress > 0) {
    // 从循环过渡到基于滚动的动画
    this.isLooping = false;
    gsap.to(this._currentFrame, {
      duration: this.transitionDuration,
      contents: this.framesPerLoop - this.transitionStartScrollOffset,
      ease: 'power2.inOut',
      onComplete: () => (this.isLooping = false),
    });
  } else if (!this.isLooping && scrollProgress === 0) {
    // 过渡回循环动画
    this.preloadFrames(this.loopStartFrame, this.loopEndFrame);
    this.isLooping = true;
    this.playLoop();
  }
}

性能优化

基于滚动方向的动态预加载

我们通过根据滚动运动动态预加载帧来增强平滑度:

  • 向下滚动:预加载前5帧
  • 向上滚动:预加载后5帧
  • 优化范围:仅加载必要的帧
  • 同步渲染:预加载与当前帧显示同步进行
 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
30
// 基于滚动方向的智能预加载
_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 设计