-
![]()
@@ -36,6 +36,7 @@
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
+import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const SLIDER_START_POSITION = 50
diff --git a/src/components/templates/thumbnails/DefaultThumbnail.spec.ts b/src/components/templates/thumbnails/DefaultThumbnail.spec.ts
index bb754c0dd0..ebe138a9ee 100644
--- a/src/components/templates/thumbnails/DefaultThumbnail.spec.ts
+++ b/src/components/templates/thumbnails/DefaultThumbnail.spec.ts
@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
+vi.mock('@/components/common/LazyImage.vue', () => ({
+ default: {
+ name: 'LazyImage',
+ template:
+ '
![]()
',
+ props: ['src', 'alt', 'imageClass', 'imageStyle']
+ }
+}))
+
describe('DefaultThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(DefaultThumbnail, {
@@ -25,9 +34,9 @@ describe('DefaultThumbnail', () => {
it('renders image with correct src and alt', () => {
const wrapper = mountThumbnail()
- const img = wrapper.find('img')
- expect(img.attributes('src')).toBe('/test-image.jpg')
- expect(img.attributes('alt')).toBe('Test Image')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ expect(lazyImage.props('src')).toBe('/test-image.jpg')
+ expect(lazyImage.props('alt')).toBe('Test Image')
})
it('applies scale transform when hovered', () => {
@@ -35,35 +44,43 @@ describe('DefaultThumbnail', () => {
isHovered: true,
hoverZoom: 10
})
- const img = wrapper.find('img')
- expect(img.attributes('style')).toContain('scale(1.1)')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
})
it('does not apply scale transform when not hovered', () => {
const wrapper = mountThumbnail({
isHovered: false
})
- const img = wrapper.find('img')
- expect(img.attributes('style')).toBeUndefined()
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ expect(lazyImage.props('imageStyle')).toBeUndefined()
})
it('applies video styling for video type', () => {
const wrapper = mountThumbnail({
isVideo: true
})
- const img = wrapper.find('img')
- expect(img.classes()).toContain('w-full')
- expect(img.classes()).toContain('h-full')
- expect(img.classes()).toContain('object-cover')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ const imageClass = lazyImage.props('imageClass')
+ const classString = Array.isArray(imageClass)
+ ? imageClass.join(' ')
+ : imageClass
+ expect(classString).toContain('w-full')
+ expect(classString).toContain('h-full')
+ expect(classString).toContain('object-cover')
})
it('applies image styling for non-video type', () => {
const wrapper = mountThumbnail({
isVideo: false
})
- const img = wrapper.find('img')
- expect(img.classes()).toContain('max-w-full')
- expect(img.classes()).toContain('object-contain')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ const imageClass = lazyImage.props('imageClass')
+ const classString = Array.isArray(imageClass)
+ ? imageClass.join(' ')
+ : imageClass
+ expect(classString).toContain('max-w-full')
+ expect(classString).toContain('object-contain')
})
it('applies correct styling for webp images', () => {
@@ -71,8 +88,12 @@ describe('DefaultThumbnail', () => {
src: '/test-video.webp',
isVideo: true
})
- const img = wrapper.find('img')
- expect(img.classes()).toContain('object-cover')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ const imageClass = lazyImage.props('imageClass')
+ const classString = Array.isArray(imageClass)
+ ? imageClass.join(' ')
+ : imageClass
+ expect(classString).toContain('object-cover')
})
it('image is not draggable', () => {
@@ -83,11 +104,15 @@ describe('DefaultThumbnail', () => {
it('applies transition classes', () => {
const wrapper = mountThumbnail()
- const img = wrapper.find('img')
- expect(img.classes()).toContain('transform-gpu')
- expect(img.classes()).toContain('transition-transform')
- expect(img.classes()).toContain('duration-300')
- expect(img.classes()).toContain('ease-out')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ const imageClass = lazyImage.props('imageClass')
+ const classString = Array.isArray(imageClass)
+ ? imageClass.join(' ')
+ : imageClass
+ expect(classString).toContain('transform-gpu')
+ expect(classString).toContain('transition-transform')
+ expect(classString).toContain('duration-300')
+ expect(classString).toContain('ease-out')
})
it('passes correct props to BaseThumbnail', () => {
diff --git a/src/components/templates/thumbnails/DefaultThumbnail.vue b/src/components/templates/thumbnails/DefaultThumbnail.vue
index 479bdc04c0..45e053e677 100644
--- a/src/components/templates/thumbnails/DefaultThumbnail.vue
+++ b/src/components/templates/thumbnails/DefaultThumbnail.vue
@@ -1,25 +1,23 @@
-
-
![]()
-
+
diff --git a/src/composables/useIntersectionObserver.ts b/src/composables/useIntersectionObserver.ts
new file mode 100644
index 0000000000..a369e95064
--- /dev/null
+++ b/src/composables/useIntersectionObserver.ts
@@ -0,0 +1,60 @@
+import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
+
+export interface UseIntersectionObserverOptions
+ extends IntersectionObserverInit {
+ immediate?: boolean
+}
+
+export function useIntersectionObserver(
+ target: Ref
,
+ callback: IntersectionObserverCallback,
+ options: UseIntersectionObserverOptions = {}
+) {
+ const { immediate = true, ...observerOptions } = options
+
+ const isSupported =
+ typeof window !== 'undefined' && 'IntersectionObserver' in window
+ const isIntersecting = ref(false)
+
+ let observer: IntersectionObserver | null = null
+
+ const cleanup = () => {
+ if (observer) {
+ observer.disconnect()
+ observer = null
+ }
+ }
+
+ const observe = () => {
+ cleanup()
+
+ if (!isSupported || !target.value) return
+
+ observer = new IntersectionObserver((entries) => {
+ isIntersecting.value = entries.some((entry) => entry.isIntersecting)
+ callback(entries, observer!)
+ }, observerOptions)
+
+ observer.observe(target.value)
+ }
+
+ const unobserve = () => {
+ if (observer && target.value) {
+ observer.unobserve(target.value)
+ }
+ }
+
+ if (immediate) {
+ watch(target, observe, { immediate: true, flush: 'post' })
+ }
+
+ onBeforeUnmount(cleanup)
+
+ return {
+ isSupported,
+ isIntersecting,
+ observe,
+ unobserve,
+ cleanup
+ }
+}
diff --git a/src/composables/useLazyPagination.ts b/src/composables/useLazyPagination.ts
new file mode 100644
index 0000000000..474ccc8ebf
--- /dev/null
+++ b/src/composables/useLazyPagination.ts
@@ -0,0 +1,107 @@
+import { type Ref, computed, ref, shallowRef, watch } from 'vue'
+
+export interface LazyPaginationOptions {
+ itemsPerPage?: number
+ initialPage?: number
+}
+
+export function useLazyPagination(
+ items: Ref | T[],
+ options: LazyPaginationOptions = {}
+) {
+ const { itemsPerPage = 12, initialPage = 1 } = options
+
+ const currentPage = ref(initialPage)
+ const isLoading = ref(false)
+ const loadedPages = shallowRef(new Set([]))
+
+ // Get reactive items array
+ const itemsArray = computed(() => {
+ const itemData = 'value' in items ? items.value : items
+ return Array.isArray(itemData) ? itemData : []
+ })
+
+ // Simulate pagination by slicing the items
+ const paginatedItems = computed(() => {
+ const itemData = itemsArray.value
+ if (itemData.length === 0) {
+ return []
+ }
+
+ const loadedPageNumbers = Array.from(loadedPages.value).sort(
+ (a, b) => a - b
+ )
+ const maxLoadedPage = Math.max(...loadedPageNumbers, 0)
+ const endIndex = maxLoadedPage * itemsPerPage
+ return itemData.slice(0, endIndex)
+ })
+
+ const hasMoreItems = computed(() => {
+ const itemData = itemsArray.value
+ if (itemData.length === 0) {
+ return false
+ }
+
+ const loadedPagesArray = Array.from(loadedPages.value)
+ const maxLoadedPage = Math.max(...loadedPagesArray, 0)
+ return maxLoadedPage * itemsPerPage < itemData.length
+ })
+
+ const totalPages = computed(() => {
+ const itemData = itemsArray.value
+ if (itemData.length === 0) {
+ return 0
+ }
+ return Math.ceil(itemData.length / itemsPerPage)
+ })
+
+ const loadNextPage = async () => {
+ if (isLoading.value || !hasMoreItems.value) return
+
+ isLoading.value = true
+ const loadedPagesArray = Array.from(loadedPages.value)
+ const nextPage = Math.max(...loadedPagesArray, 0) + 1
+
+ // Simulate network delay
+ // await new Promise((resolve) => setTimeout(resolve, 5000))
+
+ const newLoadedPages = new Set(loadedPages.value)
+ newLoadedPages.add(nextPage)
+ loadedPages.value = newLoadedPages
+ currentPage.value = nextPage
+ isLoading.value = false
+ }
+
+ // Initialize with first page
+ watch(
+ () => itemsArray.value.length,
+ (length) => {
+ if (length > 0 && loadedPages.value.size === 0) {
+ loadedPages.value = new Set([1])
+ }
+ },
+ { immediate: true }
+ )
+
+ const reset = () => {
+ currentPage.value = initialPage
+ loadedPages.value = new Set([])
+ isLoading.value = false
+
+ // Immediately load first page if we have items
+ const itemData = itemsArray.value
+ if (itemData.length > 0) {
+ loadedPages.value = new Set([1])
+ }
+ }
+
+ return {
+ paginatedItems,
+ isLoading,
+ hasMoreItems,
+ currentPage,
+ totalPages,
+ loadNextPage,
+ reset
+ }
+}
diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts
new file mode 100644
index 0000000000..14e5c6768e
--- /dev/null
+++ b/src/composables/useTemplateFiltering.ts
@@ -0,0 +1,56 @@
+import { type Ref, computed, ref } from 'vue'
+
+import type { TemplateInfo } from '@/types/workflowTemplateTypes'
+
+export interface TemplateFilterOptions {
+ searchQuery?: string
+}
+
+export function useTemplateFiltering(
+ templates: Ref | TemplateInfo[]
+) {
+ const searchQuery = ref('')
+
+ const templatesArray = computed(() => {
+ const templateData = 'value' in templates ? templates.value : templates
+ return Array.isArray(templateData) ? templateData : []
+ })
+
+ const filteredTemplates = computed(() => {
+ const templateData = templatesArray.value
+ if (templateData.length === 0) {
+ return []
+ }
+
+ if (!searchQuery.value.trim()) {
+ return templateData
+ }
+
+ const query = searchQuery.value.toLowerCase().trim()
+ return templateData.filter((template) => {
+ const searchableText = [
+ template.name,
+ template.description,
+ template.sourceModule
+ ]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase()
+
+ return searchableText.includes(query)
+ })
+ })
+
+ const resetFilters = () => {
+ searchQuery.value = ''
+ }
+
+ const filteredCount = computed(() => filteredTemplates.value.length)
+
+ return {
+ searchQuery,
+ filteredTemplates,
+ filteredCount,
+ resetFilters
+ }
+}
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index bc336c065b..3d4d770a6b 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -25,6 +25,7 @@
"confirmed": "Confirmed",
"reset": "Reset",
"resetAll": "Reset All",
+ "clearFilters": "Clear Filters",
"resetAllKeybindingsTooltip": "Reset all keybindings to default",
"customizeFolder": "Customize Folder",
"icon": "Icon",
@@ -549,6 +550,8 @@
},
"templateWorkflows": {
"title": "Get Started with a Template",
+ "loadingMore": "Loading more templates...",
+ "searchPlaceholder": "Search templates...",
"category": {
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
diff --git a/src/locales/es/main.json b/src/locales/es/main.json
index 1be05f03ac..fdec9e8c5c 100644
--- a/src/locales/es/main.json
+++ b/src/locales/es/main.json
@@ -272,6 +272,7 @@
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
+ "clearFilters": "Borrar filtros",
"close": "Cerrar",
"color": "Color",
"comingSoon": "Próximamente",
@@ -1231,6 +1232,8 @@
"Video": "Video",
"Video API": "API de Video"
},
+ "loadingMore": "Cargando más plantillas...",
+ "searchPlaceholder": "Buscar plantillas...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index cddc85f866..3e7f67f0d0 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -272,6 +272,7 @@
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"clear": "Effacer",
+ "clearFilters": "Effacer les filtres",
"close": "Fermer",
"color": "Couleur",
"comingSoon": "Bientôt disponible",
@@ -1231,6 +1232,8 @@
"Video": "Vidéo",
"Video API": "API vidéo"
},
+ "loadingMore": "Chargement de plus de modèles...",
+ "searchPlaceholder": "Rechercher des modèles...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index 2e3256eba8..cd85b6386f 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -272,6 +272,7 @@
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
+ "clearFilters": "フィルターをクリア",
"close": "閉じる",
"color": "色",
"comingSoon": "近日公開",
@@ -1231,6 +1232,8 @@
"Video": "ビデオ",
"Video API": "動画API"
},
+ "loadingMore": "さらにテンプレートを読み込み中...",
+ "searchPlaceholder": "テンプレートを検索...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index 578c149cfc..cdfacaee78 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -272,6 +272,7 @@
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
+ "clearFilters": "필터 지우기",
"close": "닫기",
"color": "색상",
"comingSoon": "곧 출시 예정",
@@ -1231,6 +1232,8 @@
"Video": "비디오",
"Video API": "비디오 API"
},
+ "loadingMore": "템플릿을 더 불러오는 중...",
+ "searchPlaceholder": "템플릿 검색...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index d8197617ab..981a74948a 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -272,6 +272,7 @@
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
+ "clearFilters": "Сбросить фильтры",
"close": "Закрыть",
"color": "Цвет",
"comingSoon": "Скоро будет",
@@ -1231,6 +1232,8 @@
"Video": "Видео",
"Video API": "Video API"
},
+ "loadingMore": "Загрузка дополнительных шаблонов...",
+ "searchPlaceholder": "Поиск шаблонов...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json
index 2b5debb777..23170e236f 100644
--- a/src/locales/zh-TW/main.json
+++ b/src/locales/zh-TW/main.json
@@ -272,6 +272,7 @@
"category": "分類",
"choose_file_to_upload": "選擇要上傳的檔案",
"clear": "清除",
+ "clearFilters": "清除篩選",
"close": "關閉",
"color": "顏色",
"comingSoon": "即將推出",
@@ -1231,6 +1232,8 @@
"Video": "影片",
"Video API": "影片 API"
},
+ "loadingMore": "正在載入更多範本...",
+ "searchPlaceholder": "搜尋範本...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index c1e7bf975f..f1cbb73ebf 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -272,6 +272,7 @@
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
+ "clearFilters": "清除篩選",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
@@ -1231,6 +1232,8 @@
"Video": "视频生成",
"Video API": "视频 API"
},
+ "loadingMore": "正在載入更多範本...",
+ "searchPlaceholder": "搜尋範本...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "混元3D 2.0 图生模型",
diff --git a/src/services/mediaCacheService.ts b/src/services/mediaCacheService.ts
new file mode 100644
index 0000000000..412f0a2267
--- /dev/null
+++ b/src/services/mediaCacheService.ts
@@ -0,0 +1,226 @@
+import { reactive } from 'vue'
+
+export interface CachedMedia {
+ src: string
+ blob?: Blob
+ objectUrl?: string
+ error?: boolean
+ isLoading: boolean
+ lastAccessed: number
+}
+
+export interface MediaCacheOptions {
+ maxSize?: number
+ maxAge?: number // in milliseconds
+ preloadDistance?: number // pixels from viewport
+}
+
+class MediaCacheService {
+ public cache = reactive(new Map())
+ private readonly maxSize: number
+ private readonly maxAge: number
+ private cleanupInterval: number | null = null
+ private urlRefCount = new Map()
+
+ constructor(options: MediaCacheOptions = {}) {
+ this.maxSize = options.maxSize ?? 100
+ this.maxAge = options.maxAge ?? 30 * 60 * 1000 // 30 minutes
+
+ // Start cleanup interval
+ this.startCleanupInterval()
+ }
+
+ private startCleanupInterval() {
+ // Clean up every 5 minutes
+ this.cleanupInterval = window.setInterval(
+ () => {
+ this.cleanup()
+ },
+ 5 * 60 * 1000
+ )
+ }
+
+ private cleanup() {
+ const now = Date.now()
+ const keysToDelete: string[] = []
+
+ // Find expired entries
+ for (const [key, entry] of Array.from(this.cache.entries())) {
+ if (now - entry.lastAccessed > this.maxAge) {
+ // Only revoke object URL if no components are using it
+ if (entry.objectUrl) {
+ const refCount = this.urlRefCount.get(entry.objectUrl) || 0
+ if (refCount === 0) {
+ URL.revokeObjectURL(entry.objectUrl)
+ this.urlRefCount.delete(entry.objectUrl)
+ keysToDelete.push(key)
+ }
+ // Don't delete cache entry if URL is still in use
+ } else {
+ keysToDelete.push(key)
+ }
+ }
+ }
+
+ // Remove expired entries
+ keysToDelete.forEach((key) => this.cache.delete(key))
+
+ // If still over size limit, remove oldest entries that aren't in use
+ if (this.cache.size > this.maxSize) {
+ const entries = Array.from(this.cache.entries())
+ entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
+
+ let removedCount = 0
+ const targetRemoveCount = this.cache.size - this.maxSize
+
+ for (const [key, entry] of entries) {
+ if (removedCount >= targetRemoveCount) break
+
+ if (entry.objectUrl) {
+ const refCount = this.urlRefCount.get(entry.objectUrl) || 0
+ if (refCount === 0) {
+ URL.revokeObjectURL(entry.objectUrl)
+ this.urlRefCount.delete(entry.objectUrl)
+ this.cache.delete(key)
+ removedCount++
+ }
+ } else {
+ this.cache.delete(key)
+ removedCount++
+ }
+ }
+ }
+ }
+
+ async getCachedMedia(src: string): Promise {
+ let entry = this.cache.get(src)
+
+ if (entry) {
+ // Update last accessed time
+ entry.lastAccessed = Date.now()
+ return entry
+ }
+
+ // Create new entry
+ entry = {
+ src,
+ isLoading: true,
+ lastAccessed: Date.now()
+ }
+
+ // Update cache with loading entry
+ this.cache.set(src, entry)
+
+ try {
+ // Fetch the media
+ const response = await fetch(src)
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`)
+ }
+
+ const blob = await response.blob()
+ const objectUrl = URL.createObjectURL(blob)
+
+ // Update entry with successful result
+ const updatedEntry: CachedMedia = {
+ src,
+ blob,
+ objectUrl,
+ isLoading: false,
+ lastAccessed: Date.now()
+ }
+
+ this.cache.set(src, updatedEntry)
+ return updatedEntry
+ } catch (error) {
+ console.warn('Failed to cache media:', src, error)
+
+ // Update entry with error
+ const errorEntry: CachedMedia = {
+ src,
+ error: true,
+ isLoading: false,
+ lastAccessed: Date.now()
+ }
+
+ this.cache.set(src, errorEntry)
+ return errorEntry
+ }
+ }
+
+ acquireUrl(src: string): string | undefined {
+ const entry = this.cache.get(src)
+ if (entry?.objectUrl) {
+ const currentCount = this.urlRefCount.get(entry.objectUrl) || 0
+ this.urlRefCount.set(entry.objectUrl, currentCount + 1)
+ return entry.objectUrl
+ }
+ return undefined
+ }
+
+ releaseUrl(src: string): void {
+ const entry = this.cache.get(src)
+ if (entry?.objectUrl) {
+ const count = (this.urlRefCount.get(entry.objectUrl) || 1) - 1
+ if (count <= 0) {
+ URL.revokeObjectURL(entry.objectUrl)
+ this.urlRefCount.delete(entry.objectUrl)
+ // Remove from cache as well
+ this.cache.delete(src)
+ } else {
+ this.urlRefCount.set(entry.objectUrl, count)
+ }
+ }
+ }
+
+ clearCache() {
+ // Revoke all object URLs
+ for (const entry of Array.from(this.cache.values())) {
+ if (entry.objectUrl) {
+ URL.revokeObjectURL(entry.objectUrl)
+ }
+ }
+ this.cache.clear()
+ this.urlRefCount.clear()
+ }
+
+ destroy() {
+ if (this.cleanupInterval) {
+ clearInterval(this.cleanupInterval)
+ this.cleanupInterval = null
+ }
+ this.clearCache()
+ }
+}
+
+// Global instance
+export let mediaCacheInstance: MediaCacheService | null = null
+
+export function useMediaCache(options?: MediaCacheOptions) {
+ if (!mediaCacheInstance) {
+ mediaCacheInstance = new MediaCacheService(options)
+ }
+
+ const getCachedMedia = (src: string) =>
+ mediaCacheInstance!.getCachedMedia(src)
+ const clearCache = () => mediaCacheInstance!.clearCache()
+ const acquireUrl = (src: string) => mediaCacheInstance!.acquireUrl(src)
+ const releaseUrl = (src: string) => mediaCacheInstance!.releaseUrl(src)
+
+ return {
+ getCachedMedia,
+ clearCache,
+ acquireUrl,
+ releaseUrl,
+ cache: mediaCacheInstance.cache
+ }
+}
+
+// Cleanup on page unload
+if (typeof window !== 'undefined') {
+ window.addEventListener('beforeunload', () => {
+ if (mediaCacheInstance) {
+ mediaCacheInstance.destroy()
+ }
+ })
+}
diff --git a/tests-ui/tests/services/mediaCacheService.test.ts b/tests-ui/tests/services/mediaCacheService.test.ts
new file mode 100644
index 0000000000..8f58559ca1
--- /dev/null
+++ b/tests-ui/tests/services/mediaCacheService.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { useMediaCache } from '../../../src/services/mediaCacheService'
+
+// Mock fetch
+global.fetch = vi.fn()
+global.URL = {
+ createObjectURL: vi.fn(() => 'blob:mock-url'),
+ revokeObjectURL: vi.fn()
+} as any
+
+describe('mediaCacheService', () => {
+ describe('URL reference counting', () => {
+ it('should handle URL acquisition for non-existent cache entry', () => {
+ const { acquireUrl } = useMediaCache()
+
+ const url = acquireUrl('non-existent.jpg')
+ expect(url).toBeUndefined()
+ })
+
+ it('should handle URL release for non-existent cache entry', () => {
+ const { releaseUrl } = useMediaCache()
+
+ // Should not throw error
+ expect(() => releaseUrl('non-existent.jpg')).not.toThrow()
+ })
+
+ it('should provide acquireUrl and releaseUrl methods', () => {
+ const cache = useMediaCache()
+
+ expect(typeof cache.acquireUrl).toBe('function')
+ expect(typeof cache.releaseUrl).toBe('function')
+ })
+ })
+})