From 1be6b4fa29b426df7cc8d336c7d796123dd250ff Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Mon, 25 May 2026 17:37:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(video):=20=E6=96=B0=E5=A2=9E=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E5=88=9B=E4=BD=9C=E5=8F=B0=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 /video 页面路由及完整视频生成界面 - 集成视频生成 API 调用及本地存储功能 - 实现提示词、参考图、视频参数设置界面 - 添加生成结果预览、下载和保存素材功能 - 支持本地生成记录管理及历史查看 - 在导航菜单中添加视频创作台入口 - 更新 CHANGELOG 包含新功能说明 --- CHANGELOG.md | 2 + docs/pending-test.md | 1 + web/src/app/(user)/video/page.tsx | 574 +++++++++++++++++++++++++++ web/src/constant/navigation-tools.ts | 7 +- 4 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 web/src/app/(user)/video/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index caef919..f5d2c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] 新增视频创作台页面。 + ## v0.0.8 - 2026-05-24 + [新增] 新增用户账号与算力点体系,支持账号密码注册登录、Linux.do OAuth。 diff --git a/docs/pending-test.md b/docs/pending-test.md index fb0fff5..7184827 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -8,3 +8,4 @@ - 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。 - 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。 - 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。 +- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入。 diff --git a/web/src/app/(user)/video/page.tsx b/web/src/app/(user)/video/page.tsx new file mode 100644 index 0000000..71b7675 --- /dev/null +++ b/web/src/app/(user)/video/page.tsx @@ -0,0 +1,574 @@ +"use client"; + +import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { App, AutoComplete, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd"; +import localforage from "localforage"; +import { nanoid } from "nanoid"; + +import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal"; +import { ModelPicker } from "@/components/model-picker"; +import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog"; +import { formatBytes, formatDuration } from "@/lib/image-utils"; +import { resolveMediaUrl, uploadMediaFile } from "@/services/file-storage"; +import { uploadImage } from "@/services/image-storage"; +import { requestVideoGeneration } from "@/services/api/video"; +import { useAssetStore } from "@/stores/use-asset-store"; +import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; +import type { ReferenceImage } from "@/types/image"; + +type GeneratedVideo = { + id: string; + url: string; + storageKey: string; + durationMs: number; + width: number; + height: number; + bytes: number; + mimeType: string; +}; + +type GenerationResult = { + id: string; + status: "pending" | "success" | "failed"; + video?: GeneratedVideo; + error?: string; +}; + +type GenerationLog = { + id: string; + createdAt: number; + title: string; + time: string; + model: string; + durationMs: number; + size: string; + resolution: string; + seconds: string; + status: "成功" | "失败"; + video?: GeneratedVideo; + error?: string; +}; + +type UpdateAiConfig = (key: K, value: AiConfig[K]) => void; + +const sizeOptions = ["1280x720", "720x1280", "1024x1024", "1792x1024", "1024x1792"].map((value) => ({ label: value, value })); +const resolutionOptions = ["720", "480"].map((value) => ({ label: `${value}p`, value })); +const secondOptions = ["6", "10", "12", "16", "20"].map((value) => ({ label: `${value}s`, value })); +const LOG_STORE_KEY = "infinite-canvas:video_generation_logs"; +const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "video_generation_logs" }); + +export default function VideoPage() { + const { message } = App.useApp(); + const fileInputRef = useRef(null); + const config = useConfigStore((state) => state.config); + const effectiveConfig = useEffectiveConfig(); + const updateConfig = useConfigStore((state) => state.updateConfig); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + const openConfigDialog = useConfigStore((state) => state.openConfigDialog); + const addAsset = useAssetStore((state) => state.addAsset); + const [prompt, setPrompt] = useState(""); + const [references, setReferences] = useState([]); + const [results, setResults] = useState([]); + const [logs, setLogs] = useState([]); + const [running, setRunning] = useState(false); + const [logsOpen, setLogsOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [promptDialogOpen, setPromptDialogOpen] = useState(false); + const [assetPickerOpen, setAssetPickerOpen] = useState(false); + const [startedAt, setStartedAt] = useState(0); + const [elapsedMs, setElapsedMs] = useState(0); + const [selectedLogIds, setSelectedLogIds] = useState([]); + const [previewLog, setPreviewLog] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + + const model = effectiveConfig.videoModel || effectiveConfig.model; + const canGenerate = Boolean(prompt.trim()); + + useEffect(() => { + if (!running || !startedAt) return; + const timer = window.setInterval(() => setElapsedMs(performance.now() - startedAt), 1000); + return () => window.clearInterval(timer); + }, [running, startedAt]); + + useEffect(() => { + void refreshLogs(); + }, []); + + const addReferences = async (files?: FileList | null) => { + const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/")).slice(0, 7 - references.length); + const nextReferences = await Promise.all( + imageFiles.map(async (file) => { + const image = await uploadImage(file); + return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; + }), + ); + setReferences((value) => [...value, ...nextReferences].slice(0, 7)); + }; + + const addReferencesFromClipboard = async () => { + try { + const items = await navigator.clipboard.read(); + const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type)))); + if (!blobs.length) { + message.error("剪切板里没有可读取的图片"); + return; + } + const nextReferences = await Promise.all( + blobs.slice(0, 7 - references.length).map(async (blob, index) => { + const image = await uploadImage(blob); + return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; + }), + ); + setReferences((value) => [...value, ...nextReferences].slice(0, 7)); + message.success(`已读取 ${nextReferences.length} 张参考图`); + } catch { + message.error("剪切板里没有可读取的图片"); + } + }; + + const generate = async () => { + const snapshot = buildRequestSnapshot(); + if (!snapshot) return; + setElapsedMs(0); + setRunning(true); + setPreviewLog(null); + setResults([{ id: nanoid(), status: "pending" }]); + const batchStartedAt = performance.now(); + setStartedAt(batchStartedAt); + try { + const blob = await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references); + const stored = await uploadMediaFile(blob, "video"); + const nextVideo: GeneratedVideo = { + id: nanoid(), + url: stored.url, + storageKey: stored.storageKey, + durationMs: performance.now() - batchStartedAt, + width: stored.width || 1280, + height: stored.height || 720, + bytes: stored.bytes, + mimeType: stored.mimeType, + }; + setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]); + saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo })); + message.success("视频已生成"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "生成失败"; + setResults([{ id: nanoid(), status: "failed", error: errorMessage }]); + saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage })); + message.error(errorMessage); + } finally { + setRunning(false); + } + }; + + const buildRequestSnapshot = () => { + const text = prompt.trim(); + if (!text) { + message.error("请输入视频提示词"); + return null; + } + if (!isAiConfigReady(effectiveConfig, model)) { + message.warning("请先完成配置"); + openConfigDialog(true); + return null; + } + return { text, config: { ...effectiveConfig, model }, references: [...references] }; + }; + + const retryResult = () => { + void generate(); + }; + + const downloadVideo = (video: GeneratedVideo) => { + const link = document.createElement("a"); + link.href = video.url; + link.download = "video.mp4"; + link.click(); + }; + + const saveResultToAssets = (video: GeneratedVideo) => { + addAsset({ + kind: "video", + title: "生成视频", + coverUrl: "", + tags: [], + source: "视频创作台", + data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType }, + metadata: { source: "video-page", prompt }, + }); + message.success("已加入我的素材"); + }; + + const insertPickedAsset = async (payload: InsertAssetPayload) => { + if (payload.kind === "text") { + setPrompt(payload.content); + } else if (payload.kind === "image") { + const stored = await uploadImage(payload.dataUrl); + setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }].slice(0, 7)); + } + setAssetPickerOpen(false); + }; + + const createSession = () => { + setPrompt(""); + setReferences([]); + setResults([]); + setElapsedMs(0); + setStartedAt(0); + setSelectedLogIds([]); + setPreviewLog(null); + }; + + const deleteSelectedLogs = () => { + void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs); + if (previewLog && selectedLogIds.includes(previewLog.id)) { + setPreviewLog(null); + setResults([]); + } + setSelectedLogIds([]); + setDeleteConfirmOpen(false); + }; + + const saveLog = (log: GenerationLog) => { + void logStore.setItem(log.id, log).then(refreshLogs); + }; + + const refreshLogs = async () => setLogs(await readStoredLogs()); + + const previewGenerationLog = (log: GenerationLog) => { + setPreviewLog(log); + setLogsOpen(false); + setResults(log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]); + }; + + return ( +
+
+ + +
+
+
+

