feat: 视频预览自动转码为浏览器兼容格式

- Rust: 新增 transcode_for_preview,用 FFmpeg 将任意视频转码为
  H.264 Baseline + YUV420p 540p,确保跨平台预览兼容
- Rust: 缓存按(路径hash + 文件大小 + 修改时间)管理,避免重复转码
- 前端: 新增 getPreviewVideoUrl 工具,统一替换视频预览的 URL 获取逻辑
- 根本性解决 Windows WebView2 视频黑屏问题,同时提升预览性能
This commit is contained in:
小鱼开发
2026-05-21 15:52:30 +08:00
parent 5250381579
commit 666842ce2b
5 changed files with 138 additions and 5 deletions
+73
View File
@@ -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 + YUV420p540p,无音频,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())
}
+31
View File
@@ -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,
},
}
}
// ============================================================
// 错误处理扩展
// ============================================================
+2 -2
View File
@@ -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);
+29
View File
@@ -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);
}