fix: 素材管理

This commit is contained in:
root
2025-07-11 11:08:48 +08:00
parent 9e95d73087
commit 074a6b7834
10 changed files with 2471 additions and 0 deletions

View File

@@ -6,6 +6,9 @@ pyautogui
celery
redis
numpy
Click
tqdm
appdirs
scipy
matplotlib
pillow

View File

@@ -0,0 +1,833 @@
"""
媒体库管理服务
管理视频素材,包括上传、转场镜头分割、标签管理等
"""
import json
import uuid
import os
import hashlib
import shutil
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, asdict
from datetime import datetime
from python_core.config import settings
from python_core.utils.logger import logger
from python_core.utils.jsonrpc import create_response_handler
# 视频处理库
try:
import cv2
import numpy as np
VIDEO_LIBS_AVAILABLE = True
except ImportError:
logger.warning("Video processing libraries not available. Install opencv-python for full functionality.")
VIDEO_LIBS_AVAILABLE = False
@dataclass
class VideoSegment:
"""视频片段数据结构"""
id: str
original_video_id: str
segment_index: int
filename: str
file_path: str
md5_hash: str
file_size: int
duration: float
width: int
height: int
fps: float
format: str
start_time: float
end_time: float
tags: List[str]
use_count: int
thumbnail_path: Optional[str] = None
scene_score: Optional[float] = None # 场景变化分数
motion_score: Optional[float] = None # 运动强度分数
brightness: Optional[float] = None
contrast: Optional[float] = None
created_at: str = ""
updated_at: str = ""
is_active: bool = True
@dataclass
class OriginalVideo:
"""原始视频数据结构"""
id: str
filename: str
file_path: str
md5_hash: str
file_size: int
duration: float
width: int
height: int
fps: float
format: str
segment_count: int
tags: List[str]
created_at: str = ""
updated_at: str = ""
is_active: bool = True
class MediaManager:
"""媒体库管理器"""
def __init__(self):
self.cache_dir = settings.temp_dir / "cache"
self.cache_dir.mkdir(parents=True, exist_ok=True)
# 数据文件
self.segments_file = self.cache_dir / "video_segments.json"
self.original_videos_file = self.cache_dir / "original_videos.json"
# 加载数据
self.video_segments = self._load_video_segments()
self.original_videos = self._load_original_videos()
# 存储目录
self.video_storage_dir = settings.temp_dir / "video_storage"
self.video_storage_dir.mkdir(parents=True, exist_ok=True)
self.segments_dir = settings.temp_dir / "video_segments"
self.segments_dir.mkdir(parents=True, exist_ok=True)
self.thumbnails_dir = settings.temp_dir / "video_thumbnails"
self.thumbnails_dir.mkdir(parents=True, exist_ok=True)
def _load_video_segments(self) -> List[VideoSegment]:
"""加载视频片段数据"""
if self.segments_file.exists():
try:
with open(self.segments_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return [VideoSegment(**item) for item in data]
except Exception as e:
logger.error(f"Failed to load video segments: {e}")
return []
else:
return []
def _load_original_videos(self) -> List[OriginalVideo]:
"""加载原始视频数据"""
if self.original_videos_file.exists():
try:
with open(self.original_videos_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return [OriginalVideo(**item) for item in data]
except Exception as e:
logger.error(f"Failed to load original videos: {e}")
return []
else:
return []
def _save_video_segments(self, segments: List[VideoSegment] = None):
"""保存视频片段数据"""
if segments is None:
segments = self.video_segments
try:
data = [asdict(segment) for segment in segments]
with open(self.segments_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"Video segments saved to {self.segments_file}")
except Exception as e:
logger.error(f"Failed to save video segments: {e}")
raise
def _save_original_videos(self, videos: List[OriginalVideo] = None):
"""保存原始视频数据"""
if videos is None:
videos = self.original_videos
try:
data = [asdict(video) for video in videos]
with open(self.original_videos_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"Original videos saved to {self.original_videos_file}")
except Exception as e:
logger.error(f"Failed to save original videos: {e}")
raise
def _calculate_md5(self, file_path: str) -> str:
"""计算文件MD5哈希值"""
hash_md5 = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def _get_video_info(self, file_path: str) -> Dict:
"""获取视频基本信息"""
if not VIDEO_LIBS_AVAILABLE:
# 如果没有视频处理库,返回基本信息
file_size = os.path.getsize(file_path)
return {
'duration': 0.0,
'width': 0,
'height': 0,
'fps': 0.0,
'frame_count': 0,
'file_size': file_size
}
try:
cap = cv2.VideoCapture(file_path)
# 获取视频属性
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
duration = frame_count / fps if fps > 0 else 0
cap.release()
return {
'duration': duration,
'width': width,
'height': height,
'fps': fps,
'frame_count': frame_count,
'file_size': os.path.getsize(file_path)
}
except Exception as e:
logger.error(f"Failed to get video info: {e}")
file_size = os.path.getsize(file_path)
return {
'duration': 0.0,
'width': 0,
'height': 0,
'fps': 0.0,
'frame_count': 0,
'file_size': file_size
}
def _detect_scene_changes(self, file_path: str, threshold: float = 30.0) -> List[float]:
"""检测场景变化点(转场镜头)"""
if not VIDEO_LIBS_AVAILABLE:
logger.warning("Video processing not available, returning empty scene changes")
return []
try:
cap = cv2.VideoCapture(file_path)
fps = cap.get(cv2.CAP_PROP_FPS)
scene_changes = [0.0] # 开始时间
prev_frame = None
frame_count = 0
while True:
ret, frame = cap.read()
if not ret:
break
# 转换为灰度图
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if prev_frame is not None:
# 计算帧间差异
diff = cv2.absdiff(prev_frame, gray)
mean_diff = np.mean(diff)
# 如果差异超过阈值,认为是场景变化
if mean_diff > threshold:
timestamp = frame_count / fps
scene_changes.append(timestamp)
logger.debug(f"Scene change detected at {timestamp:.2f}s (diff: {mean_diff:.2f})")
prev_frame = gray
frame_count += 1
cap.release()
# 添加结束时间
duration = frame_count / fps if fps > 0 else 0
if duration > 0:
scene_changes.append(duration)
logger.info(f"Detected {len(scene_changes)-1} scene changes in video")
return scene_changes
except Exception as e:
logger.error(f"Failed to detect scene changes: {e}")
return [0.0]
def _generate_thumbnail(self, video_path: str, timestamp: float, output_path: str) -> bool:
"""生成视频缩略图"""
if not VIDEO_LIBS_AVAILABLE:
return False
def _split_video_by_scenes(self, video_path: str, scene_changes: List[float],
original_video_id: str) -> List[VideoSegment]:
"""根据场景变化分割视频"""
if not VIDEO_LIBS_AVAILABLE:
logger.warning("Video processing not available, creating single segment")
# 创建单个片段
video_info = self._get_video_info(video_path)
segment_id = str(uuid.uuid4())
segment_filename = f"{segment_id}.mp4"
segment_path = self.segments_dir / segment_filename
# 复制整个视频作为单个片段
shutil.copy2(video_path, segment_path)
now = datetime.now().isoformat()
segment = VideoSegment(
id=segment_id,
original_video_id=original_video_id,
segment_index=0,
filename=segment_filename,
file_path=str(segment_path),
md5_hash=self._calculate_md5(str(segment_path)),
file_size=video_info['file_size'],
duration=video_info['duration'],
width=video_info['width'],
height=video_info['height'],
fps=video_info['fps'],
format='mp4',
start_time=0.0,
end_time=video_info['duration'],
tags=[],
use_count=0,
created_at=now,
updated_at=now
)
return [segment]
segments = []
try:
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# 设置视频编码器
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
for i in range(len(scene_changes) - 1):
start_time = scene_changes[i]
end_time = scene_changes[i + 1]
duration = end_time - start_time
# 跳过太短的片段小于1秒
if duration < 1.0:
continue
segment_id = str(uuid.uuid4())
segment_filename = f"{segment_id}.mp4"
segment_path = self.segments_dir / segment_filename
# 创建视频写入器
out = cv2.VideoWriter(str(segment_path), fourcc, fps, (width, height))
# 跳转到开始帧
start_frame = int(start_time * fps)
end_frame = int(end_time * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
frame_count = 0
while frame_count < (end_frame - start_frame):
ret, frame = cap.read()
if not ret:
break
out.write(frame)
frame_count += 1
out.release()
# 生成缩略图
thumbnail_filename = f"{segment_id}_thumb.jpg"
thumbnail_path = self.thumbnails_dir / thumbnail_filename
thumbnail_generated = self._generate_thumbnail(
str(segment_path),
duration / 2, # 中间帧作为缩略图
str(thumbnail_path)
)
# 创建片段记录
now = datetime.now().isoformat()
segment = VideoSegment(
id=segment_id,
original_video_id=original_video_id,
segment_index=i,
filename=segment_filename,
file_path=str(segment_path),
md5_hash=self._calculate_md5(str(segment_path)),
file_size=os.path.getsize(segment_path),
duration=duration,
width=width,
height=height,
fps=fps,
format='mp4',
start_time=start_time,
end_time=end_time,
tags=[],
use_count=0,
thumbnail_path=str(thumbnail_path) if thumbnail_generated else None,
created_at=now,
updated_at=now
)
segments.append(segment)
logger.info(f"Created segment {i}: {start_time:.2f}s - {end_time:.2f}s ({duration:.2f}s)")
cap.release()
except Exception as e:
logger.error(f"Failed to split video: {e}")
# 如果分割失败,创建单个片段
return self._split_video_by_scenes(video_path, [0.0, self._get_video_info(video_path)['duration']], original_video_id)
return segments
def get_video_by_md5(self, md5_hash: str) -> Optional[Dict]:
"""根据MD5获取原始视频"""
for video in self.original_videos:
if video.md5_hash == md5_hash and video.is_active:
return asdict(video)
return None
def get_segment_by_md5(self, md5_hash: str) -> Optional[Dict]:
"""根据MD5获取视频片段"""
for segment in self.video_segments:
if segment.md5_hash == md5_hash and segment.is_active:
return asdict(segment)
return None
def get_all_segments(self) -> List[Dict]:
"""获取所有视频片段"""
return [asdict(segment) for segment in self.video_segments if segment.is_active]
def get_all_original_videos(self) -> List[Dict]:
"""获取所有原始视频"""
return [asdict(video) for video in self.original_videos if video.is_active]
def get_segments_by_video_id(self, video_id: str) -> List[Dict]:
"""获取指定原始视频的所有片段"""
segments = []
for segment in self.video_segments:
if segment.original_video_id == video_id and segment.is_active:
segments.append(asdict(segment))
return sorted(segments, key=lambda x: x['segment_index'])
def upload_video_file(self, source_path: str, filename: str = None, tags: List[str] = None) -> Dict:
"""上传单个视频文件并分割成片段"""
if not os.path.exists(source_path):
raise FileNotFoundError(f"Source file not found: {source_path}")
if tags is None:
tags = []
# 计算MD5
md5_hash = self._calculate_md5(source_path)
# 检查是否已存在相同MD5的视频
existing = self.get_video_by_md5(md5_hash)
if existing:
logger.info(f"Video with MD5 {md5_hash} already exists")
return {
'original_video': existing,
'segments': self.get_segments_by_video_id(existing['id']),
'is_duplicate': True
}
# 生成新的视频ID和文件名
video_id = str(uuid.uuid4())
if filename is None:
filename = os.path.basename(source_path)
# 获取文件扩展名
file_ext = os.path.splitext(filename)[1].lower()
stored_filename = f"{video_id}{file_ext}"
stored_path = self.video_storage_dir / stored_filename
# 复制文件到存储目录
shutil.copy2(source_path, stored_path)
# 获取视频基本信息
video_info = self._get_video_info(str(stored_path))
# 检测场景变化
scene_changes = self._detect_scene_changes(str(stored_path))
# 创建原始视频记录
now = datetime.now().isoformat()
original_video = OriginalVideo(
id=video_id,
filename=filename,
file_path=str(stored_path),
md5_hash=md5_hash,
file_size=video_info['file_size'],
duration=video_info['duration'],
width=video_info['width'],
height=video_info['height'],
fps=video_info['fps'],
format=file_ext[1:] if file_ext else 'unknown',
segment_count=len(scene_changes) - 1,
tags=tags,
created_at=now,
updated_at=now
)
# 分割视频成片段
segments = self._split_video_by_scenes(str(stored_path), scene_changes, video_id)
# 保存数据
self.original_videos.append(original_video)
self.video_segments.extend(segments)
self._save_original_videos()
self._save_video_segments()
logger.info(f"Uploaded video: {filename} (MD5: {md5_hash}, {len(segments)} segments)")
return {
'original_video': asdict(original_video),
'segments': [asdict(segment) for segment in segments],
'is_duplicate': False
}
def batch_upload_video_files(self, source_directory: str, tags: List[str] = None) -> Dict:
"""批量上传视频文件"""
if not os.path.exists(source_directory):
raise FileNotFoundError(f"Source directory not found: {source_directory}")
if tags is None:
tags = []
# 支持的视频格式
video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', '.m4v'}
results = {
'total_files': 0,
'uploaded_files': 0,
'skipped_files': 0,
'failed_files': 0,
'total_segments': 0,
'uploaded_list': [],
'skipped_list': [],
'failed_list': []
}
# 遍历目录中的所有文件
for root, dirs, files in os.walk(source_directory):
for file in files:
file_path = os.path.join(root, file)
file_ext = os.path.splitext(file)[1].lower()
# 检查是否为视频文件
if file_ext not in video_extensions:
continue
results['total_files'] += 1
try:
# 尝试上传文件
result = self.upload_video_file(file_path, file, tags)
if result['is_duplicate']:
results['skipped_files'] += 1
results['skipped_list'].append({
'filename': file,
'reason': 'Already exists (same MD5)'
})
else:
results['uploaded_files'] += 1
results['total_segments'] += len(result['segments'])
results['uploaded_list'].append(result)
except Exception as e:
results['failed_files'] += 1
results['failed_list'].append({
'filename': file,
'error': str(e)
})
logger.error(f"Failed to upload {file}: {e}")
logger.info(f"Batch upload completed: {results['uploaded_files']} uploaded, "
f"{results['skipped_files']} skipped, {results['failed_files']} failed, "
f"{results['total_segments']} total segments created")
return results
def add_segment_tags(self, segment_id: str, tags: List[str]) -> bool:
"""为视频片段添加标签"""
for i, segment in enumerate(self.video_segments):
if segment.id == segment_id and segment.is_active:
# 合并标签,去重
existing_tags = set(segment.tags)
new_tags = existing_tags.union(set(tags))
segment.tags = list(new_tags)
segment.updated_at = datetime.now().isoformat()
self.video_segments[i] = segment
self._save_video_segments()
logger.info(f"Added tags {tags} to segment {segment_id}")
return True
return False
def remove_segment_tags(self, segment_id: str, tags: List[str]) -> bool:
"""移除视频片段的标签"""
for i, segment in enumerate(self.video_segments):
if segment.id == segment_id and segment.is_active:
# 移除指定标签
existing_tags = set(segment.tags)
remaining_tags = existing_tags - set(tags)
segment.tags = list(remaining_tags)
segment.updated_at = datetime.now().isoformat()
self.video_segments[i] = segment
self._save_video_segments()
logger.info(f"Removed tags {tags} from segment {segment_id}")
return True
return False
def increment_segment_usage(self, segment_id: str) -> bool:
"""增加视频片段使用次数"""
for i, segment in enumerate(self.video_segments):
if segment.id == segment_id and segment.is_active:
segment.use_count += 1
segment.updated_at = datetime.now().isoformat()
self.video_segments[i] = segment
self._save_video_segments()
logger.info(f"Incremented usage count for segment {segment_id} to {segment.use_count}")
return True
return False
def get_segments_by_tags(self, tags: List[str], match_all: bool = False) -> List[Dict]:
"""根据标签搜索视频片段"""
results = []
tag_set = set(tags)
for segment in self.video_segments:
if not segment.is_active:
continue
segment_tags = set(segment.tags)
if match_all:
# 必须包含所有标签
if tag_set.issubset(segment_tags):
results.append(asdict(segment))
else:
# 包含任意一个标签
if tag_set.intersection(segment_tags):
results.append(asdict(segment))
return results
def get_popular_segments(self, limit: int = 10) -> List[Dict]:
"""获取最常用的视频片段"""
active_segments = [segment for segment in self.video_segments if segment.is_active]
sorted_segments = sorted(active_segments, key=lambda x: x.use_count, reverse=True)
return [asdict(segment) for segment in sorted_segments[:limit]]
def search_segments(self, keyword: str) -> List[Dict]:
"""搜索视频片段"""
keyword = keyword.lower()
results = []
for segment in self.video_segments:
if not segment.is_active:
continue
# 搜索文件名和标签
if (keyword in segment.filename.lower() or
any(keyword in tag.lower() for tag in segment.tags)):
results.append(asdict(segment))
return results
def delete_segment(self, segment_id: str) -> bool:
"""删除视频片段"""
for i, segment in enumerate(self.video_segments):
if segment.id == segment_id:
# 删除物理文件
try:
if os.path.exists(segment.file_path):
os.remove(segment.file_path)
# 删除缩略图
if segment.thumbnail_path and os.path.exists(segment.thumbnail_path):
os.remove(segment.thumbnail_path)
except Exception as e:
logger.error(f"Failed to delete physical files: {e}")
# 从列表中移除
deleted_segment = self.video_segments.pop(i)
self._save_video_segments()
logger.info(f"Deleted segment: {segment_id} - {deleted_segment.filename}")
return True
return False
def delete_original_video(self, video_id: str) -> bool:
"""删除原始视频及其所有片段"""
# 先删除所有相关片段
segments_to_delete = [s for s in self.video_segments if s.original_video_id == video_id]
for segment in segments_to_delete:
self.delete_segment(segment.id)
# 删除原始视频
for i, video in enumerate(self.original_videos):
if video.id == video_id:
# 删除物理文件
try:
if os.path.exists(video.file_path):
os.remove(video.file_path)
except Exception as e:
logger.error(f"Failed to delete video file: {e}")
# 从列表中移除
deleted_video = self.original_videos.pop(i)
self._save_original_videos()
logger.info(f"Deleted original video: {video_id} - {deleted_video.filename}")
return True
return False
# 全局实例
media_manager = MediaManager()
def main():
"""命令行接口 - 使用JSON-RPC协议"""
import sys
import json
# 创建响应处理器
rpc = create_response_handler()
if len(sys.argv) < 2:
rpc.error("INVALID_REQUEST", "No command specified")
return
command = sys.argv[1]
try:
if command == "get_all_segments":
segments = media_manager.get_all_segments()
rpc.success(segments)
elif command == "get_all_original_videos":
videos = media_manager.get_all_original_videos()
rpc.success(videos)
elif command == "get_segments_by_video_id":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Video ID required")
return
video_id = sys.argv[2]
segments = media_manager.get_segments_by_video_id(video_id)
rpc.success(segments)
elif command == "upload_video_file":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Source path required")
return
source_path = sys.argv[2]
filename = sys.argv[3] if len(sys.argv) > 3 else None
tags = json.loads(sys.argv[4]) if len(sys.argv) > 4 else []
result = media_manager.upload_video_file(source_path, filename, tags)
rpc.success(result)
elif command == "batch_upload_video_files":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Source directory required")
return
source_directory = sys.argv[2]
tags = json.loads(sys.argv[3]) if len(sys.argv) > 3 else []
result = media_manager.batch_upload_video_files(source_directory, tags)
rpc.success(result)
elif command == "add_segment_tags":
if len(sys.argv) < 4:
rpc.error("INVALID_REQUEST", "Segment ID and tags required")
return
segment_id = sys.argv[2]
tags = json.loads(sys.argv[3])
success = media_manager.add_segment_tags(segment_id, tags)
rpc.success(success)
elif command == "remove_segment_tags":
if len(sys.argv) < 4:
rpc.error("INVALID_REQUEST", "Segment ID and tags required")
return
segment_id = sys.argv[2]
tags = json.loads(sys.argv[3])
success = media_manager.remove_segment_tags(segment_id, tags)
rpc.success(success)
elif command == "increment_segment_usage":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Segment ID required")
return
segment_id = sys.argv[2]
success = media_manager.increment_segment_usage(segment_id)
rpc.success(success)
elif command == "get_segments_by_tags":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Tags required")
return
tags = json.loads(sys.argv[2])
match_all = json.loads(sys.argv[3]) if len(sys.argv) > 3 else False
segments = media_manager.get_segments_by_tags(tags, match_all)
rpc.success(segments)
elif command == "get_popular_segments":
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 10
segments = media_manager.get_popular_segments(limit)
rpc.success(segments)
elif command == "search_segments":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Search keyword required")
return
keyword = sys.argv[2]
results = media_manager.search_segments(keyword)
rpc.success(results)
elif command == "delete_segment":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Segment ID required")
return
segment_id = sys.argv[2]
success = media_manager.delete_segment(segment_id)
rpc.success(success)
elif command == "delete_original_video":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Video ID required")
return
video_id = sys.argv[2]
success = media_manager.delete_original_video(video_id)
rpc.success(success)
else:
rpc.error("INVALID_REQUEST", f"Unknown command: {command}")
except Exception as e:
logger.error(f"Command execution failed: {e}")
rpc.error("INTERNAL_ERROR", str(e))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,235 @@
use serde::{Deserialize, Serialize};
use tauri::{command, AppHandle};
use crate::python_executor::execute_python_command;
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadVideoRequest {
pub source_path: String,
pub filename: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchUploadVideoRequest {
pub source_directory: String,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TagsRequest {
pub segment_id: String,
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchTagsRequest {
pub tags: Vec<String>,
pub match_all: Option<bool>,
}
/// 获取所有视频片段
#[command]
pub async fn get_all_segments(app: AppHandle) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"get_all_segments".to_string(),
];
execute_python_command(app, &args, None).await
}
/// 获取所有原始视频
#[command]
pub async fn get_all_original_videos(app: AppHandle) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"get_all_original_videos".to_string(),
];
execute_python_command(app, &args, None).await
}
/// 根据视频ID获取片段
#[command]
pub async fn get_segments_by_video_id(app: AppHandle, video_id: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"get_segments_by_video_id".to_string(),
video_id,
];
execute_python_command(app, &args, None).await
}
/// 上传单个视频文件
#[command]
pub async fn upload_video_file(app: AppHandle, request: UploadVideoRequest) -> Result<String, String> {
let mut args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"upload_video_file".to_string(),
request.source_path,
];
if let Some(filename) = request.filename {
args.push(filename);
} else {
args.push("".to_string());
}
if let Some(tags) = request.tags {
let tags_json = serde_json::to_string(&tags)
.map_err(|e| format!("Failed to serialize tags: {}", e))?;
args.push(tags_json);
} else {
args.push("[]".to_string());
}
execute_python_command(app, &args, None).await
}
/// 批量上传视频文件
#[command]
pub async fn batch_upload_video_files(app: AppHandle, request: BatchUploadVideoRequest) -> Result<String, String> {
let mut args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"batch_upload_video_files".to_string(),
request.source_directory,
];
if let Some(tags) = request.tags {
let tags_json = serde_json::to_string(&tags)
.map_err(|e| format!("Failed to serialize tags: {}", e))?;
args.push(tags_json);
} else {
args.push("[]".to_string());
}
execute_python_command(app, &args, None).await
}
/// 添加片段标签
#[command]
pub async fn add_segment_tags(app: AppHandle, request: TagsRequest) -> Result<String, String> {
let tags_json = serde_json::to_string(&request.tags)
.map_err(|e| format!("Failed to serialize tags: {}", e))?;
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"add_segment_tags".to_string(),
request.segment_id,
tags_json,
];
execute_python_command(app, &args, None).await
}
/// 移除片段标签
#[command]
pub async fn remove_segment_tags(app: AppHandle, request: TagsRequest) -> Result<String, String> {
let tags_json = serde_json::to_string(&request.tags)
.map_err(|e| format!("Failed to serialize tags: {}", e))?;
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"remove_segment_tags".to_string(),
request.segment_id,
tags_json,
];
execute_python_command(app, &args, None).await
}
/// 增加片段使用次数
#[command]
pub async fn increment_segment_usage(app: AppHandle, segment_id: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"increment_segment_usage".to_string(),
segment_id,
];
execute_python_command(app, &args, None).await
}
/// 根据标签搜索片段
#[command]
pub async fn get_segments_by_tags(app: AppHandle, request: SearchTagsRequest) -> Result<String, String> {
let tags_json = serde_json::to_string(&request.tags)
.map_err(|e| format!("Failed to serialize tags: {}", e))?;
let match_all_json = serde_json::to_string(&request.match_all.unwrap_or(false))
.map_err(|e| format!("Failed to serialize match_all: {}", e))?;
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"get_segments_by_tags".to_string(),
tags_json,
match_all_json,
];
execute_python_command(app, &args, None).await
}
/// 获取热门片段
#[command]
pub async fn get_popular_segments(app: AppHandle, limit: Option<i32>) -> Result<String, String> {
let mut args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"get_popular_segments".to_string(),
];
if let Some(limit) = limit {
args.push(limit.to_string());
}
execute_python_command(app, &args, None).await
}
/// 搜索片段
#[command]
pub async fn search_segments(app: AppHandle, keyword: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"search_segments".to_string(),
keyword,
];
execute_python_command(app, &args, None).await
}
/// 删除片段
#[command]
pub async fn delete_segment(app: AppHandle, segment_id: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"delete_segment".to_string(),
segment_id,
];
execute_python_command(app, &args, None).await
}
/// 删除原始视频
#[command]
pub async fn delete_original_video(app: AppHandle, video_id: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.media_manager".to_string(),
"delete_original_video".to_string(),
video_id,
];
execute_python_command(app, &args, None).await
}

