From 074a6b7834119f08d44939dce775913984eef68d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 11 Jul 2025 11:08:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=B4=A0=E6=9D=90=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python_core/requirements.txt | 3 + python_core/services/media_manager.py | 833 ++++++++++++++++++++++++++ src-tauri/src/commands/media.rs | 235 ++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 13 + src/App.tsx | 2 + src/components/VideoSegmentCard.tsx | 251 ++++++++ src/components/VideoUpload.tsx | 397 ++++++++++++ src/pages/MediaLibraryPage.tsx | 305 ++++++++++ src/services/mediaService.ts | 431 +++++++++++++ 10 files changed, 2471 insertions(+) create mode 100644 python_core/services/media_manager.py create mode 100644 src-tauri/src/commands/media.rs create mode 100644 src/components/VideoSegmentCard.tsx create mode 100644 src/components/VideoUpload.tsx create mode 100644 src/pages/MediaLibraryPage.tsx create mode 100644 src/services/mediaService.ts diff --git a/python_core/requirements.txt b/python_core/requirements.txt index 4656690..9b9bff3 100644 --- a/python_core/requirements.txt +++ b/python_core/requirements.txt @@ -6,6 +6,9 @@ pyautogui celery redis numpy +Click +tqdm +appdirs scipy matplotlib pillow diff --git a/python_core/services/media_manager.py b/python_core/services/media_manager.py new file mode 100644 index 0000000..82b38ea --- /dev/null +++ b/python_core/services/media_manager.py @@ -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() diff --git a/src-tauri/src/commands/media.rs b/src-tauri/src/commands/media.rs new file mode 100644 index 0000000..5227c62 --- /dev/null +++ b/src-tauri/src/commands/media.rs @@ -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, + pub tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchUploadVideoRequest { + pub source_directory: String, + pub tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TagsRequest { + pub segment_id: String, + pub tags: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SearchTagsRequest { + pub tags: Vec, + pub match_all: Option, +} + +/// 获取所有视频片段 +#[command] +pub async fn get_all_segments(app: AppHandle) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) -> Result { + 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 { + 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 { + 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 { + 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 +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 82e5cdc..663bede 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 68a36cb..1b89306 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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!()) diff --git a/src/App.tsx b/src/App.tsx index 5370a51..16556ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/VideoSegmentCard.tsx b/src/components/VideoSegmentCard.tsx new file mode 100644 index 0000000..9bba8cb --- /dev/null +++ b/src/components/VideoSegmentCard.tsx @@ -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 = ({ + segment, + onDelete, + onTagsUpdate, + onUse +}) => { + const [isPlaying, setIsPlaying] = useState(false) + const [videoElement, setVideoElement] = useState(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 ( +
+ {/* 视频缩略图 */} +
+ {segment.thumbnail_path ? ( + + ) : ( +
+
+ )} + + {/* 播放按钮覆盖层 */} +
+ +
+ + {/* 片段信息覆盖层 */} +
+ 片段 {segment.segment_index + 1} +
+ + {/* 使用次数 */} + {segment.use_count > 0 && ( +
+ + {segment.use_count} +
+ )} +
+ + {/* 片段详细信息 */} +
+ {/* 基本信息 */} +
+
+ + 时长: + {MediaService.formatDuration(segment.duration)} +
+
+ + 大小: + {MediaService.formatFileSize(segment.file_size)} +
+
+ + {/* 视频参数 */} +
+
+ 分辨率: + {MediaService.formatResolution(segment.width, segment.height)} +
+
+ 帧率: + {segment.fps.toFixed(1)} fps +
+
+ + {/* 时间范围 */} +
+ 时间范围: + + {MediaService.formatDuration(segment.start_time)} - {MediaService.formatDuration(segment.end_time)} + +
+ + {/* 标签 */} +
+
+ 标签: + +
+ + {/* 标签列表 */} +
+ {segment.tags.map((tag, index) => ( + handleRemoveTag(tag)} + title="点击移除标签" + > + {tag} + + + ))} +
+ + {/* 标签编辑器 */} + {showTagEditor && ( +
+ 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()} + /> + +
+ )} +
+ + {/* MD5哈希 */} +
+ + MD5: + + {segment.md5_hash.substring(0, 8)}... + +
+ + {/* 操作按钮 */} +
+
+ {new Date(segment.created_at).toLocaleDateString()} +
+ +
+ {/* 使用按钮 */} + + + {/* 删除按钮 */} + +
+
+
+
+ ) +} + +export default VideoSegmentCard diff --git a/src/components/VideoUpload.tsx b/src/components/VideoUpload.tsx new file mode 100644 index 0000000..817b10a --- /dev/null +++ b/src/components/VideoUpload.tsx @@ -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 = ({ + isOpen, + onClose, + onUploadComplete +}) => { + const [uploading, setUploading] = useState(false) + const [uploadResult, setUploadResult] = useState(null) + const [selectedFiles, setSelectedFiles] = useState([]) + const [selectedDirectory, setSelectedDirectory] = useState('') + const [tags, setTags] = useState([]) + 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 ( +
+
+ {/* 标题栏 */} +
+

+ + 视频上传与分割 +

+ +
+ + {/* 内容区域 */} +
+ {!uploadResult ? ( + <> + {/* 选择文件方式 */} +
+

选择上传方式

+ +
+ {/* 选择文件 */} + + + {/* 选择文件夹 */} + +
+
+ + {/* 标签设置 */} +
+

添加标签(可选)

+ + {/* 标签输入 */} +
+ 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()} + /> + +
+ + {/* 标签列表 */} + {tags.length > 0 && ( +
+ {tags.map((tag, index) => ( + + {tag} + + + ))} +
+ )} +
+ + {/* 已选择的文件/文件夹 */} + {(selectedFiles.length > 0 || selectedDirectory) && ( +
+

已选择:

+
+ {selectedDirectory ? ( +
+ + {selectedDirectory} +
+ ) : ( + selectedFiles.map((file, index) => ( +
+
+ )) + )} +
+
+ )} + + {/* 功能说明 */} +
+
+ +
+

智能视频分割

+

+ 上传的视频将自动按转场镜头分割成多个片段,每个片段都有独立的MD5标识、标签和使用统计。 +

+
+
+
+ + {/* 上传按钮 */} +
+ + +
+ + ) : ( + /* 上传结果 */ +
+

上传结果

+ + {/* 统计信息 */} +
+
+
{uploadResult.total_files}
+
总文件数
+
+
+
{uploadResult.uploaded_files}
+
成功上传
+
+
+
{uploadResult.total_segments}
+
生成片段
+
+
+
{uploadResult.skipped_files}
+
跳过文件
+
+
+
{uploadResult.failed_files}
+
失败文件
+
+
+ + {/* 详细结果 */} +
+ {/* 成功上传的文件 */} + {uploadResult.uploaded_list.map((result, index) => ( +
+ + {result.original_video.filename} + + ({MediaService.formatFileSize(result.original_video.file_size)}, {result.segments.length} 片段) + +
+ ))} + + {/* 失败的文件 */} + {uploadResult.failed_list.map((failed, index) => ( +
+ + {failed.filename} + ({failed.error}) +
+ ))} +
+ + {/* 关闭按钮 */} +
+ +
+
+ )} +
+
+
+ ) +} + +export default VideoUpload diff --git a/src/pages/MediaLibraryPage.tsx b/src/pages/MediaLibraryPage.tsx new file mode 100644 index 0000000..5b4a9e2 --- /dev/null +++ b/src/pages/MediaLibraryPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [searchTerm, setSearchTerm] = useState('') + const [showUpload, setShowUpload] = useState(false) + const [filterTags, setFilterTags] = useState([]) + 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 ( +
+ {/* 页面标题 */} +
+

媒体库

+

管理视频素材,支持智能分割、标签管理和使用统计

+
+ + {/* 搜索和操作栏 */} +
+ {/* 搜索框 */} +
+
+ + 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" + /> +
+ +
+ + {/* 操作按钮 */} +
+ + + +
+
+ + {/* 标签过滤器 */} + {allTags.length > 0 && ( +
+
+ + 标签过滤: +
+
+ {allTags.map((tag, index) => ( + + ))} + {filterTags.length > 0 && ( + + )} +
+
+ )} + + {/* 状态指示 */} + {showPopular && ( +
+
+ + 正在显示热门片段(按使用次数排序) +
+
+ )} + + {/* 视频片段列表 */} + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : filteredSegments.length === 0 ? ( +
+
+ ) : ( +
+ {filteredSegments.map((segment) => ( + + ))} +
+ )} + + {/* 上传对话框 */} + setShowUpload(false)} + onUploadComplete={handleUploadComplete} + /> +
+ ) +} + +export default MediaLibraryPage diff --git a/src/services/mediaService.ts b/src/services/mediaService.ts new file mode 100644 index 0000000..593a179 --- /dev/null +++ b/src/services/mediaService.ts @@ -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 { + 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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> { + 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 + } + + return { status: true, msg: 'ok', data: parsed } as ApiResponse + } 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}` + } +}