Files
meijiaka-zy/tauri-app/src-tauri/src/ffmpeg_cmd.rs
T
小鱼开发 915339d42a release: bump version to 1.6.1
Frontend fixes:
- fix(VideoCompose): clear step dirty flag after compose success
- refactor(MyWorks): play product videos directly without transcode cache
- feat(CoverDesign): swap main/subtitle positions in cover preview
- fix(SubtitleBurning): charge points after burn success instead of before
- fix(VoiceSynthesis/VideoGeneration/SubtitleBurning/CoverDesign): mark downstream steps dirty on re-generation
- fix(MyWorks): bind video event listeners after async videoUrl load
- fix(CoverDesign): revoke Blob URLs on upload/unmount to prevent memory leak
2026-05-25 22:35:35 +08:00

1002 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use tauri_plugin_shell::ShellExt;
use crate::StringResultExt;
use tauri_plugin_shell::process::CommandEvent;
use tauri::{AppHandle, Emitter};
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use tokio::sync::Semaphore;
/// 预览转码并发锁:保证同时只有一个 FFmpeg 进程在转码,避免 CPU/磁盘争抢
static PREVIEW_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(1));
/// 视频元数据(由 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 {
step: u8,
total: u8,
}
/// FFmpeg 路径转义(替换 `:` 为 `\:`,替换 `'` 为 `'\''`
pub fn escape_ffmpeg_path(path: &str) -> String {
path.replace("'", "'\\''")
}
/// 验证路径在允许的目录内,防止路径遍历攻击
/// 允许的目录:应用数据目录(app_local_data_dir
fn validate_safe_path(path: &str) -> Result<String, String> {
let path = std::path::Path::new(path);
// 获取绝对路径
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("无法获取当前目录: {}", e))?
.join(path)
};
// 检查是否在允许的目录内
let allowed_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {}", e))?;
// 规范化路径
let canonical = abs_path.canonicalize()
.unwrap_or(abs_path.clone());
// 检查是否在允许目录下
if !canonical.starts_with(allowed_dir) {
return Err(format!("路径不在允许目录内: {}", path.display()));
}
Ok(canonical.to_string_lossy().to_string())
}
/// 清理并验证输出路径
pub fn sanitize_output_path(path: &str) -> Result<String, String> {
let path = std::path::Path::new(path);
// 获取父目录并验证
if let Some(parent) = path.parent() {
validate_safe_path(&parent.to_string_lossy())?;
}
// 确保是绝对路径
if path.is_absolute() {
Ok(path.to_string_lossy().to_string())
} else {
std::env::current_dir()
.map_err(|e| format!("无法获取当前目录: {}", e))?
.join(path)
.to_str()
.map(|s| s.to_string())
.ok_or_else(|| "无效的路径".to_string())
}
}
/**
* 封装 FFmpeg Sidecar 调用
*
* 使用 Tauri 官方 sidecar API,自动处理开发/生产环境的 sidecar 查找。
*/
pub async fn run_ffmpeg(app: &AppHandle, args: Vec<String>) -> Result<String, String> {
let (mut rx, child) = app.shell()
.sidecar("ffmpeg")
.map_err(|e| format!("Failed to find ffmpeg sidecar: {e}"))?
.args(args)
.spawn()
.map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?;
let mut output = String::new();
let mut stderr_output = String::new();
let ffmpeg_future = async {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
output.push_str(&String::from_utf8_lossy(&line));
}
CommandEvent::Stderr(line) => {
let log = String::from_utf8_lossy(&line);
log::debug!("[ffmpeg stderr] {}", log.trim());
stderr_output.push_str(&log);
stderr_output.push('\n');
// 尝试解析进度: time=00:00:05.12
if let Some(pos) = log.find("time=") {
let time_part = &log[pos + 5..].split_whitespace().next().unwrap_or("");
if !time_part.is_empty() {
// 发送进度事件到前端
let _ = app.emit("ffmpeg-progress", time_part);
}
}
}
CommandEvent::Terminated(status) => {
match status.code {
Some(0) => {
if !stderr_output.is_empty() {
log::debug!("[ffmpeg] stderr:\n{}", stderr_output.trim());
}
return Ok(());
}
Some(code) => {
let err_detail = if stderr_output.is_empty() {
"(no stderr output)".to_string()
} else {
format!("stderr: {}", stderr_output.trim())
};
return Err(format!("FFmpeg exited with status {}. {}", code, err_detail));
}
None => return Err("FFmpeg terminated by signal".to_string()),
}
}
_ => {}
}
}
Ok(())
};
// 10 分钟超时保护:防止 FFmpeg 进程挂起导致前端无限等待
match tokio::time::timeout(std::time::Duration::from_secs(600), ffmpeg_future).await {
Ok(Ok(())) => Ok(output),
Ok(Err(e)) => Err(e),
Err(_) => {
let _ = child.kill();
Err("FFmpeg 执行超时(超过10分钟),已强制终止".to_string())
}
}
}
/**
* 标准化单个视频片段 (调整为 1080:1920, 25fps, libx264, aac 44100Hz stereo)
*/
pub async fn standardize_video(app: &AppHandle, input_path: &str, output_path: &str) -> Result<(), String> {
// 验证路径安全
let safe_input = validate_safe_path(input_path)?;
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
"-i".to_string(), safe_input,
"-vf".to_string(), "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p".to_string(),
"-c:v".to_string(), "libx264".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-ar".to_string(), "44100".to_string(),
"-ac".to_string(), "2".to_string(),
"-preset".to_string(), "veryfast".to_string(),
"-crf".to_string(), "23".to_string(),
"-r".to_string(), "25".to_string(),
"-y".to_string(),
safe_output
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 拼接视频 - 快速模式 (要求编码/分辨率一致)
*/
pub async fn concat_videos_copy(app: &AppHandle, list_path: &str, output_path: &str) -> Result<(), String> {
// 验证路径安全
let safe_list = validate_safe_path(list_path)?;
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
"-f".to_string(), "concat".to_string(),
"-safe".to_string(), "0".to_string(),
"-i".to_string(), safe_list,
"-c".to_string(), "copy".to_string(), // 音视频流直接拷贝,保留音频
"-y".to_string(),
safe_output
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 拼接视频 - 兼容模式 (多步走:标准化 -> 拼接)
*/
pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec<String>, output_path: &str) -> Result<(), String> {
// 使用输出路径的父目录作为临时目录(确保在安全目录内)
let output_parent = std::path::Path::new(output_path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::temp_dir());
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let mut standardized_paths = Vec::new();
// 1. 标准化每个片段(内部已验证路径)
for (i, path) in video_paths.iter().enumerate() {
let std_path = output_parent.join(format!("std_{}_{}.mp4", timestamp, i));
match standardize_video(app, path, std_path.to_str().unwrap()).await {
Ok(()) => standardized_paths.push(std_path),
Err(e) => {
// 清理本轮已创建的标准化文件
for p in &standardized_paths {
let _ = std::fs::remove_file(p);
}
return Err(e);
}
}
}
// 2. 生成 concat 列表
let list_path = output_parent.join(format!("concat_list_{}.txt", timestamp));
let mut list_content = String::new();
for path in &standardized_paths {
// FFmpeg concat 列表中的路径需要用单引号包裹
list_content.push_str(&format!("file '{}'\n", escape_ffmpeg_path(&path.to_string_lossy())));
}
std::fs::write(&list_path, list_content).map_err_string()?;
// 3. 执行快速拼接(输出路径会被验证)
let concat_res = concat_videos_copy(app, list_path.to_str().unwrap(), output_path).await;
// 4. 清理临时文件
for path in standardized_paths {
let _ = std::fs::remove_file(path);
}
let _ = std::fs::remove_file(list_path);
concat_res
}
/**
* 音画合并 - 优化音质
*/
pub async fn add_audio_to_video(app: &AppHandle, video_path: &str, audio_path: &str, output_path: &str) -> Result<(), String> {
// 验证路径安全
let safe_video = validate_safe_path(video_path)?;
let safe_audio = validate_safe_path(audio_path)?;
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
"-i".to_string(), safe_video,
"-i".to_string(), safe_audio,
"-c:v".to_string(), "copy".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-b:a".to_string(), "192k".to_string(), // 提高码率
"-ar".to_string(), "44100".to_string(), // 统一采样率
"-map".to_string(), "0:v:0".to_string(),
"-map".to_string(), "1:a:0".to_string(),
"-y".to_string(),
safe_output
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 将封面图转换为一段短视频 (0.5s, 1080x1920, 25fps)
* 带静音音频轨道,避免 concat 时丢失后续片段音频
*/
pub async fn create_cover_video(app: &AppHandle, input_path: &str, output_path: &str, duration: &str) -> Result<(), String> {
// 验证路径安全
let safe_input = validate_safe_path(input_path)?;
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
"-loop".to_string(), "1".to_string(),
"-i".to_string(), safe_input,
"-f".to_string(), "lavfi".to_string(),
"-i".to_string(), "anullsrc=r=44100:cl=stereo".to_string(),
"-c:v".to_string(), "libx264".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-t".to_string(), duration.to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-vf".to_string(), "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1".to_string(),
"-r".to_string(), "25".to_string(),
"-shortest".to_string(),
"-y".to_string(),
safe_output
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 获取应用资源目录下的字体路径
*
* 开发模式下,如果资源目录找不到字体,回退到源码目录 src-tauri/fonts/
*/
fn get_fonts_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
use tauri::Manager;
// 先尝试从资源目录获取(生产模式)
if let Ok(resource_path) = app.path().resource_dir() {
let fonts_path = resource_path.join("fonts");
if fonts_path.exists() {
return Ok(fonts_path);
}
}
// 开发模式回退:从当前工作目录寻找源码中的 fonts 目录
let cwd = std::env::current_dir()
.map_err(|e| format!("Failed to get cwd: {}", e))?;
// cwd 通常是 src-tauri 目录,所以直接找 fonts/
let dev_fonts_path = cwd.join("fonts");
if dev_fonts_path.exists() {
return Ok(dev_fonts_path);
}
// 如果还是找不到,尝试往上一级找(支持项目根目录结构)
if let Some(parent) = cwd.parent() {
let dev_fonts_path_alt = parent.join("src-tauri/fonts");
if dev_fonts_path_alt.exists() {
return Ok(dev_fonts_path_alt);
}
// 再往上一级(如果 cwd 是 src-tauri/src
if let Some(grandparent) = parent.parent() {
let dev_fonts_path_grand = grandparent.join("tauri-app/src-tauri/fonts");
if dev_fonts_path_grand.exists() {
return Ok(dev_fonts_path_grand);
}
}
}
Err("Could not find fonts directory in any location".to_string())
}
/**
* 压制 ASS 字幕到视频(使用嵌入字体)
*
* 使用抖音美好体 Bold (DouyinSans Bold) 作为默认字体。
* 支持可选的 PNG overlay(用于大标题/小标题,效果与前端 Canvas 预览一致)。
*/
pub async fn burn_ass_subtitle(
app: &AppHandle,
video_path: &str,
ass_path: &str,
output_path: &str,
overlay_image: Option<&str>,
) -> Result<(), String> {
// 输入路径验证:HTTP URL 直接传递,本地文件需要安全检查
let safe_video = if video_path.starts_with("http://") || video_path.starts_with("https://") {
video_path.to_string()
} else {
validate_safe_path(video_path)?
};
let safe_ass = validate_safe_path(ass_path)?;
let safe_output = sanitize_output_path(output_path)?;
let ass_path_escaped = escape_ffmpeg_path(&safe_ass);
// 构建 ASS filter(尝试带 fontsdir
let ass_filter = if let Ok(fonts_dir) = get_fonts_dir(app) {
if let Some(fonts_dir_str) = fonts_dir.to_str() {
format!("ass='{}':fontsdir='{}'", ass_path_escaped, escape_ffmpeg_path(fonts_dir_str))
} else {
format!("ass='{}'", ass_path_escaped)
}
} else {
format!("ass='{}'", ass_path_escaped)
};
// 如果有 overlay 图片,先 overlay 再 burn 字幕(两步避免 filter_complex 音频映射问题)
if let Some(img_path) = overlay_image {
let safe_img = validate_safe_path(img_path)?;
let temp_output = format!("{}.tmp_overlay.mp4", safe_output);
// Step 1: overlay PNG 到视频
let overlay_filter = format!("[0:v][1:v]overlay=0:0:format=auto");
let overlay_args = vec![
"-i".to_string(), safe_video.clone(),
"-i".to_string(), safe_img,
"-filter_complex".to_string(), overlay_filter,
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "veryfast".to_string(),
"-crf".to_string(), "18".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
temp_output.clone(),
];
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 1, total: 2 });
if let Err(e) = run_ffmpeg(app, overlay_args).await {
let _ = std::fs::remove_file(&temp_output);
return Err(format!("Overlay 图片失败: {}", e));
}
// Step 2: burn ASS 字幕
let burn_args = vec![
"-i".to_string(), temp_output.clone(),
"-vf".to_string(), ass_filter.clone(),
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output.clone(),
];
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 2, total: 2 });
let result = run_ffmpeg(app, burn_args).await;
let _ = std::fs::remove_file(&temp_output);
return result.map(|_| ());
}
// 无 overlay,走原有单步逻辑(带 fontsdir 回退)
let filter = ass_filter.clone();
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 1, total: 1 });
let args = vec![
"-i".to_string(), safe_video.clone(),
"-vf".to_string(), filter,
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output.clone(),
];
match run_ffmpeg(app, args).await {
Ok(_) => return Ok(()),
Err(e) => {
log::warn!("[ffmpeg] 带 fontsdir 的 ASS 烧录失败,尝试回退: {}", e);
}
}
// 回退:不带 fontsdir
let filter = format!("ass='{}'", ass_path_escaped);
let args = vec![
"-i".to_string(), safe_video,
"-vf".to_string(), filter,
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output,
];
run_ffmpeg(app, args).await.map(|_| ())
}
/** 压制 ASS 字幕到视频(带自定义字体目录)
* 当前未使用,保留为扩展点。
*/
#[allow(dead_code)]
pub async fn burn_ass_subtitle_with_fonts(
app: &AppHandle,
video_path: &str,
ass_path: &str,
fonts_dir: &str,
output_path: &str,
) -> Result<(), String> {
// 输入路径验证:HTTP URL 直接传递,本地文件需要安全检查
let safe_video = if video_path.starts_with("http://") || video_path.starts_with("https://") {
video_path.to_string()
} else {
validate_safe_path(video_path)?
};
let safe_ass = validate_safe_path(ass_path)?;
let safe_output = sanitize_output_path(output_path)?;
let filter = format!(
"ass='{}':fontsdir='{}'",
escape_ffmpeg_path(&safe_ass),
escape_ffmpeg_path(fonts_dir)
);
let args = vec![
"-i".to_string(), safe_video,
"-vf".to_string(), filter,
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 混合背景音乐到视频(保留原视频音频)
*
* 使用 FFmpeg amix 滤镜将 BGM 与原视频音频混合。
* 如果 BGM 短于视频,会自动循环;如果长于视频,会截断到视频长度。
*
* BGM 路径必须是本地文件(由前端下载到 bgm_cache 后传入)。
*/
pub async fn mix_bgm_to_video(
app: &AppHandle,
video_path: &str,
bgm_path: &str,
output_path: &str,
video_volume: f64,
bgm_volume: f64,
) -> Result<(), String> {
let safe_video = validate_safe_path(video_path)?;
let safe_bgm = validate_safe_path(bgm_path)?;
let safe_output = sanitize_output_path(output_path)?;
// 构建 filter_complex:
// [0:a]volume=1.0[a0]; — 原视频音频调整音量
// [1:a]volume=0.25,aloop=loop=-1:size=2e+09[bgm]; — BGM 调整音量后无限循环
// [a0][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout] — 混合,以第一个输入时长为准
let filter_complex = format!(
"[0:a]volume={:.2}[a0];[1:a]volume={:.2},aloop=loop=-1:size=2e+09[bgm];[a0][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout]",
video_volume, bgm_volume
);
let args = vec![
"-i".to_string(), safe_video,
"-i".to_string(), safe_bgm,
"-filter_complex".to_string(), filter_complex,
"-map".to_string(), "0:v:0".to_string(),
"-map".to_string(), "[aout]".to_string(),
"-c:v".to_string(), "copy".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-b:a".to_string(), "192k".to_string(),
"-ar".to_string(), "44100".to_string(),
"-ac".to_string(), "2".to_string(),
"-y".to_string(),
safe_output,
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 音频替换 — 用配音音频替换视频中的原音
*
* 适用于配音替换场景:保留原视频画面,替换为 TTS/复刻音频。
*/
pub async fn replace_audio_track(
app: &AppHandle,
video_path: &str,
audio_path: &str,
output_path: &str,
) -> Result<(), String> {
// 验证路径安全
let safe_video = validate_safe_path(video_path)?;
let safe_audio = validate_safe_path(audio_path)?;
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
"-i".to_string(), safe_video,
"-i".to_string(), safe_audio,
// 视频流:直接复制(不重新编码)
"-c:v".to_string(), "copy".to_string(),
// 音频流:转码为 AAC
"-c:a".to_string(), "aac".to_string(),
"-b:a".to_string(), "192k".to_string(),
"-ar".to_string(), "44100".to_string(),
"-ac".to_string(), "2".to_string(),
// 只保留第一个视频流和第一个音频流
"-map".to_string(), "0:v:0".to_string(),
"-map".to_string(), "1:a:0".to_string(),
"-y".to_string(),
safe_output,
];
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:format=duration".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 可能是字符串或数字;某些格式(如 MPEG-TSstream duration 为 N/A,需从 format 回退
let stream_duration = stream.get("duration")
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
let format_duration = parsed.get("format")
.and_then(|f| f.get("duration"))
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
let duration = stream_duration.or(format_duration).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)
*
* 从起始时间裁剪指定时长,同时标准化输出格式(1080x1920, 25fps, libx264, aac)。
* 适用于从人物形象素材或空镜素材中提取指定时长的片段。
*/
pub async fn clip_video(
app: &AppHandle,
input: &str,
start: f64,
duration: f64,
output_path: &str,
) -> Result<(), String> {
let safe_output = sanitize_output_path(output_path)?;
// 输入路径验证:URL 直接传递,本地文件只需验证存在性
// (本地文件来自用户通过系统文件选择器主动选取,已获用户授权)
let safe_input = if input.starts_with("http://") || input.starts_with("https://") {
input.to_string()
} else if std::path::Path::new(input).exists() {
input.to_string()
} else {
return Err(format!("输入文件不存在: {}", input));
};
let start_str = format!("{:.3}", start);
let duration_str = format!("{:.3}", duration);
let args = vec![
"-ss".to_string(), start_str,
"-t".to_string(), duration_str,
"-i".to_string(), safe_input,
"-vf".to_string(), "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p".to_string(),
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "veryfast".to_string(),
"-crf".to_string(), "23".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-ar".to_string(), "44100".to_string(),
"-ac".to_string(), "2".to_string(),
"-r".to_string(), "25".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-avoid_negative_ts".to_string(), "make_zero".to_string(),
"-y".to_string(),
safe_output,
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 截取音频片段
*
* 从指定起始时间截取指定时长的音频,输出为 MP3 格式。
*/
pub async fn extract_audio_segment(
app: &AppHandle,
input_path: &str,
start: f64,
duration: f64,
output_path: &str,
) -> Result<(), String> {
let safe_input = validate_safe_path(input_path)?;
let safe_output = sanitize_output_path(output_path)?;
let start_str = format!("{:.3}", start);
let duration_str = format!("{:.3}", duration);
let args = vec![
"-ss".to_string(), start_str,
"-t".to_string(), duration_str,
"-i".to_string(), safe_input,
"-c:a".to_string(), "libmp3lame".to_string(),
"-b:a".to_string(), "192k".to_string(),
"-ar".to_string(), "44100".to_string(),
"-ac".to_string(), "2".to_string(),
"-vn".to_string(), // 无视频
"-y".to_string(),
safe_output,
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 解析 ffprobe 返回的帧率字符串(如 "30000/1001" 或 "30/1"
*/
fn parse_frame_rate(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
if let Some(idx) = s.find('/') {
let num: f64 = s[..idx].parse().unwrap_or(0.0);
let den: f64 = s[idx + 1..].parse().unwrap_or(1.0);
if den > 0.0 {
return num / den;
}
}
s.parse().unwrap_or(0.0)
}
/**
* 为预览转码视频(统一为浏览器兼容格式)
*
* 快速通过检测:只有满足全部兼容性条件的视频才直接返回原路径,
* 任一不满足则强制转码为 H.264 Baseline + YUV420p 540p 代理。
*
* 检测项(基于主流浏览器/WebView 兼容性最佳实践):
* - codec: H.264
* - pix_fmt: YUV420p
* - profile: Baseline / Constrained Baseline / Main
* - level: <= 4.1
* - color_range: limited (tv) — macOS VideoToolbox 对 full range 支持不完整
* - has_b_frames: 0 — macOS WKWebView 对 B-frames 支持不完整
* - refs: <= 4 — 参考帧数过多可能导致解码失败
* - r_frame_rate: <= 60fps — 高帧率可能异常
* - width/height: <= 1920x1920
*
* 转码参数参考 Mux/Transloadit 主流推荐,针对预览场景优化:
* - preset ultrafast: 编码速度优先(预览不追求极致压缩率)
* - crf 28: 预览画质够用
* - g 30 / keyint_min 30: 每秒一个关键帧,seek 更精确
* - 保留音频(人物形象视频有配音)
* - +faststart: moov atom 前置,快速开始播放
*
* 并发控制:tokio Semaphore(1),保证同时只有一个 FFmpeg 进程在跑,
* 避免多视频同时预览时 CPU/磁盘争抢。
*
* 转码结果按(文件路径 + 大小 + 修改时间)缓存,避免重复处理。
*/
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));
}
let path_str = input.canonicalize()
.unwrap_or(input.to_path_buf())
.to_string_lossy()
.to_string();
// ============================================================
// 1. 快速通过检测:用 ffprobe 全面检查视频兼容性
// ============================================================
let probe_args = vec![
"-v".to_string(), "error".to_string(),
"-select_streams".to_string(), "v:0".to_string(),
"-show_entries".to_string(), "stream=codec_name,pix_fmt,width,height,profile,level,color_range,has_b_frames,refs,r_frame_rate".to_string(),
"-of".to_string(), "json".to_string(),
path_str.clone(),
];
let probe_result = app.shell().sidecar("ffprobe")
.and_then(|s| s.args(probe_args).spawn());
if let Ok((mut rx, child)) = probe_result {
let mut stdout = 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::Terminated(status) if status.code == Some(0) => return Ok(()),
_ => {}
}
}
Ok::<(), ()>(())
};
match tokio::time::timeout(std::time::Duration::from_secs(3), probe_future).await {
Ok(Ok(())) => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout) {
if let Some(stream) = parsed.get("streams").and_then(|s| s.as_array()).and_then(|a| a.first()) {
let codec = stream.get("codec_name").and_then(|v| v.as_str()).unwrap_or("");
let pix_fmt = stream.get("pix_fmt").and_then(|v| v.as_str()).unwrap_or("");
let profile = stream.get("profile").and_then(|v| v.as_str()).unwrap_or("");
let level = stream.get("level").and_then(|v| v.as_i64()).unwrap_or(0);
let color_range = stream.get("color_range").and_then(|v| v.as_str()).unwrap_or("tv");
let has_b_frames = stream.get("has_b_frames").and_then(|v| v.as_i64()).unwrap_or(0);
let refs = stream.get("refs").and_then(|v| v.as_i64()).unwrap_or(0);
let r_frame_rate = stream.get("r_frame_rate").and_then(|v| v.as_str()).unwrap_or("");
let width = stream.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
let height = stream.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
let is_safe_profile = profile == "Baseline"
|| profile == "Constrained Baseline"
|| profile == "Main";
let is_limited_range = color_range == "tv" || color_range.is_empty();
let no_b_frames = has_b_frames == 0;
let refs_ok = refs <= 4;
let level_ok = level <= 41;
let fps = parse_frame_rate(r_frame_rate);
let fps_ok = fps > 0.0 && fps <= 60.0;
let resolution_ok = width <= 1920 && height <= 1920;
let can_skip = codec == "h264"
&& pix_fmt == "yuv420p"
&& is_safe_profile
&& is_limited_range
&& no_b_frames
&& refs_ok
&& level_ok
&& fps_ok
&& resolution_ok;
if can_skip {
return Ok(path_str);
}
}
}
}
_ => {
// 超时或异常:强制结束 ffprobe,避免僵尸进程
let _ = child.kill();
}
}
}
// ============================================================
// 2. 缓存检查
// ============================================================
let metadata = std::fs::metadata(input_path)
.map_err(|e| format!("无法读取文件元数据: {}", e))?;
let mtime = metadata.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.unwrap_or_default()
.as_secs();
let file_size = metadata.len();
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))?;
// 缓存版本号:每次修改转码参数后递增,确保旧缓存自动失效
const CACHE_VERSION: &str = "v3";
let cache_path = cache_dir.join(format!("preview_{}_{}_{}_{}.mp4", path_hash, file_size, mtime, CACHE_VERSION));
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());
}
}
// ============================================================
// 3. FFmpeg 转码(带并发锁)
// ============================================================
let _permit = PREVIEW_SEMAPHORE.acquire().await
.map_err(|e| format!("无法获取转码锁: {}", e))?;
// 先写入临时文件,避免 FFmpeg 写入过程中被 WebKit 读取到不完整文件
let tmp_path = cache_path.with_extension("mp4.tmp");
let args = vec![
"-i".to_string(), path_str.clone(),
"-map".to_string(), "0:v:0".to_string(),
"-map".to_string(), "0:a:0?".to_string(),
"-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(), "23".to_string(),
"-vf".to_string(), "scale=540:-2:force_original_aspect_ratio=decrease".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-b:a".to_string(), "128k".to_string(),
"-movflags".to_string(), "+faststart".to_string(),
"-f".to_string(), "mp4".to_string(),
"-y".to_string(),
tmp_path.to_string_lossy().to_string(),
];
run_ffmpeg(app, args).await?;
// 转码完成后验证临时文件大小
let output_meta = std::fs::metadata(&tmp_path)
.map_err(|e| format!("转码后无法读取临时文件: {}", e))?;
if output_meta.len() < 1024 {
let _ = std::fs::remove_file(&tmp_path);
return Err(format!("FFmpeg 转码输出文件过小: {} bytes", output_meta.len()));
}
log::info!("[transcode_for_preview] 转码完成: {} ({} bytes)", tmp_path.display(), output_meta.len());
// 原子重命名:确保 WebKit 只读到完整文件
std::fs::rename(&tmp_path, &cache_path)
.map_err(|e| format!("重命名缓存文件失败: {}", e))?;
Ok(cache_path.to_string_lossy().to_string())
}