View File

@@ -8,6 +8,7 @@ pub mod template;
pub mod file_utils;
pub mod model;
pub mod audio;
pub mod media;
pub mod resource_category;
// Re-export all commands for easy access

View File

@@ -69,6 +69,19 @@ pub fn run() {
commands::audio::batch_upload_audio_files,
commands::audio::delete_audio_file,
commands::audio::search_audio_files,
commands::media::get_all_segments,
commands::media::get_all_original_videos,
commands::media::get_segments_by_video_id,
commands::media::upload_video_file,
commands::media::batch_upload_video_files,
commands::media::add_segment_tags,
commands::media::remove_segment_tags,
commands::media::increment_segment_usage,
commands::media::get_segments_by_tags,
commands::media::get_popular_segments,
commands::media::search_segments,
commands::media::delete_segment,
commands::media::delete_original_video,
commands::file_utils::read_image_as_data_url
])
.run(tauri::generate_context!())

View File

@@ -11,6 +11,7 @@ import ResourceCategoryPage from './pages/ResourceCategoryPage'
import ProjectManagePage from './pages/ProjectManagePage'
import ModelManagePage from './pages/ModelManagePage'
import AudioLibraryPage from './pages/AudioLibraryPage'
import MediaLibraryPage from './pages/MediaLibraryPage'
import KVTestPage from './pages/KVTestPage'
function App() {
@@ -26,6 +27,7 @@ function App() {
<Route path="/projects" element={<ProjectManagePage />} />
<Route path="/models" element={<ModelManagePage />} />
<Route path="/audio" element={<AudioLibraryPage />} />
<Route path="/media" element={<MediaLibraryPage />} />
<Route path="/kv-test" element={<KVTestPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>

View File

@@ -0,0 +1,251 @@
import React, { useState } from 'react'
import { Play, Pause, Video, Trash2, Tag, Eye, Clock, HardDrive, Hash, TrendingUp, X } from 'lucide-react'
import { VideoSegment, MediaService } from '../services/mediaService'
import ProjectImage from './ProjectImage'
interface VideoSegmentCardProps {
segment: VideoSegment
onDelete: (segmentId: string) => void
onTagsUpdate: (segmentId: string, tags: string[]) => void
onUse: (segmentId: string) => void
}
const VideoSegmentCard: React.FC<VideoSegmentCardProps> = ({
segment,
onDelete,
onTagsUpdate,
onUse
}) => {
const [isPlaying, setIsPlaying] = useState(false)
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null)
const [showTagEditor, setShowTagEditor] = useState(false)
const [newTag, setNewTag] = useState('')
const handlePlayPause = () => {
if (!videoElement) {
// 创建视频元素
const video_el = document.createElement('video')
video_el.src = `file://${segment.file_path}`
video_el.addEventListener('ended', () => setIsPlaying(false))
video_el.addEventListener('error', (e) => {
console.error('Video playback error:', e)
setIsPlaying(false)
})
setVideoElement(video_el)
video_el.play()
.then(() => setIsPlaying(true))
.catch(err => {
console.error('Failed to play video:', err)
setIsPlaying(false)
})
} else {
if (isPlaying) {
videoElement.pause()
setIsPlaying(false)
} else {
videoElement.play()
.then(() => setIsPlaying(true))
.catch(err => {
console.error('Failed to play video:', err)
setIsPlaying(false)
})
}
}
}
const handleUse = () => {
onUse(segment.id)
// 增加使用次数
MediaService.incrementSegmentUsage(segment.id)
}
const handleDelete = () => {
if (videoElement) {
videoElement.pause()
setVideoElement(null)
setIsPlaying(false)
}
onDelete(segment.id)
}
const handleAddTag = () => {
if (newTag.trim()) {
const updatedTags = [...segment.tags, newTag.trim()]
onTagsUpdate(segment.id, updatedTags)
setNewTag('')
}
}
const handleRemoveTag = (tagToRemove: string) => {
const updatedTags = segment.tags.filter(tag => tag !== tagToRemove)
onTagsUpdate(segment.id, updatedTags)
}
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
{/* 视频缩略图 */}
<div className="relative h-48 bg-gray-100 overflow-hidden">
{segment.thumbnail_path ? (
<ProjectImage
imagePath={segment.thumbnail_path}
alt={`片段 ${segment.segment_index + 1}`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<Video size={48} className="text-white" />
</div>
)}
{/* 播放按钮覆盖层 */}
<div className="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-30 transition-all flex items-center justify-center">
<button
onClick={handlePlayPause}
className="p-3 bg-white/80 rounded-full hover:bg-white transition-colors opacity-0 hover:opacity-100"
title={isPlaying ? '暂停' : '播放'}
>
{isPlaying ? <Pause size={24} className="text-gray-800" /> : <Play size={24} className="text-gray-800" />}
</button>
</div>
{/* 片段信息覆盖层 */}
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
{segment.segment_index + 1}
</div>
{/* 使用次数 */}
{segment.use_count > 0 && (
<div className="absolute top-2 right-2 bg-green-500 text-white px-2 py-1 rounded text-xs flex items-center">
<TrendingUp size={12} className="mr-1" />
{segment.use_count}
</div>
)}
</div>
{/* 片段详细信息 */}
<div className="p-4 space-y-3">
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center space-x-2">
<Clock size={14} className="text-gray-400" />
<span className="text-gray-600">:</span>
<span className="font-medium">{MediaService.formatDuration(segment.duration)}</span>
</div>
<div className="flex items-center space-x-2">
<HardDrive size={14} className="text-gray-400" />
<span className="text-gray-600">:</span>
<span className="font-medium">{MediaService.formatFileSize(segment.file_size)}</span>
</div>
</div>
{/* 视频参数 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{MediaService.formatResolution(segment.width, segment.height)}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{segment.fps.toFixed(1)} fps</span>
</div>
</div>
{/* 时间范围 */}
<div className="text-sm">
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">
{MediaService.formatDuration(segment.start_time)} - {MediaService.formatDuration(segment.end_time)}
</span>
</div>
{/* 标签 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">:</span>
<button
onClick={() => setShowTagEditor(!showTagEditor)}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="编辑标签"
>
<Tag size={14} />
</button>
</div>
{/* 标签列表 */}
<div className="flex flex-wrap gap-1">
{segment.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full cursor-pointer hover:bg-blue-200"
onClick={() => handleRemoveTag(tag)}
title="点击移除标签"
>
{tag}
<X size={10} className="ml-1" />
</span>
))}
</div>
{/* 标签编辑器 */}
{showTagEditor && (
<div className="flex space-x-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="添加标签"
className="flex-1 px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-transparent"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<button
onClick={handleAddTag}
className="px-2 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
</button>
</div>
)}
</div>
{/* MD5哈希 */}
<div className="text-xs text-gray-500 flex items-center space-x-1">
<Hash size={12} />
<span>MD5:</span>
<code className="bg-gray-100 px-1 rounded font-mono">
{segment.md5_hash.substring(0, 8)}...
</code>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div className="text-xs text-gray-400">
{new Date(segment.created_at).toLocaleDateString()}
</div>
<div className="flex space-x-2">
{/* 使用按钮 */}
<button
onClick={handleUse}
className="p-2 text-gray-400 hover:text-green-600 transition-colors"
title="使用此片段"
>
<Eye size={16} />
</button>
{/* 删除按钮 */}
<button
onClick={handleDelete}
className="p-2 text-gray-400 hover:text-red-600 transition-colors"
title="删除"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
</div>
)
}
export default VideoSegmentCard

