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 = {}) => {
})
}
// 查询视频任务状态
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.

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

View File

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

View File

@@ -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(() => {})

View File

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

View File

@@ -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 | 请求拦截器