diff --git a/tauri-app/src-tauri/src/ffmpeg_cmd.rs b/tauri-app/src-tauri/src/ffmpeg_cmd.rs index 52bf0a9..0ae287c 100644 --- a/tauri-app/src-tauri/src/ffmpeg_cmd.rs +++ b/tauri-app/src-tauri/src/ffmpeg_cmd.rs @@ -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 { + // 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) diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 256d979..649fdcb 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -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 { + 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, + }, + } +} + // ============================================================ // 错误处理扩展 // ============================================================ diff --git a/tauri-app/src/pages/VideoGeneration/utils/videoValidation.ts b/tauri-app/src/pages/VideoGeneration/utils/videoValidation.ts index e83baf4..a7bcdd8 100644 --- a/tauri-app/src/pages/VideoGeneration/utils/videoValidation.ts +++ b/tauri-app/src/pages/VideoGeneration/utils/videoValidation.ts @@ -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 { 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,