feat: 添加 AI 视频生成结果预览功能
🎬 新增功能: 1. AI 视频结果预览组件 (AIVideoResultPreview): - 支持单个和批量视频生成结果预览 - 内置视频播放器,支持在线播放 - 显示生成提示词、本地路径、在线链接 - 批量结果支持视频选择器浏览多个视频 - 优雅的错误处理和加载状态 2. 视频预览功能: - HTML5 视频播放器,支持完整控制 - 自动使用原图作为视频封面 - 播放失败时显示友好错误信息 - 响应式设计,适配不同屏幕尺寸 3. 批量结果管理: - 显示批量处理统计信息(成功/失败数量) - 视频选择器,方便浏览多个生成结果 - 每个视频显示对应的提示词和路径信息 - 统一的操作界面和交互体验 4. 文件系统集成: - 新增 open_folder Tauri 命令 - 跨平台支持(Windows/macOS/Linux) - 一键打开视频所在文件夹 - 备用方案:显示完整文件路径 5. 用户界面增强: - 任务完成后显示 '预览视频' 按钮 - 改进的成功状态显示,包含统计信息 - 模态对话框形式的预览界面 - 直观的操作按钮和状态指示 6. 操作功能: - 打开文件夹:直接定位到视频文件位置 - 在新窗口打开:使用浏览器播放在线视频 - 复制路径:方便用户获取文件信息 - 关闭预览:返回主界面 ✅ 用户体验提升: - 生成完成后可立即预览结果 ✓ - 批量处理结果一目了然 ✓ - 便捷的文件管理操作 ✓ - 专业的视频播放体验 ✓ 现在用户可以直接在应用内预览生成的 AI 视频!
This commit is contained in:
parent
8a497afa47
commit
1132cb9777
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue