(null)
+ const evictedRef = useRef(false)
const isVideo = isInlineVideoMimeType(props.block.mimeType)
const isAudio = isInlineAudioMimeType(props.block.mimeType)
const isImage = isInlineImageMimeType(props.block.mimeType)
@@ -90,6 +92,8 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
}
}, [])
+ const mediaKey = `${ctx.sessionId}:${props.block.imageId}`
+
useEffect(() => {
if (!shouldFetch) {
return
@@ -97,6 +101,34 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
let disposed = false
+ if (!isImage) {
+ // Explicitly loaded media lives in a bounded shared cache: the cap
+ // keeps at most a few tens-of-MB blobs alive per session, and the
+ // listener brings this card back to its Load button when evicted.
+ setError(null)
+ evictedRef.current = false
+ const unsubscribe = subscribeGeneratedMediaEviction(mediaKey, () => {
+ evictedRef.current = true
+ setObjectUrl(null)
+ setLoadMedia(false)
+ })
+ void loadGeneratedMediaObjectUrl(mediaKey, () => ctx.api.getGeneratedImageBlob(ctx.sessionId, props.block.imageId))
+ .then((url) => {
+ if (!disposed && !evictedRef.current) {
+ setObjectUrl(url)
+ }
+ })
+ .catch((err: unknown) => {
+ if (!disposed) {
+ setError(err instanceof Error ? err.message : 'Failed to load inline media')
+ }
+ })
+ return () => {
+ disposed = true
+ unsubscribe()
+ }
+ }
+
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
@@ -133,7 +165,7 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
return () => {
disposed = true
}
- }, [ctx.api, ctx.sessionId, props.block.imageId, isImage, shouldFetch])
+ }, [ctx.api, ctx.sessionId, props.block.imageId, isImage, shouldFetch, mediaKey])
return (
diff --git a/web/src/lib/generatedMediaCache.test.ts b/web/src/lib/generatedMediaCache.test.ts
new file mode 100644
index 00000000..1c2fd127
--- /dev/null
+++ b/web/src/lib/generatedMediaCache.test.ts
@@ -0,0 +1,81 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ _resetGeneratedMediaCacheForTests,
+ loadGeneratedMediaObjectUrl,
+ peekGeneratedMedia,
+ subscribeGeneratedMediaEviction
+} from './generatedMediaCache'
+
+afterEach(() => {
+ _resetGeneratedMediaCacheForTests()
+ vi.restoreAllMocks()
+})
+
+describe('generatedMediaCache', () => {
+ it('reuses a cached object URL without loading the blob again', async () => {
+ const load = vi.fn(async () => new Blob(['a']))
+ const first = await loadGeneratedMediaObjectUrl('s:1', load)
+ const second = await loadGeneratedMediaObjectUrl('s:1', load)
+
+ expect(second).toBe(first)
+ expect(load).toHaveBeenCalledTimes(1)
+ })
+
+ it('revokes the least recently used blob once the cap is exceeded', async () => {
+ const revoke = vi.spyOn(URL, 'revokeObjectURL')
+ const urls: string[] = []
+ for (const key of ['a', 'b', 'c', 'd']) {
+ urls.push(await loadGeneratedMediaObjectUrl(`s:${key}`, async () => new Blob([key])))
+ }
+
+ expect(peekGeneratedMedia('s:a')).toBeNull()
+ expect(peekGeneratedMedia('s:b')).toBe(urls[1])
+ expect(peekGeneratedMedia('s:c')).toBe(urls[2])
+ expect(peekGeneratedMedia('s:d')).toBe(urls[3])
+ expect(revoke).toHaveBeenCalledWith(urls[0])
+ })
+
+ it('protects a cache hit from eviction by refreshing its position', async () => {
+ const revoke = vi.spyOn(URL, 'revokeObjectURL')
+ const a = await loadGeneratedMediaObjectUrl('s:a', async () => new Blob(['a']))
+ await loadGeneratedMediaObjectUrl('s:b', async () => new Blob(['b']))
+ const c = await loadGeneratedMediaObjectUrl('s:c', async () => new Blob(['c']))
+ // Touch a without reloading; b is now the oldest.
+ await loadGeneratedMediaObjectUrl('s:a', async () => {
+ throw new Error('cache hit must not load')
+ })
+ const d = await loadGeneratedMediaObjectUrl('s:d', async () => new Blob(['d']))
+
+ expect(peekGeneratedMedia('s:b')).toBeNull()
+ expect(peekGeneratedMedia('s:a')).toBe(a)
+ expect(peekGeneratedMedia('s:c')).toBe(c)
+ expect(peekGeneratedMedia('s:d')).toBe(d)
+ expect(revoke).toHaveBeenCalledTimes(1)
+ })
+
+ it('notifies holders when their entry is evicted', async () => {
+ const evicted = vi.fn()
+ await loadGeneratedMediaObjectUrl('s:a', async () => new Blob(['a']))
+ subscribeGeneratedMediaEviction('s:a', evicted)
+
+ await loadGeneratedMediaObjectUrl('s:b', async () => new Blob(['b']))
+ await loadGeneratedMediaObjectUrl('s:c', async () => new Blob(['c']))
+ expect(evicted).not.toHaveBeenCalled()
+
+ await loadGeneratedMediaObjectUrl('s:d', async () => new Blob(['d']))
+ expect(evicted).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops notifying after unsubscribe', async () => {
+ const evicted = vi.fn()
+ await loadGeneratedMediaObjectUrl('s:a', async () => new Blob(['a']))
+ const unsubscribe = subscribeGeneratedMediaEviction('s:a', evicted)
+ unsubscribe()
+
+ await loadGeneratedMediaObjectUrl('s:b', async () => new Blob(['b']))
+ await loadGeneratedMediaObjectUrl('s:c', async () => new Blob(['c']))
+ await loadGeneratedMediaObjectUrl('s:d', async () => new Blob(['d']))
+
+ expect(evicted).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/src/lib/generatedMediaCache.ts b/web/src/lib/generatedMediaCache.ts
new file mode 100644
index 00000000..58a09655
--- /dev/null
+++ b/web/src/lib/generatedMediaCache.ts
@@ -0,0 +1,90 @@
+/**
+ * Bounded cache for generated-media object URLs (videos/audio/files the user
+ * explicitly loads).
+ *
+ * Blobs are tens of MB and the chat keeps every message mounted, so without a
+ * cap each loaded video would stay in memory for the whole session. The least
+ * recently used entry is revoked once a new load exceeds the cap; evicted
+ * holders are notified so their card can fall back to its Load button.
+ *
+ * Auto-loaded images are intentionally NOT routed through here: they are small
+ * and always rendered, so caching/evicting them would only cause reload churn.
+ */
+const MAX_CACHED_MEDIA = 3
+
+type Entry = {
+ objectUrl: string
+}
+
+/** Insertion order doubles as LRU order (Map preserves it; hits re-insert). */
+const entries = new Map()
+const listeners = new Map void>>()
+
+function notify(key: string): void {
+ const set = listeners.get(key)
+ if (!set) return
+ for (const listener of [...set]) {
+ listener()
+ }
+}
+
+function evictOverflow(): void {
+ while (entries.size > MAX_CACHED_MEDIA) {
+ const oldest = entries.entries().next().value as [string, Entry] | undefined
+ if (!oldest) return
+ entries.delete(oldest[0])
+ notify(oldest[0])
+ URL.revokeObjectURL(oldest[1].objectUrl)
+ }
+}
+
+/** Cache hit without loading; refreshes LRU position. */
+export function peekGeneratedMedia(key: string): string | null {
+ const entry = entries.get(key)
+ if (!entry) return null
+ entries.delete(key)
+ entries.set(key, entry)
+ return entry.objectUrl
+}
+
+/** Load (or reuse) the object URL for one media key, evicting the oldest over the cap. */
+export async function loadGeneratedMediaObjectUrl(key: string, load: () => Promise): Promise {
+ const cached = peekGeneratedMedia(key)
+ if (cached) return cached
+
+ const blob = await load()
+
+ // A concurrent load for the same key may have finished first.
+ const raced = peekGeneratedMedia(key)
+ if (raced) return raced
+
+ const objectUrl = URL.createObjectURL(blob)
+ entries.set(key, { objectUrl })
+ evictOverflow()
+ return objectUrl
+}
+
+/** Called when this key is evicted; the holder should drop its rendered URL. */
+export function subscribeGeneratedMediaEviction(key: string, listener: () => void): () => void {
+ let set = listeners.get(key)
+ if (!set) {
+ set = new Set()
+ listeners.set(key, set)
+ }
+ set.add(listener)
+ return () => {
+ set.delete(listener)
+ if (set.size === 0) {
+ listeners.delete(key)
+ }
+ }
+}
+
+/** Test helper: drop everything and revoke outstanding URLs. */
+export function _resetGeneratedMediaCacheForTests(): void {
+ for (const entry of entries.values()) {
+ URL.revokeObjectURL(entry.objectUrl)
+ }
+ entries.clear()
+ listeners.clear()
+}