feat: cache canvas media locally
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -24,3 +24,6 @@ export {
|
||||
|
||||
// Workflow Orchestrator Hook | 工作流编排 Hook
|
||||
export { useWorkflowOrchestrator } from './useWorkflowOrchestrator'
|
||||
|
||||
// Local media cache Hook | 本地媒体缓存 Hook
|
||||
export { useCachedMediaUrl } from './useCachedMediaUrl'
|
||||
|
||||
194
web/canvas-app/src/hooks/useCachedMediaUrl.js
Normal file
194
web/canvas-app/src/hooks/useCachedMediaUrl.js
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user