Files
20260512-skg-tk/web/canvas-app/src/components/nodes/VideoNode.vue
kang b56d5177e5 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>
2026-05-30 02:04:59 +08:00

296 lines
10 KiB
Vue

<template>
<!-- Video node wrapper | 视频节点包裹层 -->
<div class="video-node-wrapper relative" @mouseenter="showActions = true; showHandleMenu = true" @mouseleave="showActions = false; showHandleMenu = false">
<!-- Video node | 视频节点 -->
<div
class="video-node bg-[var(--bg-secondary)] rounded-xl border w-[400px] relative transition-all duration-200"
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'"
>
<!-- Header | 头部 -->
<div class="px-3 py-2 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<span
v-if="!isEditingLabel"
@dblclick="startEditLabel"
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
title="双击编辑名称"
>{{ data.label }}</span>
<input
v-else
ref="labelInputRef"
v-model="editingLabelValue"
@blur="finishEditLabel"
@keydown.enter="finishEditLabel"
@keydown.escape="cancelEditLabel"
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
/>
<div class="flex items-center gap-1">
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
<n-icon :size="14">
<CopyOutline />
</n-icon>
</button>
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
<n-icon :size="14">
<TrashOutline />
</n-icon>
</button>
</div>
</div>
<!-- Model name | 模型名称 -->
<div v-if="data.model" class="mt-1 text-xs text-[var(--text-secondary)] truncate">
{{ data.model }}
</div>
</div>
<!-- Video preview area | 视频预览区域 -->
<div class="p-3">
<!-- Loading state | 加载状态 -->
<div
v-if="(data.taskId && !data.url) || (data.loading && !data.taskId)"
class="aspect-video rounded-lg bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden"
>
<!-- Animated gradient overlay | 动画渐变遮罩 -->
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse"></div>
<!-- Loading image | 加载图片 -->
<div class="relative z-10">
<img
src="../../assets/loading.webp"
alt="Loading"
class="w-14 h-12"
/>
</div>
<span class="text-sm text-white font-medium relative z-10">{{ data.taskId ? '创作中,预计等待 1 分钟' : '任务创建中...' }}</span>
</div>
<!-- Error state | 错误状态 -->
<div
v-else-if="data.error"
class="aspect-video rounded-lg bg-red-50 dark:bg-red-900/20 flex flex-col items-center justify-center gap-2 border border-red-200 dark:border-red-800"
>
<n-icon :size="32" class="text-red-500"><CloseCircleOutline /></n-icon>
<span class="text-sm text-red-500">{{ data.error }}</span>
</div>
<!-- Video preview | 视频预览 -->
<div
v-else-if="data.url"
class="aspect-video rounded-lg overflow-hidden bg-black"
>
<video
:src="displayVideoUrl"
controls
preload="metadata"
class="w-full h-full object-contain"
/>
</div>
<!-- Empty state | 空状态 -->
<div
v-else
class="aspect-video rounded-lg bg-[var(--bg-tertiary)] flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[var(--border-color)] relative"
>
<n-icon :size="32" class="text-[var(--text-secondary)]"><VideocamOutline /></n-icon>
<span class="text-sm text-[var(--text-secondary)]">拖放视频或点击上传</span>
<input
type="file"
accept="video/*"
class="absolute inset-0 opacity-0 cursor-pointer"
@change="handleFileUpload"
/>
</div>
<!-- Duration info | 时长信息 -->
<div v-if="data.duration" class="mt-2 text-xs text-[var(--text-secondary)]">
时长: {{ formatDuration(data.duration) }}
</div>
</div>
<!-- Handles | 连接点 -->
<NodeHandleMenu :nodeId="id" nodeType="video" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
</div>
<!-- Right side - Action buttons | 右侧 - 操作按钮 -->
<div
v-show="showActions && data.url"
class="absolute right-10 top-20 -translate-y-1/2 translate-x-full flex flex-col gap-2 z-[1000]"
>
<!-- Preview button | 预览按钮 -->
<button
@click="handlePreview"
class="action-btn group p-2 bg-white rounded-lg transition-all border border-gray-200 flex items-center gap-0 hover:gap-1.5 w-max"
>
<n-icon :size="16" class="text-gray-600"><EyeOutline /></n-icon>
<span class="text-xs text-gray-600 max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">预览</span>
</button>
<!-- Download button | 下载按钮 -->
<button
@click="handleDownload"
class="action-btn group p-2 bg-white rounded-lg transition-all border border-gray-200 flex items-center gap-0 hover:gap-1.5 w-max"
>
<n-icon :size="16" class="text-gray-600"><DownloadOutline /></n-icon>
<span class="text-xs text-gray-600 max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">下载</span>
</button>
</div>
</div>
</template>
<script setup>
/**
* Video node component | 视频节点组件
* Displays and manages video content
*/
import { computed, 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 { useCachedMediaUrl } from '../../hooks/useCachedMediaUrl'
import { uploadCanvasVideo } from '../../hooks/useApi'
import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
id: String,
data: Object
})
// Vue Flow instance
const { updateNodeInternals } = useVueFlow()
const { cachedUrl: displayVideoUrl, warmCache: warmVideoCache } = useCachedMediaUrl(() => props.data?.url)
const activeVideoUrl = computed(() => displayVideoUrl.value || props.data?.url || '')
// Hover state | 悬浮状态
const showActions = ref(false)
const showHandleMenu = ref(false)
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
const editingLabelValue = ref('')
const labelInputRef = ref(null)
// Video node menu operations | 视频节点菜单操作
const operations = [
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline }
]
// Handle menu select | 处理菜单选择
const handleSelect = (item) => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
const newId = addNode('videoConfig', { x: nodeX + 400, y: nodeY }, { label: '视频生成' })
addEdge({
source: props.id,
target: newId,
sourceHandle: 'right',
targetHandle: 'left'
})
setTimeout(() => {
updateNodeInternals(newId)
}, 50)
window.$message?.success(`已创建视频生成节点`)
}
// Handle file upload | 处理文件上传
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}`)
}
}
// Format duration | 格式化时长
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// Start editing label | 开始编辑 label
const startEditLabel = () => {
editingLabelValue.value = props.data?.label || ''
isEditingLabel.value = true
nextTick(() => {
labelInputRef.value?.focus()
labelInputRef.value?.select()
})
}
// Finish editing label | 完成编辑 label
const finishEditLabel = () => {
const newLabel = editingLabelValue.value.trim()
if (newLabel && newLabel !== props.data?.label) {
updateNode(props.id, { label: newLabel })
}
isEditingLabel.value = false
}
// Cancel editing label | 取消编辑 label
const cancelEditLabel = () => {
isEditingLabel.value = false
}
// Handle delete | 处理删除
const handleDelete = () => {
removeNode(props.id)
}
// Handle preview | 处理预览
const handlePreview = () => {
if (props.data.url) {
warmVideoCache()
window.open(activeVideoUrl.value, '_blank')
}
}
// Handle download | 处理下载
const handleDownload = () => {
if (props.data.url) {
const link = document.createElement('a')
link.href = activeVideoUrl.value
link.download = props.data.fileName || `video_${Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.$message?.success('视频下载中...')
}
}
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newId = duplicateNode(props.id)
if (newId) {
// Clear selection and select the new node | 清除选中并选中新节点
updateNode(props.id, { selected: false })
updateNode(newId, { selected: true })
window.$message?.success('节点已复制')
}
}
</script>
<style scoped>
.video-node-wrapper {
padding-right: 50px;
padding-top: 20px;
}
.video-node {
cursor: default;
}
</style>