feat: cache canvas media locally
This commit is contained in:
18
api/main.py
18
api/main.py
@@ -1231,6 +1231,11 @@ def user_can_access_agent_run(run_id: str, user: dict | None) -> bool:
|
||||
JOB_PATH_RE = re.compile(r"^/jobs/([0-9a-f]{8,32})(?:/|$)")
|
||||
COPY_TO_JOB_PATH_RE = re.compile(r"^/asset-library/[^/]+/[^/]+/copy-to-job/([0-9a-f]{8,32})(?:/|$)")
|
||||
AGENT_RUN_PATH_RE = re.compile(r"^/agent-runs/([0-9a-f]{8,32})(?:/|$)")
|
||||
PRIVATE_MEDIA_PATH_RE = re.compile(
|
||||
r"^/(jobs|agent-runs)/.+\.(jpg|jpeg|png|webp|mp4|mp3|wav)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
PRIVATE_MEDIA_CACHE_CONTROL = "private, max-age=2592000, immutable"
|
||||
|
||||
|
||||
def _extract_protected_job_id(path: str) -> str:
|
||||
@@ -2139,6 +2144,19 @@ async def enforce_data_isolation(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_private_media_cache_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if (
|
||||
request.method in {"GET", "HEAD"}
|
||||
and response.status_code == 200
|
||||
and PRIVATE_MEDIA_PATH_RE.match(request.url.path)
|
||||
):
|
||||
response.headers.setdefault("Cache-Control", PRIVATE_MEDIA_CACHE_CONTROL)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/auth/check")
|
||||
def auth_check(request: Request) -> Response:
|
||||
ensure_auth_configured()
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
<p><strong>2026-05-27 上传参考图持久化:</strong>画布图片节点上传本地文件时先写入后端 creative job,再把 <code>/api/jobs/...</code> 资产 URL 保存到节点和服务端画布项目;不再把浏览器 <code>data:</code> base64 当作图片地址保存。项目自动保存增加内容签名去重和 2 秒防抖,减少连续点击或节点测量触发的重复 <code>PUT /canvas-projects</code>。</p>
|
||||
<p><strong>2026-05-27 图片模型配置化:</strong>图片生成不再把主模型写死为 <code>gpt-image-2</code>。后端通过 <code>IMAGE_MODEL</code>、<code>IMAGE_FALLBACK_MODELS</code>、<code>IMAGE_EXTRA_MODELS</code>、<code>IMAGE_MODEL_CONFIGS_JSON</code> 和 Ark 专用 <code>ARK_IMAGE_BASE_URL</code>/<code>ARK_IMAGE_API_KEY</code>/<code>ARK_SEEDREAM_IMAGE_MODEL</code> 注册模型;默认仍保持 GPT Image 2 + Gemini 兜底,新增可选 <code>doubao-seedream-4-5-251128</code>,Seedream 走 <code>/images/generations</code> + <code>reference_images</code> 并使用 2K/4K 尺寸。</p>
|
||||
<p><strong>2026-05-26 生图配置恢复版:</strong>按用户要求撤回后续“低/中/高画质、自定义尺寸、Gemini 官方 1K/2K/4K 尺寸、取消自动模型”的实验改动,恢复最初简单配置:图片模型为 <code>auto</code>、<code>gpt-image-2</code>、<code>gemini-3-pro-image-preview</code>,尺寸只保留 <code>auto</code>、<code>1024x1536</code>、<code>1024x1024</code>、<code>1536x1024</code>,画质回到单一标准项;<code>auto</code> 仍按后端既有策略优先 GPT Image 2,必要时由熔断/兜底走 Gemini。</p>
|
||||
<p><strong>2026-05-28 画布媒体本地缓存:</strong>图片节点和视频节点显示 <code>/api/jobs/...</code> 生成资产时,会先查当前浏览器的 Cache Storage;命中后使用本机 <code>blob:</code> 地址展示,未命中则先用服务器 URL 展示并在后台写入本地缓存。后端对登录保护下的 job 图片、视频和音频返回 <code>Cache-Control: private, max-age=2592000, immutable</code>,让每台电脑自己的浏览器磁盘缓存参与加速,避免刷新画布时反复从 VPS 下载同一素材。</p>
|
||||
</div>
|
||||
<p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code>、<code>/jobs/{id}/frames/{idx}/generate</code>、<code>/jobs/{id}/frames/{idx}/storyboard/video</code>,AI 润色和通用 LLM 文本生成走 <code>/prompt/polish</code> 并保持中性专业:不主动套入 SKG,不主动补产品、平台、广告语境或人物,只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节;视频提交若带参考图,会在最终提示词中条件声明“参考图里若有人物,应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片模型由后端运行时配置决定,默认 <code>auto</code> 仍优先当前 <code>IMAGE_MODEL</code> 并按 <code>IMAGE_FALLBACK_MODELS</code> 兜底;前端同时提供 GPT/Gemini 旧尺寸和 Seedream 2K/4K 尺寸。视频画幅只显示 <code>720x1280</code>、<code>1280x720</code>、<code>1024x1024</code>、<code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code>、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p>
|
||||
<div class="pipeline">
|
||||
@@ -657,6 +658,7 @@
|
||||
<tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer、<code>AI 润色</code>、<code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。工作流面板支持公共模板和我的工作流:公共模板走本地 <code>createNodes()</code>,我的工作流从云端 <code>workflow_data</code> 插回当前画布,并重新生成节点 ID、按视口中心重排、按映射重连边。Vue Flow 开启可见节点渲染,大画布不再把所有节点同时挂载到 DOM;节点数超过 120 时隐藏 MiniMap,减少点击后的同步重绘压力。底部推荐词来自共享短词池,4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度。</td></tr>
|
||||
<tr><td><code>web/canvas-app/src/config/suggestions.js</code></td><td>首页和画布共用的推荐词配置:维护 <code>QUICK_SUGGESTION_GROUPS</code>,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。</td></tr>
|
||||
<tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code>、<code>gpt-image-2</code>、<code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code>、<code>1024x1536</code>、<code>1024x1024</code>、<code>1536x1024</code>;视频只内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code>,画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。</td></tr>
|
||||
<tr><td><code>web/canvas-app/src/hooks/useCachedMediaUrl.js</code></td><td>画布媒体本地缓存 Hook:只缓存同源、登录保护下的 <code>/api/jobs/...</code> 和 <code>/api/agent-runs/...</code> 图片 / 视频 / 音频。图片节点和视频节点先用原始 URL 保证首屏可见,再后台写入浏览器 Cache Storage;下次打开同一素材时返回本机 <code>blob:</code> URL,减少反复从 VPS 下载。</td></tr>
|
||||
<tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key,而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job,再调用 <code>/frames/0/generate</code>;本地上传到图片节点的参考图也会先通过 <code>/creative/jobs/image</code> 写成后端资产,再把 <code>/api/jobs/...</code> URL 保存到节点,避免刷新后丢失。文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。<code>useChat</code> 已从 SKG 广告文案接口切到 <code>/prompt/polish</code>:AI 润色显式使用 image/video prompt 模式,LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG、产品、平台或营销语境;后端会清理旧润色模板尾巴、判断人物/无人/物体/场景意图,并在输出后检查“有人却禁止人物、无人却新增人物、未写 SKG 却出现 SKG”等冲突。图生视频实际提交到后端后,后端会对参考图追加 AI 虚拟角色条件说明,不要求前端判断图片里是否有人脸。</td></tr>
|
||||
<tr><td><code>web/scripts/sync-canvas-root.mjs</code></td><td>构建桥接脚本:在 <code>next build</code> 静态导出完成后,把 Vite 画布产物 <code>web/canvas-app/dist</code> 覆盖到 <code>web/out</code> 根目录,使 <code>https://marketing.skg.com</code> 登录后直接进入画布;旧 <code>web/scripts/sync-canvas-dist.mjs</code> 保留但不再由生产构建调用。</td></tr>
|
||||
<tr><td><code>web/app/detail/page.tsx</code></td><td>任务详情页:静态导出路由 <code>/detail/?job=<id></code>,通过 query 读取 job id,调用 <code>getJob</code> 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 <code>generateImage</code>、<code>generateStoryboardVideo</code>、<code>generateCreativeCopy</code>,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。</td></tr>
|
||||
|
||||
@@ -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