← Tất cả bài viết
Video Scrub Theo Scroll Bị Giật? Sửa Keyframe
Video scrub theo scroll bị giật, mỗi giây mới nhảy một khung hình? Đây là cách sửa tận gốc — encode all-intra bằng ffmpeg để scrub GSAP mượt từng frame.

Tháng trước tôi dựng một hero video scroll kiểu Apple: một clip ngắn tua tới theo thao tác cuộn, điều khiển bằng GSAP ScrollTrigger ghi thẳng vào video.currentTime. Code chuẩn sách giáo khoa. Kết quả thì thảm họa. Khung hình chỉ nhảy khoảng mỗi giây một lần, nên thay vì một màn reveal mượt từng frame thì bạn nhận được một slideshow giật cục theo từng đoạn một giây. Nếu video scrub theo scroll của bạn bị giật và mỗi giây mới cập nhật một lần, bạn đang dính đúng con bug tôi từng dính — và lỗi không nằm ở JavaScript. Nó nằm ở chính file video.
Đây là bài viết mà tôi ước mình tìm thấy ngay chiều hôm đó: tại sao lỗi xảy ra, cái flag ffmpeg một dòng để sửa nó, cái giá về dung lượng đi kèm, và đoạn driver React ráp tất cả lại.
Vì sao video scroll chỉ cập nhật mỗi giây một lần
Điều mà tôi mất khá lâu mới thấm: video.currentTime = x không đưa bạn tới frame x. Nó đưa bạn tới frame gần nhất có thể seek được. Và trên một video web bình thường, số frame seek được rất hiếm.
Mọi file H.264 hay VP9 đều là hỗn hợp các loại frame. Keyframe (I-frame) là ảnh hoàn chỉnh, độc lập. Mọi frame ở giữa — P-frame và B-frame — chỉ lưu phần chênh lệch so với frame khác. Đó chính là lý do video nén tốt đến vậy: đa số frame là những delta bé xíu, không phải ảnh đầy đủ. Để hiển thị một thời điểm bất kỳ, bộ giải mã cần keyframe đầy đủ gần nhất cộng với toàn bộ delta kể từ đó.
Khi bạn seek — khi GSAP set currentTime sáu mươi lần mỗi giây — trình duyệt chỉ rẻ tiền nhất khi đáp xuống một keyframe. Mà một bản encode bình thường đặt keyframe khoảng mỗi 2 đến 10 giây một cái (250 frame là mặc định phổ biến). Nên khi bạn cuộn mượt từ 0 tới 1, currentTime quét liên tục, nhưng hình ảnh lại búng về nhúm keyframe ít ỏi mà nó với tới được. Trên một clip 10 giây với keyframe mỗi giây, bạn chỉ có mười frame khác nhau cho cả hành trình scroll. Đó chính xác là cái giật "mỗi giây một lần" của bạn.
Cách sửa không phải là seek ít đi, throttle, hay debounce. Cách sửa là biến mọi frame thành keyframe để bất kỳ currentTime nào bạn yêu cầu đều seek được ngay và chính xác. Đó là encode all-intra.
Cách sửa bằng ffmpeg: all-intra với -g 1
-g là kích thước GOP — khoảng cách giữa các keyframe. Đặt nó thành 1 thì mọi frame đều thành I-frame. Giờ thì mọi lần seek đều chính xác tuyệt đối.
Đây là hai lệnh tôi dùng để ship. WebM (VP9) trước vì nó nhẹ hơn và Firefox seek nó rất đẹp; MP4 (H.264) làm fallback phổ quát cho Safari.
# MP4 / H.264 — bản fallback mọi trình duyệt đều chạy
ffmpeg -i in.mov -vf scale=1280:-2 -r 30 -movflags faststart \
-vcodec libx264 -crf 23 -g 1 -pix_fmt yuv420p -an out.mp4
# WebM / VP9 — nhẹ hơn, ship cái này trước
ffmpeg -i in.mov -vf scale=1280:-2 -r 30 -c:v libvpx-vp9 \
-crf 32 -b:v 0 -g 1 -pix_fmt yuv420p -an -row-mt 1 out.webm
Từng flag làm gì, và vì sao nó quan trọng với việc scrub:
-g 1— điểm mấu chốt. Mọi frame là keyframe, nên mọi lần seekcurrentTimeđều chính xác từng frame.-vf scale=1280:-2— scale về đúng khung render (-2giữ tỷ lệ khung hình và ép chiều cao thành số chẵn, thứ mà yuv420p bắt buộc). Đây không phải tùy chọn; xem phần dung lượng bên dưới.-r 30— ghim frame rate. Bạn muốn mộtduration × fpsxác định để scroll ánh xạ tới frame một cách dự đoán được. 30 là quá đủ cho một hero nền.-movflags faststart(MP4) — dờimoovatom ra đầu file để trình duyệt bắt đầu seek trước khi tải xong cả file.-an— bỏ audio. Một hero scrub thì đằng nào cũng tắt tiếng, và track âm thanh chỉ chống lại các lần seek của bạn.-pix_fmt yuv420p— định dạng chroma mà trình duyệt thực sự giải mã được. Bỏ nó ra thì Safari có thể chỉ hiện một ô đen.-crf— núm chỉnh chất lượng. 23 (x264) / 32 (VP9) là điểm khởi đầu tốt; chỉnh để đánh đổi chất lượng lấy dung lượng.
Cái giá không ai cảnh báo: dung lượng file
All-intra nghĩa là toàn keyframe, mà keyframe là frame đầy đủ, nên bạn đang vứt đi phần lớn khả năng nén vốn làm cho video web khả thi. Một clip 1080p all-intra có thể vượt quá 100 MB — hoàn toàn không thể ship cho một hero cần tải nhanh.
Đây là lý do flag scale mang tính sống còn, không phải trang trí. Scale nguồn xuống đúng cái khung mà nó render. Trong trường hợp của tôi, hạ xuống ~1280px rộng ở 30fps đã kéo một clip vốn khổng lồ ở full res xuống còn khoảng 8 MB cho MP4 và 13 MB cho WebM — coi đây là con số minh họa, kết quả của bạn thay đổi theo độ dài và độ chuyển động, nhưng đó là đúng cỡ độ lớn cho một hero.
Nguyên tắc: đừng bao giờ encode all-intra ở độ phân giải lớn hơn kích thước render của phần tử ở breakpoint lớn nhất. Một hero tối đa rộng 1280px trên màn hình chẳng có lý do gì phải ship frame 1920px.
| Encode bình thường | All-intra (-g 1) | |
|---|---|---|
| Khoảng keyframe | ~1 cái mỗi 2–10s (thường 250 frame) | Mọi frame |
| Độ chính xác seek | Búng về keyframe gần nhất (thô) | Chính xác từng frame |
| Dung lượng (~1280w, minh họa) | ~1–2 MB | ~8 MB MP4 / ~13 MB WebM |
| Hợp cho | Phát tuyến tính, streaming | Scrub bằng currentTime |
Sự đánh đổi rất đơn giản: encode bình thường thắng ở khâu phát, all-intra thắng ở khâu seek. Bạn không thể scrub mượt trên file tối ưu cho phát, và cũng không nên stream file tối ưu cho scrub. Chọn theo từng use case.
Driver ScrollTrigger cho React / Next.js
Với một file đã encode đúng, phần JavaScript là phần dễ — nhưng có ba cái guard không hiển nhiên. Đây là component tôi dùng, với useGSAP để tự dọn dẹp.
'use client';
import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger, useGSAP);
export function ScrollScrubVideo() {
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
useGSAP(
() => {
const video = videoRef.current;
if (!video) return;
// Đừng bao giờ giả định metadata đã sẵn sàng — duration là NaN cho tới khi load xong.
const setup = () => {
if (!video.duration || Number.isNaN(video.duration)) return;
ScrollTrigger.create({
trigger: containerRef.current,
start: 'top top',
end: 'bottom bottom',
scrub: true,
onUpdate: (self) => {
// Ánh xạ tiến độ scroll (0–1) thẳng lên timeline.
const t = self.progress * video.duration;
if (Number.isFinite(t)) video.currentTime = t;
},
});
};
if (video.readyState >= 1) setup();
else video.addEventListener('loadedmetadata', setup, { once: true });
},
{ scope: containerRef }
);
return (
<div ref={containerRef} style={{ height: '300vh' }}>
<div style={{ position: 'sticky', top: 0, height: '100vh' }}>
<video
ref={videoRef}
muted
playsInline
preload="auto"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
>
{/* WebM trước cho Firefox, MP4 fallback cho Safari */}
<source src="/hero.webm" type="video/webm" />
<source src="/hero.mp4" type="video/mp4" />
</video>
</div>
</div>
);
}
Những chỗ hay cắn người ta:
- Guard
NaNduration. Lúc mount,video.durationlàNaNcho tới khi metadata load xong. Nhân nó vào phép tính progress là bạn ghiNaNvàocurrentTime, và nó âm thầm không làm gì cả. Chờ sự kiệnloadedmetadata(hoặc kiểm trareadyState), và kiểm tra lạiNumber.isFinitetrước mỗi lần ghi. muted playsInline preload="auto", và đừng bao giờ gọi.play(). Video luôn ở trạng thái tạm dừng — bạn đang seek nó, không phải phát nó.muted+playsInlinegiữ trình duyệt mobile khỏi cướp nó vào fullscreen hoặc chặn nó.preload="auto"quan trọng (xem FAQ) vì bạn không thể seek frame mà trình duyệt chưa buffer.scrub: truevà cleanup.scrubbuộccurrentTimevào vị trí scroll thay vì chạy một lần.useGSAPtự revert ScrollTrigger khi unmount cho bạn — làm thủ công là bạn sẽ rò rỉ trigger sau mỗi lần đổi route.
Về cơ chế của scrub, pinning, và start/end, bài hướng dẫn GSAP ScrollTrigger của tôi là phần đi sâu hơn, còn tài liệu GSAP ScrollTrigger là nguồn gốc chính thống. Và MDN về HTMLMediaElement.currentTime rất đáng đọc để hiểu ngữ nghĩa seek làm nên tất cả những hành vi này.
Thứ tự tải và file mà nó fetch
Ship WebM trước trong danh sách <source>. Trình duyệt chọn source đầu tiên nó phát được, nên Firefox và Chrome vớ lấy file VP9 nhỏ hơn còn Safari rơi xuống MP4. Đảo ngược thứ tự là ai cũng phải tải file lớn hơn.
Một điều nữa đáng nhắc: tôi chạy encode trên một file nguồn nằm trong ~/Downloads, và ffmpeg đâm vào bức tường quyền của macOS — hệ điều hành chặn không cho nó đọc thư mục đó. Nếu bản encode của bạn chết với dòng "Operation not permitted" khó hiểu, đó là macOS TCC đang bảo vệ thư mục, không phải lỗi ở lệnh của bạn. Chuyển file ra khỏi thư mục được bảo vệ (~/Downloads, ~/Desktop, ~/Documents), hoặc cấp Full Disk Access cho terminal, là hết.
FAQ
Vì sao video scroll của tôi chỉ cập nhật mỗi giây một lần?
Vì keyframe trong video của bạn cách nhau khoảng một giây. video.currentTime chỉ seek rẻ tiền tới keyframe, nên một bản encode bình thường (một keyframe mỗi vài giây) chỉ cho bạn đáp xuống một nhúm frame cho cả hành trình scroll. Encode lại all-intra với -g 1 để mọi frame đều seek được.
WebM hay MP4 cho scroll scrubbing?
Cả hai — ship WebM (VP9) trước vì file nhỏ và seek tuyệt trên Firefox và Chrome, với MP4 (H.264) làm fallback cho Safari, vốn không phát VP9 ổn định. Xếp các thẻ <source> theo thứ tự WebM trước để trình duyệt đủ khả năng fetch file nhỏ hơn.
Video all-intra sẽ nặng cỡ nào?
Nặng hơn nhiều so với encode bình thường, vì mọi frame là một keyframe đầy đủ. Một clip 1080p all-intra có thể vượt 100 MB. Cách chữa là scale xuống đúng khung render: ở ~1280px rộng / 30fps tôi đáp xuống khoảng 8 MB (MP4) và 13 MB (WebM). Đừng bao giờ encode lớn hơn kích thước hiển thị trên màn của phần tử.
Tôi có cần preload cả video không?
Để scrub chính xác từng frame thì thực chất là có — dùng preload="auto". Bạn không thể seek tới frame mà trình duyệt chưa buffer, nên preload một phần sẽ cho bạn những khoảng trống và khựng giữa lúc scroll. Đây là thêm một lý do để giữ file nhỏ: preload="auto" trên file 100 MB là một first-paint tồi tệ. File all-intra nhỏ + preload="auto" là bộ đôi cho cảm giác tức thì.
Chỉ throttle các lần seek thay vì vậy có được không?
Không. Throttle thay đổi tần suất bạn seek, chứ không thay đổi nơi bạn có thể đáp xuống. Sự thô ráp đến từ khoảng keyframe trong file, nên không lượng làm mượt JS nào sửa được một video encode cho phát. Sửa bản encode trước; rồi cái driver ở trên sẽ mượt mà chẳng cần throttle gì.
Cùng một bài học, một dòng: scrub bằng currentTime đòi hỏi encode all-intra (-g 1), và vì all-intra là toàn keyframe, bạn phải scale về khung render nếu không file sẽ phình to. Làm cả hai và cái giật biến mất.
Nếu bạn muốn xem kỹ thuật này trong một hero đã ship, trang dự án có vài cái, và tôi làm những thứ này trọn gói như một phần của công việc frontend tập trung vào hiệu năng.