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 (
-
+
+ );
+}
+
+function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) {
+ return (
+ event.stopPropagation()} onClick={onClick}>
+ {children}
+
+ );
+}
+
+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" } };
},
},
),