scroll-frame-sequence
v1.0.7
Published
Scroll-driven image sequence player with hold loops, mobile pan and three-layer loading. Zero dependencies.
Downloads
1,110
Maintainers
Readme
scroll-frame-sequence
▶ 線上實例 — demo.tangyi.mx
捲捲看。然後用手機開一次:那裡有一組 Fit / Fill / Follow 切換,展示 16:9 的畫面塞進 9:16 螢幕的三種解法。
捲動驅動的逐幀影格序列,附停留循環 —— Apple 產品頁那種效果,加上通常沒人寫下來的那些部分。

向前、停住、往回。序列跟著的是捲動位置,不是時鐘 —— 這就是這個函式庫唯一的主張, 也是它跟「一支循環播放的影片」的差別。
示範影格是刻意模糊的:那是有授權的素材,不屬於這個套件;這裡要展示的是函式庫, 不是那支片。清晰版在 tangyi.mx。
一個 <section> 被捲動,一支影片在使用者的手指下一幀一幀前進。停止捲動就停在那一格,往回捲就倒著播。在指定的影格上還可以停住並播一小段循環動畫,讓「凍住」的畫面仍然有呼吸。
兩個部分,可以分開用:
tools/—— 把影片變成影格、spritesheet 與停留循環,並且給你判斷成果好不好的實測數字。src/—— 約 530 行、零相依的播放器,全程不觸發你的框架重繪。
這裡的東西都是做完上線之後整理出來的,不是寫部落格想出來的。文件裡的數字都是量出來的,包含那些比預期糟的。
為什麼不直接用 <video>
| | <video autoplay> | 捲動逐幀 |
|---|---|---|
| 播放速度 | 影片自己決定 | 使用者的手決定 |
| 停下來 | 只能暫停 | 停在那一格 |
| 倒轉 | 拖進度條 | 往回捲 |
| 文案與畫面同步 | 要監聽 timeupdate | 同一個進度值,免費 |
| 手機自動播放 | muted + playsinline,還是常被擋 | 沒有這個問題 |
代價是頻寬的形狀,不是總量。25 秒的 H.264 約 7 MB 而且是串流;同樣內容做成 150 張 WebP 約 5 MB,但它希望在使用者開始捲之前就備齊。這個專案大部分的設計都在處理「怎麼讓這件事不要變成問題」 —— 見三層載入。
放到頁面上是什麼樣子

