電影級滾動說故事
基礎架構(不可協商)
- 單一固定容器,單一時間軸。 英雄區(或主要章節)固定一個容器,持續 N 個 viewport 高度,而一個從 0→1 的進度值驅動所有動畫。如果有多個滾動動畫互相競爭,那只是一個有特效的一般網頁:不是這個。
- 絕不劫持滾輪。 使用者以自己的原生滾輪控制速度;動畫只是「scrub」(
scrub: true)。禁止劫持滾輪或替使用者移動滾輪。 - 單一動畫迴圈。 如果使用 Lenis,將其整合至 GSAP 的 ticker(
autoRaf: false)並同步 ScrollTrigger;或者使用原生滾輪與 scrub。兩個迴圈競爭是 jank 的第一大原因,且無法透過優化素材解決。 - 文字存活在 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)。
影片 Scrub(若使用 <video>)
currentTime由一個requestAnimationFrame內的進度值驅動,並加上平滑處理;合併 seeks(Safari 會累積佇列)。- 以 blob 載入,使其 100% 可搜尋;使用短 GOP 編碼(每數幀一個關鍵幀),否則 scrub 會卡頓。
- 永不 autoplay:由滾輪控制播放。
章節與覆蓋層
- 依進度範圍(10%、35%、60%、85%...)定義 beats,具有淡入淡出效果,並交替從兩側進入以創造節奏。
- 每個章節的文字保持在 DOM 中,語意順序正確,以利 SEO。
- 水平畫廊:固定區段 +
x屬性綁定進度;卡片向中心縮放。
效能與驗證
- 僅動畫
transform、opacity與filter;will-change僅在必要時使用,並在動畫結束後移除。 - 在本地端與真實行動裝置上使用
requestAnimationFrame測量幀率;目標是在中等筆電上達到 60fps。 - Headless 瀏覽器可能讀取到黑色 Canvas(緩衝區未保留):透過 DOM 驗證(計算樣式、元素、暴露在
window.debug的狀態)並在啟用減少動畫模式時測試。 - 測試寬度為 375/768/1280/1440;桌面瀏覽器調整大小無法重現 iOS 的記憶體崩潰:請在 QA 報告中說明。
建議的資料結構
一個有序的「鏡頭」陣列:每個鏡頭為 { tipo: 'frames' | 'overlay' | 'galeria', ... }。重新排序電影 = 重新排序此陣列。幀透過模式引用(/seq/plano-01/0001.webp)。