From b56d5177e58fdf97ea2d70b7c5bafc37c0b6a4f5 Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 30 May 2026 02:04:59 +0800 Subject: [PATCH] fix(canvas): persist video uploads and fix media cache/polling - VideoNode upload now goes through backend (/jobs/upload via uploadCanvasVideo) for a stable reloadable URL instead of a session-only blob: that leaked and broke on reload; cleanNodeForStorage also strips blob: URLs - useCachedMediaUrl: record real blob.size (chunked videos reported 0, making the LRU byte cap a no-op); guard the catch path with the race token - useApi: send credentials when reading reference images; drop the node-level video poll that duplicated the Canvas-level syncPendingVideoNodes loop - request.js: 60s timeout (was ~8.3h) + withCredentials - remove dead getVideoTaskStatus/pollVideoTask that ignored taskId Co-Authored-By: Claude Opus 4.8 (1M context) --- web/canvas-app/src/api/video.js | 29 +------- .../src/components/nodes/VideoNode.vue | 24 ++++--- web/canvas-app/src/hooks/useApi.js | 72 ++++++------------- web/canvas-app/src/hooks/useCachedMediaUrl.js | 20 ++++-- web/canvas-app/src/stores/projects.js | 7 +- web/canvas-app/src/utils/request.js | 5 +- 6 files changed, 65 insertions(+), 92 deletions(-) diff --git a/web/canvas-app/src/api/video.js b/web/canvas-app/src/api/video.js index 51b6580..7fe0072 100644 --- a/web/canvas-app/src/api/video.js +++ b/web/canvas-app/src/api/video.js @@ -17,29 +17,6 @@ export const createVideoTask = (data, options = {}) => { }) } -// 查询视频任务状态 -export const getVideoTaskStatus = (taskId, options = {}) => { - const { endpoint = '/videos' } = options - return request({ - url: `${endpoint}`, - method: 'get' - }) -} -// 轮询视频任务直到完成 -export const pollVideoTask = async (taskId, maxAttempts = 120, interval = 5000) => { - for (let i = 0; i < maxAttempts; i++) { - const result = await getVideoTaskStatus(taskId) - - if (result.status === 'completed' || result.data) { - return result - } - - if (result.status === 'failed') { - throw new Error(result.error?.message || '视频生成失败') - } - - await new Promise(resolve => setTimeout(resolve, interval)) - } - - throw new Error('视频生成超时') -} +// NOTE: getVideoTaskStatus / pollVideoTask were removed — they ignored taskId and +// polled the list endpoint, and were superseded by readVideoTask() in hooks/useApi.js +// plus the Canvas-level syncPendingVideoNodes() loop. Nothing imported them. diff --git a/web/canvas-app/src/components/nodes/VideoNode.vue b/web/canvas-app/src/components/nodes/VideoNode.vue index 85b1f43..f6f00cf 100644 --- a/web/canvas-app/src/components/nodes/VideoNode.vue +++ b/web/canvas-app/src/components/nodes/VideoNode.vue @@ -147,6 +147,7 @@ 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 { uploadCanvasVideo } from '../../hooks/useApi' import NodeHandleMenu from './NodeHandleMenu.vue' const props = defineProps({ @@ -195,14 +196,21 @@ const handleSelect = (item) => { } // Handle file upload | 处理文件上传 -const handleFileUpload = (event) => { - const file = event.target.files[0] - if (file) { - const url = URL.createObjectURL(file) - updateNode(props.id, { - url, - updatedAt: Date.now() - }) +const handleFileUpload = async (event) => { + const file = event.target.files?.[0] + if (!file) return + // reset so picking the same file again still fires @change + event.target.value = '' + // Upload to the backend and store the returned stable URL. A blob: object URL + // would leak (never revoked) and, once persisted, breaks on project reload. + updateNode(props.id, { loading: true }) + try { + const { url } = await uploadCanvasVideo(file) + updateNode(props.id, { url, loading: false, updatedAt: Date.now() }) + window.$message?.success('视频已上传') + } catch (e) { + updateNode(props.id, { loading: false }) + window.$message?.error(`视频上传失败:${e?.message || e}`) } } diff --git a/web/canvas-app/src/hooks/useApi.js b/web/canvas-app/src/hooks/useApi.js index 5b2985c..dab3cd0 100644 --- a/web/canvas-app/src/hooks/useApi.js +++ b/web/canvas-app/src/hooks/useApi.js @@ -49,7 +49,7 @@ const imageSourceToFile = async (source, filename = 'reference.jpg') => { if (typeof source !== 'string') return null if (source.startsWith('data:')) return dataUrlToFile(source, filename) const url = source.startsWith('/jobs/') ? apiUrl(source) : source - const response = await fetch(url) + const response = await fetch(url, { credentials: 'include' }) if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`) const blob = await response.blob() return new File([blob], filename, { type: blob.type || 'image/jpeg' }) @@ -82,6 +82,20 @@ export const uploadCanvasImage = async (file) => { } } +export const uploadCanvasVideo = async (file) => { + if (!file) throw new Error('请选择视频文件') + const form = new FormData() + form.append('file', file) + // Persist the upload server-side so the node keeps a stable, reloadable URL + // instead of a session-only blob: URL that breaks once the project reloads. + const job = await requestJson('/jobs/upload', { method: 'POST', body: form }) + if (!job?.id) throw new Error('视频已上传但未返回任务地址') + return { + url: toAssetUrl(`/jobs/${job.id}/video.mp4`), + jobId: job.id + } +} + const newestGeneratedImage = (job, frameIdx = 0) => { const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0] return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] @@ -299,57 +313,11 @@ export const useVideoGeneration = () => { } } - const pollVideoTask = async (pollTaskId, onProgress = () => {}) => { - const maxAttempts = 720 - const interval = 5000 - let transientFailures = 0 - - for (let i = 0; i < maxAttempts; i += 1) { - try { - const snapshot = await readVideoTask(pollTaskId) - const item = snapshot.video - transientFailures = 0 - const percentage = item.progress || Math.min(Math.round((i / maxAttempts) * 100), 98) - onProgress(i + 1, percentage) - progress.attempt = i + 1 - progress.percentage = percentage - if (item.status === 'completed') { - const result = { ...item, url: snapshot.url } - video.value = result - setSuccess() - return result - } - if (item.status === 'failed') { - const err = new Error(item.error || '视频生成失败') - err.terminal = true - throw err - } - } catch (err) { - if (err.terminal) throw err - transientFailures += 1 - if (transientFailures >= 24) { - const retryable = new Error(err.message || '视频状态同步暂时中断') - retryable.retryable = true - throw retryable - } - } - await new Promise(resolve => setTimeout(resolve, interval)) - } - const retryable = new Error('视频仍在生成,已交给后台同步') - retryable.retryable = true - throw retryable - } - - const generate = async (params) => { - const { taskId: newTaskId, url } = await createVideoTaskOnly(params) - if (url) { - video.value = { url } - return video.value - } - return pollVideoTask(newTaskId) - } - - return { loading, error, status, video, taskId, progress, generate, reset, createVideoTaskOnly, pollVideoTask } + // Node-local polling was removed: video status is owned by the Canvas-level + // syncPendingVideoNodes() interval (views/Canvas.vue), which reads snapshots via + // readVideoTask() and is properly torn down on unmount. A second per-node poll + // here just duplicated requests and had no unmount stop. + return { loading, error, status, video, taskId, progress, reset, createVideoTaskOnly } } export const useApi = () => { diff --git a/web/canvas-app/src/hooks/useCachedMediaUrl.js b/web/canvas-app/src/hooks/useCachedMediaUrl.js index dd6fd8f..eed9217 100644 --- a/web/canvas-app/src/hooks/useCachedMediaUrl.js +++ b/web/canvas-app/src/hooks/useCachedMediaUrl.js @@ -81,10 +81,11 @@ const pruneMediaCache = async (cache, index) => { return kept } -const touchCacheEntry = (key, response) => { +const touchCacheEntry = (key, response, sizeOverride) => { const index = readIndex() + const measured = Number(sizeOverride) index[key] = { - size: responseSize(response) || index[key]?.size || 0, + size: measured > 0 ? measured : (responseSize(response) || index[key]?.size || 0), contentType: response.headers.get('content-type') || index[key]?.contentType || '', lastAccess: Date.now() } @@ -115,8 +116,18 @@ const warmMediaCache = async (source) => { const type = response.headers.get('content-type') || '' if (!/^(image|video|audio)\//i.test(type)) return + // Measure the real byte size from a clone — videos are usually served with + // chunked transfer (no content-length), which would record size=0 and make + // the LRU byte cap a no-op (everything looks "free" to keep). + const measureClone = response.clone() await cache.put(key, response.clone()) - const index = touchCacheEntry(key, response) + let realSize = 0 + try { + realSize = (await measureClone.blob()).size + } catch { + realSize = 0 + } + const index = touchCacheEntry(key, response, realSize) await pruneMediaCache(cache, index) })().finally(() => { inflight.delete(key) @@ -175,7 +186,8 @@ export const useCachedMediaUrl = (sourceGetter) => { return } } catch { - cachedUrl.value = url + // only fall back to the raw URL if the source hasn't changed underneath us + if (currentToken === token) cachedUrl.value = url } warmMediaCache(url).catch(() => {}) diff --git a/web/canvas-app/src/stores/projects.js b/web/canvas-app/src/stores/projects.js index 47a49c7..a27756a 100644 --- a/web/canvas-app/src/stores/projects.js +++ b/web/canvas-app/src/stores/projects.js @@ -215,7 +215,12 @@ const cleanNodeForStorage = (node) => { // For uploaded images, we can't persist them in localStorage | 上传的图片无法持久化到 localStorage delete cleanedData.url } - + + // blob: object URLs are session-only and break on reload — never persist them | blob: 仅会话内有效,重载即失效,不持久化 + if (cleanedData.url?.startsWith?.('blob:')) { + delete cleanedData.url + } + // Remove mask data | 移除蒙版数据 if (cleanedData.maskData) { delete cleanedData.maskData diff --git a/web/canvas-app/src/utils/request.js b/web/canvas-app/src/utils/request.js index 000d9e4..e9066a0 100644 --- a/web/canvas-app/src/utils/request.js +++ b/web/canvas-app/src/utils/request.js @@ -9,7 +9,10 @@ import axios from 'axios' // Create axios instance | 创建 axios 实例 const instance = axios.create({ baseURL: "/api", - timeout: 30000000 + // 60s default (the old 30000000ms ≈ 8.3h was effectively no timeout, so a hung + // request would never abort). Send the auth cookie for cross-origin API bases. + timeout: 60000, + withCredentials: true }) // Request interceptor | 请求拦截器