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_MODELIMAGE_FALLBACK_MODELSIMAGE_EXTRA_MODELSIMAGE_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 尺寸、取消自动模型”的实验改动,恢复最初简单配置:图片模型为 autogpt-image-2gemini-3-pro-image-preview,尺寸只保留 auto1024x15361024x10241536x1024,画质回到单一标准项;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 尺寸。视频画幅只显示 720x12801280x7201024x1024960x1280;视频时长只显示 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画布媒体模型和规格的前端白名单:图片只内置 autogpt-image-2gemini-3-pro-image-preview,尺寸只内置 auto1024x15361024x10241536x1024;视频只内置 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 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 generateImagegenerateStoryboardVideogenerateCreativeCopy,并支持删除图片/视频。该页继续依赖后端 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) + } +} +