Я интегрировал видео анимацию, которая перематывалась в зависимости от положения скролла, для лендинга детского парка развлечений - wizardia.land.
Я думаю, я попробовал все неправильные способы, как можно это реализовать, и дальше расскажу про свой опыт.
Стек проекта: nuxt 3 (ts) / tailwindcss
Идея нашего руководства состояла в том, чтобы создать "вау" эффект для новых пользователей. Для этого оно обратились к 3д художнику, чтобы он намоделил нам видео с красивой переливающейся сферой посередине и последующим ее взрывом с разлетающимся конфетти и тематическими элементами. После того, как оказалось, что само по себе видео выглядит не так впечатляюще, они решили, что оно не должно воспроизводится сразу, а должно перематываться при скроллинге страницы - и тут все началось.
Содержание - вкратце по тупым ошибкам, которые я совершил
-
Делал перемотку напрямую видоса mp4
-
Проблема с энергосбережением на IOS
-
Проблема фактической невозможности загрузить видео на некоторых устройствах
-
Проблема "мелькания" между слайдами при скроллинге
-
Проблема долгого кеширования кадров
-
Решил использовать GSAP - ScrollTrigger: проблема с "бликающими" кадра стала меньше
-
Решил поглубже изучить GSAP и наткнулся на Image Sequence on Scroll
-
Выводы
Референс, на который я должен был опираться - hang.com
Проблемы перемотки видео в веб разработке
Изначально видео - это довольно громоздкий объект, затрачивающий ресурсы устройства, поэтому стоит использовать его осторожно. В современных плеерах используется HLS streaming (e.x. .m3u8), который работает намного шустрее древнего mp4, позволяет быстро перематывать видео на любой момент, да и в целом, выглядит более стабильно и оптимизировано. Опустим, почему данная технология не была использована в данном проекте, но, как факт, я использовал .mp4 исходник.
Видимо, под давлением сжатых сроков, я не провел ресерч возможных подходов к этому вопросу и пошел на проблему в лобовую - сделал скроллящийся контейнер, пихнул туда видео, написал обработчик скролла и соответственную перемотку видео и получил результат. Мне даже сначала показалось, что все нормально, но, если не вдаваться в подробности, на не самых новых андроидах видео просто не запускалось, на большинстве остальных устройств все жутко лагало, а также дополнительная проблема - на видео был значок плей, если на айфоне включен режим энергосбережения.
Решение проблемы с энергосбережением на IOS
Тривиальные решения проблемы с энергосбережением на мое удивление работали только если поставить аттрибут autoplay у видео (а мое видео, как вы помните, не должно сразу воспроизводиться), но без аттрибута autoplay, опять же, на мое удивление, само видео просто не показывалась на части устройств. Я сначала подумал, что я могу поставить аттрибут и останавливать видео сразу при загрузке страницы, но, конечно, браузер не позволяет взаимодействовать с видео тегом без предварительного действия пользователя (клик, скроллинг - любое действие, которое можно обработать).
Самым простым решением оказалось выгрузить вручную первый кадр видео и показывать его при загрузке страницы, а потом, как только юзер прикоснется к экрану, убирать картинку и показывать видео
Вот целиком компонент vue, в котором целиком содержится анимация вместо с загрузочным экраном.
<template>
<transition name="loading">
<div
v-if="!loading"
class="fixed left-0 top-0 w-screen h-screen flex flex-col items-center justify-center z-[100] bg-figma-background">
<img src="@/assets/images/backgroundVideo/logo.svg?inline" class="w-[148px]" ref="logo"/>
<div class="text-figma-target uppercase mt-6 text-[17px] leading-[24px] tracking-[0.04em]">
Загрузка...
</div>
</div>
</transition>
<div class="h-[400vh] lg:h-[600vh] relative z-40 bg-figma-background">
<div class="top-0 sticky">
<img
class="w-full lg:w-0 block lg:hidden mx-auto mt-[4px] absolute z-10 translate-y-[54px]"
src="@/assets/images/topBackgroundLogo.svg?inline"
/>
<img
v-if="!videoVisible"
src="@/assets/images/backgroundVideo/mobileExplosion.jpg"
alt="First Frame"
@click="showVideo"
/>
<video ref="video" video loop muted playsinline webkit-playinginline :autoplay="!videoVisible" v-if="isMobile"
class="mobile-explosion-video" @loadedmetadata="animationVideoLoaded = true" id="explosionVideo">
<source src="@/assets/images/backgroundVideo/mobileExplosion.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
<video ref="video" video loop muted playsinline webkit-playinginline v-if="isDesktop"
class="w-screen h-screen block mx-auto object-center object-cover" id="explosionVideo"
@loadedmetadata="animationVideoLoaded = true">
<source src="@/assets/images/backgroundVideo/desktopExplosion.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref} from "vue";
const video = ref(null);
const loadContent = ref(false);
const animationVideoLoaded = ref(false);
const loading = ref(false);
let videoReady = false;
let animationFrameId = null;
watch(animationVideoLoaded, value => {
if (!value) {
return;
}
setTimeout(() => {
loading.value = true;
});
})
const isMobile = computed(() => {
if (!window) {
return false;
}
return window.innerWidth <= 640
});
const isDesktop = computed(() => {
if (!window) {
return false;
}
return window.innerWidth >= 1024
});
const showMainLogo = ref(true);
let mainLogoHandler;
onMounted(() => {
mainLogoHandler = setInterval(() => {
const scrollTop = document.scrollingElement.scrollTop;
showMainLogo.value = (scrollTop + 500) <= (isDesktop.value ? 7 : 3) * window.innerHeight;
}, 100);
})
onUnmounted(() => {
clearInterval(mainLogoHandler);
})
const throttle = (func, limit) => {
let lastFunc;
let lastRan;
return function () {
const context = this;
const args = arguments;
if (!lastRan) {
func.apply(context, args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(function () {
if ((Date.now() - lastRan) >= limit) {
func.apply(context, args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
};
};
const updateVideoTime = () => {
if (!videoReady || !video.value) return;
const scrollTop = document.scrollingElement.scrollTop;
if (scrollTop > (isDesktop.value ? 7 : 3) * window.innerHeight) {
return;
}
const scrollHeight = window.innerHeight * (isDesktop.value ? 8 : 4)
const maxScroll = scrollHeight - window.innerHeight;
const scrollFraction = scrollTop / maxScroll;
let duration = video.value.duration
video.value.currentTime = duration * scrollFraction;
if (video.value.currentTime + .1 >= duration) {
loadContent.value = true;
} else {
loadContent.value = false;
}
};
const throttledScroll = throttle(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
animationFrameId = requestAnimationFrame(updateVideoTime);
}, 100);
const videoVisible = ref(false);
function showVideo() {
videoVisible.value = true;
}
const onVideoLoadedMetadata = () => {
videoReady = true;
video.value.play();
video.value.currentTime = 0;
video.value.pause();
updateVideoTime();
};
onMounted(() => {
if (isDesktop.value) {
videoVisible.value = true;
}
document.addEventListener('touchstart', () => {
videoVisible.value = true;
})
if (video.value) {
video.value.addEventListener('loadedmetadata', onVideoLoadedMetadata);
}
window.addEventListener('scroll', throttledScroll);
});
onUnmounted(() => {
if (video.value) {
video.value.removeEventListener('loadedmetadata', onVideoLoadedMetadata);
}
window.removeEventListener('scroll', throttledScroll);
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
});
</script>
Разбитие видео по кадрам
Некоторым образом моя проблема дошла до одного веб разработчика, и он за один вечер сильно освежил мою голову своим, принципиально новым для меня тогда, подходом к проблеме: он разделил видео на кадры и написал простейший скрипт - а это сразу решает проблему с энергосбережением, и сильно уменьшает вес анимации, при этом сама анимация выглядит плавнее.
Вот этот скрипт:
const frameContainer = document.getElementById("frameContainer");
const totalFrames = 163; // Количество изображений
const isMobile = window.innerWidth <= 576;
const imagePath = (index) => isMobile ? `mobile/frame${index}.jpg` : `frames/frame${index}.jpg`;
const preloadedImages = [];
const preloadCount = 5; // Количество изображений для предзагрузки
let lastScrollY = 0;
let currentFrame = 0;
// Предзагрузка изображений
function preloadImages() {
for (let i = 1; i <= totalFrames; i++) {
const img = new Image();
img.src = imagePath(i);
preloadedImages.push(img);
}
}
preloadImages(); // Предзагрузка изображений
// Задержка обновления (мс)
const updateDelay = 50;
let timeoutId;
function updateFrame() {
const scrollPosition = window.scrollY;
if (lastScrollY !== scrollPosition) {
lastScrollY = scrollPosition;
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const scrollFraction = scrollPosition / maxScroll;
// Рассчитываем текущий кадр
const frameIndex = Math.min(totalFrames - 1, Math.floor(scrollFraction * totalFrames));
// Если кадр изменился, устанавливаем новое изображение
if (frameIndex !== currentFrame) {
currentFrame = frameIndex;
// Используем изображение по умолчанию, пока загружается новое
frameContainer.style.backgroundImage = `url(${imagePath(currentFrame + 1)})`;
// Предзагрузка следующих кадров
for (let i = 1; i <= preloadCount; i++) {
const nextFrameIndex = currentFrame + i + 1;
if (nextFrameIndex <= totalFrames) {
const img = new Image();
img.src = imagePath(nextFrameIndex);
}
}
}
}
timeoutId = setTimeout(updateFrame, updateDelay); // Задержка
}
updateFrame(); // Запуск анимации
Тут используется простейшая предзагрузка изображений, чтобы скроллинг не лагал, и просто подмена картинок при скроллинге. Вероятно, код полностью написан чат гпт, но это не важно, потому что именно из-за него я понял, насколько бесполезной ерундой страдал до этого.
Две проблемы, которые содержит в себе этот скрипт: предзагрузка 160 кадров - это довольно долгий процесс, а так же, хоть анимация и стала намного плавнее, при скроллинге иногда (даже очень часто) появлялись пропуски - выглядит, как будто кадры не успевают подгружаться, но кешированием кадров это не решилось (не хочу это подробно описывать, как факт - кеширование не решило проблему)
На самом деле до того, как начать исполбзовать gsap, я еще потерял некоторое время - пытался ограничивать FPS анимации, кешировать кадры, предзагружать их иначе и т.д., и т.п., но это было настолько бессмысленно, что лучше я расскажу, как можно сделать нормально.
Решение использовать GSAP
После того, как я увидел, что подход с кадрами работает намного лучше, чем простая перемотка видео, я решил обратиться к специализированным инструментам и начал гуглить библиотеки веб анимаций, где и нашел gsap - библиотеку, предоставляющую широкий спектор возможностей в отношении анимаций на странице
Интеграция GSAP в Nuxt 3 структуру
Раз я настолько детально все описываю, то тут же расскажу, как быстро начать использовать gsap, если пользуешься nuxt.js (v3)
Скачать пакет
npm:
npm install gsap
yarn:
yarn add gsap
В папке plugins нужно создать файл gsap.client.ts:
// plugins/gsap.client.ts
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
export default defineNuxtPlugin((nuxtApp) => {
if (process.client) {
gsap.registerPlugin(ScrollTrigger);
nuxtApp.provide('gsap', gsap);
}
});
Тут регистрируется ScrollTrigger плагин, который нужен для контроля скролл-анимации
nuxt.config.js: (подключить плагин в конфиге проекта)
plugins: ['~/plugins/gsap.client.ts'],
И потом я мог использовать модуль gsap`а таким образом:
onMounted(async () => {
const nuxtApp = useNuxtApp();
const {$gsap} = nuxtApp;
});
Имплементация анимации через gsap.ScrollTrigger
Я не буду особенно объяснять код, который я сейчас приложу, потому что он делает все то же самое, что и предыдущий, но теперь использует встроенные возможности библиотеки. Предзагружает и кеширует (для этого я положил кадры в папку public) кадры, а далее, используя стролл триггер, контролирует скроллинг.
Анимация вновь стала плавнее и стабильнее, но проблема с "мелькающими" кадрами осталась - она стала реже проявляться, но все же осталась
Компонент vue для скролл-анимации через gsap.ScrollTrigger:
<template>
<transition name="loading">
<div
v-if="!framesLoaded"
class="fixed left-0 top-0 w-screen h-screen flex flex-col items-center justify-center z-[200] bg-figma-background">
<img src="@/assets/images/backgroundVideo/logo.svg?inline" class="w-[148px]" ref="logo"/>
<div class="text-figma-target uppercase mt-6 text-[17px] leading-[24px] tracking-[0.04em]">
Загрузка... {{ imagesLoaded }} / {{ frameCount }}
</div>
</div>
</transition>
<div class="scroll-container">
<img
v-if="isMobile"
src="@/assets/images/topBackgroundLogo.png"
style="width: 375px; height : 140px; position : relative; z-index:30; margin : 55px auto; "
/>
<img :src="currentImageSrc" alt="Animation Frame">
</div>
</template>
<script lang="ts" setup>
import {onMounted, ref, computed,} from 'vue';
const isMobile = computed(() => {
if (!window) {
return false;
}
return window.innerWidth <= 640
});
const framesLoaded = ref(false);
const imagesLoaded = ref(0);
const frameCount = 80 || 106 || 154;
const imgSeq = ref(0);
const displaySeq = ref(0);
let imgSrcPrefix = '/backgroundVideo/mobileFramesTest/';
const imgSrcSuffix = '.jpg';
let images: HTMLImageElement[] = [];
// Предварительная загрузка изображений
const preloadImages = async () => {
if (!isMobile.value) {
imgSrcPrefix = '/backgroundVideo/desktopFrames/frame';
}
for (let i = 1; i <= frameCount; i++) {
const img = new Image();
img.src = `${imgSrcPrefix}${i}${imgSrcSuffix}`;
images.push(img);
await img.decode(); // Декодируем изображение здесь, пока оно не отобразится
imagesLoaded.value++;
}
};
const currentImageSrc = computed(() => {
return images[displaySeq.value]?.src || `${imgSrcPrefix}1.jpg`;
});
onBeforeMount(async () => {
await preloadImages(); // Предварительная загрузка всех изображений
framesLoaded.value = true;
})
onMounted(async () => {
const nuxtApp = useNuxtApp();
const {$gsap} = nuxtApp;
$gsap.to(imgSeq, {
value: frameCount - 1,
ease: "none",
scrollTrigger: {
trigger: ".scroll-container",
start: "top top",
end: "bottom top",
scrub: 1,
pinSpacing: false,
pin: true,
}
});
// Функция для обновления кадра
const updateFrame = () => {
displaySeq.value = Math.round(imgSeq.value);
requestAnimationFrame(updateFrame);
};
requestAnimationFrame(updateFrame);
});
</script>
<style scoped>
.scroll-container {
height: 300vh;
overflow: hidden;
@apply relative z-40 w-full
}
.scroll-container img {
display: block;
width: 100vw;
height: 100vh;
object-fit: cover;
position: fixed;
top: 0;
left: 0;
}
</style>
Финальное решение
Поняв, что нужно опять найти что-то посвежее, что я еще не пробовал, я решил просмотреть всю документацию GSAP и наткнулся на Image Sequence on Scroll - я полагаю, уже из названия кристаллически понятно, насколько это подходящий для моего кейса инструмент. Я не могу сказать, каким образом я не наткнулся на него в самом начале, но вместо демагогии просто приложу финальный рабочий компонент vue:
<template>
<transition name="loading">
<loading-screen v-if="loading"/>
</transition>
<div @click="animationStarted = true">
<div class="_scroll-container">
<img
v-if="isMobile"
src="@/assets/images/topBackgroundLogo.png"
class="my-[65px] w-full absolute z-50"
/>
<img
class="w-screen h-screen object-cover object-center"
src="/backgroundVideo/mobileFrames/frame1.jpg"
v-if="!animationStarted && isMobile"
/>
<img
class="w-screen h-screen object-cover object-center"
src="/backgroundVideo/desktopFrames/frame1.jpg"
v-if="!animationStarted && isDesktop"
/>
<canvas
id="image-sequence"
:width="windowWidth"
:height="windowHeight"
/>
</div>
</div>
</template>
<script setup lang="ts">
// looking for a non-scrubbing version? https://codepen.io/GreenSock/pen/QWYdgjG
import {computed} from "vue";
import LoadingScreen from "~/components/LoadingScreen.vue";
const loading = ref(true);
const animationStarted = ref(false);
const isMobile = computed(() => {
if (!window) {
return false;
}
return window.innerWidth <= 640
});
const isDesktop = computed(() => {
if (!window) {
return false;
}
return window.innerWidth >= 1024
});
const windowWidth = computed(() => {
return isDesktop.value ? 1920 : 800;
})
const windowHeight = computed(() => {
return isDesktop.value ? 1024 : 1440
})
onMounted(() => {
const nuxtApp = useNuxtApp();
const {$gsap} = nuxtApp;
let frameCount = isDesktop.value ? 157 : 159,
urls = new Array(frameCount).fill().map((o, i) => `/backgroundVideo/${isDesktop.value ? 'desktopFrames' : 'mobileFrames'}/frame${i + 1}.jpg`);
imageSequence({
urls, // Array of image URLs
canvas: "#image-sequence", // <canvas> object to draw images to
//clear: true, // only necessary if your images contain transparency
//onUpdate: (index, image) => console.log("drew image index", index, ", image:", image),
scrollTrigger: {
trigger: "._scroll-container",
start: "top top",
end: "bottom top",
scrub: 1,
pinSpacing: false,
pin: true,
}
});
/*
Helper function that handles scrubbing through a sequence of images, drawing the appropriate one to the provided canvas.
Config object properties:
- urls [Array]: an Array of image URLs
- canvas [Canvas]: the <canvas> object to draw to
- scrollTrigger [Object]: an optional ScrollTrigger configuration object like {trigger: "#trigger", start: "top top", end: "+=1000", scrub: true, pin: true}
- clear [Boolean]: if true, it'll clear out the canvas before drawing each frame (useful if your images contain transparency)
- paused [Boolean]: true if you'd like the returned animation to be paused initially (this isn't necessary if you're passing in a ScrollTrigger that's scrubbed, but it is helpful if you just want a normal playback animation)
- fps [Number]: optional frames per second - this determines the duration of the returned animation. This doesn't matter if you're using a scrubbed ScrollTrigger. Defaults to 30fps.
- onUpdate [Function]: optional callback for when the Tween updates (probably not used very often). It'll pass two parameters: 1) the index of the image (zero-based), and 2) the Image that was drawn to the canvas
Returns a Tween instance
*/
function imageSequence(config) {
let playhead = {frame: 0},
canvas = $gsap.utils.toArray(config.canvas)[0] || console.warn("canvas not defined"),
ctx = canvas.getContext("2d"),
curFrame = -1,
onUpdate = config.onUpdate,
images,
updateImage = function () {
let frame = Math.round(playhead.frame);
if (frame !== curFrame) { // only draw if necessary
config.clear && ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(images[Math.round(playhead.frame)], 0, 0);
curFrame = frame;
onUpdate && onUpdate.call(this, frame, images[frame]);
}
};
images = config.urls.map((url, i) => {
let img = new Image();
img.src = url;
i || (img.onload = updateImage);
return img;
});
return $gsap.to(playhead, {
frame: images.length - 1,
ease: "none",
onStart: () => {
loading.value = false
},
onUpdate: updateImage,
duration: images.length / (config.fps || 30),
paused: !!config.paused,
scrollTrigger: config.scrollTrigger
});
}
})
</script>
<style scoped>
canvas {
position: fixed;
max-width: 100vw;
max-height: 100vh;
top: 0;
@apply w-screen h-screen object-center object-cover
}
._scroll-container {
@apply w-screen h-[350vh] relative z-40
}
</style>
Я просто взял код из примера, заново написал свою негромоздкую логику в более приятном формате - для десктопа одни картинки, для мобилки другие, размеры канваса динамически подставляю в размер экрана (не динамически работать не будет), убираю экран загрузки в хуке onStart, а также подставляю проверенные мной раннее параметры для scrollTrigger из предыдущей версии кода
Выводы
Перед тем, как подходить к неизвестной задаче, нужно потратить довольно много времени на тщательный подбор инструментов / стека, просмотреть и проанализировать аналогичные работы, чтобы не тратить впоследствии очень много времени и сил на в корне бессмысленные вещи. Если бы на Хабре была подобная статья на видном месте, я бы сэкономил десятки часов времени, поэтому я решил все это написать.
Да, конечно, в этом проекте я буквально выбирал только плохие стратегии, но думаю, моя статья вполне может быть полезна юному зрителю.
Автор: SharapaGorg