feat: 视频预览自动转码为浏览器兼容格式
- Rust: 新增 transcode_for_preview,用 FFmpeg 将任意视频转码为 H.264 Baseline + YUV420p 540p,确保跨平台预览兼容 - Rust: 缓存按(路径hash + 文件大小 + 修改时间)管理,避免重复转码 - 前端: 新增 getPreviewVideoUrl 工具,统一替换视频预览的 URL 获取逻辑 - 根本性解决 Windows WebView2 视频黑屏问题,同时提升预览性能
This commit is contained in:
@@ -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<String, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 错误处理扩展
|
||||
// ============================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,可直接用于 <video> 标签
|
||||
*/
|
||||
export async function getPreviewVideoUrl(path: string): Promise<string> {
|
||||
const resp = await invoke<TranscodeResponse>('transcode_for_preview_cmd', {
|
||||
request: { path },
|
||||
});
|
||||
|
||||
if (resp.code !== 200 || !resp.data) {
|
||||
throw new Error(resp.message || '预览视频处理失败');
|
||||
}
|
||||
|
||||
// Rust 返回本地文件系统路径,转为 asset:// 协议 URL
|
||||
return convertFileSrc(resp.data);
|
||||
}
|
||||
Reference in New Issue
Block a user