Files
ComfyUI_frontend/src/services/mediaCacheService.ts
Johnpaul Chiwetelu 78d0ea6fa5 LazyImage on Safari (#5626)
This pull request improves the lazy loading behavior and caching
strategy for images in the `LazyImage.vue` component. The most
significant changes are focused on optimizing image rendering and
resource management, as well as improving code clarity.

**Lazy loading behavior improvements:**

* Changed the `<img>` element to render only when `cachedSrc` is
available, ensuring that images are not displayed before they are ready.
* Updated watchers in `LazyImage.vue` to use clearer variable names
(`shouldLoadVal` instead of `shouldLoad`) for better readability and
maintainability.
[[1]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L80-R81)
[[2]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L96-R96)

**Caching strategy enhancement:**

* Modified the `fetch` call in `mediaCacheService.ts` to use `{ cache:
'force-cache' }`, which leverages the browser's cache more aggressively
when loading media, potentially improving performance and reducing
network requests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5626-LazyImage-on-Safari-2716d73d365081eeb1d3c2a96be4d408)
by [Unito](https://www.unito.io)
2025-09-18 11:20:19 -07:00

227 lines
5.8 KiB
TypeScript

import { reactive } from 'vue'
interface CachedMedia {
src: string
blob?: Blob
objectUrl?: string
error?: boolean
isLoading: boolean
lastAccessed: number
}
interface MediaCacheOptions {
maxSize?: number
maxAge?: number // in milliseconds
preloadDistance?: number // pixels from viewport
}
class MediaCacheService {
public cache = reactive(new Map<string, CachedMedia>())
private readonly maxSize: number
private readonly maxAge: number
private cleanupInterval: number | null = null
private urlRefCount = new Map<string, number>()
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<CachedMedia> {
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, { cache: 'force-cache' })
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
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()
}
})
}