Я интегрировал видео анимацию, которая перематывалась в зависимости от положения скролла, для лендинга детского парка развлечений — 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 из предыдущей версии кода
Выводы
Перед тем, как подходить к неизвестной задаче, нужно потратить довольно много времени на тщательный подбор инструментов / стека, просмотреть и проанализировать аналогичные работы, чтобы не тратить впоследствии очень много времени и сил на в корне бессмысленные вещи. Если бы на Хабре была подобная статья на видном месте, я бы сэкономил десятки часов времени, поэтому я решил все это написать.
Да, конечно, в этом проекте я буквально выбирал только плохие стратегии, но думаю, моя статья вполне может быть полезна юному зрителю.
ссылка на оригинал статьи https://habr.com/ru/articles/856382/
Добавить комментарий