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) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 02:04:59 +08:00
parent 3ed3f721eb
commit b56d5177e5
6 changed files with 65 additions and 92 deletions

View File

@@ -17,29 +17,6 @@ export const createVideoTask = (data, options = {}) => {
}) })
} }
// 查询视频任务状态 // NOTE: getVideoTaskStatus / pollVideoTask were removed — they ignored taskId and
export const getVideoTaskStatus = (taskId, options = {}) => { // polled the list endpoint, and were superseded by readVideoTask() in hooks/useApi.js
const { endpoint = '/videos' } = options // plus the Canvas-level syncPendingVideoNodes() loop. Nothing imported them.
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('视频生成超时')
}

View File

@@ -147,6 +147,7 @@ import { NIcon, NSpin } from 'naive-ui'
import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5' import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas' import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
import { useCachedMediaUrl } from '../../hooks/useCachedMediaUrl' import { useCachedMediaUrl } from '../../hooks/useCachedMediaUrl'
import { uploadCanvasVideo } from '../../hooks/useApi'
import NodeHandleMenu from './NodeHandleMenu.vue' import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({ const props = defineProps({
@@ -195,14 +196,21 @@ const handleSelect = (item) => {
} }
// Handle file upload | 处理文件上传 // Handle file upload | 处理文件上传
const handleFileUpload = (event) => { const handleFileUpload = async (event) => {
const file = event.target.files[0] const file = event.target.files?.[0]
if (file) { if (!file) return
const url = URL.createObjectURL(file) // reset so picking the same file again still fires @change
updateNode(props.id, { event.target.value = ''
url, // Upload to the backend and store the returned stable URL. A blob: object URL
updatedAt: Date.now() // 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}`)
} }
} }

View File

@@ -49,7 +49,7 @@ const imageSourceToFile = async (source, filename = 'reference.jpg') => {
if (typeof source !== 'string') return null if (typeof source !== 'string') return null
if (source.startsWith('data:')) return dataUrlToFile(source, filename) if (source.startsWith('data:')) return dataUrlToFile(source, filename)
const url = source.startsWith('/jobs/') ? apiUrl(source) : source 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}`) if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`)
const blob = await response.blob() const blob = await response.blob()
return new File([blob], filename, { type: blob.type || 'image/jpeg' }) 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 newestGeneratedImage = (job, frameIdx = 0) => {
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[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] 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 = () => {}) => { // Node-local polling was removed: video status is owned by the Canvas-level
const maxAttempts = 720 // syncPendingVideoNodes() interval (views/Canvas.vue), which reads snapshots via
const interval = 5000 // readVideoTask() and is properly torn down on unmount. A second per-node poll
let transientFailures = 0 // here just duplicated requests and had no unmount stop.
return { loading, error, status, video, taskId, progress, reset, createVideoTaskOnly }
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 }
} }
export const useApi = () => { export const useApi = () => {

View File

@@ -81,10 +81,11 @@ const pruneMediaCache = async (cache, index) => {
return kept return kept
} }
const touchCacheEntry = (key, response) => { const touchCacheEntry = (key, response, sizeOverride) => {
const index = readIndex() const index = readIndex()
const measured = Number(sizeOverride)
index[key] = { 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 || '', contentType: response.headers.get('content-type') || index[key]?.contentType || '',
lastAccess: Date.now() lastAccess: Date.now()
} }
@@ -115,8 +116,18 @@ const warmMediaCache = async (source) => {
const type = response.headers.get('content-type') || '' const type = response.headers.get('content-type') || ''
if (!/^(image|video|audio)\//i.test(type)) return 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()) 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) await pruneMediaCache(cache, index)
})().finally(() => { })().finally(() => {
inflight.delete(key) inflight.delete(key)
@@ -175,7 +186,8 @@ export const useCachedMediaUrl = (sourceGetter) => {
return return
} }
} catch { } 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(() => {}) warmMediaCache(url).catch(() => {})

View File

@@ -216,6 +216,11 @@ const cleanNodeForStorage = (node) => {
delete cleanedData.url 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 | 移除蒙版数据 // Remove mask data | 移除蒙版数据
if (cleanedData.maskData) { if (cleanedData.maskData) {
delete cleanedData.maskData delete cleanedData.maskData

View File

@@ -9,7 +9,10 @@ import axios from 'axios'
// Create axios instance | 创建 axios 实例 // Create axios instance | 创建 axios 实例
const instance = axios.create({ const instance = axios.create({
baseURL: "/api", 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 | 请求拦截器 // Request interceptor | 请求拦截器