电影级滚动讲述(Scrollytelling)
基础架构(不可妥协)
- 单一固定容器,单一时间线。 主角(或核心章节)固定一个容器,其持续 N 个视口高度,进度 0→1 驱动一切。如果有多个滚动动画在竞争,那只是一个普通的有特效的网页,不是本技能的效果。
- 绝不滚动劫持。 用户通过原生滚动控制速度;动画被“擦洗”(
scrub: true)。禁止劫持鼠标滚轮或替用户移动滚动条。 - 单一动画循环。 Lenis 集成到 GSAP 的 ticker 中(
autoRaf: false),ScrollTrigger 同步;或者使用原生滚动配合 scrub。两个循环竞争是卡顿的头号原因,且无法通过优化资源解决。 - 文本存在于 DOM 中,叠加在 Canvas 之上,根据进度分层显示。绝不在 Canvas 内部渲染文字。
帧序列(Apple 式技术)
- 首选来源:真实视频 → 使用
ffmpeg导出帧(fps=12..24,按屏幕分辨率缩放)。导出 WebP 格式,质量参数 q≈68-75,根据实际效果微调,而非凭直觉。 - 最多约 90-120 帧;在移动端提供一半帧数或 720p 版本。
- 使用 滑动窗口 加载:仅保留当前帧周围 ±6-10 帧;绝不解码完整序列(完整 1080p 序列会杀死 iOS Safari)。
- 预加载使用
IntersectionObserver(rootMargin ~800px),requestIdleCallback进行解码,fetchPriority="low"(主角帧除外)。 - Canvas 的
devicePixelRatio限制为 1.5,计算cover/contain模式;仅绘制已解码的帧;如果缺少精确帧,使用最近已加载的帧(绝不绘制黑色)。 - 使用不可变缓存(
Cache-Control: public, max-age=31536000, immutable),文件名包含版本号。 - 降级方案:当检测到
prefers-reduced-motion或网络连接慢时,使用静态海报 + 无固定效果的普通章节。 - 更简单的替代方案(当不需要后期处理时):通过更换
<img>的src来切换帧(在所有浏览器中可靠,无需 Canvas)。
视频擦洗(若使用 <video>)
- 在单一
requestAnimationFrame中由进度驱动currentTime,并做平滑处理;合并 seeks(Safari 会累积队列)。 - 将视频作为 blob 加载以确保 100% 可 seek;使用短 GOP 编码(关键帧间隔短),否则擦洗会卡顿。
- 绝不用 autoplay:由滚动驱动播放。
章节与覆盖层
- 关键节拍由进度范围定义(10%、35%、60%、85%……),包含淡入淡出效果,并交替从不同侧进入以创造节奏感。
- 每个章节在 DOM 中按正确的语义顺序保留其文本,以利于 SEO。
- 横向画廊:固定该区域 + 将
x绑定到进度;卡片向中心缩放。
性能与验证
- 仅动画化
transform、opacity和filter属性;will-change按需使用并在动画结束后移除。 - 使用
requestAnimationFrame在本地和真实移动设备上测量帧率(如可能);目标是在中等配置的笔记本电脑上达到 60fps。 - Headless 截图可能读取到黑色 Canvas(缓冲区未保留):需通过 DOM 验证(计算样式、元素、暴露在
window.debug中的状态),并测试弱网模式。 - 在 375/768/1280/1440 宽度下测试;桌面浏览器的窗口缩放无法复现 iOS 的内存崩溃:需在 QA 报告中明确说明。
推荐的数据结构
一个有序的“场景”数组:每个场景为 { type: 'frames' | 'overlay' | 'galeria', ... }。要重新编排叙事顺序,只需重新排列该数组。帧通过模式引用(例如 /seq/plano-01/0001.webp)。