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 = {}) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询视频任务状态
|
// 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('视频生成超时')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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(() => {})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 | 请求拦截器
|
||||||
|
|||||||
Reference in New Issue
Block a user