feat: 添加 AI 视频生成结果预览功能
🎬 新增功能: 1. AI 视频结果预览组件 (AIVideoResultPreview): - 支持单个和批量视频生成结果预览 - 内置视频播放器,支持在线播放 - 显示生成提示词、本地路径、在线链接 - 批量结果支持视频选择器浏览多个视频 - 优雅的错误处理和加载状态 2. 视频预览功能: - HTML5 视频播放器,支持完整控制 - 自动使用原图作为视频封面 - 播放失败时显示友好错误信息 - 响应式设计,适配不同屏幕尺寸 3. 批量结果管理: - 显示批量处理统计信息(成功/失败数量) - 视频选择器,方便浏览多个生成结果 - 每个视频显示对应的提示词和路径信息 - 统一的操作界面和交互体验 4. 文件系统集成: - 新增 open_folder Tauri 命令 - 跨平台支持(Windows/macOS/Linux) - 一键打开视频所在文件夹 - 备用方案:显示完整文件路径 5. 用户界面增强: - 任务完成后显示 '预览视频' 按钮 - 改进的成功状态显示,包含统计信息 - 模态对话框形式的预览界面 - 直观的操作按钮和状态指示 6. 操作功能: - 打开文件夹:直接定位到视频文件位置 - 在新窗口打开:使用浏览器播放在线视频 - 复制路径:方便用户获取文件信息 - 关闭预览:返回主界面 ✅ 用户体验提升: - 生成完成后可立即预览结果 ✓ - 批量处理结果一目了然 ✓ - 便捷的文件管理操作 ✓ - 专业的视频播放体验 ✓ 现在用户可以直接在应用内预览生成的 AI 视频!
This commit is contained in:
@@ -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::batch_generate_ai_videos,
|
||||||
commands::test_ai_video_environment,
|
commands::test_ai_video_environment,
|
||||||
commands::select_image_file,
|
commands::select_image_file,
|
||||||
commands::select_folder
|
commands::select_folder,
|
||||||
|
commands::open_folder
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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 { Upload, Play, Folder, FileText, Clock, Cpu, Trash2, Download } from 'lucide-react'
|
||||||
import { useAIVideoStore, useAIVideoJobs, useAIVideoProcessing, useAIVideoSettings } from '../stores/useAIVideoStore'
|
import { useAIVideoStore, useAIVideoJobs, useAIVideoProcessing, useAIVideoSettings } from '../stores/useAIVideoStore'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import AIVideoResultPreview from './AIVideoResultPreview'
|
||||||
|
|
||||||
interface AIVideoGeneratorProps {
|
interface AIVideoGeneratorProps {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -17,7 +18,18 @@ const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) =
|
|||||||
const [outputFolder, setOutputFolder] = useState<string>('')
|
const [outputFolder, setOutputFolder] = useState<string>('')
|
||||||
const [duration, setDuration] = useState<string>('5')
|
const [duration, setDuration] = useState<string>('5')
|
||||||
const [modelType, setModelType] = useState<string>('lite')
|
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
|
// Store
|
||||||
const {
|
const {
|
||||||
generateSingleVideo,
|
generateSingleVideo,
|
||||||
@@ -38,6 +50,25 @@ const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) =
|
|||||||
setModelType(defaultModelType)
|
setModelType(defaultModelType)
|
||||||
}, [defaultDuration, 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
|
// Handle file selection
|
||||||
const handleImageSelect = async () => {
|
const handleImageSelect = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -414,14 +445,26 @@ const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) =
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{job.result && job.status === 'completed' && (
|
{job.result && job.status === 'completed' && (
|
||||||
<div className="text-sm text-green-600 bg-green-50 p-2 rounded">
|
<div className="text-sm text-green-600 bg-green-50 p-3 rounded">
|
||||||
生成成功!
|
<div className="flex items-center justify-between">
|
||||||
{job.result.video_path && (
|
<div>
|
||||||
<button className="ml-2 text-blue-600 hover:text-blue-800">
|
<div className="font-medium mb-1">✅ 生成成功!</div>
|
||||||
<Download size={14} className="inline mr-1" />
|
{job.type === 'batch' && job.result.success_count && (
|
||||||
查看结果
|
<div className="text-xs text-green-600">
|
||||||
</button>
|
成功: {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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -429,6 +472,16 @@ const AIVideoGenerator: React.FC<AIVideoGeneratorProps> = ({ className = '' }) =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Video Preview Modal */}
|
||||||
|
<AIVideoResultPreview
|
||||||
|
isOpen={previewData.isOpen}
|
||||||
|
onClose={closePreview}
|
||||||
|
result={previewData.result}
|
||||||
|
jobType={previewData.jobType}
|
||||||
|
prompt={previewData.prompt}
|
||||||
|
imagePath={previewData.imagePath}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
260
src/components/AIVideoResultPreview.tsx
Normal file
260
src/components/AIVideoResultPreview.tsx
Normal 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
|
||||||
Reference in New Issue
Block a user