feat: 添加 AI 视频生成结果预览功能

🎬 新增功能:

1. AI 视频结果预览组件 (AIVideoResultPreview):
   - 支持单个和批量视频生成结果预览
   - 内置视频播放器,支持在线播放
   - 显示生成提示词、本地路径、在线链接
   - 批量结果支持视频选择器浏览多个视频
   - 优雅的错误处理和加载状态

2. 视频预览功能:
   - HTML5 视频播放器,支持完整控制
   - 自动使用原图作为视频封面
   - 播放失败时显示友好错误信息
   - 响应式设计,适配不同屏幕尺寸

3. 批量结果管理:
   - 显示批量处理统计信息(成功/失败数量)
   - 视频选择器,方便浏览多个生成结果
   - 每个视频显示对应的提示词和路径信息
   - 统一的操作界面和交互体验

4. 文件系统集成:
   - 新增 open_folder Tauri 命令
   - 跨平台支持(Windows/macOS/Linux)
   - 一键打开视频所在文件夹
   - 备用方案:显示完整文件路径

5. 用户界面增强:
   - 任务完成后显示 '预览视频' 按钮
   - 改进的成功状态显示,包含统计信息
   - 模态对话框形式的预览界面
   - 直观的操作按钮和状态指示

6. 操作功能:
   - 打开文件夹:直接定位到视频文件位置
   - 在新窗口打开:使用浏览器播放在线视频
   - 复制路径:方便用户获取文件信息
   - 关闭预览:返回主界面

 用户体验提升:
- 生成完成后可立即预览结果 ✓
- 批量处理结果一目了然 ✓
- 便捷的文件管理操作 ✓
- 专业的视频播放体验 ✓

现在用户可以直接在应用内预览生成的 AI 视频!
This commit is contained in:
root 2025-07-10 13:33:11 +08:00
parent 8a497afa47
commit 1132cb9777
4 changed files with 368 additions and 10 deletions

View File

@ -514,3 +514,47 @@ pub async fn select_folder(app: tauri::AppHandle) -> Result<String, String> {
}
}
}
#[tauri::command]
pub async fn open_folder(folder_path: String) -> Result<String, String> {
println!("Opening folder: {}", folder_path);
#[cfg(target_os = "windows")]
{
use std::process::Command;
let result = Command::new("explorer")
.arg(&folder_path)
.spawn();
match result {
Ok(_) => Ok(format!("Opened folder: {}", folder_path)),
Err(e) => Err(format!("Failed to open folder: {}", e))
}
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
let result = Command::new("open")
.arg(&folder_path)
.spawn();
match result {
Ok(_) => Ok(format!("Opened folder: {}", folder_path)),
Err(e) => Err(format!("Failed to open folder: {}", e))
}
}
#[cfg(target_os = "linux")]
{
use std::process::Command;
let result = Command::new("xdg-open")
.arg(&folder_path)
.spawn();
match result {
Ok(_) => Ok(format!("Opened folder: {}", folder_path)),
Err(e) => Err(format!("Failed to open folder: {}", e))
}
}
}

View File

@ -33,7 +33,8 @@ pub fn run() {
commands::batch_generate_ai_videos,
commands::test_ai_video_environment,
commands::select_image_file,
commands::select_folder
commands::select_folder,
commands::open_folder
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react'
import { Upload, Play, Folder, FileText, Clock, Cpu, Trash2, Download } from 'lucide-react'
import { useAIVideoStore, useAIVideoJobs, useAIVideoProcessing, useAIVideoSettings } from '../stores/useAIVideoStore'
import { invoke } from '@tauri-apps/api/core'
import AIVideoResultPreview from './AIVideoResultPreview'
interface AIVideoGeneratorProps {
className?: string
@ -17,7 +18,18 @@ const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) =
const [outputFolder, setOutputFolder] = useState<string>('')
const [duration, setDuration] = useState<string>('5')
const [modelType, setModelType] = useState<string>('lite')
const [previewData, setPreviewData] = useState<{
isOpen: boolean
result: any
jobType: 'single' | 'batch'
prompt?: string
imagePath?: string
}>({
isOpen: false,
result: null,
jobType: 'single'
})
// Store
const {
generateSingleVideo,
@ -38,6 +50,25 @@ const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) =
setModelType(defaultModelType)
}, [defaultDuration, defaultModelType])
// Preview functions
const openPreview = (job: any) => {
setPreviewData({
isOpen: true,
result: job.result,
jobType: job.type,
prompt: job.type === 'single' ? job.request.prompt : undefined,
imagePath: job.type === 'single' ? job.request.imagePath : undefined
})
}
const closePreview = () => {
setPreviewData({
isOpen: false,
result: null,
jobType: 'single'
})
}
// Handle file selection
const handleImageSelect = async () => {
try {
@ -414,14 +445,26 @@ const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) =
)}
{job.result && job.status === 'completed' && (
<div className="text-sm text-green-600 bg-green-50 p-2 rounded">
{job.result.video_path && (
<button className="ml-2 text-blue-600 hover:text-blue-800">
<Download size={14} className="inline mr-1" />
</button>
)}
<div className="text-sm text-green-600 bg-green-50 p-3 rounded">
<div className="flex items-center justify-between">
<div>
<div className="font-medium mb-1"> </div>
{job.type === 'batch' && job.result.success_count && (
<div className="text-xs text-green-600">
: {job.result.success_count} : {job.result.failed_count || 0}
</div>
)}
</div>
{(job.result.video_path || job.result.video_url || job.result.results) && (
<button
onClick={() => openPreview(job)}
className="flex items-center gap-1 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
>
<Play size={12} />
</button>
)}
</div>
</div>
)}
</div>
@ -429,6 +472,16 @@ const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) =
</div>
)}
</div>
{/* Video Preview Modal */}
<AIVideoResultPreview
isOpen={previewData.isOpen}
onClose={closePreview}
result={previewData.result}
jobType={previewData.jobType}
prompt={previewData.prompt}
imagePath={previewData.imagePath}
/>
</div>
)
}

