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:
root
2025-07-10 10:00:37 +08:00
parent 7d72e07639
commit 4cdd6560fc
8 changed files with 2113 additions and 85 deletions

View 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
View 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

View 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

View File

@@ -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 */}

View File

@@ -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
View 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
View 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
}))

View 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 })