View File

@@ -0,0 +1,397 @@
import React, { useState } from 'react'
import { Upload, FolderOpen, X, Video, CheckCircle, AlertCircle, Scissors } from 'lucide-react'
import { open } from '@tauri-apps/plugin-dialog'
import { MediaService, BatchUploadResult } from '../services/mediaService'
interface VideoUploadProps {
isOpen: boolean
onClose: () => void
onUploadComplete: () => void
}
const VideoUpload: React.FC<VideoUploadProps> = ({
isOpen,
onClose,
onUploadComplete
}) => {
const [uploading, setUploading] = useState(false)
const [uploadResult, setUploadResult] = useState<BatchUploadResult | null>(null)
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
const [selectedDirectory, setSelectedDirectory] = useState<string>('')
const [tags, setTags] = useState<string[]>([])
const [newTag, setNewTag] = useState('')
if (!isOpen) return null
const selectFiles = async () => {
try {
const files = await open({
multiple: true,
title: '选择视频文件',
filters: [
{
name: '视频文件',
extensions: ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm', 'm4v']
}
]
})
if (files && Array.isArray(files)) {
setSelectedFiles(files)
setSelectedDirectory('')
}
} catch (error) {
console.error('Failed to select files:', error)
}
}
const selectDirectory = async () => {
try {
const directory = await open({
directory: true,
multiple: false,
title: '选择视频文件夹'
})
if (directory && typeof directory === 'string') {
setSelectedDirectory(directory)
setSelectedFiles([])
}
} catch (error) {
console.error('Failed to select directory:', error)
}
}
const addTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
setTags([...tags, newTag.trim()])
setNewTag('')
}
}
const removeTag = (tagToRemove: string) => {
setTags(tags.filter(tag => tag !== tagToRemove))
}
const handleUpload = async () => {
if (selectedFiles.length === 0 && !selectedDirectory) {
alert('请先选择文件或文件夹')
return
}
setUploading(true)
setUploadResult(null)
try {
if (selectedDirectory) {
// 批量上传文件夹
const response = await MediaService.batchUploadVideoFiles({
source_directory: selectedDirectory,
tags: tags.length > 0 ? tags : undefined
})
if (response.status && response.data) {
setUploadResult(response.data)
} else {
alert('批量上传失败: ' + (response.msg || '未知错误'))
}
} else {
// 上传单个或多个文件
const results: BatchUploadResult = {
total_files: selectedFiles.length,
uploaded_files: 0,
skipped_files: 0,
failed_files: 0,
total_segments: 0,
uploaded_list: [],
skipped_list: [],
failed_list: []
}
for (const filePath of selectedFiles) {
try {
const response = await MediaService.uploadVideoFile({
source_path: filePath,
tags: tags.length > 0 ? tags : undefined
})
if (response.status && response.data) {
if (response.data.is_duplicate) {
results.skipped_files++
results.skipped_list.push({
filename: filePath.split('/').pop() || filePath,
reason: 'Already exists (same MD5)'
})
} else {
results.uploaded_files++
results.total_segments += response.data.segments.length
results.uploaded_list.push(response.data)
}
} else {
results.failed_files++
results.failed_list.push({
filename: filePath.split('/').pop() || filePath,
error: response.msg || '未知错误'
})
}
} catch (error) {
results.failed_files++
results.failed_list.push({
filename: filePath.split('/').pop() || filePath,
error: error instanceof Error ? error.message : '未知错误'
})
}
}
setUploadResult(results)
}
} catch (error) {
console.error('Upload failed:', error)
alert('上传失败: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setUploading(false)
}
}
const handleClose = () => {
if (uploadResult && uploadResult.uploaded_files > 0) {
onUploadComplete()
}
setSelectedFiles([])
setSelectedDirectory('')
setUploadResult(null)
setTags([])
setNewTag('')
onClose()
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 flex items-center">
<Upload className="mr-2" size={20} />
</h2>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
>
<X size={24} />
</button>
</div>
{/* 内容区域 */}
<div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-120px)]">
{!uploadResult ? (
<>
{/* 选择文件方式 */}
<div className="space-y-4">
<h3 className="text-md font-medium text-gray-900"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 选择文件 */}
<button
onClick={selectFiles}
disabled={uploading}
className="p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition-colors disabled:opacity-50"
>
<div className="text-center">
<Video className="mx-auto mb-2 text-gray-400" size={32} />
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
{/* 选择文件夹 */}
<button
onClick={selectDirectory}
disabled={uploading}
className="p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-green-400 hover:bg-green-50 transition-colors disabled:opacity-50"
>
<div className="text-center">
<FolderOpen className="mx-auto mb-2 text-gray-400" size={32} />
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
</div>
</div>
{/* 标签设置 */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900"></h4>
{/* 标签输入 */}
<div className="flex space-x-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="输入标签名称"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onKeyPress={(e) => e.key === 'Enter' && addTag()}
/>
<button
onClick={addTag}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
</button>
</div>
{/* 标签列表 */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
>
{tag}
<button
onClick={() => removeTag(tag)}
className="ml-2 text-blue-600 hover:text-blue-800"
>
<X size={14} />
</button>
</span>
))}
</div>
)}
</div>
{/* 已选择的文件/文件夹 */}
{(selectedFiles.length > 0 || selectedDirectory) && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-900">:</h4>
<div className="bg-gray-50 rounded-lg p-3 max-h-32 overflow-y-auto">
{selectedDirectory ? (
<div className="text-sm text-gray-700 flex items-center">
<FolderOpen size={16} className="mr-2" />
{selectedDirectory}
</div>
) : (
selectedFiles.map((file, index) => (
<div key={index} className="text-sm text-gray-700 flex items-center">
<Video size={16} className="mr-2" />
{file.split('/').pop()}
</div>
))
)}
</div>
</div>
)}
{/* 功能说明 */}
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Scissors className="text-blue-600 mt-1" size={20} />
<div>
<h4 className="text-sm font-medium text-blue-900"></h4>
<p className="text-sm text-blue-700 mt-1">
MD5标识使
</p>
</div>
</div>
</div>
{/* 上传按钮 */}
<div className="flex justify-end space-x-3">
<button
onClick={handleClose}
disabled={uploading}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
</button>
<button
onClick={handleUpload}
disabled={uploading || (selectedFiles.length === 0 && !selectedDirectory)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center"
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Upload size={16} className="mr-2" />
</>
)}
</button>
</div>
</>
) : (
/* 上传结果 */
<div className="space-y-4">
<h3 className="text-md font-medium text-gray-900"></h3>
{/* 统计信息 */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{uploadResult.total_files}</div>
<div className="text-sm text-blue-600"></div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{uploadResult.uploaded_files}</div>
<div className="text-sm text-green-600"></div>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">{uploadResult.total_segments}</div>
<div className="text-sm text-purple-600"></div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{uploadResult.skipped_files}</div>
<div className="text-sm text-yellow-600"></div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{uploadResult.failed_files}</div>
<div className="text-sm text-red-600"></div>
</div>
</div>
{/* 详细结果 */}
<div className="space-y-3 max-h-64 overflow-y-auto">
{/* 成功上传的文件 */}
{uploadResult.uploaded_list.map((result, index) => (
<div key={index} className="flex items-center space-x-2 text-sm">
<CheckCircle size={16} className="text-green-500" />
<span className="text-gray-700">{result.original_video.filename}</span>
<span className="text-gray-500">
({MediaService.formatFileSize(result.original_video.file_size)}, {result.segments.length} )
</span>
</div>
))}
{/* 失败的文件 */}
{uploadResult.failed_list.map((failed, index) => (
<div key={index} className="flex items-center space-x-2 text-sm">
<AlertCircle size={16} className="text-red-500" />
<span className="text-gray-700">{failed.filename}</span>
<span className="text-red-500 text-xs">({failed.error})</span>
</div>
))}
</div>
{/* 关闭按钮 */}
<div className="flex justify-end">
<button
onClick={handleClose}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default VideoUpload

View File

@@ -0,0 +1,305 @@
import React, { useState, useEffect } from 'react'
import { Search, Upload, Filter, TrendingUp, Video } from 'lucide-react'
import { MediaService, VideoSegment } from '../services/mediaService'
import VideoSegmentCard from '../components/VideoSegmentCard'
import VideoUpload from '../components/VideoUpload'
const MediaLibraryPage: React.FC = () => {
const [segments, setSegments] = useState<VideoSegment[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [showUpload, setShowUpload] = useState(false)
const [filterTags, setFilterTags] = useState<string[]>([])
const [showPopular, setShowPopular] = useState(false)
useEffect(() => {
loadSegments()
}, [])
const loadSegments = async () => {
try {
setLoading(true)
const response = await MediaService.getAllSegments()
if (response.status && response.data) {
setSegments(response.data)
} else {
console.error('Failed to load segments:', response.msg)
}
} catch (error) {
console.error('Failed to load segments:', error)
} finally {
setLoading(false)
}
}
const loadPopularSegments = async () => {
try {
setLoading(true)
const response = await MediaService.getPopularSegments(20)
if (response.status && response.data) {
setSegments(response.data)
setShowPopular(true)
} else {
console.error('Failed to load popular segments:', response.msg)
}
} catch (error) {
console.error('Failed to load popular segments:', error)
} finally {
setLoading(false)
}
}
const handleDeleteSegment = async (segmentId: string) => {
const isOk = confirm('确定要删除这个视频片段吗?')
if (!isOk) return
try {
const response = await MediaService.deleteSegment(segmentId)
if (response.status) {
setSegments(segments.filter(segment => segment.id !== segmentId))
} else {
console.error('删除失败:', response.msg || '未知错误')
alert('删除失败: ' + (response.msg || '未知错误'))
}
} catch (error) {
console.error('Failed to delete segment:', error)
alert('删除失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
const handleTagsUpdate = async (segmentId: string, newTags: string[]) => {
// 找到当前片段
const segment = segments.find(s => s.id === segmentId)
if (!segment) return
const currentTags = segment.tags
const tagsToAdd = newTags.filter(tag => !currentTags.includes(tag))
const tagsToRemove = currentTags.filter(tag => !newTags.includes(tag))
try {
// 添加新标签
if (tagsToAdd.length > 0) {
await MediaService.addSegmentTags({ segment_id: segmentId, tags: tagsToAdd })
}
// 移除标签
if (tagsToRemove.length > 0) {
await MediaService.removeSegmentTags({ segment_id: segmentId, tags: tagsToRemove })
}
// 更新本地状态
setSegments(segments.map(s =>
s.id === segmentId ? { ...s, tags: newTags } : s
))
} catch (error) {
console.error('Failed to update tags:', error)
alert('标签更新失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
const handleUseSegment = (segmentId: string) => {
// 更新使用次数
setSegments(segments.map(s =>
s.id === segmentId ? { ...s, use_count: s.use_count + 1 } : s
))
}
const handleUploadComplete = () => {
loadSegments()
setShowPopular(false)
}
const handleSearch = async () => {
if (!searchTerm.trim()) {
loadSegments()
return
}
try {
setLoading(true)
const response = await MediaService.searchSegments(searchTerm)
if (response.status && response.data) {
setSegments(response.data)
setShowPopular(false)
} else {
console.error('Search failed:', response.msg)
}
} catch (error) {
console.error('Failed to search segments:', error)
} finally {
setLoading(false)
}
}
const filteredSegments = segments.filter(segment => {
if (filterTags.length === 0) return true
return filterTags.some(tag => segment.tags.includes(tag))
})
// 获取所有标签
const allTags = Array.from(new Set(segments.flatMap(s => s.tags)))
return (
<div className="p-6">
{/* 页面标题 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-2">使</p>
</div>
{/* 搜索和操作栏 */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between mb-6 space-y-4 lg:space-y-0">
{/* 搜索框 */}
<div className="flex space-x-2">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="搜索视频片段..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent w-full"
/>
</div>
<button
onClick={handleSearch}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
</div>
{/* 操作按钮 */}
<div className="flex space-x-2">
<button
onClick={loadPopularSegments}
className="flex items-center px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
>
<TrendingUp size={16} className="mr-2" />
</button>
<button
onClick={() => {
loadSegments()
setShowPopular(false)
}}
className="flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
<Video size={16} className="mr-2" />
</button>
<button
onClick={() => setShowUpload(true)}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload size={16} className="mr-2" />
</button>
</div>
</div>
{/* 标签过滤器 */}
{allTags.length > 0 && (
<div className="mb-6">
<div className="flex items-center space-x-2 mb-2">
<Filter size={16} className="text-gray-400" />
<span className="text-sm text-gray-600">:</span>
</div>
<div className="flex flex-wrap gap-2">
{allTags.map((tag, index) => (
<button
key={index}
onClick={() => {
if (filterTags.includes(tag)) {
setFilterTags(filterTags.filter(t => t !== tag))
} else {
setFilterTags([...filterTags, tag])
}
}}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filterTags.includes(tag)
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tag}
</button>
))}
{filterTags.length > 0 && (
<button
onClick={() => setFilterTags([])}
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded-full hover:bg-red-200 transition-colors"
>
</button>
)}
</div>
</div>
)}
{/* 状态指示 */}
{showPopular && (
<div className="mb-4 p-3 bg-purple-50 border border-purple-200 rounded-lg">
<div className="flex items-center text-purple-700">
<TrendingUp size={16} className="mr-2" />
<span className="text-sm">使</span>
</div>
</div>
)}
{/* 视频片段列表 */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="h-48 bg-gray-200 animate-pulse"></div>
<div className="p-4 space-y-3">
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse w-1/2"></div>
</div>
</div>
))}
</div>
) : filteredSegments.length === 0 ? (
<div className="text-center py-12">
<Video className="mx-auto text-gray-400 mb-4" size={64} />
<div className="text-gray-400 mb-4">
{searchTerm || filterTags.length > 0 ? '没有找到匹配的视频片段' : '暂无视频片段'}
</div>
{!searchTerm && filterTags.length === 0 && (
<button
onClick={() => setShowUpload(true)}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload size={16} className="mr-2" />
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredSegments.map((segment) => (
<VideoSegmentCard
key={segment.id}
segment={segment}
onDelete={handleDeleteSegment}
onTagsUpdate={handleTagsUpdate}
onUse={handleUseSegment}
/>
))}
</div>
)}
{/* 上传对话框 */}
<VideoUpload
isOpen={showUpload}
onClose={() => setShowUpload(false)}
onUploadComplete={handleUploadComplete}
/>
</div>
)
}
export default MediaLibraryPage

View File

@@ -0,0 +1,431 @@
import { invoke } from '@tauri-apps/api/core'
export interface VideoSegment {
id: string
original_video_id: string
segment_index: number
filename: string
file_path: string
md5_hash: string
file_size: number
duration: number
width: number
height: number
fps: number
format: string
start_time: number
end_time: number
tags: string[]
use_count: number
thumbnail_path?: string
scene_score?: number
motion_score?: number
brightness?: number
contrast?: number
created_at: string
updated_at: string
is_active: boolean
}
export interface OriginalVideo {
id: string
filename: string
file_path: string
md5_hash: string
file_size: number
duration: number
width: number
height: number
fps: number
format: string
segment_count: number
tags: string[]
created_at: string
updated_at: string
is_active: boolean
}
export interface UploadVideoRequest {
source_path: string
filename?: string
tags?: string[]
}
export interface BatchUploadVideoRequest {
source_directory: string
tags?: string[]
}
export interface TagsRequest {
segment_id: string
tags: string[]
}
export interface SearchTagsRequest {
tags: string[]
match_all?: boolean
}
export interface UploadVideoResult {
original_video: OriginalVideo
segments: VideoSegment[]
is_duplicate: boolean
}
export interface BatchUploadResult {
total_files: number
uploaded_files: number
skipped_files: number
failed_files: number
total_segments: number
uploaded_list: UploadVideoResult[]
skipped_list: Array<{ filename: string; reason: string }>
failed_list: Array<{ filename: string; error: string }>
}
export interface ApiResponse<T> {
status: boolean
data?: T
msg?: string
}
export class MediaService {
/**
* 尝试解析JSON响应
*/
private static tryJsonParse(result: any): any {
if (typeof result === 'string') {
try {
const parsed = JSON.parse(result)
// 如果是JSON-RPC响应提取result字段
if (parsed.jsonrpc && 'result' in parsed) {
return parsed.result
}
// 如果是普通JSON响应直接返回
return parsed
} catch (error) {
console.error('Failed to parse JSON:', error)
return result
}
}
return result
}
/**
* 获取所有视频片段
*/
static async getAllSegments(): Promise<ApiResponse<VideoSegment[]>> {
try {
const result = await invoke('get_all_segments')
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<VideoSegment[]>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<VideoSegment[]>
} catch (error) {
console.error('Failed to get all segments:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 获取所有原始视频
*/
static async getAllOriginalVideos(): Promise<ApiResponse<OriginalVideo[]>> {
try {
const result = await invoke('get_all_original_videos')
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<OriginalVideo[]>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<OriginalVideo[]>
} catch (error) {
console.error('Failed to get all original videos:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 根据视频ID获取片段
*/
static async getSegmentsByVideoId(videoId: string): Promise<ApiResponse<VideoSegment[]>> {
try {
const result = await invoke('get_segments_by_video_id', { videoId })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<VideoSegment[]>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<VideoSegment[]>
} catch (error) {
console.error('Failed to get segments by video id:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 上传单个视频文件
*/
static async uploadVideoFile(request: UploadVideoRequest): Promise<ApiResponse<UploadVideoResult>> {
try {
const result = await invoke('upload_video_file', { request })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<UploadVideoResult>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<UploadVideoResult>
} catch (error) {
console.error('Failed to upload video file:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 批量上传视频文件
*/
static async batchUploadVideoFiles(request: BatchUploadVideoRequest): Promise<ApiResponse<BatchUploadResult>> {
try {
const result = await invoke('batch_upload_video_files', { request })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<BatchUploadResult>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<BatchUploadResult>
} catch (error) {
console.error('Failed to batch upload video files:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 添加片段标签
*/
static async addSegmentTags(request: TagsRequest): Promise<ApiResponse<boolean>> {
try {
const result = await invoke('add_segment_tags', { request })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<boolean>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<boolean>
} catch (error) {
console.error('Failed to add segment tags:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 移除片段标签
*/
static async removeSegmentTags(request: TagsRequest): Promise<ApiResponse<boolean>> {
try {
const result = await invoke('remove_segment_tags', { request })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<boolean>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<boolean>
} catch (error) {
console.error('Failed to remove segment tags:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 增加片段使用次数
*/
static async incrementSegmentUsage(segmentId: string): Promise<ApiResponse<boolean>> {
try {
const result = await invoke('increment_segment_usage', { segmentId })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<boolean>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<boolean>
} catch (error) {
console.error('Failed to increment segment usage:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 根据标签搜索片段
*/
static async getSegmentsByTags(request: SearchTagsRequest): Promise<ApiResponse<VideoSegment[]>> {
try {
const result = await invoke('get_segments_by_tags', { request })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<VideoSegment[]>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<VideoSegment[]>
} catch (error) {
console.error('Failed to get segments by tags:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 获取热门片段
*/
static async getPopularSegments(limit?: number): Promise<ApiResponse<VideoSegment[]>> {
try {
const result = await invoke('get_popular_segments', { limit })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<VideoSegment[]>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<VideoSegment[]>
} catch (error) {
console.error('Failed to get popular segments:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 搜索片段
*/
static async searchSegments(keyword: string): Promise<ApiResponse<VideoSegment[]>> {
try {
const result = await invoke('search_segments', { keyword })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<VideoSegment[]>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<VideoSegment[]>
} catch (error) {
console.error('Failed to search segments:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 删除片段
*/
static async deleteSegment(segmentId: string): Promise<ApiResponse<boolean>> {
try {
const result = await invoke('delete_segment', { segmentId })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<boolean>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<boolean>
} catch (error) {
console.error('Failed to delete segment:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 删除原始视频
*/
static async deleteOriginalVideo(videoId: string): Promise<ApiResponse<boolean>> {
try {
const result = await invoke('delete_original_video', { videoId })
const parsed = this.tryJsonParse(result)
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<boolean>
}
return { status: true, msg: 'ok', data: parsed } as ApiResponse<boolean>
} catch (error) {
console.error('Failed to delete original video:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 格式化文件大小
*/
static formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 格式化时长
*/
static formatDuration(seconds: number): string {
if (seconds === 0) return '0:00'
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
/**
* 格式化分辨率
*/
static formatResolution(width: number, height: number): string {
return `${width}×${height}`
}
}