diff --git a/src-tauri/src/commands/material.rs b/src-tauri/src/commands/material.rs new file mode 100644 index 0000000..9b6df72 --- /dev/null +++ b/src-tauri/src/commands/material.rs @@ -0,0 +1,143 @@ +//! 画中画/字幕素材:SQLite 元数据 + 本地文件管理 + +use std::path::{Component, Path, PathBuf}; + +use chrono::Local; +use serde_json::json; +use tauri::{AppHandle, Manager, State}; +use uuid::Uuid; + +use crate::material_db::MaterialDb; +use crate::material_store::MaterialStore; + +const ALLOWED_EXTS: &[&str] = &[ + "mp4", "mov", "avi", "mkv", "jpg", "jpeg", "png", "gif", "bmp", "webp", +]; + +fn normalize_path(path: &str) -> PathBuf { + Path::new(path) + .components() + .filter(|c| !matches!(c, Component::ParentDir)) + .collect() +} + +fn materials_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("无法获取应用数据目录: {e}"))? + .join("video-material"); + Ok(dir) +} + +fn is_under_materials_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("video-material") && target_s.starts_with(&root_s) +} + +#[tauri::command] +pub async fn list_materials(store: State<'_, MaterialStore>) -> Result, String> { + store.list().await +} + +#[tauri::command] +pub async fn update_material_name( + store: State<'_, MaterialStore>, + id: i64, + name: String, +) -> Result<(), String> { + store.update_name(id, name).await +} + +#[tauri::command] +pub async fn delete_material( + app: AppHandle, + store: State<'_, MaterialStore>, + id: i64, +) -> Result<(), String> { + let file_path = store.delete(id).await?; + if let Some(path) = file_path { + delete_material_file_if_allowed(&app, &path).await?; + } + Ok(()) +} + +/// 将用户选择的媒体文件复制到应用数据目录并写入数据库 +#[tauri::command] +pub async fn import_material_files( + app: AppHandle, + store: State<'_, MaterialStore>, + source_paths: Vec, +) -> Result, String> { + let mut imported = Vec::new(); + for raw in source_paths { + let src = normalize_path(raw.trim()); + if !src.is_file() { + return Err(format!("文件不存在: {}", src.display())); + } + let ext = src + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !ALLOWED_EXTS.contains(&ext.as_str()) { + return Err(format!("不支持的文件格式: .{ext}")); + } + + let saved = copy_to_materials_dir(&app, &src, &ext).await?; + let now = chrono::Utc::now().timestamp_millis(); + let name = MaterialDb::material_name_from_path(raw.trim()); + let material_type = MaterialDb::material_type_from_path(raw.trim()); + let info = json!({ "sourcePath": raw.trim() }); + let record = store + .insert( + name, + saved, + material_type, + info, + now, + now, + ) + .await?; + imported.push(record); + } + Ok(imported) +} + +async fn copy_to_materials_dir( + app: &AppHandle, + src: &Path, + ext: &str, +) -> Result { + let root = materials_dir(app)?; + let date = Local::now().format("%Y%m%d").to_string(); + let dir = root.join(&date); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| format!("创建素材目录失败: {e}"))?; + + let file_name = format!("{}_{}.{}", Uuid::new_v4(), Local::now().format("%H%M%S"), ext); + let dest = dir.join(file_name); + tokio::fs::copy(src, &dest) + .await + .map_err(|e| format!("复制素材失败: {e}"))?; + Ok(dest.to_string_lossy().to_string()) +} + +async fn delete_material_file_if_allowed(app: &AppHandle, path: &str) -> Result<(), String> { + let target = normalize_path(path); + let dir = materials_dir(app)?; + if !is_under_materials_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/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4810e8c..4d2ee79 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 material; pub mod video; pub mod fs_util; pub mod nodejs; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8720cfd..8a7bc85 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 material_db; +pub mod material_store; pub mod video_db; pub mod video_store; pub mod local_config; @@ -35,6 +37,7 @@ pub fn run() { .manage(auth_session::AuthSession::default()) .manage(app_config::AppConfig::new()) .manage(avatar_store::AvatarStore::new()) + .manage(material_store::MaterialStore::new()) .manage(audio_store::AudioStore::new()) .manage(video_store::VideoStore::new()) .manage(publish_store::PublishStore::new()) @@ -50,6 +53,7 @@ pub fn run() { tauri::async_runtime::block_on(async move { let app_config = handle.state::(); let avatar_store = handle.state::(); + let material_store = handle.state::(); let audio_store = handle.state::(); let video_store = handle.state::(); let publish_store = handle.state::(); @@ -60,6 +64,7 @@ pub fn run() { let db_path = dir.join("local_config.db"); app_config.init_local_store(db_path).await?; avatar_store.init(dir.join("avatars.db")).await?; + material_store.init(dir.join("video_materials.db")).await?; audio_store.init(dir.join("generated_audios.db")).await?; video_store.init(dir.join("generated_videos.db")).await?; publish_store.init(dir.join("publish_accounts.db")).await?; @@ -104,6 +109,10 @@ pub fn run() { commands::avatar::delete_avatar, commands::avatar::avatar_name_exists, commands::avatar::import_avatar_video, + commands::material::list_materials, + commands::material::update_material_name, + commands::material::delete_material, + commands::material::import_material_files, commands::audio::list_generated_audios, commands::audio::insert_generated_audio, commands::audio::delete_generated_audio, diff --git a/src-tauri/src/material_db.rs b/src-tauri/src/material_db.rs new file mode 100644 index 0000000..625c002 --- /dev/null +++ b/src-tauri/src/material_db.rs @@ -0,0 +1,202 @@ +//! 画中画/字幕素材 SQLite(对齐 Electron data_video_material) + +use std::path::PathBuf; +use std::sync::Mutex; + +use rusqlite::{params, Connection}; +use serde::Serialize; +use thiserror::Error; + +const IMAGE_EXTS: &[&str] = &[ + "jpg", "jpeg", "png", "gif", "bmp", "webp", +]; + +#[derive(Debug, Error)] +pub enum MaterialDbError { + #[error("{0}")] + Db(#[from] rusqlite::Error), + #[error("{0}")] + Message(String), +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MaterialRecord { + pub id: i64, + pub name: String, + pub path: String, + #[serde(rename = "type")] + pub material_type: String, + pub info: serde_json::Value, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug)] +pub struct MaterialDb { + conn: Mutex, +} + +impl MaterialDb { + pub fn open(path: PathBuf) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + MaterialDbError::Message(format!("创建数据库目录失败: {e}")) + })?; + } + let conn = Connection::open(path)?; + conn.execute_batch( + r#" + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS video_materials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL, + type TEXT NOT NULL, + info TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + "#, + )?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn material_type_from_path(path: &str) -> String { + let ext = path + .rsplit('.') + .next() + .unwrap_or("") + .to_lowercase(); + if IMAGE_EXTS.contains(&ext.as_str()) { + "image".to_string() + } else { + "video".to_string() + } + } + + pub fn material_name_from_path(path: &str) -> String { + let normalized = path.replace('\\', "/"); + let file_name = normalized + .rsplit('/') + .next() + .unwrap_or("未命名素材"); + let stem = file_name + .rsplit_once('.') + .map(|(s, _)| s) + .unwrap_or(file_name); + if stem.is_empty() { + file_name.to_string() + } else { + stem.to_string() + } + } + + fn row_to_record(row: &rusqlite::Row<'_>) -> Result { + let info_raw: String = row.get(4)?; + let info: serde_json::Value = serde_json::from_str(&info_raw).unwrap_or(serde_json::json!({})); + Ok(MaterialRecord { + id: row.get(0)?, + name: row.get(1)?, + path: row.get(2)?, + material_type: row.get(3)?, + info, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + } + + pub fn list(&self) -> Result, MaterialDbError> { + let conn = self.conn.lock().map_err(|_| { + MaterialDbError::Message("素材数据库锁异常".into()) + })?; + let mut stmt = conn.prepare( + "SELECT id, name, path, type, info, created_at, updated_at + FROM video_materials + ORDER BY updated_at DESC, id DESC", + )?; + let rows = stmt.query_map([], Self::row_to_record)?; + let mut items = Vec::new(); + for row in rows { + items.push(row?); + } + Ok(items) + } + + pub fn insert( + &self, + name: &str, + path: &str, + material_type: &str, + info: serde_json::Value, + created_at: i64, + updated_at: i64, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(MaterialDbError::Message("名称不能为空".into())); + } + let path = path.trim(); + if path.is_empty() { + return Err(MaterialDbError::Message("文件路径不能为空".into())); + } + let info_str = serde_json::to_string(&info).unwrap_or_else(|_| "{}".into()); + let conn = self.conn.lock().map_err(|_| { + MaterialDbError::Message("素材数据库锁异常".into()) + })?; + conn.execute( + "INSERT INTO video_materials (name, path, type, info, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![name, path, material_type, info_str, created_at, updated_at], + )?; + let id = conn.last_insert_rowid(); + Ok(MaterialRecord { + id, + name: name.to_string(), + path: path.to_string(), + material_type: material_type.to_string(), + info, + created_at, + updated_at, + }) + } + + pub fn update_name(&self, id: i64, name: &str) -> Result<(), MaterialDbError> { + let name = name.trim(); + if name.is_empty() { + return Err(MaterialDbError::Message("名称不能为空".into())); + } + let now = chrono::Utc::now().timestamp_millis(); + let conn = self.conn.lock().map_err(|_| { + MaterialDbError::Message("素材数据库锁异常".into()) + })?; + let n = conn.execute( + "UPDATE video_materials SET name = ?1, updated_at = ?2 WHERE id = ?3", + params![name, now, id], + )?; + if n == 0 { + return Err(MaterialDbError::Message("素材不存在".into())); + } + Ok(()) + } + + pub fn delete(&self, id: i64) -> Result, MaterialDbError> { + let conn = self.conn.lock().map_err(|_| { + MaterialDbError::Message("素材数据库锁异常".into()) + })?; + let path: Option = conn + .query_row( + "SELECT path FROM video_materials WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .ok(); + let Some(path) = path else { + return Ok(None); + }; + conn.execute("DELETE FROM video_materials WHERE id = ?1", params![id])?; + Ok(Some(path)) + } +} diff --git a/src-tauri/src/material_store.rs b/src-tauri/src/material_store.rs new file mode 100644 index 0000000..5c22b79 --- /dev/null +++ b/src-tauri/src/material_store.rs @@ -0,0 +1,94 @@ +//! 画中画/字幕素材:SQLite 访问与 Tauri 状态 + +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::material_db::{MaterialDb, MaterialRecord}; + +#[derive(Debug, Default)] +pub struct MaterialStore { + inner: Arc>>>, +} + +impl MaterialStore { + pub fn new() -> Self { + Self::default() + } + + pub async fn init(&self, db_path: PathBuf) -> Result<(), String> { + let db = tokio::task::spawn_blocking(move || { + MaterialDb::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: "material", "素材数据库已初始化,共 {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 insert( + &self, + name: String, + path: String, + material_type: String, + info: serde_json::Value, + created_at: i64, + updated_at: i64, + ) -> Result { + self.with_db(move |db| { + db.insert( + &name, + &path, + &material_type, + info, + created_at, + updated_at, + ) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn update_name(&self, id: i64, name: String) -> Result<(), String> { + self.with_db(move |db| db.update_name(id, &name).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/router/index.js b/src/router/index.js index 626ffb5..0e68d21 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -38,7 +38,7 @@ const mainChildren = [ name: "material", - component: placeholder, + component: () => import("../views/MaterialView.vue"), meta: { title: "素材" }, @@ -56,65 +56,65 @@ const mainChildren = [ }, - { + // { - path: "compose", + // path: "compose", - name: "compose", + // name: "compose", - component: placeholder, + // component: placeholder, - meta: { title: "合成" }, + // meta: { title: "合成" }, - }, + // }, - { + // { - path: "extract", + // path: "extract", - name: "extract", + // name: "extract", - component: placeholder, + // component: placeholder, - meta: { title: "提取" }, + // meta: { title: "提取" }, - }, + // }, - { + // { - path: "design", + // path: "design", - name: "design", + // name: "design", - component: placeholder, + // component: placeholder, - meta: { title: "设计" }, + // meta: { title: "设计" }, - }, + // }, - { + // { - path: "model", + // path: "model", - name: "model", + // name: "model", - component: placeholder, + // component: placeholder, - meta: { title: "模型" }, + // meta: { title: "模型" }, - }, + // }, - { + // { - path: "help", + // path: "help", - name: "help", + // name: "help", - component: placeholder, + // component: placeholder, - meta: { title: "帮助" }, + // meta: { title: "帮助" }, - }, + // }, { diff --git a/src/services/materialDb.js b/src/services/materialDb.js new file mode 100644 index 0000000..d083033 --- /dev/null +++ b/src/services/materialDb.js @@ -0,0 +1,49 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; + +function ensureTauri() { + if (!isTauri()) { + throw new Error("素材管理需在 Tauri 桌面端使用"); + } +} + +/** + * @typedef {{ id: number, name: string, path: string, type: 'image' | 'video', info: object, createdAt: number, updatedAt: number }} MaterialRecord + */ + +/** @returns {Promise} */ +export async function listMaterials() { + ensureTauri(); + return invoke("list_materials"); +} + +/** + * @param {number} id + * @param {string} name + */ +export async function updateMaterialName(id, name) { + ensureTauri(); + return invoke("update_material_name", { id, name }); +} + +/** + * @param {number} id + */ +export async function deleteMaterial(id) { + ensureTauri(); + return invoke("delete_material", { id }); +} + +/** + * @param {string[]} sourcePaths + * @returns {Promise} + */ +export async function importMaterialFiles(sourcePaths) { + ensureTauri(); + return invoke("import_material_files", { sourcePaths }); +} + +export const MATERIAL_UPDATED_EVENT = "video-material-updated"; + +export function dispatchMaterialUpdated() { + window.dispatchEvent(new CustomEvent(MATERIAL_UPDATED_EVENT)); +} diff --git a/src/services/materialMedia.js b/src/services/materialMedia.js new file mode 100644 index 0000000..b0ffccd --- /dev/null +++ b/src/services/materialMedia.js @@ -0,0 +1,50 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; +import { convertFileSrc } from "@tauri-apps/api/core"; + +const MEDIA_EXTENSIONS = [ + "mp4", + "mov", + "avi", + "mkv", + "jpg", + "jpeg", + "png", + "gif", + "bmp", + "webp", +]; + +/** + * @returns {Promise} + */ +export async function pickMaterialFiles() { + if (!isTauri()) { + throw new Error("请在 Tauri 桌面端选择素材文件"); + } + const selected = await open({ + multiple: true, + title: "选择素材文件", + filters: [ + { + name: "媒体文件", + extensions: MEDIA_EXTENSIONS, + }, + ], + }); + if (selected == null) return []; + if (Array.isArray(selected)) { + return selected.map(String).filter(Boolean); + } + return [String(selected)]; +} + +/** + * @param {string} filePath + */ +export function localMediaToPlayableUrl(filePath) { + if (!isTauri()) { + throw new Error("请在 Tauri 桌面端预览本地素材"); + } + return convertFileSrc(filePath); +} diff --git a/src/stores/material.js b/src/stores/material.js new file mode 100644 index 0000000..bb8975b --- /dev/null +++ b/src/stores/material.js @@ -0,0 +1,76 @@ +import { defineStore, acceptHMRUpdate } from "pinia"; +import { + listMaterials, + updateMaterialName, + deleteMaterial, + importMaterialFiles, + dispatchMaterialUpdated, +} from "../services/materialDb.js"; + +export const useMaterialStore = defineStore("material", { + state: () => ({ + items: [], + loading: false, + uploading: false, + }), + + actions: { + async refresh() { + this.loading = true; + try { + this.items = await listMaterials(); + } finally { + this.loading = false; + } + }, + + /** + * @param {string[]} sourcePaths + */ + async uploadFiles(sourcePaths) { + if (!sourcePaths.length) return { ok: true }; + this.uploading = true; + try { + await importMaterialFiles(sourcePaths); + await this.refresh(); + dispatchMaterialUpdated(); + return { ok: true }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, message: msg || "上传失败" }; + } finally { + this.uploading = false; + } + }, + + /** + * @param {number} id + * @param {string} name + */ + async renameItem(id, name) { + const trimmed = String(name || "").trim(); + if (!trimmed) { + return { ok: false, message: "名称不能为空" }; + } + try { + await updateMaterialName(id, trimmed); + await this.refresh(); + dispatchMaterialUpdated(); + return { ok: true }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, message: msg || "重命名失败" }; + } + }, + + async removeItem(item) { + await deleteMaterial(item.id); + await this.refresh(); + dispatchMaterialUpdated(); + }, + }, +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useMaterialStore, import.meta.hot)); +} diff --git a/src/views/MaterialView.vue b/src/views/MaterialView.vue new file mode 100644 index 0000000..c046c7e --- /dev/null +++ b/src/views/MaterialView.vue @@ -0,0 +1,236 @@ + + +