feat: 完成 MixVideo V2 基础架构和核心功能
🎬 主要功能: - ✅ 完整的 Tauri + React + Python 分层架构 - ✅ 视频处理核心模块(剪切、调整、特效) - ✅ 音频处理集成(节拍检测、频谱分析) - ✅ Tauri-Python 桥接通信 - ✅ 项目管理和文件管理服务 - ✅ 现代化 UI 组件(时间轴、预览、媒体库) 🛠️ 技术栈: - Frontend: Tauri 2.6.2 + React 18 + TypeScript + Tailwind CSS - Backend: Python 3.10 + MoviePy + FFmpeg + Librosa + OpenCV - State: Zustand stores for project and media management - Build: Vite + pnpm 📦 新增组件: - VideoPreview: 视频预览和播放控制 - Timeline: 多轨道时间轴编辑器 - MediaLibrary: 媒体文件管理和拖拽上传 - TauriService: 前端与 Python 通信服务 - ProjectStore/MediaStore: 状态管理 🧪 已测试功能: - Python 视频处理模块正常工作 - 项目创建和管理功能 - 前端构建成功 - Tauri 应用启动正常
This commit is contained in:
310
src/components/MediaLibrary.tsx
Normal file
310
src/components/MediaLibrary.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { Upload, Video, Music, Image, File, Search, Filter, Grid, List } from 'lucide-react'
|
||||
import { useMediaStore } from '../stores/useMediaStore'
|
||||
import { useProjectStore } from '../stores/useProjectStore'
|
||||
|
||||
interface MediaItem {
|
||||
id: string
|
||||
name: string
|
||||
type: 'video' | 'audio' | 'image'
|
||||
path: string
|
||||
size: number
|
||||
duration?: number
|
||||
thumbnail?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface MediaLibraryProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MediaLibrary: React.FC<MediaLibraryProps> = ({ className = '' }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterType, setFilterType] = useState<'all' | 'video' | 'audio' | 'image'>('all')
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const { setCurrentMedia } = useMediaStore()
|
||||
const { currentProject, addVideoTrack, addAudioTrack } = useProjectStore()
|
||||
|
||||
// Mock media items - in real app this would come from the project
|
||||
const [mediaItems, setMediaItems] = useState<MediaItem[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'sample_video.mp4',
|
||||
type: 'video',
|
||||
path: '/tmp/test_video.mp4',
|
||||
size: 1024000,
|
||||
duration: 30,
|
||||
createdAt: '2025-07-10T09:00:00Z'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'background_music.mp3',
|
||||
type: 'audio',
|
||||
path: '/tmp/audio.mp3',
|
||||
size: 512000,
|
||||
duration: 120,
|
||||
createdAt: '2025-07-10T09:15:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
// Filter media items
|
||||
const filteredItems = mediaItems.filter(item => {
|
||||
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesFilter = filterType === 'all' || item.type === filterType
|
||||
return matchesSearch && matchesFilter
|
||||
})
|
||||
|
||||
// Handle file upload
|
||||
const handleFileUpload = (files: FileList | null) => {
|
||||
if (!files) return
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const fileType = getFileType(file)
|
||||
if (fileType) {
|
||||
const newItem: MediaItem = {
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
type: fileType,
|
||||
path: URL.createObjectURL(file),
|
||||
size: file.size,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
setMediaItems(prev => [...prev, newItem])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Determine file type
|
||||
const getFileType = (file: File): 'video' | 'audio' | 'image' | null => {
|
||||
if (file.type.startsWith('video/')) return 'video'
|
||||
if (file.type.startsWith('audio/')) return 'audio'
|
||||
if (file.type.startsWith('image/')) return 'image'
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle drag and drop
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
handleFileUpload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
// Handle media item click
|
||||
const handleMediaClick = (item: MediaItem) => {
|
||||
setCurrentMedia(item.path)
|
||||
}
|
||||
|
||||
// Handle adding to timeline
|
||||
const handleAddToTimeline = (item: MediaItem) => {
|
||||
if (!currentProject) return
|
||||
|
||||
if (item.type === 'video') {
|
||||
addVideoTrack({
|
||||
id: crypto.randomUUID(),
|
||||
name: item.name,
|
||||
file_path: item.path,
|
||||
start_time: 0,
|
||||
duration: item.duration || 0
|
||||
})
|
||||
} else if (item.type === 'audio') {
|
||||
addAudioTrack({
|
||||
id: crypto.randomUUID(),
|
||||
name: item.name,
|
||||
file_path: item.path,
|
||||
start_time: 0,
|
||||
duration: item.duration || 0,
|
||||
volume: 1.0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return ''
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Get icon for media type
|
||||
const getMediaIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'video': return <Video size={16} />
|
||||
case 'audio': return <Music size={16} />
|
||||
case 'image': return <Image size={16} />
|
||||
default: return <File size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border-r border-secondary-200 flex flex-col ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-secondary-200">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-4">媒体库</h2>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-secondary-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索媒体文件..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value as any)}
|
||||
className="input w-32"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="video">视频</option>
|
||||
<option value="audio">音频</option>
|
||||
<option value="image">图片</option>
|
||||
</select>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-primary-100 text-primary-600' : 'text-secondary-600 hover:bg-secondary-100'}`}
|
||||
>
|
||||
<Grid size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded ${viewMode === 'list' ? 'bg-primary-100 text-primary-600' : 'text-secondary-600 hover:bg-secondary-100'}`}
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
className={`m-4 border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
dragOver
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-secondary-300 hover:border-secondary-400'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mx-auto mb-2 text-secondary-400" size={24} />
|
||||
<p className="text-sm text-secondary-600">
|
||||
拖拽文件到这里或点击上传
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
支持视频、音频和图片文件
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="video/*,audio/*,image/*"
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media Items */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center text-secondary-500 py-8">
|
||||
<File size={32} className="mx-auto mb-2" />
|
||||
<p>没有找到媒体文件</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewMode === 'grid' ? 'grid grid-cols-2 gap-3' : 'space-y-2'}>
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border border-secondary-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer ${
|
||||
viewMode === 'grid' ? 'aspect-square' : 'flex items-center p-3'
|
||||
}`}
|
||||
onClick={() => handleMediaClick(item)}
|
||||
onDoubleClick={() => handleAddToTimeline(item)}
|
||||
>
|
||||
{viewMode === 'grid' ? (
|
||||
<>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video bg-secondary-100 flex items-center justify-center">
|
||||
{getMediaIcon(item.type)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-2">
|
||||
<h3 className="text-sm font-medium text-secondary-900 truncate">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between text-xs text-secondary-600 mt-1">
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{item.duration && <span>{formatDuration(item.duration)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 bg-secondary-100 rounded flex items-center justify-center mr-3">
|
||||
{getMediaIcon(item.type)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-secondary-900 truncate">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-xs text-secondary-600">
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{item.duration && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDuration(item.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaLibrary
|
||||
228
src/components/Timeline.tsx
Normal file
228
src/components/Timeline.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useRef, useEffect, useState } from 'react'
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, Scissors } from 'lucide-react'
|
||||
import { useProjectStore } from '../stores/useProjectStore'
|
||||
import { useMediaStore } from '../stores/useMediaStore'
|
||||
|
||||
interface TimelineProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Timeline: React.FC<TimelineProps> = ({ className = '' }) => {
|
||||
const timelineRef = useRef<HTMLDivElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStartX, setDragStartX] = useState(0)
|
||||
|
||||
const { currentProject, setTimelinePosition, setTimelineZoom } = useProjectStore()
|
||||
const {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
setPlaying,
|
||||
setCurrentTime,
|
||||
setVolume
|
||||
} = useMediaStore()
|
||||
|
||||
const timeline = currentProject?.timeline || { duration: 0, zoom: 1, position: 0 }
|
||||
const videoTracks = currentProject?.video_tracks || []
|
||||
const audioTracks = currentProject?.audio_tracks || []
|
||||
|
||||
// Handle timeline scrubbing
|
||||
const handleTimelineClick = (e: React.MouseEvent) => {
|
||||
if (!timelineRef.current) return
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const percentage = x / rect.width
|
||||
const newTime = percentage * timeline.duration
|
||||
|
||||
setCurrentTime(newTime)
|
||||
setTimelinePosition(newTime)
|
||||
}
|
||||
|
||||
// Handle track dragging
|
||||
const handleTrackMouseDown = (e: React.MouseEvent, trackId: string, trackType: 'video' | 'audio') => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
setDragStartX(e.clientX)
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const deltaX = e.clientX - dragStartX
|
||||
const timeDelta = (deltaX / (timelineRef.current?.clientWidth || 1)) * timeline.duration
|
||||
|
||||
// Update track position
|
||||
// This would update the track's start_time
|
||||
console.log(`Moving ${trackType} track ${trackId} by ${timeDelta}s`)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// Format time for display
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Calculate track position and width
|
||||
const getTrackStyle = (startTime: number, duration: number) => {
|
||||
const left = (startTime / timeline.duration) * 100
|
||||
const width = (duration / timeline.duration) * 100
|
||||
return {
|
||||
left: `${left}%`,
|
||||
width: `${width}%`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-secondary-800 border-t border-secondary-700 ${className}`}>
|
||||
{/* Timeline Controls */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-700">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setCurrentTime(Math.max(0, currentTime - 10))}
|
||||
className="text-white hover:text-primary-400 transition-colors"
|
||||
>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPlaying(!isPlaying)}
|
||||
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
|
||||
onClick={() => setCurrentTime(Math.min(duration, currentTime + 10))}
|
||||
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="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-white text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(timeline.duration)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white text-sm">缩放:</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="5"
|
||||
step="0.1"
|
||||
value={timeline.zoom}
|
||||
onChange={(e) => setTimelineZoom(parseFloat(e.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Ruler */}
|
||||
<div className="relative h-8 bg-secondary-900 border-b border-secondary-700">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
{Array.from({ length: Math.ceil(timeline.duration / 10) + 1 }, (_, i) => {
|
||||
const time = i * 10
|
||||
const left = (time / timeline.duration) * 100
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute text-xs text-secondary-400"
|
||||
style={{ left: `${left}%` }}
|
||||
>
|
||||
{formatTime(time)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Playhead */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-10"
|
||||
style={{ left: `${(currentTime / timeline.duration) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeline Tracks */}
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="relative min-h-32 bg-secondary-100 overflow-y-auto"
|
||||
onClick={handleTimelineClick}
|
||||
>
|
||||
{/* Video Tracks */}
|
||||
{videoTracks.map((track, index) => (
|
||||
<div key={track.id} className="timeline-track">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-24 bg-secondary-800 border-r border-secondary-700 flex items-center px-2">
|
||||
<span className="text-white text-sm truncate">视频 {index + 1}</span>
|
||||
</div>
|
||||
<div
|
||||
className="timeline-clip bg-primary-500 hover:bg-primary-600 cursor-pointer"
|
||||
style={getTrackStyle(track.start_time, track.duration)}
|
||||
onMouseDown={(e) => handleTrackMouseDown(e, track.id, 'video')}
|
||||
>
|
||||
<div className="p-2 text-white text-xs truncate">
|
||||
{track.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Audio Tracks */}
|
||||
{audioTracks.map((track, index) => (
|
||||
<div key={track.id} className="timeline-track">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-24 bg-secondary-800 border-r border-secondary-700 flex items-center px-2">
|
||||
<span className="text-white text-sm truncate">音频 {index + 1}</span>
|
||||
</div>
|
||||
<div
|
||||
className="timeline-clip bg-green-500 hover:bg-green-600 cursor-pointer"
|
||||
style={getTrackStyle(track.start_time, track.duration)}
|
||||
onMouseDown={(e) => handleTrackMouseDown(e, track.id, 'audio')}
|
||||
>
|
||||
<div className="p-2 text-white text-xs truncate">
|
||||
{track.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{videoTracks.length === 0 && audioTracks.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-secondary-500">
|
||||
<div className="text-center">
|
||||
<Scissors size={32} className="mx-auto mb-2" />
|
||||
<p>拖拽媒体文件到这里开始编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Timeline
|
||||
279
src/components/VideoPreview.tsx
Normal file
279
src/components/VideoPreview.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useRef, useEffect, useState } from 'react'
|
||||
import { Play, Pause, Volume2, Maximize2, RotateCcw } from 'lucide-react'
|
||||
import { useMediaStore } from '../stores/useMediaStore'
|
||||
import { useProjectStore } from '../stores/useProjectStore'
|
||||
|
||||
interface VideoPreviewProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VideoPreview: React.FC<VideoPreviewProps> = ({ className = '' }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [controlsTimeout, setControlsTimeout] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
const {
|
||||
currentMedia,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
setPlaying,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setVolume
|
||||
} = useMediaStore()
|
||||
|
||||
const { currentProject } = useProjectStore()
|
||||
|
||||
// Handle video loading
|
||||
useEffect(() => {
|
||||
if (videoRef.current && currentMedia) {
|
||||
videoRef.current.src = currentMedia
|
||||
videoRef.current.load()
|
||||
}
|
||||
}, [currentMedia])
|
||||
|
||||
// Handle play/pause
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.play()
|
||||
} else {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
// Handle time updates
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = currentTime
|
||||
}
|
||||
}, [currentTime])
|
||||
|
||||
// Handle volume changes
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = volume
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
// Video event handlers
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (videoRef.current) {
|
||||
setVolume(videoRef.current.volume)
|
||||
}
|
||||
}
|
||||
|
||||
// Control visibility
|
||||
const showControlsTemporarily = () => {
|
||||
setShowControls(true)
|
||||
|
||||
if (controlsTimeout) {
|
||||
clearTimeout(controlsTimeout)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
setControlsTimeout(timeout)
|
||||
}
|
||||
|
||||
const handleMouseMove = () => {
|
||||
showControlsTemporarily()
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen handling
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
videoRef.current?.requestFullscreen()
|
||||
setIsFullscreen(true)
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Format time for display
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Handle seeking
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = parseFloat(e.target.value)
|
||||
setCurrentTime(newTime)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-black rounded-lg overflow-hidden ${className}`}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onClick={() => setPlaying(!isPlaying)}
|
||||
/>
|
||||
|
||||
{/* Canvas for effects preview */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* No video placeholder */}
|
||||
{!currentMedia && (
|
||||
<div className="absolute inset-0 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">
|
||||
<Play size={32} />
|
||||
</div>
|
||||
<p className="text-secondary-400">选择视频文件开始预览</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
{currentMedia && (
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent transition-opacity duration-300 ${
|
||||
showControls ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
<div className="absolute top-4 right-4 flex items-center space-x-2">
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="w-10 h-10 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center text-white transition-colors"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center Play Button */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setPlaying(!isPlaying)}
|
||||
className={`w-16 h-16 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center text-white transition-all ${
|
||||
isPlaying ? 'opacity-0 scale-75' : 'opacity-100 scale-100'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="w-full h-1 bg-white/30 rounded-lg appearance-none cursor-pointer slider"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(currentTime / (duration || 1)) * 100}%, rgba(255,255,255,0.3) ${(currentTime / (duration || 1)) * 100}%, rgba(255,255,255,0.3) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setPlaying(!isPlaying)}
|
||||
className="text-white hover:text-primary-400 transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Volume2 className="text-white" size={16} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-white text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentTime(0)}
|
||||
className="text-white hover:text-primary-400 transition-colors"
|
||||
title="重置到开始"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing Overlay */}
|
||||
{/* This would show when video is being processed */}
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center" style={{ display: 'none' }}>
|
||||
<div className="text-center text-white">
|
||||
<div className="loading-spinner w-8 h-8 mx-auto mb-4"></div>
|
||||
<p>正在处理视频...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoPreview
|
||||
@@ -1,11 +1,22 @@
|
||||
import React from 'react'
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, Scissors, Download } from 'lucide-react'
|
||||
import { Download, Save, Scissors } from 'lucide-react'
|
||||
import VideoPreview from '../components/VideoPreview'
|
||||
import Timeline from '../components/Timeline'
|
||||
import MediaLibrary from '../components/MediaLibrary'
|
||||
import { useProjectStore } from '../stores/useProjectStore'
|
||||
|
||||
const EditorPage: React.FC = () => {
|
||||
const [isPlaying, setIsPlaying] = React.useState(false)
|
||||
const { currentProject, saveProject, isSaving } = useProjectStore()
|
||||
|
||||
const togglePlayback = () => {
|
||||
setIsPlaying(!isPlaying)
|
||||
const handleSave = async () => {
|
||||
if (currentProject) {
|
||||
await saveProject()
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
// TODO: Implement export functionality
|
||||
console.log('Export project')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -13,7 +24,9 @@ const EditorPage: React.FC = () => {
|
||||
{/* 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>
|
||||
<h1 className="text-white font-semibold">
|
||||
{currentProject ? currentProject.name : '视频编辑器'}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="btn-ghost text-white px-3 py-1 text-sm">
|
||||
文件
|
||||
@@ -30,7 +43,18 @@ const EditorPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="btn-primary px-4 py-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !currentProject}
|
||||
className="btn-secondary px-4 py-2 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} className="mr-2" />
|
||||
{isSaving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="btn-primary px-4 py-2"
|
||||
>
|
||||
<Download size={16} className="mr-2" />
|
||||
导出
|
||||
</button>
|
||||
@@ -39,82 +63,17 @@ const EditorPage: React.FC = () => {
|
||||
|
||||
<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>
|
||||
<MediaLibrary className="w-64" />
|
||||
|
||||
{/* 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 className="flex-1 bg-black p-4">
|
||||
<VideoPreview className="w-full h-full" />
|
||||
</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>
|
||||
<Timeline className="h-48" />
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, FolderOpen, Video, Music, Zap } from 'lucide-react'
|
||||
import { Plus, FolderOpen, Video, Music, Zap, TestTube } from 'lucide-react'
|
||||
import { TauriService } from '../services/tauri'
|
||||
import { useProjectStore } from '../stores/useProjectStore'
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const [testResult, setTestResult] = useState<string>('')
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false)
|
||||
const { createProject } = useProjectStore()
|
||||
|
||||
const recentProjects = [
|
||||
{ id: 1, name: '旅行视频剪辑', lastModified: '2 小时前', thumbnail: '/placeholder-video.jpg' },
|
||||
{ id: 2, name: '产品宣传片', lastModified: '1 天前', thumbnail: '/placeholder-video.jpg' },
|
||||
@@ -16,6 +22,28 @@ const HomePage: React.FC = () => {
|
||||
{ icon: FolderOpen, label: '导入媒体', description: '导入视频、音频和图片文件' },
|
||||
]
|
||||
|
||||
const testTauriConnection = async () => {
|
||||
setIsTestingConnection(true)
|
||||
setTestResult('')
|
||||
|
||||
try {
|
||||
const result = await TauriService.greet('MixVideo V2')
|
||||
setTestResult(`✅ 连接成功: ${result}`)
|
||||
} catch (error) {
|
||||
setTestResult(`❌ 连接失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally {
|
||||
setIsTestingConnection(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
try {
|
||||
await createProject('新项目', '使用 MixVideo V2 创建的项目')
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Welcome Section */}
|
||||
@@ -26,6 +54,7 @@ const HomePage: React.FC = () => {
|
||||
<p className="text-lg text-secondary-600 mb-8">
|
||||
专业的视频混剪软件,让创作更简单
|
||||
</p>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/editor"
|
||||
className="btn-primary px-8 py-3 text-lg"
|
||||
@@ -33,6 +62,29 @@ const HomePage: React.FC = () => {
|
||||
<Plus className="mr-2" size={20} />
|
||||
开始新项目
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
className="btn-secondary px-6 py-3"
|
||||
>
|
||||
创建项目
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={testTauriConnection}
|
||||
disabled={isTestingConnection}
|
||||
className="btn-ghost px-6 py-3 border border-secondary-300"
|
||||
>
|
||||
<TestTube className="mr-2" size={16} />
|
||||
{isTestingConnection ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="mt-4 p-3 bg-secondary-100 rounded-lg text-sm">
|
||||
{testResult}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
|
||||
358
src/services/tauri.ts
Normal file
358
src/services/tauri.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Tauri API Service
|
||||
* Handles communication between frontend and Tauri backend
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Types for video processing
|
||||
export interface VideoProcessRequest {
|
||||
input_path: string
|
||||
output_path: string
|
||||
operation: string
|
||||
parameters: Record<string, any>
|
||||
}
|
||||
|
||||
export interface AudioAnalysisRequest {
|
||||
file_path: string
|
||||
analysis_type: string
|
||||
}
|
||||
|
||||
export interface ProjectInfo {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
created_at: string
|
||||
modified_at: string
|
||||
video_tracks: VideoTrack[]
|
||||
audio_tracks: AudioTrack[]
|
||||
timeline: {
|
||||
duration: number
|
||||
zoom: number
|
||||
position: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface VideoTrack {
|
||||
id: string
|
||||
name: string
|
||||
file_path: string
|
||||
start_time: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface AudioTrack {
|
||||
id: string
|
||||
name: string
|
||||
file_path: string
|
||||
start_time: number
|
||||
duration: number
|
||||
volume: number
|
||||
}
|
||||
|
||||
// Tauri command wrappers
|
||||
export class TauriService {
|
||||
/**
|
||||
* Test connection to backend
|
||||
*/
|
||||
static async greet(name: string): Promise<string> {
|
||||
try {
|
||||
return await invoke('greet', { name })
|
||||
} catch (error) {
|
||||
console.error('Failed to greet:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process video with specified operation
|
||||
*/
|
||||
static async processVideo(request: VideoProcessRequest): Promise<any> {
|
||||
try {
|
||||
const result = await invoke('process_video', { request })
|
||||
return JSON.parse(result as string)
|
||||
} catch (error) {
|
||||
console.error('Failed to process video:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze audio file
|
||||
*/
|
||||
static async analyzeAudio(request: AudioAnalysisRequest): Promise<any> {
|
||||
try {
|
||||
const result = await invoke('analyze_audio', { request })
|
||||
return JSON.parse(result as string)
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze audio:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project information
|
||||
*/
|
||||
static async getProjectInfo(projectPath: string): Promise<ProjectInfo> {
|
||||
try {
|
||||
return await invoke('get_project_info', { projectPath })
|
||||
} catch (error) {
|
||||
console.error('Failed to get project info:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save project
|
||||
*/
|
||||
static async saveProject(projectInfo: ProjectInfo): Promise<string> {
|
||||
try {
|
||||
return await invoke('save_project', { projectInfo })
|
||||
} catch (error) {
|
||||
console.error('Failed to save project:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project
|
||||
*/
|
||||
static async loadProject(projectPath: string): Promise<ProjectInfo> {
|
||||
try {
|
||||
return await invoke('load_project', { projectPath })
|
||||
} catch (error) {
|
||||
console.error('Failed to load project:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video processing operations
|
||||
export class VideoService {
|
||||
/**
|
||||
* Trim video to specified duration
|
||||
*/
|
||||
static async trimVideo(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<any> {
|
||||
return TauriService.processVideo({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
operation: 'trim',
|
||||
parameters: { start_time: startTime, end_time: endTime }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize video to specified dimensions
|
||||
*/
|
||||
static async resizeVideo(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
width?: number,
|
||||
height?: number
|
||||
): Promise<any> {
|
||||
return TauriService.processVideo({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
operation: 'resize',
|
||||
parameters: { width, height }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop video to specified region
|
||||
*/
|
||||
static async cropVideo(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number
|
||||
): Promise<any> {
|
||||
return TauriService.processVideo({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
operation: 'crop',
|
||||
parameters: { x1, y1, x2, y2 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust video brightness
|
||||
*/
|
||||
static async adjustBrightness(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
factor: number
|
||||
): Promise<any> {
|
||||
return TauriService.processVideo({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
operation: 'adjust_brightness',
|
||||
parameters: { factor }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust video contrast
|
||||
*/
|
||||
static async adjustContrast(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
factor: number
|
||||
): Promise<any> {
|
||||
return TauriService.processVideo({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
operation: 'adjust_contrast',
|
||||
parameters: { factor }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add text overlay to video
|
||||
*/
|
||||
static async addText(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
text: string,
|
||||
options: {
|
||||
fontsize?: number
|
||||
color?: string
|
||||
position?: [string, string]
|
||||
duration?: number
|
||||
} = {}
|
||||
): Promise<any> {
|
||||
return TauriService.processVideo({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
operation: 'add_text',
|
||||
parameters: { text, ...options }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple videos
|
||||
*/
|
||||
static async mergeVideos(
|
||||
videoPaths: string[],
|
||||
outputPath: string
|
||||
): Promise<any> {
|
||||
return TauriService.processVideo({
|
||||
input_path: '', // Not used for merge
|
||||
output_path: outputPath,
|
||||
operation: 'merge',
|
||||
parameters: { video_paths: videoPaths }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audio processing operations
|
||||
export class AudioService {
|
||||
/**
|
||||
* Analyze rhythm and beat tracking
|
||||
*/
|
||||
static async analyzeRhythm(filePath: string): Promise<any> {
|
||||
return TauriService.analyzeAudio({
|
||||
file_path: filePath,
|
||||
analysis_type: 'rhythm'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze spectral features
|
||||
*/
|
||||
static async analyzeSpectral(filePath: string): Promise<any> {
|
||||
return TauriService.analyzeAudio({
|
||||
file_path: filePath,
|
||||
analysis_type: 'spectral'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze tempo
|
||||
*/
|
||||
static async analyzeTempo(filePath: string): Promise<any> {
|
||||
return TauriService.analyzeAudio({
|
||||
file_path: filePath,
|
||||
analysis_type: 'tempo'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze pitch
|
||||
*/
|
||||
static async analyzePitch(filePath: string): Promise<any> {
|
||||
return TauriService.analyzeAudio({
|
||||
file_path: filePath,
|
||||
analysis_type: 'pitch'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze energy
|
||||
*/
|
||||
static async analyzeEnergy(filePath: string): Promise<any> {
|
||||
return TauriService.analyzeAudio({
|
||||
file_path: filePath,
|
||||
analysis_type: 'energy'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze MFCC features
|
||||
*/
|
||||
static async analyzeMFCC(filePath: string): Promise<any> {
|
||||
return TauriService.analyzeAudio({
|
||||
file_path: filePath,
|
||||
analysis_type: 'mfcc'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Project management operations
|
||||
export class ProjectService {
|
||||
/**
|
||||
* Create new project
|
||||
*/
|
||||
static async createProject(name: string, description: string = ''): Promise<ProjectInfo> {
|
||||
// This would typically call a Python script to create the project
|
||||
// For now, we'll create a basic project structure
|
||||
const projectInfo: ProjectInfo = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
path: `/projects/${name}`,
|
||||
created_at: new Date().toISOString(),
|
||||
modified_at: new Date().toISOString(),
|
||||
video_tracks: [],
|
||||
audio_tracks: [],
|
||||
timeline: {
|
||||
duration: 0,
|
||||
zoom: 1,
|
||||
position: 0
|
||||
}
|
||||
}
|
||||
|
||||
await TauriService.saveProject(projectInfo)
|
||||
return projectInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing project
|
||||
*/
|
||||
static async loadProject(projectPath: string): Promise<ProjectInfo> {
|
||||
return TauriService.loadProject(projectPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save project
|
||||
*/
|
||||
static async saveProject(projectInfo: ProjectInfo): Promise<void> {
|
||||
await TauriService.saveProject(projectInfo)
|
||||
}
|
||||
}
|
||||
578
src/stores/useMediaStore.ts
Normal file
578
src/stores/useMediaStore.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* Media Store
|
||||
* Manages media processing and analysis operations
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { VideoService, AudioService } from '../services/tauri'
|
||||
|
||||
interface ProcessingJob {
|
||||
id: string
|
||||
type: 'video' | 'audio'
|
||||
operation: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
progress: number
|
||||
inputPath: string
|
||||
outputPath?: string
|
||||
result?: any
|
||||
error?: string
|
||||
startTime: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
interface MediaState {
|
||||
// Processing jobs
|
||||
jobs: ProcessingJob[]
|
||||
|
||||
// Current playback
|
||||
currentMedia: string | null
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
duration: number
|
||||
volume: number
|
||||
|
||||
// Preview settings
|
||||
previewQuality: 'low' | 'medium' | 'high'
|
||||
|
||||
// Actions
|
||||
addJob: (job: Omit<ProcessingJob, 'id' | 'status' | 'progress' | 'startTime'>) => string
|
||||
updateJob: (jobId: string, updates: Partial<ProcessingJob>) => void
|
||||
removeJob: (jobId: string) => void
|
||||
clearCompletedJobs: () => void
|
||||
|
||||
// Video processing
|
||||
trimVideo: (inputPath: string, outputPath: string, startTime: number, endTime: number) => Promise<string>
|
||||
resizeVideo: (inputPath: string, outputPath: string, width?: number, height?: number) => Promise<string>
|
||||
cropVideo: (inputPath: string, outputPath: string, x1: number, y1: number, x2: number, y2: number) => Promise<string>
|
||||
adjustBrightness: (inputPath: string, outputPath: string, factor: number) => Promise<string>
|
||||
adjustContrast: (inputPath: string, outputPath: string, factor: number) => Promise<string>
|
||||
addText: (inputPath: string, outputPath: string, text: string, options?: any) => Promise<string>
|
||||
mergeVideos: (videoPaths: string[], outputPath: string) => Promise<string>
|
||||
|
||||
// Audio analysis
|
||||
analyzeRhythm: (filePath: string) => Promise<string>
|
||||
analyzeSpectral: (filePath: string) => Promise<string>
|
||||
analyzeTempo: (filePath: string) => Promise<string>
|
||||
analyzePitch: (filePath: string) => Promise<string>
|
||||
analyzeEnergy: (filePath: string) => Promise<string>
|
||||
analyzeMFCC: (filePath: string) => Promise<string>
|
||||
|
||||
// Playback control
|
||||
setCurrentMedia: (mediaPath: string | null) => void
|
||||
setPlaying: (playing: boolean) => void
|
||||
setCurrentTime: (time: number) => void
|
||||
setDuration: (duration: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
|
||||
// Settings
|
||||
setPreviewQuality: (quality: 'low' | 'medium' | 'high') => void
|
||||
}
|
||||
|
||||
export const useMediaStore = create<MediaState>((set, get) => ({
|
||||
// Initial state
|
||||
jobs: [],
|
||||
currentMedia: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1.0,
|
||||
previewQuality: 'medium',
|
||||
|
||||
// Job management
|
||||
addJob: (jobData) => {
|
||||
const job: ProcessingJob = {
|
||||
...jobData,
|
||||
id: crypto.randomUUID(),
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startTime: Date.now()
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
jobs: [...state.jobs, job]
|
||||
}))
|
||||
|
||||
return job.id
|
||||
},
|
||||
|
||||
updateJob: (jobId, updates) => {
|
||||
set(state => ({
|
||||
jobs: state.jobs.map(job =>
|
||||
job.id === jobId ? { ...job, ...updates } : job
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
removeJob: (jobId) => {
|
||||
set(state => ({
|
||||
jobs: state.jobs.filter(job => job.id !== jobId)
|
||||
}))
|
||||
},
|
||||
|
||||
clearCompletedJobs: () => {
|
||||
set(state => ({
|
||||
jobs: state.jobs.filter(job => job.status !== 'completed' && job.status !== 'failed')
|
||||
}))
|
||||
},
|
||||
|
||||
// Video processing operations
|
||||
trimVideo: async (inputPath, outputPath, startTime, endTime) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'video',
|
||||
operation: 'trim',
|
||||
inputPath,
|
||||
outputPath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await VideoService.trimVideo(inputPath, outputPath, startTime, endTime)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
resizeVideo: async (inputPath, outputPath, width, height) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'video',
|
||||
operation: 'resize',
|
||||
inputPath,
|
||||
outputPath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await VideoService.resizeVideo(inputPath, outputPath, width, height)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
cropVideo: async (inputPath, outputPath, x1, y1, x2, y2) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'video',
|
||||
operation: 'crop',
|
||||
inputPath,
|
||||
outputPath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await VideoService.cropVideo(inputPath, outputPath, x1, y1, x2, y2)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
adjustBrightness: async (inputPath, outputPath, factor) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'video',
|
||||
operation: 'adjust_brightness',
|
||||
inputPath,
|
||||
outputPath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await VideoService.adjustBrightness(inputPath, outputPath, factor)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
adjustContrast: async (inputPath, outputPath, factor) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'video',
|
||||
operation: 'adjust_contrast',
|
||||
inputPath,
|
||||
outputPath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await VideoService.adjustContrast(inputPath, outputPath, factor)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
addText: async (inputPath, outputPath, text, options) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'video',
|
||||
operation: 'add_text',
|
||||
inputPath,
|
||||
outputPath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await VideoService.addText(inputPath, outputPath, text, options)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
mergeVideos: async (videoPaths, outputPath) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'video',
|
||||
operation: 'merge',
|
||||
inputPath: videoPaths.join(', '),
|
||||
outputPath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await VideoService.mergeVideos(videoPaths, outputPath)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Audio analysis operations
|
||||
analyzeRhythm: async (filePath) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'audio',
|
||||
operation: 'analyze_rhythm',
|
||||
inputPath: filePath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await AudioService.analyzeRhythm(filePath)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
analyzeSpectral: async (filePath) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'audio',
|
||||
operation: 'analyze_spectral',
|
||||
inputPath: filePath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await AudioService.analyzeSpectral(filePath)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
analyzeTempo: async (filePath) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'audio',
|
||||
operation: 'analyze_tempo',
|
||||
inputPath: filePath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await AudioService.analyzeTempo(filePath)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
analyzePitch: async (filePath) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'audio',
|
||||
operation: 'analyze_pitch',
|
||||
inputPath: filePath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await AudioService.analyzePitch(filePath)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
analyzeEnergy: async (filePath) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'audio',
|
||||
operation: 'analyze_energy',
|
||||
inputPath: filePath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await AudioService.analyzeEnergy(filePath)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
analyzeMFCC: async (filePath) => {
|
||||
const { addJob, updateJob } = get()
|
||||
|
||||
const jobId = addJob({
|
||||
type: 'audio',
|
||||
operation: 'analyze_mfcc',
|
||||
inputPath: filePath
|
||||
})
|
||||
|
||||
try {
|
||||
updateJob(jobId, { status: 'processing', progress: 0 })
|
||||
|
||||
const result = await AudioService.analyzeMFCC(filePath)
|
||||
|
||||
updateJob(jobId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result,
|
||||
endTime: Date.now()
|
||||
})
|
||||
|
||||
return jobId
|
||||
} catch (error) {
|
||||
updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
endTime: Date.now()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Playback control
|
||||
setCurrentMedia: (mediaPath) => {
|
||||
set({ currentMedia: mediaPath })
|
||||
},
|
||||
|
||||
setPlaying: (playing) => {
|
||||
set({ isPlaying: playing })
|
||||
},
|
||||
|
||||
setCurrentTime: (time) => {
|
||||
set({ currentTime: time })
|
||||
},
|
||||
|
||||
setDuration: (duration) => {
|
||||
set({ duration })
|
||||
},
|
||||
|
||||
setVolume: (volume) => {
|
||||
set({ volume: Math.max(0, Math.min(1, volume)) })
|
||||
},
|
||||
|
||||
// Settings
|
||||
setPreviewQuality: (quality) => {
|
||||
set({ previewQuality: quality })
|
||||
}
|
||||
}))
|
||||
|
||||
// Selectors
|
||||
export const useProcessingJobs = () => useMediaStore(state => state.jobs)
|
||||
export const useCurrentMedia = () => useMediaStore(state => state.currentMedia)
|
||||
export const usePlaybackState = () => useMediaStore(state => ({
|
||||
isPlaying: state.isPlaying,
|
||||
currentTime: state.currentTime,
|
||||
duration: state.duration,
|
||||
volume: state.volume
|
||||
}))
|
||||
264
src/stores/useProjectStore.ts
Normal file
264
src/stores/useProjectStore.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Project Store
|
||||
* Manages project state and operations
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { ProjectInfo, VideoTrack, AudioTrack } from '../services/tauri'
|
||||
import { ProjectService } from '../services/tauri'
|
||||
|
||||
interface ProjectState {
|
||||
// Current project
|
||||
currentProject: ProjectInfo | null
|
||||
|
||||
// Project list
|
||||
projects: ProjectInfo[]
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
|
||||
// Error state
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
createProject: (name: string, description?: string) => Promise<void>
|
||||
loadProject: (projectPath: string) => Promise<void>
|
||||
saveProject: () => Promise<void>
|
||||
closeProject: () => void
|
||||
|
||||
// Track management
|
||||
addVideoTrack: (track: VideoTrack) => void
|
||||
addAudioTrack: (track: AudioTrack) => void
|
||||
removeVideoTrack: (trackId: string) => void
|
||||
removeAudioTrack: (trackId: string) => void
|
||||
updateVideoTrack: (trackId: string, updates: Partial<VideoTrack>) => void
|
||||
updateAudioTrack: (trackId: string, updates: Partial<AudioTrack>) => void
|
||||
|
||||
// Timeline management
|
||||
setTimelinePosition: (position: number) => void
|
||||
setTimelineZoom: (zoom: number) => void
|
||||
setTimelineDuration: (duration: number) => void
|
||||
|
||||
// Utility actions
|
||||
setError: (error: string | null) => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectState>((set, get) => ({
|
||||
// Initial state
|
||||
currentProject: null,
|
||||
projects: [],
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
|
||||
// Project operations
|
||||
createProject: async (name: string, description = '') => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const project = await ProjectService.createProject(name, description)
|
||||
set({
|
||||
currentProject: project,
|
||||
isLoading: false
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to create project',
|
||||
isLoading: false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
loadProject: async (projectPath: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const project = await ProjectService.loadProject(projectPath)
|
||||
set({
|
||||
currentProject: project,
|
||||
isLoading: false
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load project',
|
||||
isLoading: false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
saveProject: async () => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
set({ isSaving: true, error: null })
|
||||
|
||||
try {
|
||||
await ProjectService.saveProject(currentProject)
|
||||
set({
|
||||
currentProject: {
|
||||
...currentProject,
|
||||
modified_at: new Date().toISOString()
|
||||
},
|
||||
isSaving: false
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to save project',
|
||||
isSaving: false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
closeProject: () => {
|
||||
set({ currentProject: null })
|
||||
},
|
||||
|
||||
// Track management
|
||||
addVideoTrack: (track: VideoTrack) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
video_tracks: [...currentProject.video_tracks, track],
|
||||
modified_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
addAudioTrack: (track: AudioTrack) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
audio_tracks: [...currentProject.audio_tracks, track],
|
||||
modified_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
removeVideoTrack: (trackId: string) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
video_tracks: currentProject.video_tracks.filter(track => track.id !== trackId),
|
||||
modified_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
removeAudioTrack: (trackId: string) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
audio_tracks: currentProject.audio_tracks.filter(track => track.id !== trackId),
|
||||
modified_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
updateVideoTrack: (trackId: string, updates: Partial<VideoTrack>) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
video_tracks: currentProject.video_tracks.map(track =>
|
||||
track.id === trackId ? { ...track, ...updates } : track
|
||||
),
|
||||
modified_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
updateAudioTrack: (trackId: string, updates: Partial<AudioTrack>) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
audio_tracks: currentProject.audio_tracks.map(track =>
|
||||
track.id === trackId ? { ...track, ...updates } : track
|
||||
),
|
||||
modified_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
// Timeline management
|
||||
setTimelinePosition: (position: number) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
timeline: {
|
||||
...currentProject.timeline,
|
||||
position
|
||||
}
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
setTimelineZoom: (zoom: number) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
timeline: {
|
||||
...currentProject.timeline,
|
||||
zoom
|
||||
}
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
setTimelineDuration: (duration: number) => {
|
||||
const { currentProject } = get()
|
||||
if (!currentProject) return
|
||||
|
||||
const updatedProject = {
|
||||
...currentProject,
|
||||
timeline: {
|
||||
...currentProject.timeline,
|
||||
duration
|
||||
}
|
||||
}
|
||||
|
||||
set({ currentProject: updatedProject })
|
||||
},
|
||||
|
||||
// Utility actions
|
||||
setError: (error: string | null) => {
|
||||
set({ error })
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null })
|
||||
}
|
||||
}))
|
||||
|
||||
// Selectors for easier access to specific state
|
||||
export const useCurrentProject = () => useProjectStore(state => state.currentProject)
|
||||
export const useProjectLoading = () => useProjectStore(state => state.isLoading)
|
||||
export const useProjectSaving = () => useProjectStore(state => state.isSaving)
|
||||
export const useProjectError = () => useProjectStore(state => state.error)
|
||||
export const useVideoTracks = () => useProjectStore(state => state.currentProject?.video_tracks || [])
|
||||
export const useAudioTracks = () => useProjectStore(state => state.currentProject?.audio_tracks || [])
|
||||
export const useTimeline = () => useProjectStore(state => state.currentProject?.timeline || { duration: 0, zoom: 1, position: 0 })
|
||||
Reference in New Issue
Block a user