看捲軸,不要看畫面。 它往下、回頭、再往下 —— 而畫面每一次都跟著它走,因為那一格就是捲動位置本身。換成影片的話,捲軸停著它照樣會繼續播。
開頭那段是停留點:捲軸停在最上面,但畫面還在動。那是待機循環,也是「凍住的 Hero」不會被當成當掉的網頁的原因。
安裝
播放器就是兩個檔案,沒有建置步驟也沒有相依。裝起來,或直接複製走。
# 播放器
npm install scroll-frame-sequence
# 或把 src/scroll-sequence.js 與 src/scroll-sequence.css 複製進你的專案
# 工具
pip install -r tools/requirements.txt # Pillow、numpy;另外需要 ffmpegimport { ScrollSequence } from 'scroll-frame-sequence';
import 'scroll-frame-sequence/style.css';快速上手
1. 做出影格
python tools/extract_frames.py source.mp4 frames/ \
--count 150 --scale 1920:1080 --quality 72 --weighted
python tools/build_spritesheet.py frames/ spritesheet.webp --cols 9
# -> 印出:sheetCols 9、sheetRows 17,以及 CSS 位移公式2. 播放
<link rel="stylesheet" href="scroll-sequence.css" />
<section id="hero"></section>
<script type="module">
import { ScrollSequence } from './scroll-sequence.js';
new ScrollSequence('#hero', {
frames: Array.from({ length: 150 }, (_, i) =>
`/frames/f_${String(i + 1).padStart(3, '0')}.webp`),
sheetUrl: '/spritesheet.webp',
sheetCols: 9,
sheetRows: 17,
heightVh: 650,
dim: 0.35,
onProgress: (p, frame, total) => {
caption.textContent = `${frame + 1} / ${total}`;
},
});
</script>基本情況就這樣。examples/ 有可以直接跑的版本,以及一個含停留循環的版本。
容易做錯的地方
要抽幾張?
判準不是「幾秒」,而是每一幀分到多少捲動像素。
每幀像素 = (heightVh - 100) / 100 × 視窗高 / 幀數| 每幀像素 | 觀感 | |---|---| | 低於 10 | 太密 —— 你在為看不出來的細節付費 | | 15 – 20 | 舒適區(Apple AirPods 產品頁約 18.5)| | 超過 30 | 明顯跳格 |
兩個實際上線過的設定:
| 區塊 | 幀數 | heightVh | 每幀像素 |
|---|---|---|---|
| 滿版 Hero | 150 | 650 | 18.3 |
| 頁面中段的較短區塊 | 120 | 320 | 16.5 |
用幀號抽樣,不要用 fps
ffmpeg -vf fps=150/28 是重新取樣時間軸。依進位不同你會拿到 150 或 151 張,而且不保證抓到影片真正的第一幀與最後一幀 —— 而那兩張正是使用者盯著最久的:開場定格與結尾的定格姿勢。
幀號_i = round(i × (總幀數 - 1) / (目標張數 - 1)),i = 0 … 目標張數-1extract_frames.py 就是這樣做的。這不是偏好問題,fps 抽樣會默默弄丟你的頭尾。
依動態量抽樣,不要依時間
均勻抽樣會把「鏡頭定住兩秒」和「角色進場兩秒」分到一樣多的影格。結果就是定住的那段讀起來像**「我還在捲,但動畫凍住了」**。
--weighted 依畫面實際變化量分配預算。同一支 16 秒素材、89 張的實測:
| | 均勻 | 動態加權 | |---|---|---| | 每步變化量的變異係數 | 0.73 | 0.38 | | 低於平均一半的步數(「感覺卡住」)| 29 | 8 |
它有阻尼(w = 0.35 + 0.65 × m/mean),因為純比例分配會把安靜的段落整個刪掉,而那些安靜通常是刻意的。
spritesheet 必須小,而理由不是檔案大小
瀏覽器解碼圖片是 寬 × 高 × 4 bytes,跟它壓得多好完全無關。以 150 幀計:
| 單格 | 拼接後 | 解碼記憶體 | | |---|---|---|---| | 1280x720 | 11520 x 12240 | 538 MB | 手機直接當掉 | | 960x540 | 8640 x 9180 | 303 MB | 太重 | | 640x360 | 5760 x 6120 | 134 MB | 用這個 | | 480x270 | 4320 x 4590 | 76 MB | 明顯糊 |
所以拼接圖註定是低解析度層。它的任務是讓動畫立刻能動;清晰度是全解析度影格的事。
三層載入
底層 spritesheet 約 2 MB、一個請求、一秒左右就能開始捲
中層 全解析度影格 約 5 MB、陸續載入,載到哪張就蓋掉哪一格
上層 停留循環 使用者靠近時才抓任何地方都不需要「載好了沒」的狀態判斷。<img> 的 src 還沒解碼時它本來就不繪製,底下那層自然露出來,位元組到了自動蓋上去。
預載順序是二分逼近,不是循序:
0 -> 149 -> 75 -> 37 -> 112 -> ...循序載入的話,在下載快結束之前只有開頭能看。二分逼近讓大約兩成的位元組就覆蓋完整條動線,只是比較粗 —— 而粗但完整永遠勝過細但只有前半段。
停留循環靠近才載。 一度讓三組全部先載,那是約 9 MB 擋在主序列前面;改成 40 幀的提前量之後,進站成本降到約 1.6 MB。
停留循環
停留點把序列釘在某一格,在上面播一小段循環,捲過去之後交還。呼吸、霓虹閃爍、頭髮被風吹 —— 足夠讓凍住的畫面不像當掉的網頁。

