Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/capabilities/desktop.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"core:window:allow-get-all-windows",
"core:window:allow-close",
"core:window:allow-create",
"core:window:allow-destroy",
"core:window:allow-is-visible",
"core:window:allow-set-focus",
"core:window:allow-show",
Expand Down
322 changes: 20 additions & 302 deletions src-tauri/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,30 @@ use log::{set_max_level, LevelFilter};
use quicklook_archive::{extractors, Extract};
use quicklook_docs as docs;
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
use tauri::{command, AppHandle, Manager};
use windows::Win32::Foundation::HWND;

#[path = "helper/mod.rs"]
mod helper;
use helper::{audio, monitor, win};
// use helper::{archives, docs, ffmp, monitor, win};
use helper::{audio, ffmp, monitor, win};

#[command]
pub fn check_ffmpeg() -> bool {
ffmp::check_ffmpeg()
}

#[command]
pub async fn convert_video_to_hls(path: String) -> Result<String, String> {
let path_for_work = path.clone();
tauri::async_runtime::spawn_blocking(move || ffmp::convert_video_to_hls(&path_for_work))
.await
.map_err(|e| format!("转码任务执行失败: {}", e))?
}

#[command]
pub fn cancel_video_conversion() {
ffmp::cancel_video_conversion()
}

#[command]
pub fn show_open_with_dialog(app: AppHandle, path: &str) {
Expand Down Expand Up @@ -123,310 +139,12 @@ pub fn parse_lrc(path: &str) -> Result<audio::Lrc, String> {
audio::parse_lrc(path)
}

/// 全局记录正在运行的 ffmpeg 进程 PID 及其对应的临时目录,用于取消时终止进程并清理。
static FFMPEG_PROCESS: LazyLock<Mutex<Option<(u32, PathBuf)>>> = LazyLock::new(|| Mutex::new(None));

/// 检测本机是否安装了 ffmpeg
#[command]
pub fn check_ffmpeg() -> bool {
std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}

/// 将视频转换为 HLS (m3u8) 格式以供播放。
/// 如果视频已经是 h264 编码,则直接封装为 HLS;否则先转码为 h264 再封装。
/// 返回生成的 m3u8 文件路径。
#[command]
pub fn convert_video_to_hls(path: &str) -> Result<String, String> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

// 确认 ffmpeg 可用
if !check_ffmpeg() {
return Err("ffmpeg 未找到,请确保 ffmpeg 已安装并添加到 PATH 中".to_string());
}

// 根据文件路径生成唯一临时目录(同一文件复用缓存)
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
let hash = hasher.finish();

let mut temp_dir = std::env::temp_dir();
temp_dir.push(format!("quicklook_hls_{:x}", hash));

let m3u8_path = temp_dir.join("index.m3u8");
let m3u8_result = m3u8_path.to_string_lossy().to_string();

// 同一文件若已在转换中,直接等待播放列表就绪并返回,避免重复启动 ffmpeg。
{
let guard = FFMPEG_PROCESS.lock().unwrap();
if let Some((pid, running_dir)) = guard.as_ref() {
if *running_dir == temp_dir {
log::info!(
"检测到同一路径已有 ffmpeg 正在运行 (PID: {}),等待 m3u8 就绪后复用",
pid
);
drop(guard);

for _ in 0..120 {
if m3u8_path.exists() {
return Ok(m3u8_result);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
return Err("ffmpeg 正在运行,但 m3u8 尚未就绪,请稍后重试".to_string());
}
}
}

// 如果已经转换过,优先复用缓存;但要避免历史版本留下的错误分片路径,且必须是绝对分片 URI。
if m3u8_path.exists() {
let should_rebuild = std::fs::read_to_string(&m3u8_path)
.map(|content| {
let has_invalid_backslash = content.contains(":\\") || content.contains('\\');
let has_absolute_segment_uri = content.lines().any(|line| {
let l = line.trim();
if l.is_empty() || l.starts_with('#') {
return false;
}
l.contains("://")
|| (l.len() > 2
&& l.as_bytes()[1] == b':'
&& (l.as_bytes()[2] == b'/' || l.as_bytes()[2] == b'\\'))
|| l.starts_with('/')
});
has_invalid_backslash || !has_absolute_segment_uri
})
.unwrap_or(true);

if !should_rebuild {
log::info!("命中 HLS 缓存: {:?}", m3u8_path);
return Ok(m3u8_result);
}

log::warn!("检测到旧版 HLS 缓存路径格式异常,准备重建: {:?}", m3u8_path);
let _ = std::fs::remove_dir_all(&temp_dir);
}

std::fs::create_dir_all(&temp_dir).map_err(|e| e.to_string())?;

// 用 ffprobe 检测视频流编解码器
let codec_result = std::process::Command::new("ffprobe")
.args([
"-v",
"quiet",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
"-of",
"default=noprint_wrappers=1:nokeys=1",
path,
])
.output();
let codec = match codec_result {
Ok(out) => match String::from_utf8(out.stdout) {
Ok(s) => s.trim().to_lowercase(),
Err(e) => {
log::warn!("ffprobe 输出解析失败: {},将使用转码模式", e);
String::new()
},
},
Err(e) => {
log::warn!("ffprobe 执行失败: {},将使用转码模式", e);
String::new()
},
};
log::info!("检测到视频编解码器: {}", codec);

// 如果已是 h264,直接复制视频流;否则转码为 libx264
let video_codec = if codec == "h264" { "copy" } else { "libx264" };

let seg_filename = "seg_%03d.ts".to_string();
let m3u8_file_name = "index.m3u8".to_string();
let mut hls_base_url = format!("{}", temp_dir.to_string_lossy().replace('\\', "/"));
if !hls_base_url.ends_with('/') {
hls_base_url.push('/');
}

let mut ffmpeg = std::process::Command::new("ffmpeg");
ffmpeg
.current_dir(&temp_dir)
.arg("-i")
.arg(path)
.arg("-c:v")
.arg(video_codec);

// 转码时固定像素格式和 profile,提升浏览器/MSE 兼容性;copy 模式保持原编码。
if video_codec != "copy" {
ffmpeg
.arg("-preset")
.arg("veryfast")
.arg("-pix_fmt")
.arg("yuv420p")
.arg("-profile:v")
.arg("main")
.arg("-level")
.arg("4.0");
}

let mut child = ffmpeg
.args([
"-c:a",
"aac",
"-b:a",
"128k",
"-ac",
"2",
"-ar",
"48000",
"-hls_time",
"2",
"-hls_list_size",
"0",
"-hls_flags",
"independent_segments+append_list",
"-hls_base_url",
&hls_base_url,
"-hls_segment_filename",
&seg_filename,
"-f",
"hls",
&m3u8_file_name,
])
.spawn()
.map_err(|e| e.to_string())?;

// 记录 PID 和临时目录,以便在取消时终止进程
{
let mut guard = FFMPEG_PROCESS
.lock()
.unwrap_or_else(|e| e.into_inner());
*guard = Some((child.id(), temp_dir.clone()));
}

let child_pid = child.id();
let temp_dir_for_wait = temp_dir.clone();
std::thread::spawn(move || {
let status = child.wait();

{
let mut guard = FFMPEG_PROCESS
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some((pid, _)) = guard.as_ref() {
if *pid == child_pid {
*guard = None;
}
}
}

match status {
Ok(exit) if exit.success() => {
log::info!("HLS 转换完成,PID: {}", child_pid);
},
Ok(exit) => {
let code = exit.code().unwrap_or(-1);
log::error!("ffmpeg 转换失败,PID: {}, 退出码: {}", child_pid, code);
let _ = std::fs::remove_dir_all(&temp_dir_for_wait);
},
Err(e) => {
log::error!("等待 ffmpeg 进程失败,PID: {}, 错误: {}", child_pid, e);
let _ = std::fs::remove_dir_all(&temp_dir_for_wait);
},
}
});

// 边转边播:等待首个播放列表文件生成后立即返回给前端。
for _ in 0..120 {
if m3u8_path.exists() {
log::info!("m3u8 已就绪,开始边转边播: {:?}", m3u8_path);
return Ok(m3u8_result);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}

// m3u8 在 12 秒内未生成:终止进程、清理临时目录并向前端报错。
log::error!("ffmpeg 已启动,但 m3u8 生成超时,正在终止进程并清理临时文件");
kill_ffmpeg_process();
let _ = std::fs::remove_dir_all(&temp_dir);
Err("ffmpeg 已启动,但 m3u8 生成超时".to_string())
}

/// 从全局取出正在运行的 ffmpeg 进程记录,终止该进程并删除临时目录。
/// 如果当前没有记录则直接返回。
fn kill_ffmpeg_process() {
let entry = FFMPEG_PROCESS
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();

if let Some((pid, temp_dir)) = entry {
log::info!("正在终止 ffmpeg 进程 (PID: {})", pid);
// 使用 taskkill 强制结束进程(Windows 平台)
let result = std::process::Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.output();
match result {
Ok(out) if out.status.success() => {
log::info!("ffmpeg 进程 (PID: {}) 已终止", pid);
},
Ok(out) => {
log::warn!(
"终止 ffmpeg 进程时出现警告: {}",
String::from_utf8_lossy(&out.stderr)
);
},
Err(e) => {
log::error!("终止 ffmpeg 进程失败: {}", e);
},
}
let _ = std::fs::remove_dir_all(&temp_dir);
log::info!("已清理临时目录: {:?}", temp_dir);
}
}

/// 取消正在进行的 ffmpeg 视频转换,清理临时文件。
#[command]
pub fn cancel_video_conversion() {
kill_ffmpeg_process();
}

/// 清理所有由 quicklook 生成的 ffmpeg HLS 转码缓存目录。
/// 返回被删除的目录数量。
pub fn clear_ffmpeg_cache() -> Result<u32, String> {
let temp_dir = std::env::temp_dir();
let entries = std::fs::read_dir(&temp_dir).map_err(|e| e.to_string())?;

let mut removed = 0u32;
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("quicklook_hls_") && entry.path().is_dir() {
match std::fs::remove_dir_all(entry.path()) {
Ok(_) => {
removed += 1;
log::info!("已清理缓存目录: {}", entry.path().display());
},
Err(e) => {
log::warn!("清理缓存目录失败: {}, 错误: {}", entry.path().display(), e);
},
}
}
}
log::info!("共清理 {} 个 ffmpeg 缓存目录", removed);
Ok(removed)
}

/// 汇总清理所有 quicklook 产生的缓存,目前包含 ffmpeg HLS 转码缓存。
/// 返回被删除的目录/文件总数量。
#[command]
pub fn clear_cache() -> Result<u32, String> {
let mut total = 0u32;
total += clear_ffmpeg_cache()?;
total += ffmp::clear_ffmpeg_cache()?;
log::info!("缓存清理完成,共删除 {} 个目录/文件", total);
Ok(total)
}
Loading
Loading