fix: 素材管理
This commit is contained in:
@@ -6,6 +6,9 @@ pyautogui
|
||||
celery
|
||||
redis
|
||||
numpy
|
||||
Click
|
||||
tqdm
|
||||
appdirs
|
||||
scipy
|
||||
matplotlib
|
||||
pillow
|
||||
|
||||
833
python_core/services/media_manager.py
Normal file
833
python_core/services/media_manager.py
Normal 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()
|
||||
235
src-tauri/src/commands/media.rs
Normal file
235
src-tauri/src/commands/media.rs
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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!())
|
||||
|
||||
@@ -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>
|
||||
|
||||
251
src/components/VideoSegmentCard.tsx
Normal file
251
src/components/VideoSegmentCard.tsx
Normal 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
|
||||
397
src/components/VideoUpload.tsx
Normal file
397
src/components/VideoUpload.tsx
Normal 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
|
||||
305
src/pages/MediaLibraryPage.tsx
Normal file
305
src/pages/MediaLibraryPage.tsx
Normal 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
|
||||
431
src/services/mediaService.ts
Normal file
431
src/services/mediaService.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user