blurhash draft 2

This commit is contained in:
wanji
2025-12-17 10:51:14 +03:00
parent 736134a1d5
commit 768df2daf4
3 changed files with 111 additions and 108 deletions

View File

@@ -17,6 +17,7 @@
"@vueuse/integrations": "^9.2.0",
"@vueuse/motion": "^2.0.0",
"axios": "^0.26.1",
"blurhash": "^2.0.5",
"fuse.js": "^6.6.2",
"motion": "^10.15.5",
"node-vibrant": "3.1.6",

View File

@@ -17,138 +17,75 @@
<img
v-for="(img, index) in images"
:key="img.key"
:ref="el => setImageRef(img.key, el)"
:src="img.src"
:class="`${index === activeIndex ? 'active' : ''} il-image ${imgClass || ''}`"
:class="`${index === activeIndex && readyToShowKeys.has(img.key) ? 'active' : ''} il-image ${
imgClass || ''
}`"
:style="{
transitionDuration: `${duration}ms`,
}"
@load="onImageLoad(img.key, $event)"
@load="onDomImageLoad(img.key, $event)"
/>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { decode } from 'blurhash'
import { computed, ref, watch, nextTick, onMounted } from 'vue'
const props = defineProps<{
image: string
duration: number
blurhash?: string
imgClass?: string
duration: number
preloadImage?: string
}>()
const imageKey = ref(0)
const activeIndex = ref(0)
const imageLoaded = ref(false)
const readyToShowKeys = ref<Set<number>>(new Set())
const imageHeights = ref<Record<number, number>>({})
const imageLoader = ref<HTMLDivElement | null>(null)
const blurhashCanvas = ref<HTMLCanvasElement | null>(null)
const images = ref<Array<{ src: string; key: number }>>([])
const activeIndex = ref(0)
const imageKey = ref(0)
const preloadedImageUrl = ref<string | null>(null)
const preloadImageElement = ref<HTMLImageElement | null>(null)
const imageHeights = ref<Record<number, number>>({})
const imageNaturalHeights = ref<Record<number, number>>({})
const containerWidth = ref(0)
const imageLoaded = ref(false)
let resizeObserver: ResizeObserver | null = null
function updateContainerWidth() {
if (imageLoader.value) {
containerWidth.value = imageLoader.value.clientWidth
if (containerWidth.value) {
recalculateHeights(containerWidth.value)
if (props.blurhash) {
renderBlurhash()
}
}
}
}
function recalculateHeights(width: number) {
const updated: Record<number, number> = {}
Object.entries(imageNaturalHeights.value).forEach(([key, naturalHeight]) => {
updated[Number(key)] = Math.min(width, naturalHeight)
})
if (Object.keys(updated).length) {
imageHeights.value = { ...imageHeights.value, ...updated }
}
}
onMounted(() => {
updateContainerWidth()
if (typeof ResizeObserver !== 'undefined' && imageLoader.value) {
resizeObserver = new ResizeObserver(updateContainerWidth)
resizeObserver.observe(imageLoader.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
watch(
() => props.preloadImage,
newPreloadImage => {
if (!newPreloadImage) {
preloadedImageUrl.value = null
preloadImageElement.value = null
return
}
if (preloadedImageUrl.value === newPreloadImage) {
return
}
preloadedImageUrl.value = newPreloadImage
const img = new Image()
img.src = newPreloadImage
preloadImageElement.value = img
}
)
const imageRefs = ref<Record<number, HTMLImageElement | null>>({})
const containerWidth = computed(() => imageLoader.value?.clientWidth)
watch(
() => props.image,
newImage => {
async newImage => {
if (!newImage) return
renderBlurhash()
imageLoaded.value = false
readyToShowKeys.value.clear()
if (preloadedImageUrl.value === newImage && preloadImageElement.value?.complete) {
preloadedImageUrl.value = null
preloadImageElement.value = null
} else if (preloadedImageUrl.value !== newImage) {
preloadedImageUrl.value = null
preloadImageElement.value = null
}
const imageKeyValue = imageKey.value++
const newImageObj = {
src: newImage,
key: imageKey.value++,
key: imageKeyValue,
}
images.value.push(newImageObj)
if (images.value.length > 2) {
images.value.shift()
const removedImage = images.value.shift()
if (removedImage) {
delete imageRefs.value[removedImage.key]
readyToShowKeys.value.delete(removedImage.key)
}
}
setTimeout(() => {
activeIndex.value = images.value.length - 1
}, 10)
await loadImageManually(newImage, imageKeyValue)
},
{ immediate: true }
)
watch(
() => [props.blurhash, containerWidth.value, imageHeights.value],
() => {
if (props.blurhash && containerWidth.value && blurhashCanvas.value) {
renderBlurhash()
}
},
{ immediate: true, deep: true }
)
function renderBlurhash() {
if (!props.blurhash || !blurhashCanvas.value || !containerWidth.value) return
@@ -172,27 +109,87 @@ function renderBlurhash() {
}
}
function onImageLoad(imageKeyValue: number, event: Event) {
const target = event.target as HTMLImageElement | null
if (!target || !imageLoader.value) return
imageLoaded.value = true
imageNaturalHeights.value = {
...imageNaturalHeights.value,
[imageKeyValue]: target.naturalHeight,
}
const minHeight = Math.min(imageLoader.value.clientWidth || 0, target.naturalHeight)
imageHeights.value = { ...imageHeights.value, [imageKeyValue]: minHeight }
if (images.value.length > 1 && activeIndex.value === images.value.length - 1) {
setTimeout(() => {
images.value = images.value.slice(-1)
activeIndex.value = 0
}, props.duration)
function setImageRef(key: number, el: unknown) {
if (el && el instanceof HTMLImageElement) {
imageRefs.value[key] = el
} else {
imageRefs.value[key] = null
}
}
function onDomImageLoad(imageKeyValue: number, eventOrElement: Event | HTMLImageElement) {
if (readyToShowKeys.value.has(imageKeyValue)) return
const imgElement = eventOrElement instanceof Event ? (eventOrElement.target as HTMLImageElement) : eventOrElement
if (!imgElement || !imageLoader.value) return
if (imgElement.complete && imgElement.naturalHeight > 0) {
const minHeight = Math.min(imageLoader.value?.clientWidth || 0, imgElement.naturalHeight)
imageHeights.value = { ...imageHeights.value, [imageKeyValue]: minHeight }
requestAnimationFrame(() => {
requestAnimationFrame(() => {
readyToShowKeys.value.add(imageKeyValue)
imageLoaded.value = true
})
})
}
}
async function loadImageManually(imageSrc: string, imageKeyValue: number) {
try {
const response = await fetch(imageSrc)
if (!response.ok) {
throw new Error(`Failed to load image: ${response.statusText}`)
}
const blob = await response.blob()
const imageUrl = URL.createObjectURL(blob)
await nextTick()
const imageIndex = images.value.findIndex(img => img.key === imageKeyValue)
if (imageIndex !== -1) {
const imgElement = imageRefs.value[imageKeyValue]
if (imgElement) {
if (imgElement.complete && imgElement.naturalHeight > 0) {
onDomImageLoad(imageKeyValue, imgElement)
}
}
images.value[imageIndex].src = imageUrl
}
} catch (error) {
console.error('Error loading image:', error)
await nextTick()
const imageIndex = images.value.findIndex(img => img.key === imageKeyValue)
if (imageIndex !== -1) {
const imgElement = imageRefs.value[imageKeyValue]
if (imgElement) {
if (imgElement.complete && imgElement.naturalHeight > 0) {
onDomImageLoad(imageKeyValue, imgElement)
}
}
images.value[imageIndex].src = imageSrc
}
}
}
watch(
() => activeIndex.value,
() => {
if (images.value.length > 1 && activeIndex.value === images.value.length - 1) {
setTimeout(() => {
images.value = images.value.slice(-1)
activeIndex.value = 0
}, props.duration)
}
}
)
onMounted(() => {
renderBlurhash()
})
</script>
<style lang="scss">

View File

@@ -2102,6 +2102,11 @@ bl@^4.0.3:
inherits "^2.0.4"
readable-stream "^3.4.0"
blurhash@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.5.tgz#efde729fc14a2f03571a6aa91b49cba80d1abe4b"
integrity sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==
bmp-js@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233"