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 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 EditorPage: React.FC = () => {
|
||||||
const [isPlaying, setIsPlaying] = React.useState(false)
|
const { currentProject, saveProject, isSaving } = useProjectStore()
|
||||||
|
|
||||||
const togglePlayback = () => {
|
const handleSave = async () => {
|
||||||
setIsPlaying(!isPlaying)
|
if (currentProject) {
|
||||||
|
await saveProject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
// TODO: Implement export functionality
|
||||||
|
console.log('Export project')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,7 +24,9 @@ const EditorPage: React.FC = () => {
|
|||||||
{/* Top Toolbar */}
|
{/* Top Toolbar */}
|
||||||
<div className="bg-secondary-800 border-b border-secondary-700 p-4 flex items-center justify-between">
|
<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">
|
<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">
|
<div className="flex items-center space-x-2">
|
||||||
<button className="btn-ghost text-white px-3 py-1 text-sm">
|
<button className="btn-ghost text-white px-3 py-1 text-sm">
|
||||||
文件
|
文件
|
||||||
@@ -30,7 +43,18 @@ const EditorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<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" />
|
<Download size={16} className="mr-2" />
|
||||||
导出
|
导出
|
||||||
</button>
|
</button>
|
||||||
@@ -39,82 +63,17 @@ const EditorPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Media Library Sidebar */}
|
{/* Media Library Sidebar */}
|
||||||
<div className="w-64 bg-secondary-800 border-r border-secondary-700 flex flex-col">
|
<MediaLibrary className="w-64" />
|
||||||
<div className="p-4 border-b border-secondary-700">
|
|
||||||
<h2 className="text-white font-medium">媒体库</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="drop-zone h-32 mb-4 bg-secondary-700 border-secondary-600">
|
|
||||||
<p className="text-secondary-400 text-sm">拖拽文件到这里</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="bg-secondary-700 rounded p-3 text-white text-sm">
|
|
||||||
示例视频.mp4
|
|
||||||
</div>
|
|
||||||
<div className="bg-secondary-700 rounded p-3 text-white text-sm">
|
|
||||||
背景音乐.mp3
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* Video Preview */}
|
{/* Video Preview */}
|
||||||
<div className="flex-1 bg-black flex items-center justify-center">
|
<div className="flex-1 bg-black p-4">
|
||||||
<div className="video-player w-full max-w-4xl aspect-video bg-secondary-800 rounded-lg">
|
<VideoPreview className="w-full h-full" />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Timeline */}
|
||||||
<div className="bg-secondary-800 border-t border-secondary-700 p-4">
|
<Timeline className="h-48" />
|
||||||
<div className="flex items-center justify-center space-x-4 mb-4">
|
|
||||||
<button className="text-white hover:text-primary-400 transition-colors">
|
|
||||||
<SkipBack size={20} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={togglePlayback}
|
|
||||||
className="w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full flex items-center justify-center text-white transition-colors"
|
|
||||||
>
|
|
||||||
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
|
||||||
</button>
|
|
||||||
<button className="text-white hover:text-primary-400 transition-colors">
|
|
||||||
<SkipForward size={20} />
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center space-x-2 ml-8">
|
|
||||||
<Volume2 className="text-white" size={16} />
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
defaultValue="50"
|
|
||||||
className="w-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<div className="timeline h-32">
|
|
||||||
<div className="timeline-track">
|
|
||||||
<div className="timeline-clip left-4 w-32 bg-primary-500">
|
|
||||||
<div className="p-2 text-white text-xs">视频轨道 1</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="timeline-track">
|
|
||||||
<div className="timeline-clip left-8 w-24 bg-green-500">
|
|
||||||
<div className="p-2 text-white text-xs">音频轨道 1</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Properties Panel */}
|
{/* Properties Panel */}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 HomePage: React.FC = () => {
|
||||||
|
const [testResult, setTestResult] = useState<string>('')
|
||||||
|
const [isTestingConnection, setIsTestingConnection] = useState(false)
|
||||||
|
const { createProject } = useProjectStore()
|
||||||
|
|
||||||
const recentProjects = [
|
const recentProjects = [
|
||||||
{ id: 1, name: '旅行视频剪辑', lastModified: '2 小时前', thumbnail: '/placeholder-video.jpg' },
|
{ id: 1, name: '旅行视频剪辑', lastModified: '2 小时前', thumbnail: '/placeholder-video.jpg' },
|
||||||
{ id: 2, name: '产品宣传片', lastModified: '1 天前', 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: '导入视频、音频和图片文件' },
|
{ 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 (
|
return (
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-6 space-y-8">
|
||||||
{/* Welcome Section */}
|
{/* Welcome Section */}
|
||||||
@@ -26,13 +54,37 @@ const HomePage: React.FC = () => {
|
|||||||
<p className="text-lg text-secondary-600 mb-8">
|
<p className="text-lg text-secondary-600 mb-8">
|
||||||
专业的视频混剪软件,让创作更简单
|
专业的视频混剪软件,让创作更简单
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<div className="flex items-center space-x-4">
|
||||||
to="/editor"
|
<Link
|
||||||
className="btn-primary px-8 py-3 text-lg"
|
to="/editor"
|
||||||
>
|
className="btn-primary px-8 py-3 text-lg"
|
||||||
<Plus className="mr-2" size={20} />
|
>
|
||||||
开始新项目
|
<Plus className="mr-2" size={20} />
|
||||||
</Link>
|
开始新项目
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* 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