From ef7772a70349fe0db1b0c6605a2544591400bded Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Sat, 23 May 2026 18:02:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E6=B7=BB=E5=8A=A0=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=E5=92=8C=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在系统配置中新增 defaultVideoModel 字段用于设置默认视频模型 - 更新应用配置模态框以支持视频模型的选择和验证 - 修改画布节点逻辑以支持视频类型的生成配置 - 实现视频节点的生成和上传功能 - 更新模型选择器组件以支持视频模型选择 - 在管理后台设置页面添加视频模型配置选项 - 优化模型选择器的层级和交互体验 - 添加对 DeepSeek 和 GLM 模型图标的支持 --- CHANGELOG.md | 2 + docs/pending-test.md | 2 +- .../(user)/canvas/[id]/canvas-client-page.tsx | 32 +++-- .../components/canvas-config-node-panel.tsx | 7 +- .../components/canvas-node-prompt-panel.tsx | 8 ++ .../canvas-video-settings-popover.tsx | 129 ++++++++++++++++++ web/src/app/(user)/canvas/types.ts | 2 + web/src/services/api/video.ts | 24 +++- web/src/stores/use-config-store.ts | 6 +- 9 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 35b1b44..927aed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] 新增视频生成节点。 + ## v0.0.7 - 2026-05-23 + [新增] 管理后台提示词管理支持多选批量删除。 diff --git a/docs/pending-test.md b/docs/pending-test.md index 2449f3f..a6881ae 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,6 +1,6 @@ # 待测试 -- 配置弹窗和后台公开配置新增默认视频模型;视频节点生成会使用该默认视频模型,需要确认本地直连和云端渠道下默认视频模型选择、保存和生成请求都正常。 +- 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data`、`size`、`seconds`、`vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。 - 画布新增视频节点:工具栏可新建视频节点,上传/拖拽本地视频后可在节点内播放、下载、保存到我的素材;节点下方对话框和生成配置节点支持视频生成,前端会调用 OpenAI 兼容 `videos` 接口并把生成结果保存为本地视频 Blob;后端远程渠道新增 `/api/v1/videos`、`/api/v1/videos/:id`、`/api/v1/videos/:id/content` 代理,需要确认本地渠道和远程渠道都能生成、轮询和下载视频。 - 模型选择器改为共享的 shadcn 风格下拉,按模型名称显示 OpenAI、Claude、Gemini 图标,并把画布节点下方、右侧助手和配置弹窗统一到同一套组件;右侧助手的模型和模式按钮会随工具栏宽度自动在文字态和图标态之间切换,需要确认生图页、画布助手、节点配置和配置弹窗中的选中态和下拉列表显示正常。 - 画布图像设置里的尺寸输入去掉了原生数字步进箭头,需要确认宽高仍可直接输入、禁用态正常、设置值回写正常。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index ee8e336..4e606ed 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -156,22 +156,24 @@ function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: Pending
- } title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} /> - } title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} /> - } title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} /> - } title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} /> + } title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} /> + } title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} /> + } title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} /> + } title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
); } -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 ( - ); @@ -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), }; } diff --git a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx index a88ba8f..ea98816 100644 --- a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx @@ -10,6 +10,7 @@ import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-confi import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasSizePicker } from "./canvas-size-picker"; +import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; import type { NodeGenerationInput } from "./canvas-node-generation"; import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types"; @@ -116,8 +117,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
event.stopPropagation()}> onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth /> - {mode === "image" || mode === "video" ? onConfigChange(node.id, { size: value })} /> : null} - onConfigChange(node.id, { count: Number(value) || 1 })} /> + {mode === "video" ? onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} /> : mode === "image" ? onConfigChange(node.id, { size: value })} /> : null} + {mode === "video" ? null : onConfigChange(node.id, { count: Number(value) || 1 })} />}
+ + ); +} + +function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) { + return ( + + ); +} + +function SettingGroup({ title, color, children }: { title: string; color: string; children: ReactNode }) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +function normalizeVideoSize(value: string) { + if (/^\d+x\d+$/.test(value || "")) return value; + return ["9:16", "2:3", "3:4"].includes(value) ? "720x1280" : "1280x720"; +} + +function qualityLabel(value: string) { + return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record)[value] || value; +} + +function sizeLabel(value: string) { + return value === "720x1280" ? "竖屏" : "横屏"; +} diff --git a/web/src/app/(user)/canvas/types.ts b/web/src/app/(user)/canvas/types.ts index b4c2765..7321195 100644 --- a/web/src/app/(user)/canvas/types.ts +++ b/web/src/app/(user)/canvas/types.ts @@ -32,6 +32,8 @@ export type CanvasNodeMetadata = { size?: string; quality?: string; count?: number; + seconds?: string; + vquality?: string; references?: string[]; naturalWidth?: number; naturalHeight?: number; diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index ce09b8e..bc73d1e 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -1,6 +1,9 @@ import axios from "axios"; +import { dataUrlToFile } from "@/lib/image-utils"; +import { imageToDataUrl } from "@/services/image-storage"; import { buildApiUrl, type AiConfig } from "@/stores/use-config-store"; +import type { ReferenceImage } from "@/types/image"; type VideoResponse = { id: string; status?: string; error?: { message?: string } }; @@ -12,9 +15,16 @@ function aiHeaders(config: AiConfig) { return config.channelMode === "remote" ? undefined : { Authorization: `Bearer ${config.apiKey}` }; } -export async function requestVideoGeneration(config: AiConfig, prompt: string) { +export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = []) { const model = config.model || config.videoModel; - const created = await axios.post(aiApiUrl(config, "/videos"), { model, prompt, size: config.size || undefined }, { headers: { ...(aiHeaders(config) || {}), "Content-Type": "application/json" } }); + const body = new FormData(); + body.append("model", model); + body.append("prompt", prompt); + body.append("seconds", normalizeVideoSeconds(config.videoSeconds)); + if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!); + if (config.vquality) body.append("vquality", config.vquality); + if (references[0]) body.append("input_reference", dataUrlToFile({ ...references[0], dataUrl: await imageToDataUrl(references[0]) })); + const created = await axios.post(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config) }); for (;;) { const video = await axios.get(aiApiUrl(config, `/videos/${created.data.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined }); if (video.data.status === "completed") break; @@ -24,3 +34,13 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string) { const content = await axios.get(aiApiUrl(config, `/videos/${created.data.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined, responseType: "blob" }); return content.data; } + +function normalizeVideoSeconds(value: string) { + return String(Math.max(1, Math.floor(Number(value) || 6))); +} + +function normalizeVideoSize(value: string) { + const size = value || "1280x720"; + if (/^\d+x\d+$/.test(size)) return size; + return ["9:16", "2:3", "3:4"].includes(size) ? "720x1280" : "1280x720"; +} diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 698eaca..649fdf9 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -15,6 +15,8 @@ export type AiConfig = { imageModel: string; videoModel: string; textModel: string; + videoSeconds: string; + vquality: string; systemPrompt: string; models: string[]; quality: string; @@ -32,6 +34,8 @@ export const defaultConfig: AiConfig = { imageModel: "gpt-image-2", videoModel: "sora-2", textModel: "gpt-5.5", + videoSeconds: "6", + vquality: "auto", systemPrompt: "", models: [], quality: "auto", @@ -107,7 +111,7 @@ export const useConfigStore = create()( partialize: (state) => ({ config: state.config }), merge: (persisted, current) => { const config = { ...defaultConfig, ...((persisted as Partial).config || {}) }; - return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, videoModel: config.videoModel || "sora-2", textModel: config.textModel || config.model } }; + return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, videoModel: config.videoModel || "sora-2", textModel: config.textModel || config.model, videoSeconds: config.videoSeconds || "6", vquality: config.vquality || "auto" } }; }, }, ),