915339d42a
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
1002 lines
37 KiB
Rust
1002 lines
37 KiB
Rust
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-TS)stream 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())
|
||
}
|