first commit
93
.gitignore
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
target/
|
||||
python_core/__pycache__/
|
||||
python_core/**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/Cargo.lock
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.exe
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Temporary files
|
||||
temp/
|
||||
tmp/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Video/Audio files (for testing)
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.mkv
|
||||
*.wmv
|
||||
*.flv
|
||||
*.mp3
|
||||
*.wav
|
||||
*.aac
|
||||
*.flac
|
||||
*.ogg
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
cache/
|
||||
.mixvideo/
|
||||
|
||||
# Python specific
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Rust specific
|
||||
Cargo.lock
|
||||
target/
|
||||
|
||||
# Node specific
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
86
README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# MixVideo V2 - 视频混剪软件
|
||||
|
||||
A modern video editing software built with Tauri (Rust + React) frontend and Python core processing engine.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 分层架构设计 (Layered Architecture)
|
||||
|
||||
- **前端层 (Frontend Layer)**: Tauri + React - UI渲染和用户交互
|
||||
- **桥接层 (Bridge Layer)**: Tauri Commands - 前端与Python核心的通信
|
||||
- **核心层 (Core Layer)**: Python - 视频处理核心逻辑
|
||||
- **服务层 (Service Layer)**: Python - 后台服务和文件管理
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mixvideo_v2/
|
||||
├── src-tauri/ # Tauri backend (Rust)
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── commands/ # Tauri commands for Python bridge
|
||||
│ │ └── utils/
|
||||
│ ├── Cargo.toml
|
||||
│ └── tauri.conf.json
|
||||
├── src/ # React frontend
|
||||
│ ├── components/ # UI components
|
||||
│ ├── hooks/ # React hooks
|
||||
│ ├── services/ # API services
|
||||
│ ├── stores/ # State management
|
||||
│ └── utils/
|
||||
├── python_core/ # Python video processing engine
|
||||
│ ├── video_processing/ # Core video processing modules
|
||||
│ ├── audio_processing/ # Audio processing modules
|
||||
│ ├── services/ # Background services
|
||||
│ ├── utils/ # Utility functions
|
||||
│ └── requirements.txt
|
||||
├── assets/ # Static assets
|
||||
├── docs/ # Documentation
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
## Core Libraries
|
||||
|
||||
### Video Processing
|
||||
- **MoviePy**: 剪辑拼接、字幕添加、特效调整
|
||||
- **FFmpeg-Python**: 底层编码/解码、格式转换
|
||||
- **OpenCV-Python**: 帧级处理、人脸识别
|
||||
- **PySceneDetect**: 自动检测镜头切换点
|
||||
|
||||
### Audio Processing
|
||||
- **Librosa**: 节拍跟踪、频谱分析
|
||||
- **Pydub**: 音频剪切、音量调整、混音
|
||||
- **PyAudio**: 麦克风输入流采集
|
||||
- **Spleeter**: 人声/伴奏分离
|
||||
|
||||
### AI & Machine Learning
|
||||
- **Video-Transformers**: 视频内容理解
|
||||
- **Magenta**: AI音乐生成
|
||||
- **TempoCNN**: 基于CNN的BPM预测
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- Rust 1.70+
|
||||
- Python 3.9+
|
||||
- FFmpeg
|
||||
|
||||
### Installation
|
||||
1. Clone the repository
|
||||
2. Install frontend dependencies: `npm install`
|
||||
3. Install Python dependencies: `pip install -r python_core/requirements.txt`
|
||||
4. Run development server: `npm run tauri dev`
|
||||
|
||||
## Features
|
||||
|
||||
- 🎬 视频剪辑拼接
|
||||
- 🎵 音频处理与节拍同步
|
||||
- 🎨 特效与滤镜
|
||||
- 🤖 AI辅助剪辑
|
||||
- 📱 多平台适配
|
||||
- ⚡ 分布式任务处理
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
30
index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MixVideo V2 - 视频混剪软件</title>
|
||||
<style>
|
||||
/* Prevent text selection and context menu for a more native app feel */
|
||||
body {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Allow text selection in input fields and content areas */
|
||||
input, textarea, [contenteditable="true"], .selectable {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "mixvideo-v2",
|
||||
"version": "2.0.0",
|
||||
"description": "Modern video editing software with Tauri and Python",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"format": "prettier --write src/**/*.{js,jsx,ts,tsx,css,md}"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"zustand": "^4.4.0",
|
||||
"lucide-react": "^0.263.1",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
},
|
||||
"keywords": [
|
||||
"video-editing",
|
||||
"tauri",
|
||||
"react",
|
||||
"python",
|
||||
"desktop-app"
|
||||
],
|
||||
"author": "MixVideo Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
3821
pnpm-lock.yaml
generated
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
178
prompt.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 视频混剪软件
|
||||
- 分层架构设计
|
||||
* 前端层Tauri负责UI渲染和用户交互
|
||||
* 桥接层Tauri Command实现前端与Python核心的通信
|
||||
* 核心层Python 视频处理核心逻辑
|
||||
* 服务层Python 后台服务和文件管理
|
||||
|
||||
- MoviePy
|
||||
|
||||
```
|
||||
功能:支持剪辑拼接、字幕添加、特效调整(亮度/对比度/曝光)、音视频分离、格式转换等
|
||||
优势:API简洁,可直接调用FFmpeg底层能力,适合复杂合成场景
|
||||
```
|
||||
|
||||
- FFmpeg-Python
|
||||
|
||||
```
|
||||
定位:FFmpeg的Python封装,处理底层编码/解码
|
||||
适用场景:精准切割、格式转换、批量处理等高性能操作
|
||||
```
|
||||
|
||||
- PyAutoGUI
|
||||
|
||||
```
|
||||
功能:模拟鼠标键盘操作,控制桌面软件(如剪映)
|
||||
适用场景:无法直接调用API时的自动化流程(如导入素材、点击导出按钮)
|
||||
```
|
||||
|
||||
- PySceneDetect
|
||||
|
||||
```
|
||||
功能:自动检测镜头切换点,提取高光片段
|
||||
典型应用:快速生成短视频精华版或预告片
|
||||
```
|
||||
|
||||
- OpenCV-Python
|
||||
|
||||
```
|
||||
作用:帧级处理(人脸识别、动态追踪)
|
||||
场景:需逐帧分析的自动化任务(如自动打码)
|
||||
```
|
||||
|
||||
- Pydub
|
||||
|
||||
```
|
||||
定位:音频处理(剪切、音量调整、混音)
|
||||
协同使用:与MoviePy配合实现音视频分别处理
|
||||
```
|
||||
|
||||
- TTS库(gTTS/pyttsx3)
|
||||
```
|
||||
功能:文本转语音,自动生成配音
|
||||
扩展应用:结合百度API支持粤语/普通话
|
||||
```
|
||||
|
||||
|
||||
- Librosa
|
||||
|
||||
```
|
||||
定位:专业音频分析库,提供完整的节奏识别流水线
|
||||
核心功能:
|
||||
1. 节拍跟踪 librosa.beat.beat_track() 自动检测BPM与节拍点23
|
||||
2. 频谱特征提取(梅尔频谱、MFCC等)
|
||||
3. 时序对齐与波形可视化
|
||||
```
|
||||
|
||||
- PyAudio
|
||||
|
||||
```
|
||||
使用 PyAudio 采集麦克风输入流
|
||||
```
|
||||
|
||||
|
||||
- Audiolazy
|
||||
|
||||
```
|
||||
用途:实时信号处理
|
||||
场景:流式音频节奏分析
|
||||
```
|
||||
|
||||
|
||||
- Madmom
|
||||
|
||||
```
|
||||
用途:深度学习节拍检测
|
||||
场景:高精度复杂音乐分析
|
||||
```
|
||||
|
||||
- TempoCNN
|
||||
|
||||
```
|
||||
用途:基于CNN的BPM预测
|
||||
场景:EDM/摇滚等强节奏音乐
|
||||
```
|
||||
|
||||
|
||||
- Music21
|
||||
|
||||
```
|
||||
功能:乐谱生成、和弦序列编排、转调处理
|
||||
```
|
||||
|
||||
|
||||
- PySynth
|
||||
|
||||
```
|
||||
原理:通过正弦波/方波合成基础音色
|
||||
特点:无需依赖外部音源,适合生成电子音效
|
||||
```
|
||||
|
||||
|
||||
- FluidSynth
|
||||
|
||||
```
|
||||
用途:MIDI转音频
|
||||
场景:结合SF2音色库生成高质量输出
|
||||
```
|
||||
|
||||
|
||||
- Magenta
|
||||
|
||||
```
|
||||
用途:AI音乐生成
|
||||
场景:Google开发的音乐机器学习框架
|
||||
```
|
||||
|
||||
|
||||
- Spleeter
|
||||
|
||||
```
|
||||
Spleeter分离人声/伴奏
|
||||
```
|
||||
|
||||
- Sonic Pi
|
||||
|
||||
```
|
||||
用途:实时编码
|
||||
场景:表演级电子音乐生成
|
||||
```
|
||||
|
||||
- 分布式任务调度
|
||||
|
||||
```
|
||||
使用Celery+Redis构建剪辑任务队列
|
||||
```
|
||||
|
||||
|
||||
- Elasticsearch
|
||||
|
||||
```
|
||||
Elasticsearch建立素材标签索引(按场景/时长/主题分类)
|
||||
```
|
||||
|
||||
```
|
||||
本地SSD缓存高频素材 + 对象存储(OSS/S3)管理历史资源
|
||||
```
|
||||
|
||||
|
||||
- Video-Transformers
|
||||
|
||||
```
|
||||
功能:视频内容理解
|
||||
场景:自动打标与关键帧提取
|
||||
```
|
||||
|
||||
|
||||
- 易媒矩阵工具
|
||||
|
||||
```
|
||||
功能:多平台适配模板
|
||||
场景:一键生成横竖屏多版本
|
||||
```
|
||||
|
||||
- OpenGL滤镜
|
||||
- Magenta + NumPy视觉化
|
||||
- LC3编码器 + 绿幕分层渲染
|
||||
- MoviePy + PySceneDetect
|
||||
- FFmpeg-python + OpenGL滤镜
|
||||
21
python_core/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
MixVideo V2 Python Core
|
||||
视频混剪软件核心处理模块
|
||||
|
||||
This module provides the core video and audio processing capabilities
|
||||
for the MixVideo V2 application.
|
||||
"""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__author__ = "MixVideo Team"
|
||||
|
||||
from .video_processing import VideoProcessor
|
||||
from .audio_processing import AudioProcessor
|
||||
from .services import FileManager, TaskQueue
|
||||
|
||||
__all__ = [
|
||||
"VideoProcessor",
|
||||
"AudioProcessor",
|
||||
"FileManager",
|
||||
"TaskQueue"
|
||||
]
|
||||
20
python_core/audio_processing/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Audio Processing Module
|
||||
音频处理模块
|
||||
|
||||
Handles audio editing, rhythm detection, and audio effects using Librosa, Pydub, and other audio libraries.
|
||||
"""
|
||||
|
||||
from .core import AudioProcessor
|
||||
from .rhythm_detection import RhythmDetector
|
||||
from .audio_effects import AudioEffects
|
||||
from .voice_separation import VoiceSeparator
|
||||
from .tts import TextToSpeech
|
||||
|
||||
__all__ = [
|
||||
"AudioProcessor",
|
||||
"RhythmDetector",
|
||||
"AudioEffects",
|
||||
"VoiceSeparator",
|
||||
"TextToSpeech"
|
||||
]
|
||||
351
python_core/audio_processing/core.py
Normal file
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Audio Processing Core Module
|
||||
音频处理核心模块
|
||||
|
||||
This module provides audio processing functionality using Librosa, Pydub, and other audio libraries.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
from pydub import AudioSegment
|
||||
import librosa
|
||||
import librosa.display
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from config import settings
|
||||
from utils import setup_logger, validate_audio_file
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
class AudioProcessor:
|
||||
"""Main audio processing class."""
|
||||
|
||||
def __init__(self):
|
||||
self.temp_dir = settings.temp_dir
|
||||
self.cache_dir = settings.cache_dir
|
||||
self.sample_rate = settings.default_sample_rate
|
||||
|
||||
def analyze_audio(self, file_path: str, analysis_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze audio file with specified analysis type.
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
analysis_type: Type of analysis to perform
|
||||
|
||||
Returns:
|
||||
Dictionary with analysis results
|
||||
"""
|
||||
try:
|
||||
if not validate_audio_file(file_path):
|
||||
raise ValueError(f"Invalid audio file: {file_path}")
|
||||
|
||||
logger.info(f"Analyzing audio: {analysis_type} on {file_path}")
|
||||
|
||||
# Load audio
|
||||
y, sr = librosa.load(file_path, sr=self.sample_rate)
|
||||
|
||||
if analysis_type == "rhythm":
|
||||
result = self._analyze_rhythm(y, sr)
|
||||
elif analysis_type == "spectral":
|
||||
result = self._analyze_spectral(y, sr)
|
||||
elif analysis_type == "tempo":
|
||||
result = self._analyze_tempo(y, sr)
|
||||
elif analysis_type == "pitch":
|
||||
result = self._analyze_pitch(y, sr)
|
||||
elif analysis_type == "energy":
|
||||
result = self._analyze_energy(y, sr)
|
||||
elif analysis_type == "mfcc":
|
||||
result = self._analyze_mfcc(y, sr)
|
||||
else:
|
||||
raise ValueError(f"Unknown analysis type: {analysis_type}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"file_path": file_path,
|
||||
"analysis_type": analysis_type,
|
||||
"sample_rate": sr,
|
||||
"duration": len(y) / sr,
|
||||
"result": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Audio analysis failed: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def _analyze_rhythm(self, y: np.ndarray, sr: int) -> Dict[str, Any]:
|
||||
"""Analyze rhythm and beat tracking."""
|
||||
# Beat tracking
|
||||
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
|
||||
beat_times = librosa.frames_to_time(beats, sr=sr)
|
||||
|
||||
# Onset detection
|
||||
onset_frames = librosa.onset.onset_detect(y=y, sr=sr)
|
||||
onset_times = librosa.frames_to_time(onset_frames, sr=sr)
|
||||
|
||||
# Rhythm patterns
|
||||
beat_intervals = np.diff(beat_times)
|
||||
rhythm_stability = 1.0 - np.std(beat_intervals) / np.mean(beat_intervals)
|
||||
|
||||
return {
|
||||
"tempo": float(tempo),
|
||||
"beat_count": len(beats),
|
||||
"beat_times": beat_times.tolist(),
|
||||
"onset_count": len(onset_frames),
|
||||
"onset_times": onset_times.tolist(),
|
||||
"rhythm_stability": float(rhythm_stability),
|
||||
"average_beat_interval": float(np.mean(beat_intervals))
|
||||
}
|
||||
|
||||
def _analyze_spectral(self, y: np.ndarray, sr: int) -> Dict[str, Any]:
|
||||
"""Analyze spectral features."""
|
||||
# Spectral centroid
|
||||
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
|
||||
|
||||
# Spectral rolloff
|
||||
spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr)[0]
|
||||
|
||||
# Spectral bandwidth
|
||||
spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr)[0]
|
||||
|
||||
# Zero crossing rate
|
||||
zcr = librosa.feature.zero_crossing_rate(y)[0]
|
||||
|
||||
return {
|
||||
"spectral_centroid_mean": float(np.mean(spectral_centroids)),
|
||||
"spectral_centroid_std": float(np.std(spectral_centroids)),
|
||||
"spectral_rolloff_mean": float(np.mean(spectral_rolloff)),
|
||||
"spectral_rolloff_std": float(np.std(spectral_rolloff)),
|
||||
"spectral_bandwidth_mean": float(np.mean(spectral_bandwidth)),
|
||||
"spectral_bandwidth_std": float(np.std(spectral_bandwidth)),
|
||||
"zero_crossing_rate_mean": float(np.mean(zcr)),
|
||||
"zero_crossing_rate_std": float(np.std(zcr))
|
||||
}
|
||||
|
||||
def _analyze_tempo(self, y: np.ndarray, sr: int) -> Dict[str, Any]:
|
||||
"""Analyze tempo and timing."""
|
||||
# Tempo estimation
|
||||
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
|
||||
|
||||
# Dynamic tempo analysis
|
||||
hop_length = 512
|
||||
tempo_dynamic = librosa.beat.tempo(
|
||||
onset_envelope=librosa.onset.onset_strength(y=y, sr=sr),
|
||||
sr=sr,
|
||||
hop_length=hop_length
|
||||
)
|
||||
|
||||
return {
|
||||
"tempo": float(tempo),
|
||||
"tempo_confidence": float(np.std(tempo_dynamic)),
|
||||
"tempo_stability": float(1.0 - np.std(tempo_dynamic) / np.mean(tempo_dynamic)) if np.mean(tempo_dynamic) > 0 else 0.0,
|
||||
"beat_count": len(beats)
|
||||
}
|
||||
|
||||
def _analyze_pitch(self, y: np.ndarray, sr: int) -> Dict[str, Any]:
|
||||
"""Analyze pitch and harmonic content."""
|
||||
# Pitch tracking using piptrack
|
||||
pitches, magnitudes = librosa.piptrack(y=y, sr=sr)
|
||||
|
||||
# Extract fundamental frequency
|
||||
pitch_values = []
|
||||
for t in range(pitches.shape[1]):
|
||||
index = magnitudes[:, t].argmax()
|
||||
pitch = pitches[index, t]
|
||||
if pitch > 0:
|
||||
pitch_values.append(pitch)
|
||||
|
||||
if pitch_values:
|
||||
pitch_mean = np.mean(pitch_values)
|
||||
pitch_std = np.std(pitch_values)
|
||||
pitch_range = np.max(pitch_values) - np.min(pitch_values)
|
||||
else:
|
||||
pitch_mean = pitch_std = pitch_range = 0.0
|
||||
|
||||
# Harmonic-percussive separation
|
||||
y_harmonic, y_percussive = librosa.effects.hpss(y)
|
||||
harmonic_ratio = np.mean(np.abs(y_harmonic)) / (np.mean(np.abs(y)) + 1e-8)
|
||||
|
||||
return {
|
||||
"pitch_mean": float(pitch_mean),
|
||||
"pitch_std": float(pitch_std),
|
||||
"pitch_range": float(pitch_range),
|
||||
"pitch_count": len(pitch_values),
|
||||
"harmonic_ratio": float(harmonic_ratio)
|
||||
}
|
||||
|
||||
def _analyze_energy(self, y: np.ndarray, sr: int) -> Dict[str, Any]:
|
||||
"""Analyze energy and dynamics."""
|
||||
# RMS energy
|
||||
rms = librosa.feature.rms(y=y)[0]
|
||||
|
||||
# Short-time energy
|
||||
frame_length = 2048
|
||||
hop_length = 512
|
||||
energy = []
|
||||
for i in range(0, len(y) - frame_length, hop_length):
|
||||
frame = y[i:i + frame_length]
|
||||
energy.append(np.sum(frame ** 2))
|
||||
|
||||
energy = np.array(energy)
|
||||
|
||||
# Dynamic range
|
||||
dynamic_range = np.max(rms) - np.min(rms)
|
||||
|
||||
return {
|
||||
"rms_mean": float(np.mean(rms)),
|
||||
"rms_std": float(np.std(rms)),
|
||||
"rms_max": float(np.max(rms)),
|
||||
"rms_min": float(np.min(rms)),
|
||||
"energy_mean": float(np.mean(energy)),
|
||||
"energy_std": float(np.std(energy)),
|
||||
"dynamic_range": float(dynamic_range)
|
||||
}
|
||||
|
||||
def _analyze_mfcc(self, y: np.ndarray, sr: int) -> Dict[str, Any]:
|
||||
"""Analyze MFCC (Mel-frequency cepstral coefficients)."""
|
||||
# Extract MFCC features
|
||||
mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
|
||||
|
||||
# Statistical features for each MFCC coefficient
|
||||
mfcc_features = {}
|
||||
for i in range(mfccs.shape[0]):
|
||||
mfcc_features[f"mfcc_{i+1}_mean"] = float(np.mean(mfccs[i]))
|
||||
mfcc_features[f"mfcc_{i+1}_std"] = float(np.std(mfccs[i]))
|
||||
|
||||
return mfcc_features
|
||||
|
||||
def process_audio(self, input_path: str, output_path: str, operation: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Process audio with specified operation and parameters.
|
||||
|
||||
Args:
|
||||
input_path: Path to input audio file
|
||||
output_path: Path to output audio file
|
||||
operation: Type of operation to perform
|
||||
parameters: Operation-specific parameters
|
||||
|
||||
Returns:
|
||||
Dictionary with processing results
|
||||
"""
|
||||
try:
|
||||
if not validate_audio_file(input_path):
|
||||
raise ValueError(f"Invalid audio file: {input_path}")
|
||||
|
||||
logger.info(f"Processing audio: {operation} on {input_path}")
|
||||
|
||||
# Load audio with pydub for processing
|
||||
audio = AudioSegment.from_file(input_path)
|
||||
|
||||
if operation == "trim":
|
||||
result_audio = self._trim_audio(audio, parameters)
|
||||
elif operation == "volume":
|
||||
result_audio = self._adjust_volume(audio, parameters)
|
||||
elif operation == "fade":
|
||||
result_audio = self._apply_fade(audio, parameters)
|
||||
elif operation == "normalize":
|
||||
result_audio = self._normalize_audio(audio, parameters)
|
||||
elif operation == "merge":
|
||||
result_audio = self._merge_audio(parameters)
|
||||
else:
|
||||
raise ValueError(f"Unknown operation: {operation}")
|
||||
|
||||
# Export result
|
||||
result_audio.export(output_path, format="wav")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"output_path": output_path,
|
||||
"duration": len(result_audio) / 1000.0,
|
||||
"channels": result_audio.channels,
|
||||
"sample_rate": result_audio.frame_rate
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Audio processing failed: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def _trim_audio(self, audio: AudioSegment, params: Dict[str, Any]) -> AudioSegment:
|
||||
"""Trim audio to specified start and end times."""
|
||||
start_ms = int(params.get("start_time", 0) * 1000)
|
||||
end_ms = int(params.get("end_time", len(audio) / 1000) * 1000)
|
||||
return audio[start_ms:end_ms]
|
||||
|
||||
def _adjust_volume(self, audio: AudioSegment, params: Dict[str, Any]) -> AudioSegment:
|
||||
"""Adjust audio volume."""
|
||||
volume_change = params.get("volume_db", 0)
|
||||
return audio + volume_change
|
||||
|
||||
def _apply_fade(self, audio: AudioSegment, params: Dict[str, Any]) -> AudioSegment:
|
||||
"""Apply fade in/out effects."""
|
||||
fade_in_ms = int(params.get("fade_in", 0) * 1000)
|
||||
fade_out_ms = int(params.get("fade_out", 0) * 1000)
|
||||
|
||||
result = audio
|
||||
if fade_in_ms > 0:
|
||||
result = result.fade_in(fade_in_ms)
|
||||
if fade_out_ms > 0:
|
||||
result = result.fade_out(fade_out_ms)
|
||||
|
||||
return result
|
||||
|
||||
def _normalize_audio(self, audio: AudioSegment, params: Dict[str, Any]) -> AudioSegment:
|
||||
"""Normalize audio to specified level."""
|
||||
target_dBFS = params.get("target_db", -20.0)
|
||||
return audio.normalize().apply_gain(target_dBFS - audio.dBFS)
|
||||
|
||||
def _merge_audio(self, params: Dict[str, Any]) -> AudioSegment:
|
||||
"""Merge multiple audio files."""
|
||||
audio_paths = params.get("audio_paths", [])
|
||||
if len(audio_paths) < 2:
|
||||
raise ValueError("At least 2 audio files required for merge operation")
|
||||
|
||||
result = AudioSegment.from_file(audio_paths[0])
|
||||
for path in audio_paths[1:]:
|
||||
audio = AudioSegment.from_file(path)
|
||||
result = result + audio
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""Command line interface for audio processing."""
|
||||
parser = argparse.ArgumentParser(description="Audio Processing Core")
|
||||
parser.add_argument("--file", required=True, help="Audio file path")
|
||||
parser.add_argument("--analysis", required=True, help="Analysis type to perform")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
processor = AudioProcessor()
|
||||
result = processor.analyze_audio(args.file, args.analysis)
|
||||
print(json.dumps(result))
|
||||
|
||||
except Exception as e:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
print(json.dumps(error_result))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
python_core/config.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Configuration Module
|
||||
配置模块
|
||||
|
||||
Central configuration management for the MixVideo V2 application.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
try:
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field
|
||||
except ImportError:
|
||||
# Fallback for older pydantic versions
|
||||
from pydantic import BaseSettings, Field
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings with environment variable support."""
|
||||
|
||||
# Application Info
|
||||
app_name: str = "MixVideo V2"
|
||||
app_version: str = "2.0.0"
|
||||
debug: bool = Field(default=False, env="DEBUG")
|
||||
|
||||
# Paths
|
||||
project_root: Path = Path(__file__).parent.parent
|
||||
temp_dir: Path = Field(default_factory=lambda: Path.home() / ".mixvideo" / "temp")
|
||||
cache_dir: Path = Field(default_factory=lambda: Path.home() / ".mixvideo" / "cache")
|
||||
projects_dir: Path = Field(default_factory=lambda: Path.home() / "MixVideo Projects")
|
||||
|
||||
# Video Processing
|
||||
max_video_resolution: str = "1920x1080"
|
||||
default_fps: int = 30
|
||||
default_video_codec: str = "libx264"
|
||||
default_audio_codec: str = "aac"
|
||||
|
||||
# Audio Processing
|
||||
default_sample_rate: int = 44100
|
||||
default_audio_bitrate: str = "192k"
|
||||
|
||||
# Performance
|
||||
max_workers: int = Field(default=4, env="MAX_WORKERS")
|
||||
chunk_size: int = 1024 * 1024 # 1MB chunks
|
||||
|
||||
# Redis (for task queue)
|
||||
redis_host: str = Field(default="localhost", env="REDIS_HOST")
|
||||
redis_port: int = Field(default=6379, env="REDIS_PORT")
|
||||
redis_db: int = Field(default=0, env="REDIS_DB")
|
||||
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
log_file: str = "mixvideo.log"
|
||||
|
||||
# File Formats
|
||||
supported_video_formats: list = [".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv"]
|
||||
supported_audio_formats: list = [".mp3", ".wav", ".aac", ".flac", ".ogg"]
|
||||
supported_image_formats: list = [".jpg", ".jpeg", ".png", ".bmp", ".tiff"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# Ensure directories exist
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.projects_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class Config:
|
||||
"""Configuration helper class."""
|
||||
|
||||
@staticmethod
|
||||
def get_temp_path(filename: str = None) -> Path:
|
||||
"""Get temporary file path."""
|
||||
if filename:
|
||||
return settings.temp_dir / filename
|
||||
return settings.temp_dir
|
||||
|
||||
@staticmethod
|
||||
def get_cache_path(filename: str = None) -> Path:
|
||||
"""Get cache file path."""
|
||||
if filename:
|
||||
return settings.cache_dir / filename
|
||||
return settings.cache_dir
|
||||
|
||||
@staticmethod
|
||||
def get_project_path(project_name: str = None) -> Path:
|
||||
"""Get project directory path."""
|
||||
if project_name:
|
||||
return settings.projects_dir / project_name
|
||||
return settings.projects_dir
|
||||
|
||||
@staticmethod
|
||||
def is_supported_video(file_path: str) -> bool:
|
||||
"""Check if file is a supported video format."""
|
||||
return Path(file_path).suffix.lower() in settings.supported_video_formats
|
||||
|
||||
@staticmethod
|
||||
def is_supported_audio(file_path: str) -> bool:
|
||||
"""Check if file is a supported audio format."""
|
||||
return Path(file_path).suffix.lower() in settings.supported_audio_formats
|
||||
|
||||
@staticmethod
|
||||
def is_supported_image(file_path: str) -> bool:
|
||||
"""Check if file is a supported image format."""
|
||||
return Path(file_path).suffix.lower() in settings.supported_image_formats
|
||||
67
python_core/requirements.txt
Normal file
@@ -0,0 +1,67 @@
|
||||
# Core Video Processing
|
||||
moviepy>=1.0.3
|
||||
ffmpeg-python>=0.2.0
|
||||
opencv-python>=4.8.0
|
||||
PySceneDetect>=0.6.2
|
||||
|
||||
# Audio Processing
|
||||
librosa>=0.10.0
|
||||
pydub>=0.25.1
|
||||
pyaudio>=0.2.11
|
||||
spleeter>=2.3.2
|
||||
|
||||
# Audio Analysis & Generation
|
||||
audiolazy>=0.7
|
||||
madmom>=0.16.1
|
||||
music21>=8.1.0
|
||||
pysynth>=0.9.3
|
||||
magenta>=2.1.4
|
||||
|
||||
# AI & Machine Learning
|
||||
transformers>=4.30.0
|
||||
torch>=2.0.0
|
||||
torchvision>=0.15.0
|
||||
torchaudio>=2.0.0
|
||||
|
||||
# Automation & Control
|
||||
pyautogui>=0.9.54
|
||||
pynput>=1.7.6
|
||||
|
||||
# Text-to-Speech
|
||||
gTTS>=2.3.2
|
||||
pyttsx3>=2.90
|
||||
|
||||
# Task Queue & Distributed Processing
|
||||
celery>=5.3.0
|
||||
redis>=4.5.0
|
||||
|
||||
# Database & Search
|
||||
elasticsearch>=8.8.0
|
||||
|
||||
# Utilities
|
||||
numpy>=1.24.0
|
||||
scipy>=1.10.0
|
||||
matplotlib>=3.7.0
|
||||
pillow>=10.0.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Development & Testing
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.21.0
|
||||
black>=23.0.0
|
||||
flake8>=6.0.0
|
||||
|
||||
# GUI Framework (if needed for standalone Python GUI)
|
||||
tkinter-tooltip>=2.0.0
|
||||
|
||||
# File Format Support
|
||||
python-magic>=0.4.27
|
||||
mutagen>=1.46.0
|
||||
|
||||
# Logging & Monitoring
|
||||
loguru>=0.7.0
|
||||
|
||||
# Configuration
|
||||
pydantic>=2.0.0
|
||||
pyyaml>=6.0
|
||||
18
python_core/services/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Services Module
|
||||
服务层模块
|
||||
|
||||
Provides background services, file management, and task queue functionality.
|
||||
"""
|
||||
|
||||
from .file_manager import FileManager
|
||||
from .task_queue import TaskQueue
|
||||
from .project_manager import ProjectManager
|
||||
from .cache_manager import CacheManager
|
||||
|
||||
__all__ = [
|
||||
"FileManager",
|
||||
"TaskQueue",
|
||||
"ProjectManager",
|
||||
"CacheManager"
|
||||
]
|
||||
383
python_core/services/file_manager.py
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
File Manager Service
|
||||
文件管理服务
|
||||
|
||||
Handles file operations, media import, and asset management.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import hashlib
|
||||
import mimetypes
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from config import settings
|
||||
from utils import setup_logger, validate_video_file, validate_audio_file, validate_image_file, get_file_info
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
class FileManager:
|
||||
"""File management service."""
|
||||
|
||||
def __init__(self):
|
||||
self.temp_dir = settings.temp_dir
|
||||
self.cache_dir = settings.cache_dir
|
||||
self.projects_dir = settings.projects_dir
|
||||
|
||||
def import_file(self, source_path: str, project_path: str, copy_file: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Import a media file into a project.
|
||||
|
||||
Args:
|
||||
source_path: Source file path
|
||||
project_path: Project directory path
|
||||
copy_file: Whether to copy file to project assets
|
||||
|
||||
Returns:
|
||||
Dictionary with import result
|
||||
"""
|
||||
try:
|
||||
source = Path(source_path)
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"Source file not found: {source_path}")
|
||||
|
||||
# Validate file type
|
||||
file_type = self._get_file_type(source_path)
|
||||
if file_type == "unknown":
|
||||
raise ValueError(f"Unsupported file type: {source.suffix}")
|
||||
|
||||
# Get file info
|
||||
file_info = get_file_info(source_path)
|
||||
if "error" in file_info:
|
||||
raise ValueError(f"Failed to get file info: {file_info['error']}")
|
||||
|
||||
# Determine destination
|
||||
project_dir = Path(project_path)
|
||||
assets_dir = project_dir / "assets"
|
||||
assets_dir.mkdir(exist_ok=True)
|
||||
|
||||
if copy_file:
|
||||
# Copy file to project assets
|
||||
dest_path = assets_dir / source.name
|
||||
|
||||
# Handle name conflicts
|
||||
counter = 1
|
||||
while dest_path.exists():
|
||||
stem = source.stem
|
||||
suffix = source.suffix
|
||||
dest_path = assets_dir / f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
|
||||
shutil.copy2(source, dest_path)
|
||||
final_path = str(dest_path)
|
||||
logger.info(f"Copied file to: {final_path}")
|
||||
else:
|
||||
# Use original path
|
||||
final_path = source_path
|
||||
logger.info(f"Linked file: {final_path}")
|
||||
|
||||
# Generate file hash for deduplication
|
||||
file_hash = self._calculate_file_hash(final_path)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"file_path": final_path,
|
||||
"file_type": file_type,
|
||||
"file_info": file_info,
|
||||
"file_hash": file_hash,
|
||||
"imported_at": file_info.get("modified", 0)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import file: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def export_file(self, source_path: str, dest_path: str,
|
||||
move_file: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Export a file from project to external location.
|
||||
|
||||
Args:
|
||||
source_path: Source file path
|
||||
dest_path: Destination file path
|
||||
move_file: Whether to move instead of copy
|
||||
|
||||
Returns:
|
||||
Dictionary with export result
|
||||
"""
|
||||
try:
|
||||
source = Path(source_path)
|
||||
dest = Path(dest_path)
|
||||
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"Source file not found: {source_path}")
|
||||
|
||||
# Ensure destination directory exists
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Handle name conflicts
|
||||
if dest.exists():
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
stem = dest.stem
|
||||
suffix = dest.suffix
|
||||
dest = dest.parent / f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
|
||||
if move_file:
|
||||
shutil.move(source, dest)
|
||||
logger.info(f"Moved file to: {dest}")
|
||||
else:
|
||||
shutil.copy2(source, dest)
|
||||
logger.info(f"Copied file to: {dest}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"dest_path": str(dest),
|
||||
"operation": "move" if move_file else "copy"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export file: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def list_project_assets(self, project_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
List all assets in a project.
|
||||
|
||||
Args:
|
||||
project_path: Project directory path
|
||||
|
||||
Returns:
|
||||
Dictionary with asset list
|
||||
"""
|
||||
try:
|
||||
project_dir = Path(project_path)
|
||||
assets_dir = project_dir / "assets"
|
||||
|
||||
if not assets_dir.exists():
|
||||
return {
|
||||
"status": "success",
|
||||
"assets": [],
|
||||
"count": 0
|
||||
}
|
||||
|
||||
assets = []
|
||||
for file_path in assets_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
file_type = self._get_file_type(str(file_path))
|
||||
if file_type != "unknown":
|
||||
file_info = get_file_info(str(file_path))
|
||||
|
||||
assets.append({
|
||||
"name": file_path.name,
|
||||
"path": str(file_path),
|
||||
"relative_path": str(file_path.relative_to(project_dir)),
|
||||
"type": file_type,
|
||||
"size": file_info.get("size", 0),
|
||||
"size_mb": file_info.get("size_mb", 0),
|
||||
"modified": file_info.get("modified", 0),
|
||||
"duration": file_info.get("duration"),
|
||||
"width": file_info.get("width"),
|
||||
"height": file_info.get("height"),
|
||||
"file_hash": self._calculate_file_hash(str(file_path))
|
||||
})
|
||||
|
||||
# Sort by name
|
||||
assets.sort(key=lambda x: x["name"].lower())
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"assets": assets,
|
||||
"count": len(assets)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list project assets: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def create_temp_file(self, suffix: str = "", prefix: str = "mixvideo_") -> str:
|
||||
"""
|
||||
Create a temporary file.
|
||||
|
||||
Args:
|
||||
suffix: File suffix/extension
|
||||
prefix: File prefix
|
||||
|
||||
Returns:
|
||||
Path to temporary file
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=self.temp_dir)
|
||||
os.close(fd) # Close file descriptor
|
||||
|
||||
logger.debug(f"Created temp file: {temp_path}")
|
||||
return temp_path
|
||||
|
||||
def cleanup_temp_files(self, max_age_hours: int = 24) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up old temporary files.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age of files to keep
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup result
|
||||
"""
|
||||
try:
|
||||
from utils import clean_temp_files
|
||||
|
||||
clean_temp_files(self.temp_dir, max_age_hours)
|
||||
clean_temp_files(self.cache_dir, max_age_hours)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Cleaned temp files older than {max_age_hours} hours"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup temp files: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_disk_usage(self, path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get disk usage information for a path.
|
||||
|
||||
Args:
|
||||
path: Directory path
|
||||
|
||||
Returns:
|
||||
Dictionary with disk usage info
|
||||
"""
|
||||
try:
|
||||
path_obj = Path(path)
|
||||
if not path_obj.exists():
|
||||
raise FileNotFoundError(f"Path not found: {path}")
|
||||
|
||||
# Get disk usage
|
||||
usage = shutil.disk_usage(path)
|
||||
|
||||
# Calculate directory size if it's a directory
|
||||
if path_obj.is_dir():
|
||||
dir_size = sum(f.stat().st_size for f in path_obj.rglob('*') if f.is_file())
|
||||
else:
|
||||
dir_size = path_obj.stat().st_size
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"path": path,
|
||||
"total_space": usage.total,
|
||||
"used_space": usage.used,
|
||||
"free_space": usage.free,
|
||||
"directory_size": dir_size,
|
||||
"usage_percent": (usage.used / usage.total) * 100
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get disk usage: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def _get_file_type(self, file_path: str) -> str:
|
||||
"""
|
||||
Determine file type based on extension and validation.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
|
||||
Returns:
|
||||
File type: 'video', 'audio', 'image', or 'unknown'
|
||||
"""
|
||||
if validate_video_file(file_path):
|
||||
return "video"
|
||||
elif validate_audio_file(file_path):
|
||||
return "audio"
|
||||
elif validate_image_file(file_path):
|
||||
return "image"
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
def _calculate_file_hash(self, file_path: str) -> str:
|
||||
"""
|
||||
Calculate MD5 hash of a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
|
||||
Returns:
|
||||
MD5 hash string
|
||||
"""
|
||||
try:
|
||||
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()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def find_duplicates(self, project_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Find duplicate files in a project based on hash.
|
||||
|
||||
Args:
|
||||
project_path: Project directory path
|
||||
|
||||
Returns:
|
||||
Dictionary with duplicate files
|
||||
"""
|
||||
try:
|
||||
assets_result = self.list_project_assets(project_path)
|
||||
if assets_result["status"] != "success":
|
||||
return assets_result
|
||||
|
||||
# Group files by hash
|
||||
hash_groups = {}
|
||||
for asset in assets_result["assets"]:
|
||||
file_hash = asset["file_hash"]
|
||||
if file_hash:
|
||||
if file_hash not in hash_groups:
|
||||
hash_groups[file_hash] = []
|
||||
hash_groups[file_hash].append(asset)
|
||||
|
||||
# Find duplicates (groups with more than one file)
|
||||
duplicates = {hash_val: files for hash_val, files in hash_groups.items() if len(files) > 1}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"duplicates": duplicates,
|
||||
"duplicate_count": sum(len(files) - 1 for files in duplicates.values()),
|
||||
"total_duplicate_size": sum(
|
||||
sum(file["size"] for file in files[1:]) # Skip first file in each group
|
||||
for files in duplicates.values()
|
||||
)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find duplicates: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
427
python_core/services/project_manager.py
Normal file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Project Manager Service
|
||||
项目管理服务
|
||||
|
||||
Handles project creation, loading, saving, and management.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from config import settings
|
||||
from utils import setup_logger, ensure_directory, get_unique_filename
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
class ProjectManager:
|
||||
"""Project management service."""
|
||||
|
||||
def __init__(self):
|
||||
self.projects_dir = settings.projects_dir
|
||||
self.temp_dir = settings.temp_dir
|
||||
|
||||
def create_project(self, name: str, description: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new project.
|
||||
|
||||
Args:
|
||||
name: Project name
|
||||
description: Project description
|
||||
|
||||
Returns:
|
||||
Dictionary with project information
|
||||
"""
|
||||
try:
|
||||
# Create project directory
|
||||
project_path = self.projects_dir / name
|
||||
ensure_directory(project_path)
|
||||
|
||||
# Create project structure
|
||||
(project_path / "assets").mkdir(exist_ok=True)
|
||||
(project_path / "exports").mkdir(exist_ok=True)
|
||||
(project_path / "temp").mkdir(exist_ok=True)
|
||||
|
||||
# Create project info
|
||||
project_info = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"description": description,
|
||||
"path": str(project_path),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"modified_at": datetime.now().isoformat(),
|
||||
"version": "1.0",
|
||||
"settings": {
|
||||
"resolution": settings.max_video_resolution,
|
||||
"fps": settings.default_fps,
|
||||
"video_codec": settings.default_video_codec,
|
||||
"audio_codec": settings.default_audio_codec
|
||||
},
|
||||
"video_tracks": [],
|
||||
"audio_tracks": [],
|
||||
"timeline": {
|
||||
"duration": 0.0,
|
||||
"zoom": 1.0,
|
||||
"position": 0.0
|
||||
}
|
||||
}
|
||||
|
||||
# Save project file
|
||||
project_file = project_path / f"{name}.mixvideo"
|
||||
with open(project_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(project_info, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Created project: {name} at {project_path}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"project": project_info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create project: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def load_project(self, project_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load an existing project.
|
||||
|
||||
Args:
|
||||
project_path: Path to project file or directory
|
||||
|
||||
Returns:
|
||||
Dictionary with project information
|
||||
"""
|
||||
try:
|
||||
path = Path(project_path)
|
||||
|
||||
# If it's a directory, look for .mixvideo file
|
||||
if path.is_dir():
|
||||
mixvideo_files = list(path.glob("*.mixvideo"))
|
||||
if not mixvideo_files:
|
||||
raise FileNotFoundError(f"No .mixvideo file found in {path}")
|
||||
project_file = mixvideo_files[0]
|
||||
else:
|
||||
project_file = path
|
||||
|
||||
if not project_file.exists():
|
||||
raise FileNotFoundError(f"Project file not found: {project_file}")
|
||||
|
||||
# Load project data
|
||||
with open(project_file, 'r', encoding='utf-8') as f:
|
||||
project_info = json.load(f)
|
||||
|
||||
# Update modified time
|
||||
project_info["modified_at"] = datetime.now().isoformat()
|
||||
|
||||
logger.info(f"Loaded project: {project_info['name']}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"project": project_info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load project: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def save_project(self, project_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Save project to file.
|
||||
|
||||
Args:
|
||||
project_info: Project information dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary with save result
|
||||
"""
|
||||
try:
|
||||
project_path = Path(project_info["path"])
|
||||
project_name = project_info["name"]
|
||||
|
||||
# Update modified time
|
||||
project_info["modified_at"] = datetime.now().isoformat()
|
||||
|
||||
# Save project file
|
||||
project_file = project_path / f"{project_name}.mixvideo"
|
||||
with open(project_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(project_info, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Saved project: {project_name}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Project saved to {project_file}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save project: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def list_projects(self) -> Dict[str, Any]:
|
||||
"""
|
||||
List all projects in the projects directory.
|
||||
|
||||
Returns:
|
||||
Dictionary with list of projects
|
||||
"""
|
||||
try:
|
||||
projects = []
|
||||
|
||||
for project_file in self.projects_dir.glob("**/*.mixvideo"):
|
||||
try:
|
||||
with open(project_file, 'r', encoding='utf-8') as f:
|
||||
project_info = json.load(f)
|
||||
|
||||
# Add basic project info
|
||||
projects.append({
|
||||
"id": project_info.get("id"),
|
||||
"name": project_info.get("name"),
|
||||
"description": project_info.get("description", ""),
|
||||
"path": str(project_file.parent),
|
||||
"created_at": project_info.get("created_at"),
|
||||
"modified_at": project_info.get("modified_at"),
|
||||
"file_size": project_file.stat().st_size
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read project file {project_file}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Sort by modified time (newest first)
|
||||
projects.sort(key=lambda x: x.get("modified_at", ""), reverse=True)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"projects": projects,
|
||||
"count": len(projects)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list projects: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def delete_project(self, project_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a project.
|
||||
|
||||
Args:
|
||||
project_path: Path to project directory
|
||||
|
||||
Returns:
|
||||
Dictionary with deletion result
|
||||
"""
|
||||
try:
|
||||
import shutil
|
||||
|
||||
path = Path(project_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Project path not found: {path}")
|
||||
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
|
||||
logger.info(f"Deleted project: {path}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Project deleted: {path}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete project: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def add_video_track(self, project_info: Dict[str, Any], file_path: str,
|
||||
start_time: float = 0.0, duration: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a video track to the project.
|
||||
|
||||
Args:
|
||||
project_info: Project information
|
||||
file_path: Path to video file
|
||||
start_time: Start time in timeline
|
||||
duration: Duration (auto-detect if None)
|
||||
|
||||
Returns:
|
||||
Updated project information
|
||||
"""
|
||||
try:
|
||||
from utils import get_file_info
|
||||
|
||||
file_info = get_file_info(file_path)
|
||||
if "error" in file_info:
|
||||
raise ValueError(f"Invalid video file: {file_info['error']}")
|
||||
|
||||
track_id = str(uuid.uuid4())
|
||||
video_track = {
|
||||
"id": track_id,
|
||||
"name": Path(file_path).stem,
|
||||
"file_path": file_path,
|
||||
"start_time": start_time,
|
||||
"duration": duration or file_info.get("duration", 0.0),
|
||||
"file_info": file_info,
|
||||
"effects": [],
|
||||
"volume": 1.0,
|
||||
"enabled": True
|
||||
}
|
||||
|
||||
project_info["video_tracks"].append(video_track)
|
||||
project_info["modified_at"] = datetime.now().isoformat()
|
||||
|
||||
logger.info(f"Added video track: {video_track['name']}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"project": project_info,
|
||||
"track_id": track_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add video track: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def add_audio_track(self, project_info: Dict[str, Any], file_path: str,
|
||||
start_time: float = 0.0, duration: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Add an audio track to the project.
|
||||
|
||||
Args:
|
||||
project_info: Project information
|
||||
file_path: Path to audio file
|
||||
start_time: Start time in timeline
|
||||
duration: Duration (auto-detect if None)
|
||||
|
||||
Returns:
|
||||
Updated project information
|
||||
"""
|
||||
try:
|
||||
from utils import get_file_info
|
||||
|
||||
file_info = get_file_info(file_path)
|
||||
if "error" in file_info:
|
||||
raise ValueError(f"Invalid audio file: {file_info['error']}")
|
||||
|
||||
track_id = str(uuid.uuid4())
|
||||
audio_track = {
|
||||
"id": track_id,
|
||||
"name": Path(file_path).stem,
|
||||
"file_path": file_path,
|
||||
"start_time": start_time,
|
||||
"duration": duration or file_info.get("duration", 0.0),
|
||||
"file_info": file_info,
|
||||
"effects": [],
|
||||
"volume": 1.0,
|
||||
"enabled": True
|
||||
}
|
||||
|
||||
project_info["audio_tracks"].append(audio_track)
|
||||
project_info["modified_at"] = datetime.now().isoformat()
|
||||
|
||||
logger.info(f"Added audio track: {audio_track['name']}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"project": project_info,
|
||||
"track_id": track_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add audio track: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Command line interface for project management."""
|
||||
parser = argparse.ArgumentParser(description="Project Manager")
|
||||
parser.add_argument("--action", required=True,
|
||||
choices=["create", "load", "save", "list", "delete", "get_info"],
|
||||
help="Action to perform")
|
||||
parser.add_argument("--name", help="Project name")
|
||||
parser.add_argument("--path", help="Project path")
|
||||
parser.add_argument("--data", help="Project data as JSON string")
|
||||
parser.add_argument("--description", help="Project description")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
manager = ProjectManager()
|
||||
|
||||
if args.action == "create":
|
||||
if not args.name:
|
||||
raise ValueError("Project name is required for create action")
|
||||
result = manager.create_project(args.name, args.description or "")
|
||||
|
||||
elif args.action == "load":
|
||||
if not args.path:
|
||||
raise ValueError("Project path is required for load action")
|
||||
result = manager.load_project(args.path)
|
||||
|
||||
elif args.action == "save":
|
||||
if not args.data:
|
||||
raise ValueError("Project data is required for save action")
|
||||
project_data = json.loads(args.data)
|
||||
result = manager.save_project(project_data)
|
||||
|
||||
elif args.action == "list":
|
||||
result = manager.list_projects()
|
||||
|
||||
elif args.action == "delete":
|
||||
if not args.path:
|
||||
raise ValueError("Project path is required for delete action")
|
||||
result = manager.delete_project(args.path)
|
||||
|
||||
elif args.action == "get_info":
|
||||
if not args.path:
|
||||
raise ValueError("Project path is required for get_info action")
|
||||
result = manager.load_project(args.path)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {args.action}")
|
||||
|
||||
print(json.dumps(result))
|
||||
|
||||
except Exception as e:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
print(json.dumps(error_result))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
python_core/utils/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Utilities Module
|
||||
工具模块
|
||||
|
||||
Common utilities and helper functions used across the application.
|
||||
"""
|
||||
|
||||
from .logger import setup_logger
|
||||
from .validators import validate_video_file, validate_audio_file, validate_image_file
|
||||
from .helpers import format_duration, get_file_info, ensure_directory, get_unique_filename, format_file_size, clean_temp_files
|
||||
|
||||
__all__ = [
|
||||
"setup_logger",
|
||||
"validate_video_file",
|
||||
"validate_audio_file",
|
||||
"validate_image_file",
|
||||
"format_duration",
|
||||
"get_file_info",
|
||||
"ensure_directory",
|
||||
"get_unique_filename",
|
||||
"format_file_size",
|
||||
"clean_temp_files"
|
||||
]
|
||||
195
python_core/utils/helpers.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Helper utilities for MixVideo V2
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Union
|
||||
import ffmpeg
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""
|
||||
Format duration in seconds to human-readable string.
|
||||
|
||||
Args:
|
||||
seconds: Duration in seconds
|
||||
|
||||
Returns:
|
||||
Formatted duration string (e.g., "1:23:45")
|
||||
"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
else:
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
|
||||
def get_file_info(file_path: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get basic file information.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
|
||||
Returns:
|
||||
Dictionary with file information
|
||||
"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
|
||||
if not path.exists():
|
||||
return {"error": "File not found"}
|
||||
|
||||
stat = path.stat()
|
||||
|
||||
info = {
|
||||
"name": path.name,
|
||||
"size": stat.st_size,
|
||||
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||
"modified": stat.st_mtime,
|
||||
"extension": path.suffix.lower(),
|
||||
"is_file": path.is_file(),
|
||||
"is_directory": path.is_dir()
|
||||
}
|
||||
|
||||
# Try to get media info if it's a media file
|
||||
if path.suffix.lower() in ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.mp3', '.wav', '.aac', '.flac', '.ogg']:
|
||||
try:
|
||||
probe = ffmpeg.probe(str(path))
|
||||
format_info = probe.get('format', {})
|
||||
|
||||
info.update({
|
||||
"duration": float(format_info.get('duration', 0)),
|
||||
"duration_formatted": format_duration(float(format_info.get('duration', 0))),
|
||||
"bitrate": int(format_info.get('bit_rate', 0)),
|
||||
"format_name": format_info.get('format_name', ''),
|
||||
})
|
||||
|
||||
# Video specific info
|
||||
video_streams = [s for s in probe.get('streams', []) if s.get('codec_type') == 'video']
|
||||
if video_streams:
|
||||
video_stream = video_streams[0]
|
||||
info.update({
|
||||
"width": int(video_stream.get('width', 0)),
|
||||
"height": int(video_stream.get('height', 0)),
|
||||
"fps": eval(video_stream.get('r_frame_rate', '0/1')),
|
||||
"video_codec": video_stream.get('codec_name', ''),
|
||||
})
|
||||
|
||||
# Audio specific info
|
||||
audio_streams = [s for s in probe.get('streams', []) if s.get('codec_type') == 'audio']
|
||||
if audio_streams:
|
||||
audio_stream = audio_streams[0]
|
||||
info.update({
|
||||
"sample_rate": int(audio_stream.get('sample_rate', 0)),
|
||||
"channels": int(audio_stream.get('channels', 0)),
|
||||
"audio_codec": audio_stream.get('codec_name', ''),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
info["media_info_error"] = str(e)
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def ensure_directory(directory: Union[str, Path]) -> Path:
|
||||
"""
|
||||
Ensure directory exists, create if it doesn't.
|
||||
|
||||
Args:
|
||||
directory: Directory path
|
||||
|
||||
Returns:
|
||||
Path object of the directory
|
||||
"""
|
||||
path = Path(directory)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_unique_filename(file_path: Union[str, Path]) -> Path:
|
||||
"""
|
||||
Get a unique filename by appending a number if file already exists.
|
||||
|
||||
Args:
|
||||
file_path: Original file path
|
||||
|
||||
Returns:
|
||||
Unique file path
|
||||
"""
|
||||
path = Path(file_path)
|
||||
|
||||
if not path.exists():
|
||||
return path
|
||||
|
||||
counter = 1
|
||||
while True:
|
||||
stem = path.stem
|
||||
suffix = path.suffix
|
||||
parent = path.parent
|
||||
|
||||
new_name = f"{stem}_{counter}{suffix}"
|
||||
new_path = parent / new_name
|
||||
|
||||
if not new_path.exists():
|
||||
return new_path
|
||||
|
||||
counter += 1
|
||||
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""
|
||||
Format file size in bytes to human-readable string.
|
||||
|
||||
Args:
|
||||
size_bytes: Size in bytes
|
||||
|
||||
Returns:
|
||||
Formatted size string (e.g., "1.5 MB")
|
||||
"""
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
|
||||
size_names = ["B", "KB", "MB", "GB", "TB"]
|
||||
i = 0
|
||||
size = float(size_bytes)
|
||||
|
||||
while size >= 1024.0 and i < len(size_names) - 1:
|
||||
size /= 1024.0
|
||||
i += 1
|
||||
|
||||
return f"{size:.1f} {size_names[i]}"
|
||||
|
||||
|
||||
def clean_temp_files(temp_dir: Union[str, Path], max_age_hours: int = 24):
|
||||
"""
|
||||
Clean old temporary files.
|
||||
|
||||
Args:
|
||||
temp_dir: Temporary directory path
|
||||
max_age_hours: Maximum age of files to keep in hours
|
||||
"""
|
||||
import time
|
||||
|
||||
temp_path = Path(temp_dir)
|
||||
if not temp_path.exists():
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
max_age_seconds = max_age_hours * 3600
|
||||
|
||||
for file_path in temp_path.rglob('*'):
|
||||
if file_path.is_file():
|
||||
file_age = current_time - file_path.stat().st_mtime
|
||||
if file_age > max_age_seconds:
|
||||
try:
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass # Ignore errors when deleting files
|
||||
48
python_core/utils/logger.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Logging utilities for MixVideo V2
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
def setup_logger(name: str = None):
|
||||
"""
|
||||
Set up logger with appropriate configuration.
|
||||
|
||||
Args:
|
||||
name: Logger name (optional)
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
"""
|
||||
# Remove default handler
|
||||
logger.remove()
|
||||
|
||||
# Console handler
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level=settings.log_level,
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||
colorize=True
|
||||
)
|
||||
|
||||
# File handler
|
||||
log_file = settings.temp_dir / settings.log_file
|
||||
logger.add(
|
||||
log_file,
|
||||
level=settings.log_level,
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
||||
rotation="10 MB",
|
||||
retention="7 days",
|
||||
compression="zip"
|
||||
)
|
||||
|
||||
return logger
|
||||
117
python_core/utils/validators.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
File validation utilities for MixVideo V2
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
def validate_video_file(file_path: Union[str, Path]) -> bool:
|
||||
"""
|
||||
Validate if file is a supported video format.
|
||||
|
||||
Args:
|
||||
file_path: Path to video file
|
||||
|
||||
Returns:
|
||||
True if valid video file, False otherwise
|
||||
"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
|
||||
# Check if file exists
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
# Check if it's a file (not directory)
|
||||
if not path.is_file():
|
||||
return False
|
||||
|
||||
# Check file extension
|
||||
if path.suffix.lower() not in settings.supported_video_formats:
|
||||
return False
|
||||
|
||||
# Check file size (not empty)
|
||||
if path.stat().st_size == 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def validate_audio_file(file_path: Union[str, Path]) -> bool:
|
||||
"""
|
||||
Validate if file is a supported audio format.
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
|
||||
Returns:
|
||||
True if valid audio file, False otherwise
|
||||
"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
|
||||
# Check if file exists
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
# Check if it's a file (not directory)
|
||||
if not path.is_file():
|
||||
return False
|
||||
|
||||
# Check file extension
|
||||
if path.suffix.lower() not in settings.supported_audio_formats:
|
||||
return False
|
||||
|
||||
# Check file size (not empty)
|
||||
if path.stat().st_size == 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def validate_image_file(file_path: Union[str, Path]) -> bool:
|
||||
"""
|
||||
Validate if file is a supported image format.
|
||||
|
||||
Args:
|
||||
file_path: Path to image file
|
||||
|
||||
Returns:
|
||||
True if valid image file, False otherwise
|
||||
"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
|
||||
# Check if file exists
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
# Check if it's a file (not directory)
|
||||
if not path.is_file():
|
||||
return False
|
||||
|
||||
# Check file extension
|
||||
if path.suffix.lower() not in settings.supported_image_formats:
|
||||
return False
|
||||
|
||||
# Check file size (not empty)
|
||||
if path.stat().st_size == 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
18
python_core/video_processing/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Video Processing Module
|
||||
视频处理核心模块
|
||||
|
||||
Handles video editing, effects, and transformations using MoviePy, FFmpeg, and OpenCV.
|
||||
"""
|
||||
|
||||
from .core import VideoProcessor
|
||||
from .effects import EffectsProcessor
|
||||
from .scene_detection import SceneDetector
|
||||
from .format_converter import FormatConverter
|
||||
|
||||
__all__ = [
|
||||
"VideoProcessor",
|
||||
"EffectsProcessor",
|
||||
"SceneDetector",
|
||||
"FormatConverter"
|
||||
]
|
||||
243
python_core/video_processing/core.py
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Video Processing Core Module
|
||||
视频处理核心模块
|
||||
|
||||
This module provides the main video processing functionality using MoviePy and FFmpeg.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import moviepy.editor as mp
|
||||
from moviepy.video.fx import resize, crop, rotate
|
||||
from moviepy.audio.fx import volumex
|
||||
import ffmpeg
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from config import settings
|
||||
from utils import setup_logger, validate_video_file
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
class VideoProcessor:
|
||||
"""Main video processing class."""
|
||||
|
||||
def __init__(self):
|
||||
self.temp_dir = settings.temp_dir
|
||||
self.cache_dir = settings.cache_dir
|
||||
|
||||
def process_video(self, input_path: str, output_path: str, operation: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Process video with specified operation and parameters.
|
||||
|
||||
Args:
|
||||
input_path: Path to input video file
|
||||
output_path: Path to output video file
|
||||
operation: Type of operation to perform
|
||||
parameters: Operation-specific parameters
|
||||
|
||||
Returns:
|
||||
Dictionary with processing results
|
||||
"""
|
||||
try:
|
||||
if not validate_video_file(input_path):
|
||||
raise ValueError(f"Invalid video file: {input_path}")
|
||||
|
||||
logger.info(f"Processing video: {operation} on {input_path}")
|
||||
|
||||
# Load video
|
||||
video = mp.VideoFileClip(input_path)
|
||||
|
||||
# Apply operation based on type
|
||||
if operation == "trim":
|
||||
result_video = self._trim_video(video, parameters)
|
||||
elif operation == "resize":
|
||||
result_video = self._resize_video(video, parameters)
|
||||
elif operation == "crop":
|
||||
result_video = self._crop_video(video, parameters)
|
||||
elif operation == "rotate":
|
||||
result_video = self._rotate_video(video, parameters)
|
||||
elif operation == "adjust_brightness":
|
||||
result_video = self._adjust_brightness(video, parameters)
|
||||
elif operation == "adjust_contrast":
|
||||
result_video = self._adjust_contrast(video, parameters)
|
||||
elif operation == "add_text":
|
||||
result_video = self._add_text(video, parameters)
|
||||
elif operation == "merge":
|
||||
result_video = self._merge_videos(parameters)
|
||||
else:
|
||||
raise ValueError(f"Unknown operation: {operation}")
|
||||
|
||||
# Write output
|
||||
result_video.write_videofile(
|
||||
output_path,
|
||||
codec=settings.default_video_codec,
|
||||
audio_codec=settings.default_audio_codec,
|
||||
fps=parameters.get("fps", settings.default_fps)
|
||||
)
|
||||
|
||||
# Clean up
|
||||
video.close()
|
||||
result_video.close()
|
||||
|
||||
# Get output info
|
||||
output_info = self._get_video_info(output_path)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"output_path": output_path,
|
||||
"info": output_info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Video processing failed: {str(e)}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def _trim_video(self, video: mp.VideoFileClip, params: Dict[str, Any]) -> mp.VideoFileClip:
|
||||
"""Trim video to specified start and end times."""
|
||||
start_time = params.get("start_time", 0)
|
||||
end_time = params.get("end_time", video.duration)
|
||||
return video.subclip(start_time, end_time)
|
||||
|
||||
def _resize_video(self, video: mp.VideoFileClip, params: Dict[str, Any]) -> mp.VideoFileClip:
|
||||
"""Resize video to specified dimensions."""
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
|
||||
if width and height:
|
||||
return video.fx(resize, newsize=(width, height))
|
||||
elif width:
|
||||
return video.fx(resize, width=width)
|
||||
elif height:
|
||||
return video.fx(resize, height=height)
|
||||
else:
|
||||
raise ValueError("Width or height must be specified for resize operation")
|
||||
|
||||
def _crop_video(self, video: mp.VideoFileClip, params: Dict[str, Any]) -> mp.VideoFileClip:
|
||||
"""Crop video to specified region."""
|
||||
x1 = params.get("x1", 0)
|
||||
y1 = params.get("y1", 0)
|
||||
x2 = params.get("x2", video.w)
|
||||
y2 = params.get("y2", video.h)
|
||||
return video.fx(crop, x1=x1, y1=y1, x2=x2, y2=y2)
|
||||
|
||||
def _rotate_video(self, video: mp.VideoFileClip, params: Dict[str, Any]) -> mp.VideoFileClip:
|
||||
"""Rotate video by specified angle."""
|
||||
angle = params.get("angle", 0)
|
||||
return video.fx(rotate, angle)
|
||||
|
||||
def _adjust_brightness(self, video: mp.VideoFileClip, params: Dict[str, Any]) -> mp.VideoFileClip:
|
||||
"""Adjust video brightness."""
|
||||
factor = params.get("factor", 1.0)
|
||||
|
||||
def brightness_effect(get_frame, t):
|
||||
frame = get_frame(t)
|
||||
return np.clip(frame * factor, 0, 255).astype(np.uint8)
|
||||
|
||||
return video.fl(brightness_effect)
|
||||
|
||||
def _adjust_contrast(self, video: mp.VideoFileClip, params: Dict[str, Any]) -> mp.VideoFileClip:
|
||||
"""Adjust video contrast."""
|
||||
factor = params.get("factor", 1.0)
|
||||
|
||||
def contrast_effect(get_frame, t):
|
||||
frame = get_frame(t)
|
||||
return np.clip(128 + factor * (frame - 128), 0, 255).astype(np.uint8)
|
||||
|
||||
return video.fl(contrast_effect)
|
||||
|
||||
def _add_text(self, video: mp.VideoFileClip, params: Dict[str, Any]) -> mp.VideoFileClip:
|
||||
"""Add text overlay to video."""
|
||||
text = params.get("text", "")
|
||||
fontsize = params.get("fontsize", 50)
|
||||
color = params.get("color", "white")
|
||||
position = params.get("position", ("center", "bottom"))
|
||||
duration = params.get("duration", video.duration)
|
||||
|
||||
txt_clip = mp.TextClip(text, fontsize=fontsize, color=color).set_position(position).set_duration(duration)
|
||||
return mp.CompositeVideoClip([video, txt_clip])
|
||||
|
||||
def _merge_videos(self, params: Dict[str, Any]) -> mp.VideoFileClip:
|
||||
"""Merge multiple videos."""
|
||||
video_paths = params.get("video_paths", [])
|
||||
if len(video_paths) < 2:
|
||||
raise ValueError("At least 2 videos required for merge operation")
|
||||
|
||||
clips = [mp.VideoFileClip(path) for path in video_paths]
|
||||
return mp.concatenate_videoclips(clips)
|
||||
|
||||
def _get_video_info(self, video_path: str) -> Dict[str, Any]:
|
||||
"""Get video file information."""
|
||||
try:
|
||||
probe = ffmpeg.probe(video_path)
|
||||
video_stream = next((stream for stream in probe['streams'] if stream['codec_type'] == 'video'), None)
|
||||
audio_stream = next((stream for stream in probe['streams'] if stream['codec_type'] == 'audio'), None)
|
||||
|
||||
info = {
|
||||
"duration": float(probe['format']['duration']),
|
||||
"size": int(probe['format']['size']),
|
||||
"bitrate": int(probe['format']['bit_rate']),
|
||||
}
|
||||
|
||||
if video_stream:
|
||||
info.update({
|
||||
"width": int(video_stream['width']),
|
||||
"height": int(video_stream['height']),
|
||||
"fps": eval(video_stream['r_frame_rate']),
|
||||
"video_codec": video_stream['codec_name']
|
||||
})
|
||||
|
||||
if audio_stream:
|
||||
info.update({
|
||||
"audio_codec": audio_stream['codec_name'],
|
||||
"sample_rate": int(audio_stream['sample_rate']),
|
||||
"channels": int(audio_stream['channels'])
|
||||
})
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get video info: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
def main():
|
||||
"""Command line interface for video processing."""
|
||||
parser = argparse.ArgumentParser(description="Video Processing Core")
|
||||
parser.add_argument("--input", required=True, help="Input video file path")
|
||||
parser.add_argument("--output", required=True, help="Output video file path")
|
||||
parser.add_argument("--operation", required=True, help="Operation to perform")
|
||||
parser.add_argument("--parameters", required=True, help="Operation parameters as JSON string")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
parameters = json.loads(args.parameters)
|
||||
processor = VideoProcessor()
|
||||
result = processor.process_video(args.input, args.output, args.operation, parameters)
|
||||
print(json.dumps(result))
|
||||
|
||||
except Exception as e:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
print(json.dumps(error_result))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
31
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "mixvideo-v2"
|
||||
version = "2.0.0"
|
||||
description = "MixVideo V2 - Professional video editing software"
|
||||
authors = ["MixVideo Team"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/mixvideo/mixvideo-v2"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.3.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.6.2", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
anyhow = "1.0"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
167
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VideoProcessRequest {
|
||||
pub input_path: String,
|
||||
pub output_path: String,
|
||||
pub operation: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AudioAnalysisRequest {
|
||||
pub file_path: String,
|
||||
pub analysis_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub created_at: String,
|
||||
pub modified_at: String,
|
||||
pub video_tracks: Vec<VideoTrack>,
|
||||
pub audio_tracks: Vec<AudioTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VideoTrack {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub file_path: String,
|
||||
pub start_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AudioTrack {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub file_path: String,
|
||||
pub start_time: f64,
|
||||
pub duration: f64,
|
||||
pub volume: f64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn greet(name: &str) -> Result<String, String> {
|
||||
Ok(format!("Hello, {}! Welcome to MixVideo V2!", name))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn process_video(request: VideoProcessRequest) -> Result<String, String> {
|
||||
// Call Python video processing script
|
||||
let output = Command::new("python3")
|
||||
.arg("python_core/video_processing/core.py")
|
||||
.arg("--input")
|
||||
.arg(&request.input_path)
|
||||
.arg("--output")
|
||||
.arg(&request.output_path)
|
||||
.arg("--operation")
|
||||
.arg(&request.operation)
|
||||
.arg("--parameters")
|
||||
.arg(request.parameters.to_string())
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute Python script: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(result.to_string())
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("Python script error: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn analyze_audio(request: AudioAnalysisRequest) -> Result<String, String> {
|
||||
// Call Python audio analysis script
|
||||
let output = Command::new("python3")
|
||||
.arg("python_core/audio_processing/core.py")
|
||||
.arg("--file")
|
||||
.arg(&request.file_path)
|
||||
.arg("--analysis")
|
||||
.arg(&request.analysis_type)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute Python script: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(result.to_string())
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("Python script error: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_project_info(project_path: String) -> Result<ProjectInfo, String> {
|
||||
// Call Python project management script
|
||||
let output = Command::new("python3")
|
||||
.arg("python_core/services/project_manager.py")
|
||||
.arg("--action")
|
||||
.arg("get_info")
|
||||
.arg("--path")
|
||||
.arg(&project_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute Python script: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
let project_info: ProjectInfo = serde_json::from_str(&result)
|
||||
.map_err(|e| format!("Failed to parse project info: {}", e))?;
|
||||
Ok(project_info)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("Python script error: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_project(project_info: ProjectInfo) -> Result<String, String> {
|
||||
// Call Python project management script to save
|
||||
let project_json = serde_json::to_string(&project_info)
|
||||
.map_err(|e| format!("Failed to serialize project: {}", e))?;
|
||||
|
||||
let output = Command::new("python3")
|
||||
.arg("python_core/services/project_manager.py")
|
||||
.arg("--action")
|
||||
.arg("save")
|
||||
.arg("--data")
|
||||
.arg(&project_json)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute Python script: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(result.to_string())
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("Python script error: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_project(project_path: String) -> Result<ProjectInfo, String> {
|
||||
// Call Python project management script to load
|
||||
let output = Command::new("python3")
|
||||
.arg("python_core/services/project_manager.py")
|
||||
.arg("--action")
|
||||
.arg("load")
|
||||
.arg("--path")
|
||||
.arg(&project_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute Python script: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
let project_info: ProjectInfo = serde_json::from_str(&result)
|
||||
.map_err(|e| format!("Failed to parse project info: {}", e))?;
|
||||
Ok(project_info)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("Python script error: {}", error))
|
||||
}
|
||||
}
|
||||
31
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use tauri::Manager;
|
||||
|
||||
mod commands;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::greet,
|
||||
commands::process_video,
|
||||
commands::analyze_audio,
|
||||
commands::get_project_info,
|
||||
commands::save_project,
|
||||
commands::load_project
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
45
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "MixVideo V2",
|
||||
"version": "2.0.0",
|
||||
"identifier": "com.mixvideo.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "MixVideo V2 - 视频混剪软件",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 1200,
|
||||
"minHeight": 700,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"category": "VideoUtility",
|
||||
"shortDescription": "Professional video editing software",
|
||||
"longDescription": "MixVideo V2 is a modern video editing software built with Tauri and Python, featuring professional editing tools, AI-assisted editing, and advanced audio processing capabilities."
|
||||
}
|
||||
}
|
||||
20
src/App.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import HomePage from './pages/HomePage'
|
||||
import EditorPage from './pages/EditorPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/editor" element={<EditorPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
27
src/components/Layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import TitleBar from './TitleBar'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const location = useLocation()
|
||||
const isEditorPage = location.pathname === '/editor'
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-secondary-50">
|
||||
<TitleBar />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{!isEditorPage && <Sidebar />}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
60
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Home, Video, Settings, FolderOpen, Music, Image } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const location = useLocation()
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: Home, label: '首页' },
|
||||
{ path: '/editor', icon: Video, label: '编辑器' },
|
||||
{ path: '/projects', icon: FolderOpen, label: '项目' },
|
||||
{ path: '/media', icon: Image, label: '媒体库' },
|
||||
{ path: '/audio', icon: Music, label: '音频' },
|
||||
{ path: '/settings', icon: Settings, label: '设置' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-white border-r border-secondary-200 flex flex-col">
|
||||
<div className="p-4 border-b border-secondary-200">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">工作区</h2>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = location.pathname === item.path
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
to={item.path}
|
||||
className={clsx(
|
||||
'flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'
|
||||
)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-secondary-200">
|
||||
<div className="text-xs text-secondary-500">
|
||||
<p>MixVideo V2</p>
|
||||
<p>版本 2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
56
src/components/TitleBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { Minimize2, Maximize2, X } from 'lucide-react'
|
||||
|
||||
const TitleBar: React.FC = () => {
|
||||
const handleMinimize = () => {
|
||||
// TODO: Implement minimize functionality with Tauri
|
||||
console.log('Minimize window')
|
||||
}
|
||||
|
||||
const handleMaximize = () => {
|
||||
// TODO: Implement maximize functionality with Tauri
|
||||
console.log('Maximize window')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
// TODO: Implement close functionality with Tauri
|
||||
console.log('Close window')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-secondary-800 text-white flex items-center justify-between px-4 select-none"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-primary-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-xs font-bold">M</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">MixVideo V2</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="w-6 h-6 flex items-center justify-center hover:bg-secondary-700 rounded transition-colors"
|
||||
>
|
||||
<Minimize2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="w-6 h-6 flex items-center justify-center hover:bg-secondary-700 rounded transition-colors"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-6 h-6 flex items-center justify-center hover:bg-red-600 rounded transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleBar
|
||||
96
src/index.css
Normal file
@@ -0,0 +1,96 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-secondary-200;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-secondary-900 font-sans;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary-200 text-secondary-900 hover:bg-secondary-300 active:bg-secondary-400;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn hover:bg-secondary-100 hover:text-secondary-900;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply flex h-10 w-full rounded-md border border-secondary-300 bg-white px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-secondary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-lg border border-secondary-200 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
@apply relative bg-secondary-100 rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
@apply relative h-16 bg-secondary-200 border-b border-secondary-300;
|
||||
}
|
||||
|
||||
.timeline-clip {
|
||||
@apply absolute top-0 h-full bg-primary-500 rounded border border-primary-600 cursor-pointer hover:bg-primary-600 transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-secondary-100;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-secondary-400 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-secondary-500;
|
||||
}
|
||||
|
||||
/* Video player styles */
|
||||
.video-player {
|
||||
@apply relative bg-black rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
@apply absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4;
|
||||
}
|
||||
|
||||
/* Loading animations */
|
||||
.loading-spinner {
|
||||
@apply animate-spin rounded-full border-2 border-secondary-300 border-t-primary-600;
|
||||
}
|
||||
|
||||
/* Drag and drop styles */
|
||||
.drop-zone {
|
||||
@apply border-2 border-dashed border-secondary-300 rounded-lg p-8 text-center transition-colors;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
@apply border-primary-500 bg-primary-50;
|
||||
}
|
||||
13
src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
173
src/pages/EditorPage.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from 'react'
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, Scissors, Download } from 'lucide-react'
|
||||
|
||||
const EditorPage: React.FC = () => {
|
||||
const [isPlaying, setIsPlaying] = React.useState(false)
|
||||
|
||||
const togglePlayback = () => {
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-secondary-900">
|
||||
{/* Top Toolbar */}
|
||||
<div className="bg-secondary-800 border-b border-secondary-700 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-white font-semibold">视频编辑器</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="btn-ghost text-white px-3 py-1 text-sm">
|
||||
文件
|
||||
</button>
|
||||
<button className="btn-ghost text-white px-3 py-1 text-sm">
|
||||
编辑
|
||||
</button>
|
||||
<button className="btn-ghost text-white px-3 py-1 text-sm">
|
||||
效果
|
||||
</button>
|
||||
<button className="btn-ghost text-white px-3 py-1 text-sm">
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="btn-primary px-4 py-2">
|
||||
<Download size={16} className="mr-2" />
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Media Library Sidebar */}
|
||||
<div className="w-64 bg-secondary-800 border-r border-secondary-700 flex flex-col">
|
||||
<div className="p-4 border-b border-secondary-700">
|
||||
<h2 className="text-white font-medium">媒体库</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="drop-zone h-32 mb-4 bg-secondary-700 border-secondary-600">
|
||||
<p className="text-secondary-400 text-sm">拖拽文件到这里</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-secondary-700 rounded p-3 text-white text-sm">
|
||||
示例视频.mp4
|
||||
</div>
|
||||
<div className="bg-secondary-700 rounded p-3 text-white text-sm">
|
||||
背景音乐.mp3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Video Preview */}
|
||||
<div className="flex-1 bg-black flex items-center justify-center">
|
||||
<div className="video-player w-full max-w-4xl aspect-video bg-secondary-800 rounded-lg">
|
||||
<div className="w-full h-full flex items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="w-24 h-24 bg-secondary-700 rounded-full flex items-center justify-center mb-4 mx-auto">
|
||||
{isPlaying ? <Pause size={32} /> : <Play size={32} />}
|
||||
</div>
|
||||
<p className="text-secondary-400">视频预览区域</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-secondary-800 border-t border-secondary-700 p-4">
|
||||
<div className="flex items-center justify-center space-x-4 mb-4">
|
||||
<button className="text-white hover:text-primary-400 transition-colors">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlayback}
|
||||
className="w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full flex items-center justify-center text-white transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
<button className="text-white hover:text-primary-400 transition-colors">
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<div className="flex items-center space-x-2 ml-8">
|
||||
<Volume2 className="text-white" size={16} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue="50"
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="timeline h-32">
|
||||
<div className="timeline-track">
|
||||
<div className="timeline-clip left-4 w-32 bg-primary-500">
|
||||
<div className="p-2 text-white text-xs">视频轨道 1</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="timeline-track">
|
||||
<div className="timeline-clip left-8 w-24 bg-green-500">
|
||||
<div className="p-2 text-white text-xs">音频轨道 1</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
<div className="w-64 bg-secondary-800 border-l border-secondary-700 flex flex-col">
|
||||
<div className="p-4 border-b border-secondary-700">
|
||||
<h2 className="text-white font-medium">属性面板</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">
|
||||
亮度
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
defaultValue="100"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">
|
||||
对比度
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
defaultValue="100"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white text-sm font-medium mb-2">
|
||||
饱和度
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
defaultValue="100"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-primary w-full">
|
||||
<Scissors size={16} className="mr-2" />
|
||||
应用效果
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorPage
|
||||
110
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, FolderOpen, Video, Music, Zap } from 'lucide-react'
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const recentProjects = [
|
||||
{ id: 1, name: '旅行视频剪辑', lastModified: '2 小时前', thumbnail: '/placeholder-video.jpg' },
|
||||
{ id: 2, name: '产品宣传片', lastModified: '1 天前', thumbnail: '/placeholder-video.jpg' },
|
||||
{ id: 3, name: '音乐MV制作', lastModified: '3 天前', thumbnail: '/placeholder-video.jpg' },
|
||||
]
|
||||
|
||||
const quickActions = [
|
||||
{ icon: Video, label: '新建视频项目', description: '创建一个新的视频编辑项目' },
|
||||
{ icon: Music, label: '音频处理', description: '处理音频文件,添加效果' },
|
||||
{ icon: Zap, label: 'AI 自动剪辑', description: '使用 AI 自动生成视频剪辑' },
|
||||
{ icon: FolderOpen, label: '导入媒体', description: '导入视频、音频和图片文件' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="text-center py-8">
|
||||
<h1 className="text-4xl font-bold text-secondary-900 mb-4">
|
||||
欢迎使用 MixVideo V2
|
||||
</h1>
|
||||
<p className="text-lg text-secondary-600 mb-8">
|
||||
专业的视频混剪软件,让创作更简单
|
||||
</p>
|
||||
<Link
|
||||
to="/editor"
|
||||
className="btn-primary px-8 py-3 text-lg"
|
||||
>
|
||||
<Plus className="mr-2" size={20} />
|
||||
开始新项目
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-secondary-900 mb-4">快速操作</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{quickActions.map((action, index) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="card p-6 hover:shadow-md transition-shadow cursor-pointer group"
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center group-hover:bg-primary-200 transition-colors">
|
||||
<Icon className="text-primary-600" size={24} />
|
||||
</div>
|
||||
<h3 className="font-medium text-secondary-900">{action.label}</h3>
|
||||
<p className="text-sm text-secondary-600">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Projects */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-semibold text-secondary-900">最近项目</h2>
|
||||
<Link to="/projects" className="btn-ghost px-4 py-2">
|
||||
查看全部
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{recentProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="card overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<div className="aspect-video bg-secondary-200 flex items-center justify-center">
|
||||
<Video className="text-secondary-400" size={48} />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium text-secondary-900 mb-1">{project.name}</h3>
|
||||
<p className="text-sm text-secondary-600">{project.lastModified}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Highlight */}
|
||||
<div className="bg-gradient-to-r from-primary-500 to-primary-600 rounded-lg p-8 text-white">
|
||||
<h2 className="text-2xl font-bold mb-4">强大的功能特性</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">🎬 专业剪辑</h3>
|
||||
<p className="text-primary-100">支持多轨道编辑、特效添加、字幕制作</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">🎵 音频处理</h3>
|
||||
<p className="text-primary-100">节拍检测、音频分离、智能混音</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">🤖 AI 辅助</h3>
|
||||
<p className="text-primary-100">自动剪辑、场景检测、内容理解</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
201
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React from 'react'
|
||||
import { Save, FolderOpen, Monitor, Volume2, Cpu } from 'lucide-react'
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-secondary-900 mb-8">设置</h1>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* General Settings */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-4 flex items-center">
|
||||
<Monitor className="mr-2" size={20} />
|
||||
常规设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
默认项目路径
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
defaultValue="/Users/username/MixVideo Projects"
|
||||
/>
|
||||
<button className="btn-secondary px-4 py-2">
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
自动保存间隔
|
||||
</label>
|
||||
<select className="input w-48">
|
||||
<option value="1">1 分钟</option>
|
||||
<option value="5">5 分钟</option>
|
||||
<option value="10">10 分钟</option>
|
||||
<option value="30">30 分钟</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="checkbox" id="auto-backup" className="rounded" />
|
||||
<label htmlFor="auto-backup" className="text-sm text-secondary-700">
|
||||
启用自动备份
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Settings */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-4 flex items-center">
|
||||
<Monitor className="mr-2" size={20} />
|
||||
视频设置
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
默认分辨率
|
||||
</label>
|
||||
<select className="input">
|
||||
<option value="1920x1080">1920x1080 (Full HD)</option>
|
||||
<option value="1280x720">1280x720 (HD)</option>
|
||||
<option value="3840x2160">3840x2160 (4K)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
默认帧率
|
||||
</label>
|
||||
<select className="input">
|
||||
<option value="24">24 fps</option>
|
||||
<option value="30">30 fps</option>
|
||||
<option value="60">60 fps</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
视频编码器
|
||||
</label>
|
||||
<select className="input">
|
||||
<option value="h264">H.264</option>
|
||||
<option value="h265">H.265 (HEVC)</option>
|
||||
<option value="av1">AV1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
质量预设
|
||||
</label>
|
||||
<select className="input">
|
||||
<option value="high">高质量</option>
|
||||
<option value="medium">中等质量</option>
|
||||
<option value="low">低质量</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio Settings */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-4 flex items-center">
|
||||
<Volume2 className="mr-2" size={20} />
|
||||
音频设置
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
采样率
|
||||
</label>
|
||||
<select className="input">
|
||||
<option value="44100">44.1 kHz</option>
|
||||
<option value="48000">48 kHz</option>
|
||||
<option value="96000">96 kHz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
音频编码器
|
||||
</label>
|
||||
<select className="input">
|
||||
<option value="aac">AAC</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="flac">FLAC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
比特率
|
||||
</label>
|
||||
<select className="input">
|
||||
<option value="128">128 kbps</option>
|
||||
<option value="192">192 kbps</option>
|
||||
<option value="320">320 kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Settings */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-4 flex items-center">
|
||||
<Cpu className="mr-2" size={20} />
|
||||
性能设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
最大工作线程数
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input w-32"
|
||||
defaultValue="4"
|
||||
min="1"
|
||||
max="16"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
内存缓存大小 (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input w-32"
|
||||
defaultValue="1024"
|
||||
min="256"
|
||||
max="8192"
|
||||
step="256"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="checkbox" id="gpu-acceleration" className="rounded" />
|
||||
<label htmlFor="gpu-acceleration" className="text-sm text-secondary-700">
|
||||
启用 GPU 加速
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input type="checkbox" id="preview-quality" className="rounded" />
|
||||
<label htmlFor="preview-quality" className="text-sm text-secondary-700">
|
||||
预览时降低质量以提高性能
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button className="btn-primary px-6 py-3">
|
||||
<Save className="mr-2" size={16} />
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
56
tailwind.config.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'build',
|
||||
sourcemap: true,
|
||||
},
|
||||
// Tauri expects a fixed port, fail if that port is not available
|
||||
clearScreen: false,
|
||||
// tauri-apps/tauri/pull/3131
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
})
|
||||