fix: Windows 视频分辨率 0x0 问题 — 改用 Rust 层 ffprobe 读取元数据

- 新增 ffmpeg_cmd::get_video_metadata,通过 ffprobe sidecar 读取视频信息
- 新增 Tauri Command get_video_metadata_cmd 供前端调用
- 前端 videoValidation.ts 不再依赖 HTML5 <video> 标签,改为调用 Rust ffprobe
- 解决 macOS 与 Windows 浏览器视频解码器差异导致的元数据读取不一致问题
This commit is contained in:
小鱼开发
2026-05-21 14:23:44 +08:00
parent 3c4c765f2a
commit 2720dacc1d
3 changed files with 202 additions and 62 deletions
+116 -1
View File
@@ -2,7 +2,16 @@ use tauri_plugin_shell::ShellExt;
use crate::StringResultExt;
use tauri_plugin_shell::process::CommandEvent;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use serde::{Deserialize, Serialize};
/// 视频元数据(由 ffprobe 解析)
#[derive(Debug, Serialize, Deserialize)]
pub struct VideoMetadata {
pub width: u32,
pub height: u32,
pub duration: f64,
pub fps: f64,
}
#[derive(Serialize, Clone)]
struct PhaseInfo {
@@ -519,6 +528,112 @@ pub async fn replace_audio_track(
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 通过 ffprobe 获取视频元数据(分辨率、时长、帧率)
*
* 跨平台可靠,不依赖浏览器视频解码器。
*/
pub async fn get_video_metadata(app: &AppHandle, input_path: &str) -> Result<VideoMetadata, String> {
// URL 直接传递;本地文件只检查是否存在(用户通过系统文件选择器选取,已获授权)
let safe_input = if input_path.starts_with("http://") || input_path.starts_with("https://") {
input_path.to_string()
} else if std::path::Path::new(input_path).exists() {
input_path.to_string()
} else {
return Err(format!("输入文件不存在: {}", input_path));
};
let args = vec![
"-v".to_string(), "error".to_string(),
"-select_streams".to_string(), "v:0".to_string(),
"-show_entries".to_string(), "stream=width,height,duration,r_frame_rate".to_string(),
"-of".to_string(), "json".to_string(),
safe_input,
];
let (mut rx, child) = app.shell()
.sidecar("ffprobe")
.map_err(|e| format!("Failed to find ffprobe sidecar: {e}"))?
.args(args)
.spawn()
.map_err(|e| format!("Failed to spawn ffprobe: {e}"))?;
let mut stdout = String::new();
let mut stderr = String::new();
let probe_future = async {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
stdout.push_str(&String::from_utf8_lossy(&line));
}
CommandEvent::Stderr(line) => {
stderr.push_str(&String::from_utf8_lossy(&line));
}
CommandEvent::Terminated(status) => {
match status.code {
Some(0) => return Ok(()),
Some(code) => {
return Err(format!("ffprobe exited with status {}. stderr: {}", code, stderr.trim()));
}
None => return Err("ffprobe terminated by signal".to_string()),
}
}
_ => {}
}
}
Ok(())
};
match tokio::time::timeout(std::time::Duration::from_secs(30), probe_future).await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => {
let _ = child.kill();
return Err("ffprobe 执行超时(超过30秒),已强制终止".to_string());
}
}
// 解析 JSON 输出
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| format!("ffprobe JSON 解析失败: {}. raw: {}", e, &stdout))?;
let stream = parsed.get("streams")
.and_then(|s| s.as_array())
.and_then(|arr| arr.first())
.ok_or_else(|| format!("ffprobe 未返回视频流信息: {}", &stdout))?;
let width = stream.get("width")
.and_then(|v| v.as_u64())
.ok_or_else(|| "无法解析视频宽度".to_string())? as u32;
let height = stream.get("height")
.and_then(|v| v.as_u64())
.ok_or_else(|| "无法解析视频高度".to_string())? as u32;
// duration 可能是字符串或数字
let duration = stream.get("duration")
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.unwrap_or(0.0);
// 帧率格式为 "25/1" 或 "30000/1001"
let fps = stream.get("r_frame_rate")
.and_then(|v| v.as_str())
.and_then(|s| {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() == 2 {
let num: f64 = parts[0].parse().ok()?;
let den: f64 = parts[1].parse().ok()?;
if den != 0.0 { Some(num / den) } else { None }
} else {
s.parse().ok()
}
})
.unwrap_or(0.0);
Ok(VideoMetadata { width, height, duration, fps })
}
/**
* 裁剪视频片段(支持本地文件和 HTTP URL)
+31
View File
@@ -185,6 +185,8 @@ pub fn run() {
commands::video_compose::generate_empty_shot_clip,
commands::video_compose::upload_video_file,
commands::video_compose::download_file,
// 视频元数据读取(ffprobe
get_video_metadata_cmd,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -286,6 +288,35 @@ async fn video_composite_synthesis(
}
}
// ============================================================
// 视频元数据读取(ffprobe
// ============================================================
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct GetVideoMetadataRequest {
path: String,
}
#[tauri::command]
async fn get_video_metadata_cmd(
app: tauri::AppHandle,
request: GetVideoMetadataRequest,
) -> ApiResponse<ffmpeg_cmd::VideoMetadata> {
match ffmpeg_cmd::get_video_metadata(&app, &request.path).await {
Ok(meta) => ApiResponse {
code: 200,
message: "读取成功".to_string(),
data: Some(meta),
},
Err(e) => ApiResponse {
code: 500,
message: e,
data: None,
},
}
}
// ============================================================
// 错误处理扩展
// ============================================================
@@ -1,78 +1,72 @@
import { getLocalFileUrl } from '../../../utils/fileUrl';
import { invoke } from '@tauri-apps/api/core';
/**
* 验证本地视频文件(格式、时长、分辨率)
*/
export async function validateLocalVideo(
filePath: string,
options?: { minDuration?: number; maxDuration?: number; requireResolution?: boolean }
): Promise<{
interface VideoMetadata {
width: number;
height: number;
duration: number;
fps: number;
}
interface VideoValidationResult {
valid: boolean;
duration: number;
width: number;
height: number;
error?: string;
}> {
}
/**
* 验证本地视频文件(格式、时长、分辨率)
*
* 通过 Rust 层 ffprobe 读取元数据,跨平台可靠,
* 不依赖浏览器视频解码器。
*/
export async function validateLocalVideo(
filePath: string,
options?: { minDuration?: number; maxDuration?: number; requireResolution?: boolean }
): Promise<VideoValidationResult> {
const { minDuration = 5, maxDuration = 20, requireResolution = true } = options || {};
try {
const url = await getLocalFileUrl(filePath);
const resp = await invoke<{
code: number;
message: string;
data?: VideoMetadata;
}>('get_video_metadata_cmd', { request: { path: filePath } });
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
const timeoutId = setTimeout(() => {
resolve({
valid: false,
duration: 0,
width: 0,
height: 0,
error: '读取视频超时,请重试',
});
}, 15000);
video.onerror = () => {
clearTimeout(timeoutId);
resolve({
valid: false,
duration: 0,
width: 0,
height: 0,
error: '无法读取视频文件,请检查文件是否损坏',
});
if (resp.code !== 200 || !resp.data) {
return {
valid: false,
duration: 0,
width: 0,
height: 0,
error: resp.message || '读取视频元数据失败',
};
}
video.onloadedmetadata = () => {
clearTimeout(timeoutId);
const duration = video.duration;
const width = video.videoWidth;
const height = video.videoHeight;
const { width, height, duration } = resp.data;
if (duration < minDuration || duration > maxDuration) {
resolve({
valid: false,
duration,
width,
height,
error: `视频时长 ${duration.toFixed(1)} 秒,要求 ${minDuration}-${maxDuration}`,
});
return;
}
if (requireResolution && (width !== 1080 || height !== 1920)) {
resolve({
valid: false,
duration,
width,
height,
error: `视频分辨率 ${width}x${height},要求 1080x1920`,
});
return;
}
resolve({ valid: true, duration, width, height });
if (duration < minDuration || duration > maxDuration) {
return {
valid: false,
duration,
width,
height,
error: `视频时长 ${duration.toFixed(1)} 秒,要求 ${minDuration}-${maxDuration}`,
};
}
video.src = url;
});
if (requireResolution && (width !== 1080 || height !== 1920)) {
return {
valid: false,
duration,
width,
height,
error: `视频分辨率 ${width}x${height},要求 1080x1920`,
};
}
return { valid: true, duration, width, height };
} catch (e) {
return {
valid: false,