View File

@ -0,0 +1,260 @@
import React, { useState } from 'react'
import { X, Download, ExternalLink, Play, Folder } from 'lucide-react'
import { invoke } from '@tauri-apps/api/core'
interface VideoResult {
status?: boolean
video_path?: string
video_url?: string
msg?: string
}
interface BatchResult {
status: boolean
success_count: number
failed_count: number
results: Array<{
image_path: string
prompt: string
result: VideoResult
}>
msg: string
}
interface AIVideoResultPreviewProps {
isOpen: boolean
onClose: () => void
result: VideoResult | BatchResult
jobType: 'single' | 'batch'
prompt?: string
imagePath?: string
}
const AIVideoResultPreview: React.FC<AIVideoResultPreviewProps> = ({
isOpen,
onClose,
result,
jobType,
prompt,
imagePath
}) => {
const [selectedVideoIndex, setSelectedVideoIndex] = useState(0)
const [videoError, setVideoError] = useState(false)
if (!isOpen) return null
const handleVideoError = () => setVideoError(true)
const handleOpenFolder = async (videoPath: string) => {
try {
// Extract folder path
const folderPath = videoPath.substring(0, videoPath.lastIndexOf('\\') || videoPath.lastIndexOf('/'))
console.log('Opening folder:', folderPath)
// Try to open folder using Tauri command
try {
await invoke('open_folder', { folderPath })
console.log('Folder opened successfully')
} catch (tauriError) {
console.error('Failed to open folder via Tauri:', tauriError)
// Fallback to showing path
alert(`视频已保存到: ${videoPath}\n\n文件夹: ${folderPath}`)
}
} catch (error) {
console.error('Failed to open folder:', error)
alert(`无法打开文件夹: ${error}`)
}
}
const handleOpenUrl = (url: string) => {
window.open(url, '_blank')
}
// Render single video result
const renderSingleVideo = (videoResult: VideoResult, videoPrompt?: string, videoImagePath?: string) => (
<div className="space-y-4">
{/* Video Player */}
<div className="relative bg-black rounded-lg overflow-hidden">
{videoError ? (
<div className="aspect-video flex items-center justify-center text-white">
<div className="text-center">
<div className="text-red-400 mb-2"></div>
<div className="text-sm text-gray-400">
</div>
</div>
</div>
) : (
<video
className="w-full aspect-video"
controls
preload="metadata"
onError={handleVideoError}
poster={videoImagePath}
>
<source src={videoResult.video_url} type="video/mp4" />
</video>
)}
</div>
{/* Video Info */}
<div className="space-y-3">
{videoPrompt && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded">
{videoPrompt}
</div>
</div>
)}
{videoResult.video_path && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded font-mono">
{videoResult.video_path}
</div>
</div>
)}
{videoResult.msg && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="text-sm text-green-600 bg-green-50 p-3 rounded">
{videoResult.msg}
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t">
{videoResult.video_path && (
<button
onClick={() => handleOpenFolder(videoResult.video_path!)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Folder size={16} />
</button>
)}
{videoResult.video_url && (
<button
onClick={() => handleOpenUrl(videoResult.video_url!)}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<ExternalLink size={16} />
</button>
)}
</div>
</div>
)
// Render batch results
const renderBatchResults = (batchResult: BatchResult) => {
const successResults = batchResult.results.filter(r => r.result.status)
if (successResults.length === 0) {
return (
<div className="text-center py-8">
<div className="text-gray-500"></div>
</div>
)
}
const currentResult = successResults[selectedVideoIndex]
return (
<div className="space-y-4">
{/* Batch Summary */}
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-sm font-medium text-blue-900 mb-2"></div>
<div className="text-sm text-blue-700">
: {batchResult.results.length} |
: {batchResult.success_count} |
: {batchResult.failed_count}
</div>
</div>
{/* Video Selector */}
{successResults.length > 1 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
({selectedVideoIndex + 1}/{successResults.length})
</label>
<div className="flex gap-2 overflow-x-auto pb-2">
{successResults.map((_, index) => (
<button
key={index}
onClick={() => setSelectedVideoIndex(index)}
className={`flex-shrink-0 px-3 py-2 text-sm rounded-lg border ${
selectedVideoIndex === index
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{index + 1}
</button>
))}
</div>
</div>
)}
{/* Current Video */}
{currentResult && renderSingleVideo(
currentResult.result,
currentResult.prompt,
currentResult.image_path
)}
</div>
)
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold text-gray-900">
{jobType === 'batch' ? '批量生成结果' : '视频生成结果'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="p-6">
{jobType === 'batch' ?
renderBatchResults(result as BatchResult) :
renderSingleVideo(result as VideoResult, prompt, imagePath)
}
{/* Close Button */}
<div className="flex justify-end mt-6 pt-4 border-t">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
</div>
</div>
</div>
</div>
)
}
export default AIVideoResultPreview