Я интегрировал видео анимацию, которая перематывалась в зависимости от положения скролла, для лендинга детского парка развлечений - 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 gsapyarn:
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 из предыдущей версии кода
Выводы
Перед тем, как подходить к неизвестной задаче, нужно потратить довольно много времени на тщательный подбор инструментов / стека, просмотреть и проанализировать аналогичные работы, чтобы не тратить впоследствии очень много времени и сил на в корне бессмысленные вещи. Если бы на Хабре была подобная статья на видном месте, я бы сэкономил десятки часов времени, поэтому я решил все это написать.
Да, конечно, в этом проекте я буквально выбирал только плохие стратегии, но думаю, моя статья вполне может быть полезна юному зрителю.
Комментарии (6)
 - ionicman06.11.2024 07:47- Эффект тормозит на Intel i7 с 32 гигами ОЗУ и GeForce RTX 2060 super - это просто космос!  - Ну и, конечно, CSS is awesome (Firefox dev 121). - Вопрос - а стоило-ли все это делать вообще? - Мучить пользователя ожиданием загрузски и всеми этим спец-эффектами? Я еще понимаю - лэндинг, но для сайта? 
 - mendler06.11.2024 07:47- в мобильной версии после повторной загрузки (при выключенном кеше), не показывается инициализационное изображение  - , 
 
           
 



aamonster
Т.е. вообще все? Неправилен ведь сам факт подключения такого эффекта. Правильно – не делать плохо пользователю.
SharapaGorg Автор
Я согласен, такой сайт в принципе не должен содержать в себе подобных эффектов, но меня просто перед фактом поставили, что это нужно сделать. Ну а неправильные способы я пробовал не от большого желания, а от того, что сильно провтыкал..