feat(canvas): 添加视频模型配置和生成功能

- 在系统配置中新增 defaultVideoModel 字段用于设置默认视频模型
- 更新应用配置模态框以支持视频模型的选择和验证
- 修改画布节点逻辑以支持视频类型的生成配置
- 实现视频节点的生成和上传功能
- 更新模型选择器组件以支持视频模型选择
- 在管理后台设置页面添加视频模型配置选项
- 优化模型选择器的层级和交互体验
- 添加对 DeepSeek 和 GLM 模型图标的支持
This commit is contained in:
HouYunFei
2026-05-23 18:02:05 +08:00
parent 370263cd83
commit ef7772a703
9 changed files with 192 additions and 20 deletions
@@ -156,22 +156,24 @@ function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: Pending
</button>
</div>
<div className="grid gap-1">
<ConnectionCreateOption icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
</div>
</div>
);
}
function ConnectionCreateOption({ icon, title, description, onClick }: { icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) {
function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) {
return (
<button type="button" className="flex h-16 w-full cursor-pointer items-center gap-3 rounded-2xl px-3 text-left transition hover:bg-white/10" onClick={onClick}>
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-white/10 text-stone-200">{icon}</span>
<button type="button" className="flex h-16 w-full cursor-pointer items-center gap-3 rounded-2xl px-3 text-left transition" style={{ color: theme.node.text }} onClick={onClick} onMouseEnter={(event) => (event.currentTarget.style.background = theme.node.fill)} onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}>
<span className="grid size-11 shrink-0 place-items-center rounded-xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-base font-semibold leading-5 text-stone-100">{title}</span>
{description ? <span className="mt-1 block truncate text-sm text-stone-500">{description}</span> : null}
<span className="flex items-center gap-2 text-base font-semibold leading-5">{title}</span>
{description ? <span className="mt-1 block truncate text-sm" style={{ color: theme.node.muted }}>{description}</span> : null}
</span>
</button>
);
@@ -1733,13 +1735,13 @@ function InfiniteCanvasPage() {
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
width: isEmptyVideoNode ? sourceNode.width : spec.width,
height: isEmptyVideoNode ? sourceNode.height : spec.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size },
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationContext.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)) },
};
pendingChildIds = [videoId];
setNodes((prev) => (isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode]));
if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]);
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, effectivePrompt), "video");
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size } } : node)));
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages), "video");
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationContext.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)) } } : node)));
return;
}
@@ -1856,8 +1858,8 @@ function InfiniteCanvasPage() {
return;
}
if (node.type === CanvasNodeType.Video) {
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, prompt), "video");
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size } } : item)));
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, prompt, retryReferenceImages || []), "video");
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality } } : item)));
return;
}
@@ -2587,6 +2589,8 @@ function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefine
model: node?.metadata?.model || defaultModel || config.model || defaultConfig.model,
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
count: String(node?.metadata?.count || (mode === "image" ? 3 : config.count) || defaultConfig.count),
};
}