這裡的捲動位置完全沒有移動。底下那一格被釘住,上面播的是一組 24 張的循環。這一組用的是來回播放:它的首尾差距是正常單步的 2.9 倍,直接照 1..24, 1..24 播的話,每一輪都會看到明顯的跳動。
捲動預算以「單位」計算:每格影格佔 1,停留點額外再佔 weight。
總單位 = 影格數 + 所有 weight 加總holds: [
{ index: 0, weight: 1, mode: 'pingpong', fps: 10, frames: [...] },
{ index: 60, weight: 60, mode: 'pingpong', fps: 10, frames: [...] },
{ index: 149, weight: 60, mode: 'loop', fps: 10, frames: [...],
intro: [...] }, // 可選,第一次進入時播一次
]第一格用 weight: 1 就是待機動畫的寫法:使用者停在頂端時無限播,一往下捲就立刻離開。
來回播放才是讓這件事實用的關鍵
生成出來的短片通常不會閉合,即使你照標準做法把同一張圖填進首尾幀要求無縫也一樣。同一批素材的實測,第一幀與最後一幀的差異,換算成「正常走一步」的倍數:
| 素材 | 倍數 | |---|---| | A —— 緊特寫、小動作 | 4.2x | | B —— 廣角、多個主體 | 6.3x | | C —— 同構圖、動作更小 | 7.7x | | D —— 平坦背景、低對比 | 2.3x |
播 1..N 再 N-1..2 在結構上就沒有接點。唯一代價是動作有一半時間在倒著跑 —— 呼吸、衣料、燈光呼吸看不出來;碼流下墜、單向的風就很明顯。
make_loop.py 會量接點、鏡頭飄移、亮度穩定度與位移偏向,然後告訴你該用哪個模式、為什麼:
$ python tools/make_loop.py hold.mp4 out/ --count 24
source 97 frames
adjacent change 0.59
first->last 16.96 = 28.9x a normal step does NOT close
luma amplitude 3.7% stable
camera drift dy=+0 dx=-1 none
flow bias 0.00 (no consistent direction) safe to reverse
chosen mode pingpong (no consistent direction, and ping-pong removes the seam entirely)如果動作是單向的、而且鏡頭鎖死,它會改推薦交叉溶接 —— 對隨機紋理(雨、碼流、粒子)特別有效,因為兩端在統計上相同,即使數值不相等。它也會幫你印出一個要注意的地方:溶接後的循環是從來源第 L 幀開始,不是第 0 幀,所以沒辦法對上精確的進場定格 —— 停留點必須跟底下那一格對齊時,用來回播放。
播放用 rAF,不用 setInterval
setInterval 會累積漂移,而且分頁在背景時被瀏覽器節流、回來時一次補播好幾幀。用真實經過時間推算索引可以同時解決兩者:
const step = Math.floor((now - t0) / interval);
const k = ((step % period) + period) % period;就緒檢查:找最久的那個坑
循環一進場就開始播,但影格還沒下載完。把 .src 換成尚未解碼的圖會先畫成空白,主序列露出來,圖到了又蓋回去 —— 一直重複。看起來就是閃爍,而且看不出來原因是載入。
修法是跳過,不是等待:
if (url && isReady(url) && idx !== shown) node.src = url; // 否則維持現在的畫面循環會粗糙一下下然後自己變順,不需要任何載入狀態。
手機直式:把搖鏡做在瀏覽器裡
16:9 的畫面在 9:16 的視窗裡只看得到中央約 26–32% 的寬度。把橫向排開的一排主體置中裁切,等於只給觀眾看其中一個。
與其做第二套直式素材,不如送出一份時間表 —— 幾百個位元組,告訴播放器要看哪裡:
{
"totalFrames": 150,
"interpolation": { "cx": "smooth", "fit": "step" },
"keyframes": [
{ "frame": 1, "cx": 0.35, "fit": "cover", "note": "主體在中線偏左" },
{ "frame": 40, "cx": 0.50, "fit": "cover", "note": "跨 39 幀移動,才讀得出是搖鏡" },
{ "frame": 41, "cx": 0.50, "fit": "contain", "note": "切在畫面本來就在變的那一幀" },
{ "frame": 110, "cx": 0.50, "fit": "cover" }
]
}cx是比例不是像素 —— 裁切窗依裝置而異,寫死像素會在某些機型上偏掉。它會平滑內插。fit是階梯函數:cover滿版,contain上下留白讓寬構圖活下來。
三條從做錯中得到的規則:
fit整支片切換不超過 4 次。 更多就變成版面在閃。- 切在畫面本來就在變的地方 —— 白光淹沒、粒子爆散。轉換就藏進那個變化裡。
cx的移動要跨至少 10 幀。 少於 5 幀看起來像抽搐。
還有一件事值得接受:當兩個重要主體分處畫面兩端,9:16 的窗口在數學上就是裝不下。那是取景決策不是演算法 —— 選 contain 讓大家都變小,或選一邊放棄另一邊。唯一錯的做法是默默決定不講。
三種解法,沒有哪一種永遠對
同一份時間表可以有三種讀法。線上實例把它們做成切換按鈕,因為這件事用眼睛判斷比讀一段文字快得多。
| | fit | cx | 你放棄的是 |
|---|---|---|---|
| Fit | 依時間表,寬構圖用 contain | 依時間表 | 上下黑邊。但構圖完整保留 |
| Fill | 全部 cover | 固定 0.5 | 中央約 30% 以外的所有東西 |
| Follow | 全部 cover | 依時間表 | 幾乎沒有 —— 直到兩個主體分處兩端,那時它只能選一個 |
三種都只是同一份時間表的一行轉換:
const fill = schedule.map((k) => ({ ...k, cx: 0.5, fit: 'cover' }));
const follow = schedule.map((k) => ({ ...k, fit: 'cover' }));
sequence.update({ mobilePan: follow });大多數素材的正確預設是 Follow,那也是當初要標 cx 的理由。只有在畫面真的不能裁的地方才用 Fit —— 群像橫排、寬版標題卡 —— 並且只在那幾格接受黑邊,不要整支片都吃。
除錯
網址加上 ?seqdebug=1:
progress 43.2% frame 61/150 || pingpong 8/24 loop 3 pan 50% contain ...f_061.webp| 症狀 | 檢查 |
|---|---|
| 完全不動 | <html> 上的 overflow-x: hidden —— 見下 |
| 最後一幀不對 | 面板上的檔名,真的是 f_150 嗎 |
| 幀數是兩倍 | 重複上傳,重傳前要先清空 |
| 循環播兩次就停 | 不是壞掉,是 weight 太小被捲過去了 |
| 進場閃爍 | 面板會顯示 loading n/N |
會花掉你一個下午的那一個
html, body { overflow-x: hidden; } /* ✗ 讓 <html> 變成捲動容器 */
body { overflow-x: clip; } /* ✓ 一樣裁掉溢出,但不建立容器 */<html> 上的 overflow-x: hidden 會讓它變成捲動容器,於是 position: sticky 對整頁所有子孫元素靜默失效。沒有錯誤、沒有警告,序列就是不動。
API
new ScrollSequence(container, options)| 選項 | 預設 | |
|---|---|---|
| frames | [] | 影格網址陣列 |
| poster | '' | 任何東西解碼之前顯示的靜態圖 |
| dim | 0.35 | 黑幕濃度 |
| heightVh | 500 | 區塊高度,也就是捲動距離 |
| sheetUrl / sheetCols / sheetRows | — | 快速載入層 |
| holds | [] | 見上 |
| mobilePan | [] | 時間表的關鍵影格 |
| mobileFrameCount | 48 | 手機抽樣張數,0 表示不抽樣 |
| mobileBreakpoint | 768 | px |
| mobileHeightVh | 0 | 0 表示沿用 heightVh |
| holdLookahead | 40 | 距離停留點幾幀開始預載 |
| maxConcurrent | 6 | 並行圖片請求數 |
| aspect | 16/9 | 素材長寬比,用來換算直式跟拍的對準點 |
| respectReducedMotion | true | 見下方 |
| debug | null | null 自動偵測 ?seqdebug=1;true/false 強制開關 |
| onProgress | — | (progress, frameIndex, frameTotal) |
| onFrame | — | (frameIndex),只在改變時觸發 |
方法:update(patch)、destroy(),以及 sequence.overlay 讓你把自己的 DOM 掛上去。
onProgress 一律回報完整序列的序號,即使手機正在跑 48 張的抽樣版 —— 所以你的文案節拍不用管有沒有抽樣。
減少動態(reduced motion)
prefers-reduced-motion 要求的是少一點自己會動的動畫。序列本身只有在使用者捲動時才前進,所以留著;停留循環是自己在播的,所以拿掉。預設開啟 respectReducedMotion 時,循環不會啟動,那些影格也完全不會下載。停留的那一格照樣顯示、照樣佔用它的捲動距離,版面讀起來一模一樣 —— 只是不再呼吸。
搭配框架使用
播放器自己管理它的 DOM,也完全不經過框架狀態,所以它只需要一個 ref 和一次清理。
import { useEffect, useRef } from 'react';
import { ScrollSequence } from 'scroll-frame-sequence';
import 'scroll-frame-sequence/style.css';
export function Hero({ frames }) {
const ref = useRef(null);
useEffect(() => {
const seq = new ScrollSequence(ref.current, { frames, heightVh: 650 });
return () => seq.destroy(); // 一定要,它掛著捲動監聽器
}, [frames]);
return <section ref={ref} />;
}Vue 的 onMounted / onUnmounted、Svelte 的 onMount 回傳值都是同一套寫法。React 18+ 的 Strict Mode 在開發時會跑兩次 effect,有 destroy() 就沒事。
不要把 onProgress 每次都寫進 state。 它是連續觸發的。直接寫 DOM,或先量化 —— 見 docs/player.zh-TW.md。
什麼時候該用別的東西
這個函式庫只做一件事:把捲動位置對應到影格序號。有三個相鄰的問題,用別的工具更好。
| 你想要 | 用 |
|---|---|
| 360° 產品旋轉,或讓序列自己高幀率播放 | 為播放而生的序列渲染器,例如 fast-image-sequence |
| 捲到某一段就觸發某件事 —— 圖表、字幕、地圖移動 | scrollama |
| 平滑/慣性捲動 | Lenis,它跟這個是互補的 |
| 綁在捲動上的通用動畫時間軸 | GSAP ScrollTrigger。這個比它窄,但附了素材產製管線 |
這個專案之所以要獨立存在:上面沒有一個把捲動位置當成影格本身,也沒有一個告訴你影格要怎麼生出來。
常見問題
跟「把影片的 currentTime 綁在捲動上」差在哪?
在每次捲動事件去 seek 影片,跨瀏覽器並不可靠 —— seek 是非同步的、會對齊關鍵影格,而且在手機上會被節流。靜態圖沒有 seek 這件事。代價是影格必須在使用者捲到之前就備齊,而那正是三層載入要解決的。
要幾張影格? 判準不是秒數,是每一格分到多少捲動像素。15–20 px 是舒適區。
5 MB 的影格不會很慢嗎? 如果你等它全部載完就會。但你不用等:約 2 MB 的拼接圖讓動畫一秒左右就能捲,全解析度影格在背後陸續補上。手機預設抽樣成 48 張。
它有用 canvas 嗎?
沒有,用的是兩張交替的 <img>。canvas 要你自己寫解碼與繪製迴圈,而且每張點陣圖在記憶體裡會多存一份;<img> 這些瀏覽器本來就做好了,包含快取和載入優先序。canvas 的優勢在合成與特效,而這個專案不做那些。
需要 CMS 或後端嗎? 不需要。影格就是網址。靜態託管加一份 JSON manifest 就是完整的部署 —— 如果有非工程師要改內容,見 docs/authoring.zh-TW.md。
可以搭配 Next.js、Nuxt 這類伺服器端渲染嗎?
可以,規則跟任何操作 DOM 的函式庫一樣:在 effect 裡建立,不要在 render 期間建立。 建構子會讀 window 和 document,在伺服器端呼叫會直接拋錯。上面的 React 範例已經是正確寫法 —— 不需要 dynamic import,也不需要 ssr: false。
有 TypeScript 型別嗎?
有。型別跟著套件一起發佈(src/scroll-sequence.d.ts),import { ScrollSequence } 直接就有型別,不用另外裝 @types。已用 tsc --strict 驗過。
可以在執行中換掉影格嗎?
sequence.update({ frames })。它會重算捲動預算、保留已經抓好的圖,並在當前捲動位置重新套用。
支援哪些瀏覽器?
有 position: sticky 的都可以,也就是所有現行瀏覽器。img.decode() 有就用,沒有就退回 load 事件。
無障礙呢?
prefers-reduced-motion 會拿掉停留循環 —— 那是自己會動的動畫 —— 但保留捲動驅動的序列,因為那不是。循環的影格也完全不會下載。
可以商用嗎? 可以,MIT。把著作權聲明留在你的原始碼裡;看得見的出處標註是我們的請求,不是條件。見授權。
為什麼 README 裡的示範是模糊的? 那是有授權的素材,用來展示函式庫,不屬於授權釋出的範圍。清晰版在 tangyi.mx。
文件
| | | |---|---| | docs/pipeline.zh-TW.md | 素材生產,含所有實測數字 | | docs/player.zh-TW.md | 播放器怎麼運作、為什麼這樣設計 | | docs/authoring.zh-TW.md | 餵資料給它的後台怎麼設計,與平台無關 | | docs/lessons.zh-TW.md | 什麼失敗了、量到什麼、什麼不要再試 |
English: README.md · pipeline · player · authoring · lessons
範圍
這個專案不包含 CMS、上傳器或儲存後端。影格就是網址,它們從哪來是你的事。
這是刻意畫的界線,不是打發你——怎麼存是關於你的團隊的決定,不該由一個動畫元件替你決定。 但那也是剩下最多工作的地方,所以 docs/authoring.zh-TW.md 把「產生那些網址的東西」的設計寫完整了:它只需要做的四件事、150 個網址要怎麼存才存得住(分塊紀錄加瀏覽器端快取——150 次讀取變 6 次,回訪是 0 次)、操作者必須能改到的每一個設定,以及兩種完全不會報錯的失敗模式。
其中最糟那個的最短版本:不要把 150 個網址塞進 CMS 的單一文字欄位。 會撞到長度上限、寫入失敗,而如果你的儲存流程是「先寫 localStorage 再寫資料庫」,站長自己看永遠正常,只有訪客看不到。
授權
程式碼、工具與文件採 MIT,見 LICENSE。
Copyright (c) 2026 瑭宜網路多媒體有限公司 Tangyi Studio Co., Ltd.
可以商用、可以修改、可以放進閉源產品。MIT 只要求一件事,而且那不是選配:把著作權聲明與授權條文留在原始碼裡。 那就是出處標註 —— 它存在於你的程式庫中,不是你的網頁上。
看得見的出處標註是我們的請求,不是條件。 如果這個專案幫你省下一個禮拜,在你的製作名單裡寫一行、或給 tangyi.mx 一個連結,對一間小工作室意義很大。不做也不會怎麼樣,這是請求不是條款。
為 tangyi.mx 而做,並從中抽出來開源。
三個明確的例外:docs/demo.webp、docs/page-demo.webp 與 docs/hold-loop.webp 是該網站的影格序列,放在這裡是為了讓 README 能展示這個函式庫在做什麼。它們的著作權屬於瑭宜網路多媒體有限公司,不在 MIT 授權範圍內,也不包含在發佈到 npm 的套件裡。其餘所有內容都可以自由使用。
除此之外不含任何影音素材,請自備影片。
