D
P
0

CSS & Web Animation

Seam Hero `<video>` ke Foto di Bawahnya Tidak Pernah Nyambung? Frame Video Itu Fungsi Waktu, Bukan Posisi Scroll

28 Juli 2026·5 menit baca
Seam Hero `<video>` ke Foto di Bawahnya Tidak Pernah Nyambung? Frame Video Itu Fungsi Waktu, Bukan Posisi Scroll

Di sebuah situs klien, bagian hero-nya adalah loop video pantai enam detik. Tepat di bawahnya ada satu foto still yang mestinya menyambung mulus dari frame terakhir video, seolah gambar yang tadinya bergerak membeku jadi potret di bawah lipatan. Konsepnya bagus di atas kertas. Prakteknya, sambungan itu tidak pernah nyambung. Selalu ada seam: garis patah di batas antara <video> dan foto di bawahnya. Kadang garis lautnya meleset beberapa piksel, kadang ombaknya kepotong di tengah gerakan. Tidak pernah sama dua kali.

Yang bikin frustrasi, foto di bawahnya statis dan sempurna, video-nya juga mulus. Yang rusak cuma titik temu keduanya, dan titik temu itu berubah setiap kali halaman dibuka.

Gejalanya

Refleks pertama saya salah, seperti biasa. Saya kira ini soal menyamakan frame: kalau saya bisa memaksa video berhenti di frame yang tepat saat batas blok masuk viewport, foto di bawah tinggal dipotong dari frame itu. Saya coba pause() di scroll tertentu, saya coba currentTime yang dipatok, saya coba requestVideoFrameCallback untuk membaca frame aktif. Semua rapuh: currentTime pada elemen <video> tidak akurat sampai ke frame, seeking-nya lompat ke keyframe terdekat, dan hasilnya tetap meleset.

Di atas itu, <video> bawa keluarga bug-nya sendiri. Ada black flash sesaat waktu render pertama (DirectComposition di Chromium), ada artefak seek tiap kali loop balik ke awal, dan kebijakan autoplay yang bikin video kadang tidak mau jalan tanpa muted. Saya habiskan waktu menambal gejala ini satu per satu sebelum sadar saya sedang memperbaiki hal yang salah.

Akar masalahnya

Titik balik datang waktu saya berhenti dan menuliskan apa yang sebenarnya saya minta. Sebuah <video> yang sedang jalan itu fungsi dari WAKTU. Frame yang tampak bergantung pada kapan tepatnya pengguna menggulir ke batas blok, dan itu nondeterministik: satu pengunjung sampai di sana pada detik 2.3, yang lain pada detik 4.8. Frame di batas itu acak. Kalau frame di batas acak, saya tidak akan pernah bisa memotong satu foto lanjutan yang cocok, karena saya tidak tahu frame mana yang harus disambung.

Masalahnya bukan seeking yang tidak akurat. Masalahnya adalah saya memakai sumber yang parameternya waktu, padahal yang saya butuhkan adalah sesuatu yang parameternya posisi scroll. Selama sumbernya waktu, seam itu tidak akan pernah bisa dikontrol.

Perbaikannya

Solusinya: buang <video> sepenuhnya, ganti dengan urutan frame .webp yang digambar ke <canvas>, di mana frame yang tampil adalah fungsi dari posisi scroll, bukan waktu. Kalau frame deterministik terhadap scroll, saya bisa memaksa batas blok selalu jatuh di frame yang sama, lalu memotong foto lanjutan dari frame itu sekali saja.

Langkah pertama, ekstrak frame. Tapi loop mentah dari video kelihatan patah waktu balik ke awal, jadi saya bikin loop yang menyilang ke dirinya sendiri pakai xfade di ffmpeg:

ffmpeg -i coast.mp4 -filter_complex "[0]split[a][b];[a]trim=2:8,setpts=PTS-STARTPTS[main];[b]trim=0:2,setpts=PTS-STARTPTS[head];[main][head]xfade=transition=fade:duration=2:offset=4,fps=20,scale=1920:1080:flags=lanczos" -c:v libwebp -q:v 68 -start_number 1 frames/frame-%04d.webp

