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

@@ -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()

View File

@@ -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=&lt;id&gt;</code>,通过 query 读取 job id调用 <code>getJob</code> 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 <code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code>,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。</td></tr>

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)
}
}