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:
@@ -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.
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | 请求拦截器
|
||||
|
||||
Reference in New Issue
Block a user