视频创作台

+
+ + +
+
+ +
+
+
+ 提示词 +
+ + +
+
+ setPrompt(event.target.value)} rows={7} placeholder="描述镜头运动、主体动作、场景氛围和画面风格" /> +
+ +
+
+ 参考图 +
+ + +
+
+
+ {references.map((item) => ( +
+ {item.name} + +
+ ))} + {!references.length ?
暂无参考图,最多 7 张
: null} +
+
+ +
+ + {model} · {effectiveConfig.vquality}p · {effectiveConfig.size} · {effectiveConfig.videoSeconds}s + + +
+ +
+ +
+
+ +
+ +
+
+ +
+
+

生成结果

+ {running ? 等待 {formatDuration(elapsedMs)} : null} +
+ {results.length ? ( +
+ {results.map((result) => (result.status === "success" && result.video ? : result.status === "failed" ? : ))} +
+ ) : ( +
+ + +
+ )} +
+
+
+ { + void addReferences(event.target.files); + event.target.value = ""; + }} + /> + setLogsOpen(false)}> + setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} /> + + setSettingsOpen(false)}> +
+ +
+
+ + void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} /> + setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> + 确定删除选中的 {selectedLogIds.length} 条生成记录吗? + +
+ ); +} + +function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) { + return ( + <> + + + + + + ); +} + +function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedVideo; onDownload: (video: GeneratedVideo) => void; onSaveAsset: (video: GeneratedVideo) => void }) { + return ( +
+
+ ); +} + +function PendingVideoCard() { + return ( +
+
+ + 生成中 +
+
+ ); +} + +function FailedVideoCard({ error, onRetry }: { error: string; onRetry: () => void }) { + return ( +
+
+
生成失败
+ + {error} + +
+
+ +
+
+ ); +} + +function LogPanel({ + logs, + selectedLogIds, + activeLogId, + onSelectedLogIdsChange, + onCreateSession, + onDeleteSelected, + onPreviewLog, +}: { + logs: GenerationLog[]; + selectedLogIds: string[]; + activeLogId?: string; + onSelectedLogIdsChange: (ids: string[]) => void; + onCreateSession: () => void; + onDeleteSelected: () => void; + onPreviewLog: (log: GenerationLog) => void; +}) { + const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length; + const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id)); + + return ( + <> +
+

生成记录

+ {logs.length} +
+
+ + + +
+
+ {logs.map((log) => ( + onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))} onClick={() => onPreviewLog(log)} /> + ))} + {!logs.length ?
暂无生成记录
: null} +
+ + ); +} + +function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) { + return ( + + ); +} + +async function readStoredLogs() { + if (typeof window === "undefined") return []; + try { + const logs: GenerationLog[] = []; + await logStore.iterate((value) => { + logs.push(value); + }); + return (await Promise.all(logs.map(normalizeLog))).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + } catch { + return []; + } +} + +async function normalizeLog(log: Partial): Promise { + const video = log.video?.storageKey ? { ...log.video, url: await resolveMediaUrl(log.video.storageKey, log.video.url) } : log.video; + return { + id: log.id || nanoid(), + createdAt: log.createdAt || Date.now(), + title: log.title || log.model || "未命名", + time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }), + model: log.model || "", + durationMs: log.durationMs || 0, + size: log.size || "", + resolution: normalizeResolution(log.resolution || ""), + seconds: log.seconds || "", + status: log.status || "成功", + video, + error: log.error, + }; +} + +function buildLog({ prompt, model, config, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog { + return { + id: nanoid(), + createdAt: Date.now(), + title: prompt.slice(0, 12) || "未命名", + time: new Date().toLocaleString("zh-CN", { hour12: false }), + model, + durationMs, + size: config.size, + resolution: normalizeResolution(config.vquality), + seconds: config.videoSeconds, + status, + video, + error, + }; +} + +function normalizeResolution(value: string) { + return value.replace(/p$/i, "") || "720"; +} diff --git a/web/src/constant/navigation-tools.ts b/web/src/constant/navigation-tools.ts index b26032a..f941b85 100644 --- a/web/src/constant/navigation-tools.ts +++ b/web/src/constant/navigation-tools.ts @@ -1,4 +1,4 @@ -import { FileText, ImagePlus, Images, Maximize2 } from "lucide-react"; +import { FileText, ImagePlus, Images, Maximize2, Video } from "lucide-react"; export const navigationTools = [ { @@ -11,6 +11,11 @@ export const navigationTools = [ label: "生图工作台", icon: ImagePlus, }, + { + slug: "video", + label: "视频创作台", + icon: Video, + }, { slug: "prompts", label: "提示词库",