Markup-nya sederhana: satu <canvas> yang membawa daftar frame-nya. Canvas itu elemen tergantikan, jadi object-fit: cover tetap valid seperti di gambar biasa.

<canvas class="coast-canvas" data-frames="..." data-frame-count="120"></canvas>

Saat idle, sebuah loop GSAP memutar penghitung frame, dan tiap update saya gambar frame yang sesuai. Saya tidak clearRect karena setiap frame menutup penuh canvas:

gsap.to(ani, {
  frame: "+=" + 120,
  duration: 6,
  ease: "none",
  repeat: -1,
  onUpdate: render,
});
 
function render() {
  const i = Math.round(ani.frame) % 120;
  ctx.drawImage(frames[i], 0, 0, canvas.width, canvas.height);
}

Inti triknya ada di zona serah terima tepat sebelum tepi blok. Sebuah ScrollTrigger membentang dari blockH - vh - 1.0*vh sampai blockH - vh - 0.05*vh. Waktu zona itu masuk, saya matikan loop idle, catat frame saat ini sebagai c, lalu scrub frame mengikuti progress scroll sejauh k:

const c = ani.frame % 120;
const k = (120 - c) + 120; // selalu berakhir di kelipatan 120
 
scrub.onUpdate = (self) => {
  ani.frame = c + self.progress * k;
  render();
};

Kenapa k = (120 - c) + 120? Karena 120 - c membawa penghitung dari posisi acak c naik ke kelipatan 120 berikutnya, dan + 120 menambah satu putaran penuh biar geraknya tidak terlalu pendek waktu c kebetulan besar. Kelipatan 120 modulo 120 sama dengan frame 0. Artinya, berapa pun frame acak saat pengguna tiba di zona itu, tepi blok DIJAMIN mendarat di frame 0. Sekali frame di tepi selalu frame 0, saya potong strip poster dari frame 1, tempel persis di bawah canvas, dan sambungannya mulus. Seam-nya hilang bukan karena saya menebak frame yang benar, tapi karena saya memaksa frame yang benar.

Bonus yang tidak saya duga: begitu <video> hilang, seluruh keluarga bug-nya ikut hilang. Tidak ada lagi black flash DirectComposition, tidak ada artefak seek, tidak ada drama autoplay, karena tidak ada elemen video sama sekali.

Beberapa detail produksi. Saya render dua set aset: desktop 360 frame 1920x1080 pada 60fps (sekitar 83MB), dan set ringan untuk mobile atau perangkat lemah 180 frame 960x540 pada 30fps (sekitar 13MB). Pemilihannya lewat matchMedia di bawah 991px, atau deviceMemory <= 4, atau hardwareConcurrency <= 4. Konteks canvas saya buat dengan alpha: false, buffer 1:1 dengan CSS pixel, dan render hanya dipanggil saat indeks yang dibulatkan berubah biar tidak menggambar frame yang sama berkali-kali. Loop otomatis berhenti waktu hero keluar layar, dan preloader menahan handoff sampai semua frame termuat. Bake 15fps sempat saya coba dan terasa patah; 60fps baru masuk akal justru karena loop-nya cuma enam detik, jadi 360 frame masih wajar.

Checklist

  • Kalau sambungan antara <video> dan elemen di bawahnya tidak pernah konsisten, ingat bahwa frame video adalah fungsi waktu, bukan posisi scroll.
  • Jangan buang waktu menyamakan currentTime atau seeking; seeking <video> lompat ke keyframe dan tidak akurat sampai ke frame.
  • Ganti video hero dengan urutan frame di <canvas> kalau kamu butuh frame yang deterministik terhadap scroll.
  • Bikin loop mulus dengan xfade ffmpeg supaya sambungan awal dan akhir tidak kelihatan patah.
  • Pakai math sederhana k = (120 - c) + 120 supaya scrub selalu mendarat di frame 0 di tepi blok, lalu potong poster dari frame 1.
  • Siapkan aset responsif (frame lebih sedikit dan resolusi lebih kecil untuk perangkat lemah) dan gerbangi handoff dengan preloader.