mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +00:00
fix(website): smooth scene transitions and clean up pending asset loads in LottieVideoPlayer
- Defer lottie seek + video.play() to rAF so the parent opacity transition paints its first frame before the main thread is blocked - Track preloaded Image and Video objects via eager assignment so a new src/assetsPath load aborts in-flight downloads from the previous gen - Simplify poster: drop the inner CSS fade and let the parent's outer opacity transition handle visual smoothing - Mobile showcase: gate desktop/mobile trees by viewport (useMediaQuery) and use v-show + :playing for symmetry with desktop's all-mounted pattern, eliminating cross-viewport double playback Amp-Thread-ID: https://ampcode.com/threads/T-019e19a1-6377-77df-ad0c-4993387c9f32 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -22,34 +22,19 @@ const emit = defineEmits<{ ready: [] }>()
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
const XLINK_NS = 'http://www.w3.org/1999/xlink'
|
||||
|
||||
const POSTER_FADE_MS = 500
|
||||
|
||||
const lottieContainer = useTemplateRef<HTMLDivElement>('lottieContainer')
|
||||
const assetsReady = ref(false)
|
||||
const posterFaded = ref(!poster)
|
||||
const assetsLoaded = ref(false)
|
||||
let anim: AnimationItem | null = null
|
||||
let videos: HTMLVideoElement[] = []
|
||||
let images: HTMLImageElement[] = []
|
||||
let loadGen = 0
|
||||
let fadeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let playRaf: number | null = null
|
||||
|
||||
watch(assetsReady, (ready) => {
|
||||
if (fadeTimer) {
|
||||
clearTimeout(fadeTimer)
|
||||
fadeTimer = null
|
||||
}
|
||||
if (!ready) {
|
||||
posterFaded.value = !poster
|
||||
return
|
||||
}
|
||||
if (!poster) {
|
||||
posterFaded.value = true
|
||||
return
|
||||
}
|
||||
fadeTimer = setTimeout(() => {
|
||||
posterFaded.value = true
|
||||
fadeTimer = null
|
||||
}, POSTER_FADE_MS)
|
||||
})
|
||||
function abortPreloadedImages(toAbort: HTMLImageElement[]) {
|
||||
// Setting src to '' aborts in-flight downloads in most browsers and lets
|
||||
// already-loaded Image objects be released.
|
||||
for (const img of toAbort) img.src = ''
|
||||
}
|
||||
|
||||
function swapImageForVideo(
|
||||
image: SVGImageElement,
|
||||
@@ -86,9 +71,15 @@ function whenLoaded(el: HTMLVideoElement | HTMLImageElement): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareAssets(container: HTMLElement): Promise<void> {
|
||||
function prepareAssets(container: HTMLElement): {
|
||||
videos: HTMLVideoElement[]
|
||||
images: HTMLImageElement[]
|
||||
ready: Promise<void>
|
||||
} {
|
||||
const svg = container.querySelector('svg')
|
||||
if (!svg) return Promise.resolve()
|
||||
if (!svg) return { videos: [], images: [], ready: Promise.resolve() }
|
||||
const collectedVideos: HTMLVideoElement[] = []
|
||||
const collectedImages: HTMLImageElement[] = []
|
||||
const pending: Promise<void>[] = []
|
||||
for (const image of Array.from(svg.querySelectorAll('image'))) {
|
||||
const href =
|
||||
@@ -96,15 +87,20 @@ function prepareAssets(container: HTMLElement): Promise<void> {
|
||||
if (!href) continue
|
||||
if (/\.(webm|mp4)$/i.test(href)) {
|
||||
const v = swapImageForVideo(image, href)
|
||||
videos.push(v)
|
||||
collectedVideos.push(v)
|
||||
pending.push(whenLoaded(v))
|
||||
} else {
|
||||
const img = new Image()
|
||||
img.src = href
|
||||
collectedImages.push(img)
|
||||
pending.push(whenLoaded(img))
|
||||
}
|
||||
}
|
||||
return Promise.all(pending).then(() => undefined)
|
||||
return {
|
||||
videos: collectedVideos,
|
||||
images: collectedImages,
|
||||
ready: Promise.all(pending).then(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -112,10 +108,12 @@ watch(
|
||||
async ([container]) => {
|
||||
const gen = ++loadGen
|
||||
for (const v of videos) v.pause()
|
||||
abortPreloadedImages(images)
|
||||
videos = []
|
||||
images = []
|
||||
anim?.destroy()
|
||||
anim = null
|
||||
assetsReady.value = false
|
||||
assetsLoaded.value = false
|
||||
if (!container) return
|
||||
try {
|
||||
const { default: lottie } = await import('lottie-web')
|
||||
@@ -133,9 +131,18 @@ watch(
|
||||
created.addEventListener('DOMLoaded', () => {
|
||||
if (gen !== loadGen || anim !== created) return
|
||||
created.goToAndStop(0, true)
|
||||
void prepareAssets(container).then(() => {
|
||||
const {
|
||||
videos: loadedVideos,
|
||||
images: loadedImages,
|
||||
ready
|
||||
} = prepareAssets(container)
|
||||
// Assign eagerly so the next-gen cleanup pass can pause/abort these
|
||||
// assets even if `ready` is still pending when src changes again.
|
||||
videos = loadedVideos
|
||||
images = loadedImages
|
||||
void ready.then(() => {
|
||||
if (gen !== loadGen || anim !== created) return
|
||||
assetsReady.value = true
|
||||
assetsLoaded.value = true
|
||||
emit('ready')
|
||||
})
|
||||
})
|
||||
@@ -149,7 +156,7 @@ watch(
|
||||
console.error('[LottieVideoPlayer] failed to initialize:', src, err)
|
||||
anim?.destroy()
|
||||
anim = null
|
||||
assetsReady.value = false
|
||||
assetsLoaded.value = false
|
||||
emit('ready')
|
||||
}
|
||||
},
|
||||
@@ -157,13 +164,25 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => assetsReady.value && posterFaded.value && playing,
|
||||
() => assetsLoaded.value && playing,
|
||||
(shouldPlay) => {
|
||||
if (playRaf !== null) {
|
||||
cancelAnimationFrame(playRaf)
|
||||
playRaf = null
|
||||
}
|
||||
if (shouldPlay) {
|
||||
anim?.goToAndPlay(0, true)
|
||||
for (const v of videos) {
|
||||
void v.play().catch(() => {})
|
||||
}
|
||||
// Defer heavy startup work (lottie SVG seek + video decoder init) to the
|
||||
// next animation frame so the parent's opacity transition can paint its
|
||||
// first frame before the main thread is blocked.
|
||||
const gen = loadGen
|
||||
playRaf = requestAnimationFrame(() => {
|
||||
playRaf = null
|
||||
if (gen !== loadGen) return
|
||||
anim?.goToAndPlay(0, true)
|
||||
for (const v of videos) {
|
||||
void v.play().catch(() => {})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
anim?.pause()
|
||||
for (const v of videos) {
|
||||
@@ -175,8 +194,9 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (fadeTimer) clearTimeout(fadeTimer)
|
||||
if (playRaf !== null) cancelAnimationFrame(playRaf)
|
||||
for (const v of videos) v.pause()
|
||||
abortPreloadedImages(images)
|
||||
anim?.destroy()
|
||||
})
|
||||
</script>
|
||||
@@ -185,14 +205,13 @@ onBeforeUnmount(() => {
|
||||
<div class="relative">
|
||||
<div ref="lottieContainer" class="size-full" />
|
||||
<img
|
||||
v-if="poster"
|
||||
v-if="poster && !assetsLoaded"
|
||||
:src="poster"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 size-full object-cover transition-opacity duration-500',
|
||||
assetsReady ? 'opacity-0' : 'opacity-100',
|
||||
'pointer-events-none absolute inset-0 size-full object-cover',
|
||||
posterClass
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { useIntersectionObserver, useMediaQuery } from '@vueuse/core'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
@@ -53,6 +53,7 @@ const badgeSegments = [
|
||||
const activeIndex = ref(0)
|
||||
const sectionRef = useTemplateRef<HTMLElement>('sectionRef')
|
||||
const isVisible = ref(false)
|
||||
const isMobile = useMediaQuery('(max-width: 1023px)')
|
||||
|
||||
useIntersectionObserver(sectionRef, ([entry]) => {
|
||||
isVisible.value = entry?.isIntersecting ?? false
|
||||
@@ -75,7 +76,7 @@ useIntersectionObserver(sectionRef, ([entry]) => {
|
||||
<!-- Content area -->
|
||||
<div class="mt-12 flex flex-col lg:mt-24 lg:flex-row lg:items-stretch">
|
||||
<!-- Lottie area (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div v-if="!isMobile" class="flex-1">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
@@ -93,7 +94,7 @@ useIntersectionObserver(sectionRef, ([entry]) => {
|
||||
:key="feature.title"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 transition-opacity duration-300 will-change-[opacity]',
|
||||
'absolute inset-0 transition-opacity duration-300',
|
||||
activeIndex === i ? 'opacity-100' : 'opacity-0'
|
||||
)
|
||||
"
|
||||
@@ -116,8 +117,9 @@ useIntersectionObserver(sectionRef, ([entry]) => {
|
||||
<template v-for="(feature, i) in features" :key="feature.title">
|
||||
<!-- Lottie area (mobile, rendered before active item) -->
|
||||
<div
|
||||
v-if="activeIndex === i"
|
||||
:class="cn('aspect-video lg:hidden', i !== 0 && 'mt-4')"
|
||||
v-if="isMobile"
|
||||
v-show="activeIndex === i"
|
||||
:class="cn('aspect-video', i !== 0 && 'mt-4')"
|
||||
>
|
||||
<div
|
||||
class="animate-border-spin size-full overflow-hidden rounded-4xl p-0.5"
|
||||
@@ -129,6 +131,7 @@ useIntersectionObserver(sectionRef, ([entry]) => {
|
||||
:src="feature.lottie.src"
|
||||
:assets-path="feature.lottie.assetsPath"
|
||||
:poster="feature.lottie.poster"
|
||||
:playing="activeIndex === i"
|
||||
poster-class="bg-transparency-white-t4"
|
||||
class="bg-transparency-white-t4 size-full"
|
||||
/>
|
||||
@@ -138,8 +141,8 @@ useIntersectionObserver(sectionRef, ([entry]) => {
|
||||
|
||||
<!-- Connector (mobile) -->
|
||||
<div
|
||||
v-if="activeIndex === i"
|
||||
class="flex h-5 items-center overflow-visible lg:hidden"
|
||||
v-if="isMobile && activeIndex === i"
|
||||
class="flex h-5 items-center overflow-visible"
|
||||
>
|
||||
<img
|
||||
src="/icons/node-link.svg"
|
||||
|
||||
Reference in New Issue
Block a user