auto-save 2026-05-27 14:53 (+1, ~6)
This commit is contained in:
@@ -140,12 +140,11 @@
|
||||
* Video node component | 视频节点组件
|
||||
* Displays and manages video content
|
||||
*/
|
||||
import { ref, nextTick, watch, onMounted } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||
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 { useVideoGeneration } from '../../hooks/useApi'
|
||||
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -156,9 +155,6 @@ const props = defineProps({
|
||||
// Vue Flow instance
|
||||
const { updateNodeInternals } = useVueFlow()
|
||||
|
||||
// Get pollVideoTask from useVideoGeneration | 从 useVideoGeneration 获取轮询函数
|
||||
const { pollVideoTask } = useVideoGeneration()
|
||||
|
||||
// Hover state | 悬浮状态
|
||||
const showActions = ref(false)
|
||||
const showHandleMenu = ref(false)
|
||||
@@ -173,61 +169,6 @@ const operations = [
|
||||
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline }
|
||||
]
|
||||
|
||||
// Polling state | 轮询状态
|
||||
const isPolling = ref(false)
|
||||
|
||||
// Watch for taskId changes and start polling | 监听 taskId 变化并开始轮询
|
||||
watch(() => props.data?.taskId, (taskId) => {
|
||||
if (taskId && !props.data?.url && !isPolling.value) {
|
||||
startPolling(taskId)
|
||||
}
|
||||
})
|
||||
|
||||
// 页面刷新后恢复轮询 | Resume polling after page refresh
|
||||
onMounted(() => {
|
||||
const { taskId, url } = props.data || {}
|
||||
if (taskId && !url && !isPolling.value) {
|
||||
startPolling(taskId)
|
||||
}
|
||||
})
|
||||
|
||||
// Start polling for video result | 开始轮询获取视频结果
|
||||
const startPolling = async (taskId) => {
|
||||
if (isPolling.value) return
|
||||
|
||||
isPolling.value = true
|
||||
|
||||
try {
|
||||
const result = await pollVideoTask(taskId, (attempt, percentage) => {
|
||||
// 更新进度
|
||||
updateNode(props.id, {
|
||||
progress: percentage,
|
||||
attempt
|
||||
})
|
||||
})
|
||||
// 轮询成功,更新视频节点
|
||||
updateNode(props.id, {
|
||||
url: result.url,
|
||||
loading: false,
|
||||
progress: 100,
|
||||
label: '视频生成',
|
||||
taskId: null // 清除 taskId
|
||||
})
|
||||
window.$message?.success('视频生成成功')
|
||||
} catch (err) {
|
||||
// 轮询失败
|
||||
updateNode(props.id, {
|
||||
loading: false,
|
||||
error: err.message || '生成失败',
|
||||
label: '生成失败',
|
||||
taskId: null // 清除 taskId
|
||||
})
|
||||
window.$message?.error(err.message || '视频生成失败')
|
||||
} finally {
|
||||
isPolling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle menu select | 处理菜单选择
|
||||
const handleSelect = (item) => {
|
||||
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
useChat,
|
||||
useImageGeneration,
|
||||
useVideoGeneration,
|
||||
readVideoTask,
|
||||
useApi
|
||||
} from './useApi'
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ const parseVideoTaskId = (pollTaskId) => {
|
||||
|
||||
export const readVideoTask = async (pollTaskId) => {
|
||||
const { jobId, videoId } = parseVideoTaskId(pollTaskId)
|
||||
// Canvas-level video sync reads one snapshot at a time instead of owning a long node-local poll.
|
||||
const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
|
||||
const item = (job.generated_videos || []).find(v => v.id === videoId)
|
||||
if (!item) {
|
||||
|
||||
@@ -299,7 +299,7 @@ import {
|
||||
} from '@vicons/ionicons5'
|
||||
import { nodes, edges, addNode, addNodes, addEdge, addEdges, updateNode, initSampleData, loadProject, saveProject, clearCanvas, canvasViewport, updateViewport, undo, redo, canUndo, canRedo, manualSaveHistory, startBatchOperation, endBatchOperation } from '../stores/canvas'
|
||||
import { loadAllModels } from '../stores/models'
|
||||
import { useChat, useWorkflowOrchestrator } from '../hooks'
|
||||
import { useChat, useWorkflowOrchestrator, readVideoTask } from '../hooks'
|
||||
import { useModelStore } from '../stores/pinia'
|
||||
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
|
||||
import { myWorkflows, workflowSyncStatus, loadMyWorkflows, saveMyWorkflow, deleteMyWorkflow } from '../stores/workflows'
|
||||
@@ -436,6 +436,10 @@ const projectName = computed(() => {
|
||||
|
||||
const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing')
|
||||
|
||||
const videoTaskSyncing = new Set()
|
||||
let videoTaskSyncTimer = null
|
||||
let videoTaskSyncToastShown = false
|
||||
|
||||
// Project dropdown options | 项目下拉选项
|
||||
const projectOptions = [
|
||||
{ label: '重命名', key: 'rename' },
|
||||
@@ -631,6 +635,106 @@ const handleDeleteWorkflow = async (workflow) => {
|
||||
}
|
||||
}
|
||||
|
||||
const pendingVideoNodes = () => nodes.value.filter(node =>
|
||||
node.type === 'video' && node.data?.taskId && !node.data?.url
|
||||
)
|
||||
|
||||
const syncPendingVideoNodes = async () => {
|
||||
const pending = pendingVideoNodes()
|
||||
if (!pending.length) return
|
||||
|
||||
for (const node of pending) {
|
||||
const taskId = node.data?.taskId
|
||||
const syncKey = `${node.id}:${taskId}`
|
||||
if (!taskId || videoTaskSyncing.has(syncKey)) continue
|
||||
|
||||
videoTaskSyncing.add(syncKey)
|
||||
readVideoTask(taskId)
|
||||
.then((snapshot) => {
|
||||
const item = snapshot.video
|
||||
if (item.status === 'completed') {
|
||||
updateNode(node.id, {
|
||||
url: snapshot.url,
|
||||
loading: false,
|
||||
progress: 100,
|
||||
label: '视频生成',
|
||||
taskId: null,
|
||||
error: '',
|
||||
syncError: '',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
window.$message?.success('视频生成成功,已自动回填到画布')
|
||||
return
|
||||
}
|
||||
|
||||
if (item.status === 'failed') {
|
||||
updateNode(node.id, {
|
||||
loading: false,
|
||||
error: item.error || '视频生成失败',
|
||||
label: '生成失败',
|
||||
taskId: null,
|
||||
queueMessage: '',
|
||||
syncError: '',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
window.$message?.error(item.error || '视频生成失败')
|
||||
return
|
||||
}
|
||||
|
||||
updateNode(node.id, {
|
||||
loading: true,
|
||||
progress: item.progress || node.data?.progress || 0,
|
||||
queuePosition: item.queue_position || 0,
|
||||
queueSize: item.queue_size || 0,
|
||||
queueMessage: item.queue_message || '',
|
||||
error: '',
|
||||
syncError: '',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.terminal) {
|
||||
updateNode(node.id, {
|
||||
loading: false,
|
||||
error: err.message || '视频任务不存在',
|
||||
label: '生成失败',
|
||||
taskId: null,
|
||||
syncError: '',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updateNode(node.id, {
|
||||
loading: true,
|
||||
syncError: err.message || '视频状态同步暂时中断',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
videoTaskSyncing.delete(syncKey)
|
||||
})
|
||||
}
|
||||
|
||||
if (!videoTaskSyncToastShown) {
|
||||
videoTaskSyncToastShown = true
|
||||
window.$message?.info('视频生成中,完成后会自动显示在画布里')
|
||||
}
|
||||
}
|
||||
|
||||
const startVideoTaskSync = () => {
|
||||
if (videoTaskSyncTimer) return
|
||||
syncPendingVideoNodes()
|
||||
videoTaskSyncTimer = window.setInterval(syncPendingVideoNodes, 5000)
|
||||
}
|
||||
|
||||
const stopVideoTaskSync = () => {
|
||||
if (!videoTaskSyncTimer) return
|
||||
window.clearInterval(videoTaskSyncTimer)
|
||||
videoTaskSyncTimer = null
|
||||
videoTaskSyncing.clear()
|
||||
}
|
||||
|
||||
// Handle connection | 处理连接
|
||||
const onConnect = (params) => {
|
||||
// Check connection types | 检查连接类型
|
||||
@@ -952,6 +1056,16 @@ watch(showWorkflowPanel, (visible) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => nodes.value
|
||||
.filter(node => node.type === 'video')
|
||||
.map(node => `${node.id}:${node.data?.taskId || ''}:${node.data?.url || ''}`)
|
||||
.join('|'),
|
||||
() => {
|
||||
if (pendingVideoNodes().length) syncPendingVideoNodes()
|
||||
}
|
||||
)
|
||||
|
||||
// Initialize | 初始化
|
||||
onMounted(async () => {
|
||||
checkMobile()
|
||||
@@ -963,6 +1077,7 @@ onMounted(async () => {
|
||||
// Load project data | 加载项目数据
|
||||
loadProjectById(route.params.id)
|
||||
refreshMyWorkflows()
|
||||
startVideoTaskSync()
|
||||
|
||||
// Check for initial prompt from home page | 检查来自首页的初始提示词
|
||||
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
|
||||
@@ -979,6 +1094,7 @@ onMounted(async () => {
|
||||
// Cleanup on unmount | 卸载时清理
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
stopVideoTaskSync()
|
||||
// Save project before leaving | 离开前保存项目
|
||||
saveProject()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user