From 0b39d7e36fb41d7bd696a0f734aaf0c2fd2d0c45 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Mon, 18 May 2026 15:14:37 +0800 Subject: [PATCH] 11 --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/video.rs | 121 ++++++++++++++++++++ src-tauri/src/lib.rs | 9 ++ src-tauri/src/video_db.rs | 196 ++++++++++++++++++++++++++++++++ src-tauri/src/video_store.rs | 84 ++++++++++++++ src/services/localVideo.js | 3 +- src/services/videoDb.js | 42 +++++++ 7 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/video.rs create mode 100644 src-tauri/src/video_db.rs create mode 100644 src-tauri/src/video_store.rs create mode 100644 src/services/videoDb.js diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 449ee0e..bea2016 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod app_config; pub mod auth; pub mod audio; pub mod avatar; +pub mod video; pub mod fs_util; pub mod nodejs; pub mod quickjs; diff --git a/src-tauri/src/commands/video.rs b/src-tauri/src/commands/video.rs new file mode 100644 index 0000000..5da8c20 --- /dev/null +++ b/src-tauri/src/commands/video.rs @@ -0,0 +1,121 @@ +//! 工作流口播视频历史(SQLite + 文件落盘) + +use std::path::{Component, Path, PathBuf}; + +use tauri::{AppHandle, Manager, State}; +use uuid::Uuid; + +use crate::video_db::GeneratedVideoRecord; +use crate::video_store::VideoStore; + +fn normalize_path(path: &str) -> PathBuf { + Path::new(path) + .components() + .filter(|c| !matches!(c, Component::ParentDir)) + .collect() +} + +fn generated_videos_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("无法获取应用数据目录: {e}"))? + .join("generated_videos"); + Ok(dir) +} + +fn is_under_generated_videos_dir(root: &Path, target: &Path) -> bool { + if let (Ok(root), Ok(canonical)) = (root.canonicalize(), target.canonicalize()) { + return canonical.starts_with(&root); + } + let root_s = root.to_string_lossy().to_lowercase(); + let target_s = target.to_string_lossy().to_lowercase(); + target_s.contains("generated_videos") && target_s.starts_with(&root_s) +} + +#[tauri::command] +pub async fn list_generated_videos( + store: State<'_, VideoStore>, +) -> Result, String> { + store.list().await +} + +#[tauri::command] +pub async fn insert_generated_video( + store: State<'_, VideoStore>, + name: String, + file_path: String, + avatar_id: Option, + audio_id: Option, +) -> Result { + store.insert(name, file_path, avatar_id, audio_id).await +} + +#[tauri::command] +pub async fn delete_generated_video( + app: AppHandle, + store: State<'_, VideoStore>, + id: i64, +) -> Result<(), String> { + let file_path = store.delete(id).await?; + if let Some(path) = file_path { + delete_video_file_if_allowed(&app, &path).await?; + } + Ok(()) +} + +/// 将数字人输出视频复制到应用数据目录并写入 SQLite +#[tauri::command] +pub async fn import_generated_video( + app: AppHandle, + store: State<'_, VideoStore>, + source_path: String, + name: String, + avatar_id: Option, + audio_id: Option, +) -> Result { + let src = normalize_path(source_path.trim()); + if !src.is_file() { + return Err(format!("视频文件不存在: {}", src.display())); + } + + let ext = src + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("mp4") + .to_lowercase(); + let allowed = ["mp4", "mov", "webm", "mkv", "m4v", "avi"]; + if !allowed.contains(&ext.as_str()) { + return Err("仅支持 mp4、mov、webm、mkv 等常见视频格式".into()); + } + + let dir = generated_videos_dir(&app)?; + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| format!("创建视频目录失败: {e}"))?; + + let file_name = format!("{}.{}", Uuid::new_v4(), ext); + let dest = dir.join(&file_name); + + tokio::fs::copy(&src, &dest) + .await + .map_err(|e| format!("复制视频失败: {e}"))?; + + store + .insert(name, dest.to_string_lossy().to_string(), avatar_id, audio_id) + .await +} + +async fn delete_video_file_if_allowed(app: &AppHandle, path: &str) -> Result<(), String> { + let target = normalize_path(path); + let dir = generated_videos_dir(app)?; + if !is_under_generated_videos_dir(&dir, &target) { + return Err("不允许删除该路径下的文件".into()); + } + if target.is_file() { + tokio::fs::remove_file(&target) + .await + .map_err(|e| format!("删除文件失败: {e}"))?; + } + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01b9f42..8603c0e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,8 @@ pub mod audio_db; pub mod audio_store; pub mod avatar_db; pub mod avatar_store; +pub mod video_db; +pub mod video_store; pub mod local_config; pub mod asr; pub mod auth_session; @@ -27,12 +29,14 @@ pub fn run() { .manage(app_config::AppConfig::new()) .manage(avatar_store::AvatarStore::new()) .manage(audio_store::AudioStore::new()) + .manage(video_store::VideoStore::new()) .setup(|app| { let handle = app.handle().clone(); tauri::async_runtime::block_on(async move { let app_config = handle.state::(); let avatar_store = handle.state::(); let audio_store = handle.state::(); + let video_store = handle.state::(); let dir = handle .path() .app_data_dir() @@ -41,6 +45,7 @@ pub fn run() { app_config.init_local_store(db_path).await?; avatar_store.init(dir.join("avatars.db")).await?; audio_store.init(dir.join("generated_audios.db")).await?; + video_store.init(dir.join("generated_videos.db")).await?; Ok::<(), String>(()) }) .map_err(|e| -> Box { e.into() })?; @@ -81,6 +86,10 @@ pub fn run() { commands::audio::list_generated_audios, commands::audio::insert_generated_audio, commands::audio::delete_generated_audio, + commands::video::list_generated_videos, + commands::video::insert_generated_video, + commands::video::import_generated_video, + commands::video::delete_generated_video, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/video_db.rs b/src-tauri/src/video_db.rs new file mode 100644 index 0000000..e01b5b9 --- /dev/null +++ b/src-tauri/src/video_db.rs @@ -0,0 +1,196 @@ +//! 工作流生成的口播视频 SQLite 存储 + +use std::path::PathBuf; +use std::sync::Mutex; + +use chrono::{Datelike, Local, Timelike}; +use rusqlite::{params, Connection}; +use serde::Serialize; +use thiserror::Error; + +const MAX_RECORDS: i64 = 30; + +#[derive(Debug, Error)] +pub enum VideoDbError { + #[error("{0}")] + Db(#[from] rusqlite::Error), + #[error("{0}")] + Message(String), +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedVideoRecord { + pub id: i64, + pub name: String, + pub file_path: String, + pub avatar_id: Option, + pub audio_id: Option, + pub created_at: String, +} + +#[derive(Debug)] +pub struct VideoDb { + conn: Mutex, +} + +impl VideoDb { + pub fn open(path: PathBuf) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + VideoDbError::Message(format!("创建数据库目录失败: {e}")) + })?; + } + let conn = Connection::open(path)?; + conn.execute_batch( + r#" + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS generated_videos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + avatar_id INTEGER, + audio_id INTEGER, + created_at TEXT NOT NULL + ); + "#, + )?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + fn default_name() -> String { + let n = Local::now(); + format!( + "{:02}-{:02} {:02}:{:02}", + n.month(), + n.day(), + n.hour(), + n.minute() + ) + } + + pub fn list(&self) -> Result, VideoDbError> { + let conn = self.conn.lock().map_err(|_| { + VideoDbError::Message("视频数据库锁异常".into()) + })?; + let mut stmt = conn.prepare( + "SELECT id, name, file_path, avatar_id, audio_id, created_at + FROM generated_videos ORDER BY id DESC LIMIT ?1", + )?; + let rows = stmt.query_map(params![MAX_RECORDS], |row| { + Ok(GeneratedVideoRecord { + id: row.get(0)?, + name: row.get(1)?, + file_path: row.get(2)?, + avatar_id: row.get(3)?, + audio_id: row.get(4)?, + created_at: row.get(5)?, + }) + })?; + let mut items = Vec::new(); + for row in rows { + items.push(row?); + } + Ok(items) + } + + pub fn get(&self, id: i64) -> Result, VideoDbError> { + let conn = self.conn.lock().map_err(|_| { + VideoDbError::Message("视频数据库锁异常".into()) + })?; + let mut stmt = conn.prepare( + "SELECT id, name, file_path, avatar_id, audio_id, created_at + FROM generated_videos WHERE id = ?1", + )?; + let mut rows = stmt.query_map(params![id], |row| { + Ok(GeneratedVideoRecord { + id: row.get(0)?, + name: row.get(1)?, + file_path: row.get(2)?, + avatar_id: row.get(3)?, + audio_id: row.get(4)?, + created_at: row.get(5)?, + }) + })?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } + } + + pub fn insert( + &self, + name: &str, + file_path: &str, + avatar_id: Option, + audio_id: Option, + ) -> Result { + let file_path = file_path.trim(); + if file_path.is_empty() { + return Err(VideoDbError::Message("文件路径不能为空".into())); + } + let name = name.trim(); + let name = if name.is_empty() { + Self::default_name() + } else { + name.to_string() + }; + let created_at = chrono::Utc::now().to_rfc3339(); + let conn = self.conn.lock().map_err(|_| { + VideoDbError::Message("视频数据库锁异常".into()) + })?; + conn.execute( + "INSERT INTO generated_videos (name, file_path, avatar_id, audio_id, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![name, file_path, avatar_id, audio_id, created_at], + )?; + let id = conn.last_insert_rowid(); + self.prune_excess(&conn)?; + Ok(GeneratedVideoRecord { + id, + name, + file_path: file_path.to_string(), + avatar_id, + audio_id, + created_at, + }) + } + + fn prune_excess(&self, conn: &Connection) -> Result<(), VideoDbError> { + let count: i64 = conn.query_row( + "SELECT COUNT(1) FROM generated_videos", + [], + |row| row.get(0), + )?; + if count <= MAX_RECORDS { + return Ok(()); + } + let excess = count - MAX_RECORDS; + conn.execute( + "DELETE FROM generated_videos WHERE id IN ( + SELECT id FROM generated_videos ORDER BY id ASC LIMIT ?1 + )", + params![excess], + )?; + Ok(()) + } + + pub fn delete(&self, id: i64) -> Result, VideoDbError> { + let conn = self.conn.lock().map_err(|_| { + VideoDbError::Message("视频数据库锁异常".into()) + })?; + let file_path: String = match conn.query_row( + "SELECT file_path FROM generated_videos WHERE id = ?1", + params![id], + |row| row.get(0), + ) { + Ok(p) => p, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + conn.execute("DELETE FROM generated_videos WHERE id = ?1", params![id])?; + Ok(Some(file_path)) + } +} diff --git a/src-tauri/src/video_store.rs b/src-tauri/src/video_store.rs new file mode 100644 index 0000000..ff4b13b --- /dev/null +++ b/src-tauri/src/video_store.rs @@ -0,0 +1,84 @@ +//! 口播视频:SQLite 访问与 Tauri 状态 + +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::video_db::{VideoDb, GeneratedVideoRecord}; + +#[derive(Debug, Default)] +pub struct VideoStore { + inner: Arc>>>, +} + +impl VideoStore { + pub fn new() -> Self { + Self::default() + } + + pub async fn init(&self, db_path: PathBuf) -> Result<(), String> { + let db = tokio::task::spawn_blocking(move || { + VideoDb::open(db_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let count = tokio::task::spawn_blocking({ + let db = Arc::new(db); + let db2 = db.clone(); + move || db2.list().map(|l| (db, l.len())).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let (db, n) = count; + *self.inner.write().await = Some(db); + log::info!(target: "video", "口播视频历史数据库已初始化,共 {n} 条记录"); + Ok(()) + } + + async fn with_db(&self, f: F) -> Result + where + F: FnOnce(Arc) -> Result + Send + 'static, + T: Send + 'static, + { + let db = self + .inner + .read() + .await + .clone() + .ok_or_else(|| "视频数据库未初始化".to_string())?; + tokio::task::spawn_blocking(move || f(db)) + .await + .map_err(|e| e.to_string())? + } + + pub async fn list(&self) -> Result, String> { + self.with_db(|db| db.list().map_err(|e| e.to_string())).await + } + + pub async fn get(&self, id: i64) -> Result, String> { + self.with_db(move |db| db.get(id).map_err(|e| e.to_string())) + .await + } + + pub async fn insert( + &self, + name: String, + file_path: String, + avatar_id: Option, + audio_id: Option, + ) -> Result { + self.with_db(move |db| { + db.insert(&name, &file_path, avatar_id, audio_id) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn delete(&self, id: i64) -> Result, String> { + self.with_db(move |db| db.delete(id).map_err(|e| e.to_string())) + .await + } +} diff --git a/src/services/localVideo.js b/src/services/localVideo.js index 6c84316..8b45f06 100644 --- a/src/services/localVideo.js +++ b/src/services/localVideo.js @@ -1,12 +1,11 @@ import { convertFileSrc, isTauri } from "@tauri-apps/api/core"; /** - * 本地视频文件转为 WebView 可播放地址 * @param {string} filePath */ export function localVideoToPlayableUrl(filePath) { if (!isTauri()) { - throw new Error("请在 Tauri 桌面端预览视频"); + throw new Error("请在 Tauri 桌面端使用视频播放功能"); } return convertFileSrc(filePath); } diff --git a/src/services/videoDb.js b/src/services/videoDb.js new file mode 100644 index 0000000..7f567da --- /dev/null +++ b/src/services/videoDb.js @@ -0,0 +1,42 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; + +function ensureTauri() { + if (!isTauri()) { + throw new Error("视频历史需在 Tauri 桌面端使用"); + } +} + +/** + * @typedef {{ id: number, name: string, filePath: string, avatarId: number | null, audioId: number | null, createdAt: string }} GeneratedVideoRecord + */ + +/** @returns {Promise} */ +export async function listGeneratedVideos() { + ensureTauri(); + return invoke("list_generated_videos"); +} + +/** + * @param {string} sourcePath + * @param {string} name + * @param {number | null} [avatarId] + * @param {number | null} [audioId] + * @returns {Promise} + */ +export async function importGeneratedVideo(sourcePath, name, avatarId = null, audioId = null) { + ensureTauri(); + return invoke("import_generated_video", { + sourcePath, + name, + avatarId: avatarId ?? null, + audioId: audioId ?? null, + }); +} + +/** + * @param {number} id + */ +export async function deleteGeneratedVideo(id) { + ensureTauri(); + return invoke("delete_generated_video", { id }); +}