Files
20260512-skg-tk/web/canvas-app/src/components/nodes/VideoConfigNode.vue

618 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- Video config node wrapper | 视频配置节点包裹层 -->
<div class="video-config-node-wrapper relative" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
<!-- Video config node | 视频配置节点 -->
<div class="video-config-node bg-[var(--bg-secondary)] rounded-xl border min-w-[300px] 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="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
<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 type="button" @pointerdown.stop @mousedown.stop @click.stop.prevent="handleDuplicate" class="nodrag nopan p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
<n-icon :size="14">
<CopyOutline />
</n-icon>
</button>
<button type="button" @pointerdown.stop @mousedown.stop @click.stop.prevent="handleDelete" class="nodrag nopan p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
<n-icon :size="14">
<TrashOutline />
</n-icon>
</button>
</div>
</div>
<!-- Config options | 配置选项 -->
<div class="p-3 space-y-3">
<!-- Model selector | 模型选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">模型</span>
<n-dropdown trigger="click" :options="modelOptions" @select="handleModelSelect">
<button type="button" @pointerdown.stop @mousedown.stop @click.stop class="nodrag nopan flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ displayModelName }}
<n-icon :size="12"><ChevronDownOutline /></n-icon>
</button>
</n-dropdown>
</div>
<!-- Aspect ratio selector | 宽高比选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">比例</span>
<n-dropdown trigger="click" :options="ratioOptions" @select="handleRatioSelect">
<button type="button" @pointerdown.stop @mousedown.stop @click.stop class="nodrag nopan flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ localRatio }}
<n-icon :size="12">
<ChevronForwardOutline />
</n-icon>
</button>
</n-dropdown>
</div>
<!-- Duration selector | 时长选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">时长</span>
<n-dropdown trigger="click" :options="durationOptions" @select="handleDurationSelect">
<button type="button" @pointerdown.stop @mousedown.stop @click.stop class="nodrag nopan flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ localDuration }}s
<n-icon :size="12">
<ChevronForwardOutline />
</n-icon>
</button>
</n-dropdown>
</div>
<!-- Resolution selector | 清晰度选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">清晰度</span>
<n-dropdown trigger="click" :options="resolutionOptions" @select="handleResolutionSelect">
<button type="button" @pointerdown.stop @mousedown.stop @click.stop class="nodrag nopan flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ localResolution }}
<n-icon :size="12">
<ChevronForwardOutline />
</n-icon>
</button>
</n-dropdown>
</div>
<!-- Connected inputs indicator | 连接输入指示 -->
<div
class="flex items-center gap-2 text-xs text-[var(--text-secondary)] py-1 border-t border-[var(--border-color)]">
<span class="px-2 py-0.5 rounded-full"
:class="connectedPrompt ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
提示词 {{ connectedPrompt ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.firstFrame ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.lastFrame ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
尾帧 {{ imagesByRole.lastFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.referenceImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
参考图 {{ imagesByRole.referenceImages.length > 0 ? `${imagesByRole.referenceImages.length}` : '○' }}
</span>
</div>
<!-- Progress bar | 进度条 -->
<!-- <div v-if="status === 'polling'" class="space-y-1">
<div class="flex justify-between text-xs text-[var(--text-secondary)]">
<span>生成中...</span>
<span>{{ progress.percentage }}%</span>
</div>
<n-progress type="line" :percentage="progress.percentage" :show-indicator="false" :height="4" />
</div> -->
<!-- Generate button | 生成按钮 -->
<button type="button" @pointerdown.stop @mousedown.stop @touchstart.stop @click.stop.prevent="handleGenerate" :disabled="isGenerating || !canGenerate"
class="nodrag nopan w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<n-spin v-if="isGenerating" :size="14" />
<template v-else>
<n-icon :size="16">
<VideocamOutline />
</n-icon>
生成视频
</template>
</button>
<div v-if="!canGenerate" class="text-xs text-amber-500 mt-2">
当前环境未配置视频 API只能预览模型比例和时长
</div>
<!-- Error message | 错误信息 -->
<div v-if="error" class="text-xs text-red-500 mt-2">
{{ error.message || '生成失败' }}
</div>
<!-- Generated video preview | 生成视频预览 -->
<!-- <div v-if="generatedVideo?.url" class="mt-3 space-y-2">
<div class="text-xs text-[var(--text-secondary)]">生成结果:</div>
<div class="aspect-video rounded-lg overflow-hidden bg-black">
<video :src="generatedVideo.url" controls class="w-full h-full object-contain" />
</div>
</div> -->
</div>
<!-- Handles | 连接点 -->
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
<NodeHandleMenu :nodeId="id" nodeType="videoConfig" :visible="showHandleMenu" :operations="[]" />
</div>
</div>
</template>
<script setup>
/**
* Video config node component | 视频配置节点组件
* Configuration panel for video generation with API integration
*/
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NDropdown, NSpin } from 'naive-ui'
import { ChevronForwardOutline, ChevronDownOutline, TrashOutline, VideocamOutline, CopyOutline, CreateOutline } from '@vicons/ionicons5'
import { useVideoGeneration } from '../../hooks'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes, edges } from '../../stores/canvas'
import NodeHandleMenu from './NodeHandleMenu.vue'
import { useModelStore } from '../../stores/pinia'
import { getModelRatioOptions, getModelDurationOptions, getModelResolutionOptions, getModelConfig, DEFAULT_VIDEO_MODEL } from '../../stores/models'
// 使用 Pinia store 获取模型选项(根据渠道过滤)
const modelStore = useModelStore()
const props = defineProps({
id: String,
data: Object
})
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
// Video generation hook | 视频生成 hook
const { loading, error, status, video: generatedVideo, progress, createVideoTaskOnly } = useVideoGeneration()
const currentModelDefaultResolution = (modelKey) => {
const config = getModelConfig(modelKey)
return config?.defaultParams?.resolution || config?.defaultResolution || config?.resolutions?.[0] || '720p'
}
const normalizeResolutionForModel = (modelKey, resolution) => {
const options = getModelResolutionOptions(modelKey)
const allowed = options.map(option => option.key)
if (resolution && allowed.includes(resolution)) return resolution
return currentModelDefaultResolution(modelKey)
}
// Local state | 本地状态
const showHandleMenu = ref(false)
const isGenerating = ref(false) // 任务创建中状态
const localModel = ref(props.data?.model || DEFAULT_VIDEO_MODEL)
const localRatio = ref(props.data?.ratio || '16:9')
const localDuration = ref(props.data?.dur || 5)
const localResolution = ref(props.data?.resolution || currentModelDefaultResolution(props.data?.model || DEFAULT_VIDEO_MODEL))
const availableVideoModels = computed(() => Array.isArray(modelStore.availableVideoModels) ? modelStore.availableVideoModels : [])
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
const editingLabelValue = ref('')
const labelInputRef = ref(null)
// Get connected images with roles | 获取连接的图片及其角色
const connectedImages = computed(() => {
const connectedEdges = edges.value.filter(e => e.target === props.id)
const images = []
for (const edge of connectedEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (sourceNode?.type === 'image' && sourceNode.data?.url) {
images.push({
nodeId: sourceNode.id,
edgeId: edge.id,
url: sourceNode.data.url,
base64: sourceNode.data.base64,
role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
})
}
}
return images
})
// Get images by role | 按角色获取图片
const imagesByRole = computed(() => {
const firstFrame = connectedImages.value.find(img => img.role === 'first_frame_image')
const lastFrame = connectedImages.value.find(img => img.role === 'last_frame_image')
const referenceImages = connectedImages.value.filter(img => img.role === 'input_reference')
return {
firstFrame,
lastFrame,
referenceImages
}
})
// Get current model config | 获取当前模型配置
const currentModelConfig = computed(() => getModelConfig(localModel.value))
const isConfigured = computed(() => !!modelStore.currentApiKey)
const currentModelAvailable = computed(() => availableVideoModels.value.some(model => model.key === localModel.value))
const canGenerate = computed(() => isConfigured.value && currentModelAvailable.value && currentModelConfig.value?.available !== false)
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
const modelOptions = computed(() => modelStore.videoModelOptions)
// Display model name | 显示模型名称
const displayModelName = computed(() => {
const model = modelOptions.value.find(m => m.key === localModel.value)
// 如果当前模型不在选项中,尝试从 allVideoModels 找到
if (!model) {
const allModel = modelStore.allVideoModels.find(m => m.key === localModel.value)
return allModel?.label || localModel.value || '选择模型'
}
return model?.label || localModel.value || '选择模型'
})
// Ratio options based on model | 基于模型的比例选项
const ratioOptions = computed(() => {
return getModelRatioOptions(localModel.value)
})
// Duration options based on model | 基于模型的时长选项
const durationOptions = computed(() => {
return getModelDurationOptions(localModel.value)
})
// Resolution options based on model | 基于模型的清晰度选项
const resolutionOptions = computed(() => {
return getModelResolutionOptions(localModel.value)
})
// Handle model selection | 处理模型选择
const applyModelSelection = (key) => {
if (!key) return
localModel.value = key
// Update ratio and duration to model's default | 更新为模型默认比例和时长
const config = getModelConfig(key)
const updates = { model: key }
if (config?.defaultParams?.ratio) {
localRatio.value = config.defaultParams.ratio
updates.ratio = config.defaultParams.ratio
}
if (config?.defaultParams?.duration) {
localDuration.value = config.defaultParams.duration
updates.dur = config.defaultParams.duration
}
const nextResolution = currentModelDefaultResolution(key)
localResolution.value = nextResolution
updates.resolution = nextResolution
updateNode(props.id, updates)
}
const syncModelWithAvailableOptions = () => {
const availableModels = availableVideoModels.value
if (!availableModels.length) return
const isModelAvailable = availableModels.some(model => model.key === localModel.value)
if (!localModel.value || !isModelAvailable) {
const selected = availableModels.find(model => model.key === modelStore.selectedVideoModel)?.key
applyModelSelection(selected || availableModels[0]?.key || DEFAULT_VIDEO_MODEL)
return
}
const nextResolution = normalizeResolutionForModel(localModel.value, localResolution.value)
if (nextResolution !== localResolution.value || !props.data?.resolution) {
localResolution.value = nextResolution
updateNode(props.id, { resolution: nextResolution })
}
}
const handleModelSelect = (key) => {
applyModelSelection(key)
}
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newNodeId = duplicateNode(props.id)
window.$message?.success('节点已复制')
if (newNodeId) {
setTimeout(() => {
updateNodeInternals(newNodeId)
}, 50)
}
}
// Handle ratio selection | 处理比例选择
const handleRatioSelect = (key) => {
localRatio.value = key
updateNode(props.id, { ratio: key })
}
// Handle duration selection | 处理时长选择
const handleDurationSelect = (key) => {
localDuration.value = key
updateNode(props.id, { dur: key })
}
// Handle resolution selection | 处理清晰度选择
const handleResolutionSelect = (key) => {
localResolution.value = key
updateNode(props.id, { resolution: key })
}
// Get connected inputs by role | 根据角色获取连接的输入
const getConnectedInputs = () => {
const connectedEdges = edges.value.filter(e => e.target === props.id)
let prompt = ''
let first_frame_image = ''
let last_frame_image = ''
const images = [] // input_reference images | 参考图
for (const edge of connectedEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (!sourceNode) continue
if (sourceNode.type === 'text') {
prompt = sourceNode.data?.content || ''
} else if (sourceNode.type === 'llmConfig') {
// LLM node output as prompt | LLM 节点输出作为提示词
const content = sourceNode.data?.outputContent || ''
if (content) prompt = content
} else if (sourceNode.type === 'image' && sourceNode.data?.url) {
const imageData = sourceNode.data.base64 || sourceNode.data.url
const role = edge.data?.imageRole || 'first_frame_image'
if (role === 'first_frame_image') {
first_frame_image = imageData
} else if (role === 'last_frame_image') {
last_frame_image = imageData
} else if (role === 'input_reference') {
images.push(imageData)
}
}
}
return { prompt, first_frame_image, last_frame_image, images }
}
// Computed connected prompt | 计算连接的提示词
const connectedPrompt = computed(() => {
return getConnectedInputs().prompt
})
// Created video node ID | 创建的视频节点 ID
const createdVideoNodeId = ref(null)
// Handle generate action | 处理生成操作
const handleGenerate = async () => {
// 设置生成中状态
isGenerating.value = true
const { prompt, first_frame_image, last_frame_image, images } = getConnectedInputs()
const hasInput = prompt || first_frame_image || last_frame_image || images.length > 0
if (!hasInput) {
window.$message?.warning('请先连接文本节点或图片节点')
isGenerating.value = false
return
}
if (!isConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
isGenerating.value = false
return
}
// Get current node position | 获取当前节点位置
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create video node with loading state | 创建带加载状态的视频节点
const videoNodeId = addNode('video', { x: nodeX + 350, y: nodeY }, {
url: '',
loading: true,
label: '视频生成中...'
})
createdVideoNodeId.value = videoNodeId
// Auto-connect videoConfig → video | 自动连接 视频配置 → 视频
addEdge({
source: props.id,
target: videoNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals(videoNodeId)
}, 50)
try {
// Build request params (raw form data) | 构建请求参数(原始表单数据)
// These will be transformed by inputTransform | 这些会被 inputTransform 转换
const params = {
model: localModel.value
}
// Add prompt if provided | 如果有提示词则添加
if (prompt) {
params.prompt = prompt
}
// Add first frame image | 添加首帧图片
if (first_frame_image) {
params.first_frame_image = first_frame_image
}
// Add last frame image | 添加尾帧图片
if (last_frame_image) {
params.last_frame_image = last_frame_image
}
// Add reference images (input_reference) | 添加参考图
if (images.length > 0) {
params.images = images
}
// Add ratio/size | 添加比例参数
if (localRatio.value) {
params.ratio = localRatio.value
}
// Add duration | 添加时长
if (localDuration.value) {
params.dur = localDuration.value
}
// Add resolution | 添加清晰度
if (localResolution.value) {
params.resolution = localResolution.value
}
// 只创建任务,获取 taskId不在这里轮询
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
// 如果有直接 URL更新视频节点
if (url) {
updateNode(videoNodeId, {
url: url,
loading: false,
label: '视频生成',
model: localModel.value,
updatedAt: Date.now()
})
window.$message?.success('视频生成成功')
// Mark this config node as executed | 标记配置节点已执行
updateNode(props.id, { executed: true, outputNodeId: videoNodeId })
} else if (newTaskId) {
// 需要轮询,传递 taskId 给 VideoNode
updateNode(videoNodeId, {
taskId: newTaskId,
loading: true,
label: '视频生成中...',
model: localModel.value,
updatedAt: Date.now()
})
window.$message?.success('视频任务已创建')
// Mark this config node as executed | 标记配置节点已执行
updateNode(props.id, { executed: true, outputNodeId: videoNodeId })
}
} catch (err) {
// Update node to show error | 更新节点显示错误
updateNode(videoNodeId, {
loading: false,
error: err.message || '生成失败',
label: '生成失败',
updatedAt: Date.now()
})
window.$message?.error(err.message || '视频生成失败')
} finally {
isGenerating.value = false
}
}
// 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)
}
// Initialize on mount | 挂载时初始化
onMounted(() => {
syncModelWithAvailableOptions()
})
// Watch for model changes from props | 监听 props 中模型变化
watch(() => props.data?.model, (newModel) => {
if (newModel && newModel !== localModel.value) {
localModel.value = newModel
localResolution.value = normalizeResolutionForModel(newModel, props.data?.resolution || localResolution.value)
syncModelWithAvailableOptions()
}
})
watch(
() => availableVideoModels.value.map(model => model.key).join('|'),
() => syncModelWithAvailableOptions(),
{ immediate: true }
)
watch(() => props.data?.resolution, (newResolution) => {
if (newResolution && newResolution !== localResolution.value) {
localResolution.value = normalizeResolutionForModel(localModel.value, newResolution)
}
})
// 修复 Vue Flow visibility: hidden 问题
// 当节点数据变化时,强制更新内部状态
watch(() => props.data, () => {
nextTick(() => {
updateNodeInternals(props.id)
})
}, { deep: true })
// Watch for auto-execute flag | 监听自动执行标志
watch(
() => props.data?.autoExecute,
(shouldExecute) => {
if (shouldExecute && !loading.value) {
// Clear the flag first to prevent re-triggering | 先清除标志防止重复触发
updateNode(props.id, { autoExecute: false })
// Delay to ensure node connections are established | 延迟确保节点连接已建立
setTimeout(() => {
handleGenerate()
}, 100)
}
},
{ immediate: true }
)
</script>
<style scoped>
.video-config-node-wrapper {
position: relative;
padding-top: 20px;
}
.video-config-node {
cursor: default;
position: relative;
}
</style>