feat: cache canvas media locally

This commit is contained in:
2026-05-28 15:43:54 +08:00
parent 4bcca76098
commit 854947a239
6 changed files with 232 additions and 7 deletions

View File

@@ -135,7 +135,7 @@
ref="imageContainerRef"
>
<img
:src="data.url"
:src="displayImageUrl"
:alt="data.label"
class="w-full h-auto object-cover"
:class="{ 'pointer-events-none': isInpaintMode }"
@@ -267,7 +267,7 @@
<!-- Image preview dialog | 图片预览弹窗 -->
<n-image-preview
v-model:show="showRef"
:src="props.data?.url"
:src="displayImageUrl"
/>
<!-- Replace image modal | 替换图片弹窗 -->
@@ -328,6 +328,7 @@ import { NIcon, NTooltip, NSwitch, NImagePreview, NModal, NButton } from 'naive-
import { TrashOutline, ExpandOutline, ImageOutline, CloseCircleOutline, CopyOutline, VideocamOutline, DownloadOutline, EyeOutline, BrushOutline, RefreshOutline, ColorWandOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
import { uploadCanvasImage } from '../../hooks/useApi'
import { useCachedMediaUrl } from '../../hooks/useCachedMediaUrl'
import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
@@ -337,6 +338,7 @@ const props = defineProps({
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
const { cachedUrl: displayImageUrl, warmCache: warmImageCache } = useCachedMediaUrl(() => props.data?.url)
// Hover state | 悬浮状态
const showActions = ref(true)
@@ -890,6 +892,7 @@ const showRef = ref(false)
// Handle preview | 处理预览
const handlePreview = () => {
if (props.data.url) {
warmImageCache()
showRef.value = true
}
}
@@ -898,7 +901,7 @@ const handlePreview = () => {
const handleDownload = () => {
if (props.data.url) {
const link = document.createElement('a')
link.href = props.data.url
link.href = displayImageUrl.value || props.data.url
link.download = props.data.fileName || `image_${Date.now()}.png`
document.body.appendChild(link)
link.click()

View File

@@ -79,8 +79,9 @@
class="aspect-video rounded-lg overflow-hidden bg-black"
>
<video
:src="data.url"
:src="displayVideoUrl"
controls
preload="metadata"
class="w-full h-full object-contain"
/>
</div>
@@ -140,11 +141,12 @@
* Video node component | 视频节点组件
* Displays and manages video content
*/
import { ref, nextTick } from 'vue'
import { computed, ref, nextTick } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NSpin } from 'naive-ui'
import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
import { useCachedMediaUrl } from '../../hooks/useCachedMediaUrl'
import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
@@ -154,6 +156,8 @@ const props = defineProps({
// Vue Flow instance
const { updateNodeInternals } = useVueFlow()
const { cachedUrl: displayVideoUrl, warmCache: warmVideoCache } = useCachedMediaUrl(() => props.data?.url)
const activeVideoUrl = computed(() => displayVideoUrl.value || props.data?.url || '')
// Hover state | 悬浮状态
const showActions = ref(false)
@@ -241,7 +245,8 @@ const handleDelete = () => {
// Handle preview | 处理预览
const handlePreview = () => {
if (props.data.url) {
window.open(props.data.url, '_blank')
warmVideoCache()
window.open(activeVideoUrl.value, '_blank')
}
}
@@ -249,7 +254,7 @@ const handlePreview = () => {
const handleDownload = () => {
if (props.data.url) {
const link = document.createElement('a')
link.href = props.data.url
link.href = activeVideoUrl.value
link.download = props.data.fileName || `video_${Date.now()}.mp4`
document.body.appendChild(link)
link.click()

View File

@@ -24,3 +24,6 @@ export {
// Workflow Orchestrator Hook | 工作流编排 Hook
export { useWorkflowOrchestrator } from './useWorkflowOrchestrator'
// Local media cache Hook | 本地媒体缓存 Hook
export { useCachedMediaUrl } from './useCachedMediaUrl'

View File

@@ -0,0 +1,194 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const CACHE_NAME = 'skg-canvas-media-v1'
const INDEX_KEY = 'skg-canvas-media-index-v1'
const MAX_CACHE_BYTES = 700 * 1024 * 1024
const MAX_CACHE_ITEMS = 240
const CACHEABLE_PATHS = ['/api/jobs/', '/api/agent-runs/']
const inflight = new Map()
const canUseBrowserCache = () => (
typeof window !== 'undefined'
&& typeof caches !== 'undefined'
&& window.isSecureContext
)
const readIndex = () => {
try {
return JSON.parse(window.localStorage.getItem(INDEX_KEY) || '{}')
} catch {
return {}
}
}
const writeIndex = (index) => {
try {
window.localStorage.setItem(INDEX_KEY, JSON.stringify(index))
} catch {
// The media itself is still in Cache Storage; the index only helps pruning.
}
}
const normalizeMediaUrl = (source) => {
if (!source || typeof source !== 'string') return ''
if (/^(blob:|data:)/i.test(source)) return source
if (/^https?:\/\//i.test(source)) return source
if (source.startsWith('/api/')) return source
if (source.startsWith('/jobs/') || source.startsWith('/agent-runs/')) {
return `/api${source}`
}
return source
}
const cacheKeyFor = (source) => {
const normalized = normalizeMediaUrl(source)
if (!normalized || /^(blob:|data:)/i.test(normalized)) return ''
try {
const url = new URL(normalized, window.location.origin)
if (url.origin !== window.location.origin) return ''
if (!CACHEABLE_PATHS.some(path => url.pathname.startsWith(path))) return ''
return url.href
} catch {
return ''
}
}
const responseSize = (response) => {
const length = Number(response.headers.get('content-length') || 0)
return Number.isFinite(length) && length > 0 ? length : 0
}
const pruneMediaCache = async (cache, index) => {
const entries = Object.entries(index)
.sort((a, b) => (b[1]?.lastAccess || 0) - (a[1]?.lastAccess || 0))
let total = entries.reduce((sum, [, meta]) => sum + Number(meta?.size || 0), 0)
const kept = {}
for (const [key, meta] of entries) {
const keepByCount = Object.keys(kept).length < MAX_CACHE_ITEMS
const keepBySize = total <= MAX_CACHE_BYTES || !meta?.size
if (keepByCount && keepBySize) {
kept[key] = meta
continue
}
await cache.delete(key)
total -= Number(meta?.size || 0)
}
writeIndex(kept)
return kept
}
const touchCacheEntry = (key, response) => {
const index = readIndex()
index[key] = {
size: responseSize(response) || index[key]?.size || 0,
contentType: response.headers.get('content-type') || index[key]?.contentType || '',
lastAccess: Date.now()
}
writeIndex(index)
return index
}
const warmMediaCache = async (source) => {
if (!canUseBrowserCache()) return
const key = cacheKeyFor(source)
if (!key) return
if (inflight.has(key)) return inflight.get(key)
const run = (async () => {
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(key)
if (cached) {
touchCacheEntry(key, cached)
return
}
const response = await fetch(key, {
credentials: 'include',
cache: 'force-cache'
})
if (!response.ok) return
const type = response.headers.get('content-type') || ''
if (!/^(image|video|audio)\//i.test(type)) return
await cache.put(key, response.clone())
const index = touchCacheEntry(key, response)
await pruneMediaCache(cache, index)
})().finally(() => {
inflight.delete(key)
})
inflight.set(key, run)
return run
}
const cachedObjectUrl = async (source) => {
if (!canUseBrowserCache()) return ''
const key = cacheKeyFor(source)
if (!key) return ''
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(key)
if (!cached) return ''
touchCacheEntry(key, cached)
const blob = await cached.blob()
if (!blob.size) return ''
return URL.createObjectURL(blob)
}
export const useCachedMediaUrl = (sourceGetter) => {
const cachedUrl = ref('')
const sourceUrl = computed(() => normalizeMediaUrl(sourceGetter() || ''))
let activeObjectUrl = ''
let token = 0
const clearObjectUrl = () => {
if (activeObjectUrl) {
URL.revokeObjectURL(activeObjectUrl)
activeObjectUrl = ''
}
}
watch(
sourceUrl,
async (url) => {
token += 1
const currentToken = token
clearObjectUrl()
cachedUrl.value = url
if (!url) return
try {
const localUrl = await cachedObjectUrl(url)
if (currentToken !== token) {
if (localUrl) URL.revokeObjectURL(localUrl)
return
}
if (localUrl) {
activeObjectUrl = localUrl
cachedUrl.value = localUrl
return
}
} catch {
cachedUrl.value = url
}
warmMediaCache(url).catch(() => {})
},
{ immediate: true }
)
onBeforeUnmount(clearObjectUrl)
return {
cachedUrl,
sourceUrl,
warmCache: () => warmMediaCache(sourceUrl.value)
}
}