diff --git a/tauri-app/src-tauri/src/ffmpeg_cmd.rs b/tauri-app/src-tauri/src/ffmpeg_cmd.rs index 47375a1..4c4b4d0 100644 --- a/tauri-app/src-tauri/src/ffmpeg_cmd.rs +++ b/tauri-app/src-tauri/src/ffmpeg_cmd.rs @@ -723,3 +723,76 @@ pub async fn extract_audio_segment( } + +/** + * 为预览转码视频(统一为浏览器兼容格式) + * + * 将任意格式的视频转码为 H.264 Baseline + YUV420p 540p, + * 确保在所有平台的浏览器/WebView 中都能正常预览。 + * 转码结果按(文件路径 + 大小 + 修改时间)缓存,避免重复处理。 + */ +pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result { + let input = std::path::Path::new(input_path); + if !input.exists() { + return Err(format!("输入文件不存在: {}", input_path)); + } + + // 获取文件元数据用于缓存 key + let metadata = std::fs::metadata(input_path) + .map_err(|e| format!("无法读取文件元数据: {}", e))?; + let mtime = metadata.modified() + .map_err(|e| format!("无法读取修改时间: {}", e))? + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let file_size = metadata.len(); + + // 计算缓存路径(基于文件路径 hash + 大小 + 修改时间) + let path_str = input.canonicalize() + .unwrap_or(input.to_path_buf()) + .to_string_lossy() + .to_string(); + let path_hash = { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + path_str.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + }; + + let app_data_dir = crate::storage::paths::get_app_data_dir() + .map_err(|e| format!("无法获取应用数据目录: {}", e))?; + let cache_dir = app_data_dir.join("video_cache"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("无法创建缓存目录: {}", e))?; + let cache_path = cache_dir.join(format!("preview_{}_{}_{}.mp4", path_hash, file_size, mtime)); + + // 缓存命中且文件完整,直接返回 + if cache_path.exists() { + let cache_meta = std::fs::metadata(&cache_path) + .map_err(|e| format!("无法读取缓存文件: {}", e))?; + if cache_meta.len() > 1024 { + return Ok(cache_path.to_string_lossy().to_string()); + } + } + + // FFmpeg 转码:H.264 Baseline + YUV420p,540p,无音频,faststart + let args = vec![ + "-i".to_string(), path_str, + "-c:v".to_string(), "libx264".to_string(), + "-profile:v".to_string(), "baseline".to_string(), + "-level".to_string(), "3.0".to_string(), + "-pix_fmt".to_string(), "yuv420p".to_string(), + "-preset".to_string(), "ultrafast".to_string(), + "-crf".to_string(), "28".to_string(), + "-vf".to_string(), "scale=540:-2:force_original_aspect_ratio=decrease".to_string(), + "-an".to_string(), + "-movflags".to_string(), "+faststart".to_string(), + "-y".to_string(), + cache_path.to_string_lossy().to_string(), + ]; + + run_ffmpeg(app, args).await?; + + Ok(cache_path.to_string_lossy().to_string()) +} diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index ea0f64b..40099c0 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -198,6 +198,8 @@ pub fn run() { commands::video_compose::download_file, // 视频元数据读取(ffprobe) get_video_metadata_cmd, + // 视频预览转码(统一浏览器兼容格式) + transcode_for_preview_cmd, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -328,6 +330,35 @@ async fn get_video_metadata_cmd( } } +// ============================================================ +// 视频预览转码 +// ============================================================ + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct TranscodeForPreviewRequest { + path: String, +} + +#[tauri::command] +async fn transcode_for_preview_cmd( + app: tauri::AppHandle, + request: TranscodeForPreviewRequest, +) -> ApiResponse { + match ffmpeg_cmd::transcode_for_preview(&app, &request.path).await { + Ok(path) => ApiResponse { + code: 200, + message: "转码成功".to_string(), + data: Some(path), + }, + Err(e) => ApiResponse { + code: 500, + message: e, + data: None, + }, + } +} + // ============================================================ // 错误处理扩展 // ============================================================ diff --git a/tauri-app/src/hooks/useLocalVideo.ts b/tauri-app/src/hooks/useLocalVideo.ts index 2ba29a1..ae08c52 100644 --- a/tauri-app/src/hooks/useLocalVideo.ts +++ b/tauri-app/src/hooks/useLocalVideo.ts @@ -7,7 +7,7 @@ */ import { useState, useEffect } from 'react'; -import { getLocalFileUrl } from '../utils/fileUrl'; +import { getPreviewVideoUrl } from '../utils/videoPreview'; interface UseLocalVideoResult { videoUrl: string | undefined; @@ -49,7 +49,7 @@ export function useLocalVideo(filePath: string | undefined): UseLocalVideoResult setError(null); try { - const url = await getLocalFileUrl(filePath!); + const url = await getPreviewVideoUrl(filePath!); if (canceled) {return;} setVideoUrl(url); diff --git a/tauri-app/src/pages/VideoGeneration/index.tsx b/tauri-app/src/pages/VideoGeneration/index.tsx index 91df465..fd59912 100644 --- a/tauri-app/src/pages/VideoGeneration/index.tsx +++ b/tauri-app/src/pages/VideoGeneration/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; import { exists } from '@tauri-apps/plugin-fs'; -import { getLocalFileUrl } from '../../utils/fileUrl'; +import { getPreviewVideoUrl } from '../../utils/videoPreview'; import { toast } from '../../store/uiStore'; import { useProjectStore, saveMetaToLocalFile } from '../../store'; import { useLocalVideo } from '../../hooks/useLocalVideo'; @@ -193,7 +193,7 @@ export default function VideoGeneration() { } else if (selectedAvatarMaterial) { (async () => { try { - const url = await getLocalFileUrl(selectedAvatarMaterial.path); + const url = await getPreviewVideoUrl(selectedAvatarMaterial.path); setPreviewVideoUrl(url); } catch (e) { console.error('[VideoGeneration] 预览形象视频失败:', e); @@ -237,7 +237,7 @@ export default function VideoGeneration() { // 本地视频 try { - const url = await getLocalFileUrl(urlOrPath); + const url = await getPreviewVideoUrl(urlOrPath); setPreviewVideoUrl(url); } catch (e) { console.error('[VideoGeneration] 预览本地视频失败:', e); diff --git a/tauri-app/src/utils/videoPreview.ts b/tauri-app/src/utils/videoPreview.ts new file mode 100644 index 0000000..dfbe28a --- /dev/null +++ b/tauri-app/src/utils/videoPreview.ts @@ -0,0 +1,29 @@ +import { invoke, convertFileSrc } from '@tauri-apps/api/core'; + +interface TranscodeResponse { + code: number; + message: string; + data?: string; +} + +/** + * 获取视频预览 URL + * + * 后台自动将视频转码为浏览器兼容格式(H.264 Baseline + YUV420p)。 + * 结果按文件路径+大小+修改时间缓存,重复预览时直接返回缓存。 + * + * @param path 本地视频绝对路径 + * @returns asset:// URL,可直接用于