feat(canvas): 添加视频节点支持功能

- 在后端添加 AI 视频相关接口处理函数
- 扩展前端画布组件支持视频节点类型
- 实现视频上传、生成和播放功能
- 更新画布数据结构文档以包含视频节点
- 添加视频节点的操作工具栏和配置面板
- 集成视频存储和检索服务
- 优化视频节点的渲染和交互体验
This commit is contained in:
HouYunFei
2026-05-23 16:53:21 +08:00
parent 0cc27b5a4c
commit 44c5825cb0
23 changed files with 306 additions and 57 deletions
+25
View File
@@ -0,0 +1,25 @@
import axios from "axios";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
type VideoResponse = { id: string; status?: string; error?: { message?: string } };
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig) {
return config.channelMode === "remote" ? undefined : { Authorization: `Bearer ${config.apiKey}` };
}
export async function requestVideoGeneration(config: AiConfig, prompt: string) {
const created = await axios.post<VideoResponse>(aiApiUrl(config, "/videos"), { model: config.model, prompt, size: config.size || undefined }, { headers: { ...(aiHeaders(config) || {}), "Content-Type": "application/json" } });
for (;;) {
const video = await axios.get<VideoResponse>(aiApiUrl(config, `/videos/${created.data.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: config.model } : undefined });
if (video.data.status === "completed") break;
if (video.data.status === "failed" || video.data.status === "cancelled") throw new Error(video.data.error?.message || "视频生成失败");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${created.data.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: config.model } : undefined, responseType: "blob" });
return content.data;
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import localforage from "localforage";
import { nanoid } from "nanoid";
export type UploadedFile = { url: string; storageKey: string; bytes: number; mimeType: string };
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "media_files" });
const objectUrls = new Map<string, string>();
export async function uploadMediaFile(input: string | Blob, prefix = "file"): Promise<UploadedFile> {
const blob = typeof input === "string" ? await (await fetch(input)).blob() : input;
const storageKey = `${prefix}:${nanoid()}`;
await store.setItem(storageKey, blob);
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
return { url, storageKey, bytes: blob.size, mimeType: blob.type || "application/octet-stream" };
}
export async function resolveMediaUrl(storageKey?: string, fallback = "") {
if (!storageKey) return fallback;
const cached = objectUrls.get(storageKey);
if (cached) return cached;
const blob = await store.getItem<Blob>(storageKey);
if (!blob) return fallback;
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
return url;
}
export async function cleanupUnusedMedia(usedData: unknown) {
const usedKeys = collectMediaStorageKeys(usedData);
const unused: string[] = [];
await store.iterate((_value, key) => {
if (!usedKeys.has(key)) unused.push(key);
});
await Promise.all(unused.map((key) => store.removeItem(key)));
}
export function collectMediaStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return keys;
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectMediaStorageKeys(child, keys)) : collectMediaStorageKeys(item, keys)));
return keys;
}