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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user