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