From f8b0cf596ce0b3bc97fd9e644e2eab2d8db98813 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 03:36:02 +0000 Subject: [PATCH 1/5] refactor: move ffmpeg code from command.rs to helper/ffmp.rs Agent-Logs-Url: https://github.com/GuoJikun/quicklook/sessions/10af0b8a-ff1c-4b78-b209-23bebe40027a Co-authored-by: GuoJikun <21582741+GuoJikun@users.noreply.github.com> --- src-tauri/src/command.rs | 306 +------------------------- src-tauri/src/helper/ffmp.rs | 406 ++++++++++++++++++++++++++--------- src-tauri/src/helper/mod.rs | 1 + 3 files changed, 306 insertions(+), 407 deletions(-) diff --git a/src-tauri/src/command.rs b/src-tauri/src/command.rs index 9062bab..af85c3c 100644 --- a/src-tauri/src/command.rs +++ b/src-tauri/src/command.rs @@ -2,14 +2,14 @@ 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}; + +pub use ffmp::{cancel_video_conversion, check_ffmpeg, convert_video_to_hls}; #[command] pub fn show_open_with_dialog(app: AppHandle, path: &str) { @@ -123,310 +123,12 @@ pub fn parse_lrc(path: &str) -> Result { audio::parse_lrc(path) } -/// 全局记录正在运行的 ffmpeg 进程 PID 及其对应的临时目录,用于取消时终止进程并清理。 -static FFMPEG_PROCESS: LazyLock>> = 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 { - 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 { - 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 { let mut total = 0u32; - total += clear_ffmpeg_cache()?; + total += ffmp::clear_ffmpeg_cache()?; log::info!("缓存清理完成,共删除 {} 个目录/文件", total); Ok(total) } diff --git a/src-tauri/src/helper/ffmp.rs b/src-tauri/src/helper/ffmp.rs index 38179d9..4305e30 100644 --- a/src-tauri/src/helper/ffmp.rs +++ b/src-tauri/src/helper/ffmp.rs @@ -1,105 +1,301 @@ -// use ffmpeg_next::{codec::Context as CodecContext, encoder::find as encoderFind, format::input, media::Type}; -// use mp4::{AvcConfig, MediaConfig, Mp4Config, Mp4Sample, Mp4Writer, TrackConfig}; -// use std::io::Cursor; -// use tauri::{AppHandle, Emitter, EventTarget}; - -// #[allow(unused)] -// pub fn decode_video_stream(app: AppHandle, path: &str, label: String) -> Result<(), String> { -// ffmpeg_next::init().map_err(|e| e.to_string())?; - -// // 打开视频文件 -// let mut ictx = input(path).map_err(|e| e.to_string())?; -// let stream = ictx -// .streams() -// .best(Type::Video) -// .ok_or("No video stream found")?; - -// let stream_index = stream.index(); - -// // 设置解码器 -// let encoder_codec = encoderFind(ffmpeg_next::codec::Id::H264).ok_or("No decoder found")?; -// let context_decoder = CodecContext::new_with_codec(encoder_codec); -// let mut decoder = context_decoder.decoder().video().map_err(|e| e.to_string())?; - -// // 每个片段的基本配置 -// let mp4_config = Mp4Config { -// major_brand: str::parse("isom").unwrap(), -// minor_version: 512, -// compatible_brands: vec![ -// str::parse("isom").unwrap(), -// str::parse("iso2").unwrap(), -// str::parse("avc1").unwrap(), -// str::parse("mp41").unwrap(), -// ], -// timescale: 1000, -// }; - -// let mut frame_count = 0; -// let mut segment_data: Vec = Vec::new(); - -// for (stream, packet) in ictx.packets() { -// if stream.index() == stream_index { -// decoder.send_packet(&packet).map_err(|e| e.to_string())?; - -// let mut decoded = ffmpeg_next::frame::Video::empty(); -// while decoder.receive_frame(&mut decoded).is_ok() { -// frame_count += 1; - -// // 每30帧创建一个新片段 -// if frame_count % 30 == 0 { -// let mut buffer = Vec::new(); -// let writer = Cursor::new(&mut buffer); -// let mut mp4_writer = Mp4Writer::write_start(writer, &mp4_config) -// .map_err(|e| e.to_string())?; - -// // 配置视频轨道 -// let track_config = TrackConfig { -// track_type: mp4::TrackType::Video, -// timescale: 1000, -// language: "und".to_string(), -// media_conf: MediaConfig::AvcConfig(AvcConfig { -// width: decoder.width() as u16, -// height: decoder.height() as u16, -// seq_param_set: vec![], -// pic_param_set: vec![], -// }), -// }; - -// mp4_writer -// .add_track(&track_config) -// .map_err(|e| e.to_string())?; - -// // 写入视频数据 -// mp4_writer -// .write_sample( -// 0, -// &Mp4Sample { -// start_time: frame_count as u64, -// duration: 40, -// rendering_offset: 0, -// is_sync: packet.is_key(), -// bytes: packet.data().unwrap_or_default().to_vec().into(), -// }, -// ) -// .map_err(|e| e.to_string())?; - -// mp4_writer.write_end().map_err(|e| e.to_string())?; - -// // 发送片段到前端 -// app.emit_to( -// EventTarget::WebviewWindow { -// label: label.clone(), -// }, -// "video-chunk", -// buffer, -// ) -// .map_err(|e| e.to_string())?; - -// segment_data.clear(); -// } -// } -// } -// } - -// Ok(()) -// } +use std::path::PathBuf; +use std::sync::{LazyLock, Mutex}; +use tauri::command; + +/// 全局记录正在运行的 ffmpeg 进程 PID 及其对应的临时目录,用于取消时终止进程并清理。 +static FFMPEG_PROCESS: LazyLock>> = 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 { + 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 { + 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) +} diff --git a/src-tauri/src/helper/mod.rs b/src-tauri/src/helper/mod.rs index b94b677..10cc53c 100644 --- a/src-tauri/src/helper/mod.rs +++ b/src-tauri/src/helper/mod.rs @@ -4,6 +4,7 @@ use tauri::{ pub mod audio; pub mod config; +pub mod ffmp; pub mod monitor; pub mod selected_file; pub mod win; From b1d7f799d740718f7ccb27dc64549cf93d377f4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 03:40:13 +0000 Subject: [PATCH 2/5] refactor: keep #[command] in command.rs, plain impl in helper/ffmp.rs Agent-Logs-Url: https://github.com/GuoJikun/quicklook/sessions/562616bb-179e-4739-95ac-3f4f0ab6fad6 Co-authored-by: GuoJikun <21582741+GuoJikun@users.noreply.github.com> --- src-tauri/src/command.rs | 15 ++++++++++++++- src-tauri/src/helper/ffmp.rs | 4 ---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/command.rs b/src-tauri/src/command.rs index af85c3c..bbcc749 100644 --- a/src-tauri/src/command.rs +++ b/src-tauri/src/command.rs @@ -9,7 +9,20 @@ use windows::Win32::Foundation::HWND; mod helper; use helper::{audio, ffmp, monitor, win}; -pub use ffmp::{cancel_video_conversion, check_ffmpeg, convert_video_to_hls}; +#[command] +pub fn check_ffmpeg() -> bool { + ffmp::check_ffmpeg() +} + +#[command] +pub fn convert_video_to_hls(path: &str) -> Result { + ffmp::convert_video_to_hls(path) +} + +#[command] +pub fn cancel_video_conversion() { + ffmp::cancel_video_conversion() +} #[command] pub fn show_open_with_dialog(app: AppHandle, path: &str) { diff --git a/src-tauri/src/helper/ffmp.rs b/src-tauri/src/helper/ffmp.rs index 4305e30..f0fdba6 100644 --- a/src-tauri/src/helper/ffmp.rs +++ b/src-tauri/src/helper/ffmp.rs @@ -1,12 +1,10 @@ use std::path::PathBuf; use std::sync::{LazyLock, Mutex}; -use tauri::command; /// 全局记录正在运行的 ffmpeg 进程 PID 及其对应的临时目录,用于取消时终止进程并清理。 static FFMPEG_PROCESS: LazyLock>> = LazyLock::new(|| Mutex::new(None)); /// 检测本机是否安装了 ffmpeg -#[command] pub fn check_ffmpeg() -> bool { std::process::Command::new("ffmpeg") .arg("-version") @@ -18,7 +16,6 @@ pub fn check_ffmpeg() -> bool { /// 将视频转换为 HLS (m3u8) 格式以供播放。 /// 如果视频已经是 h264 编码,则直接封装为 HLS;否则先转码为 h264 再封装。 /// 返回生成的 m3u8 文件路径。 -#[command] pub fn convert_video_to_hls(path: &str) -> Result { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; @@ -269,7 +266,6 @@ fn kill_ffmpeg_process() { } /// 取消正在进行的 ffmpeg 视频转换,清理临时文件。 -#[command] pub fn cancel_video_conversion() { kill_ffmpeg_process(); } From d3b7115820da6ce810e3acd198e4a5e0cbcbc7a0 Mon Sep 17 00:00:00 2001 From: _zhiqiu Date: Sat, 9 May 2026 11:44:11 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E5=BA=94=E7=94=A8=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=94=B1github=E4=BC=98=E5=85=88=E6=9B=B4=E6=94=B9?= =?UTF-8?q?=E4=B8=BAgitee=E4=BC=98=E5=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/tauri.conf.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 46f0602..31419ee 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -66,8 +66,8 @@ "dangerousInsecureTransportProtocol": false, "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlENkEzMjUyN0RENTU2MgpSV1JpVmQwbkphUFdDZTExMEgwU1RiWkFTNDFhNU05TFRYRFVNbDV5OVp4dkZkZ2lKNStXMDdKZwo=", "endpoints": [ - "https://github.com/GuoJikun/quicklook/releases/latest/download/latest.json", - "https://gitee.com/guojikun/quicklook/releases/download/latest/latest.json" + "https://gitee.com/guojikun/quicklook/releases/download/latest/latest.json", + "https://github.com/GuoJikun/quicklook/releases/latest/download/latest.json" ] } } From 799ecdb9562d643e58af2bf05074b7018038e06a Mon Sep 17 00:00:00 2001 From: _zhiqiu Date: Sat, 9 May 2026 14:55:34 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E8=BD=AC?= =?UTF-8?q?=E7=A0=81=E6=94=B9=E4=B8=BA=E5=BC=82=E6=AD=A5=20commad=20?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E9=98=BB=E5=A1=9E=20webview=20=E7=AA=97?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/capabilities/desktop.json | 1 + src-tauri/src/command.rs | 7 ++-- src-tauri/src/helper/ffmp.rs | 21 +++++++---- src/views/preview/video.vue | 54 +++++++++++++++++++++++------ 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index 3ec5a60..109cedb 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -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", diff --git a/src-tauri/src/command.rs b/src-tauri/src/command.rs index bbcc749..0ad65b5 100644 --- a/src-tauri/src/command.rs +++ b/src-tauri/src/command.rs @@ -15,8 +15,11 @@ pub fn check_ffmpeg() -> bool { } #[command] -pub fn convert_video_to_hls(path: &str) -> Result { - ffmp::convert_video_to_hls(path) +pub async fn convert_video_to_hls(path: String) -> Result { + 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] diff --git a/src-tauri/src/helper/ffmp.rs b/src-tauri/src/helper/ffmp.rs index f0fdba6..21a0be9 100644 --- a/src-tauri/src/helper/ffmp.rs +++ b/src-tauri/src/helper/ffmp.rs @@ -1,8 +1,10 @@ use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{LazyLock, Mutex}; /// 全局记录正在运行的 ffmpeg 进程 PID 及其对应的临时目录,用于取消时终止进程并清理。 static FFMPEG_PROCESS: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +static FFMPEG_CANCELLED: AtomicBool = AtomicBool::new(false); /// 检测本机是否安装了 ffmpeg pub fn check_ffmpeg() -> bool { @@ -17,6 +19,8 @@ pub fn check_ffmpeg() -> bool { /// 如果视频已经是 h264 编码,则直接封装为 HLS;否则先转码为 h264 再封装。 /// 返回生成的 m3u8 文件路径。 pub fn convert_video_to_hls(path: &str) -> Result { + FFMPEG_CANCELLED.store(false, Ordering::Relaxed); + use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; @@ -48,6 +52,9 @@ pub fn convert_video_to_hls(path: &str) -> Result { drop(guard); for _ in 0..120 { + if FFMPEG_CANCELLED.load(Ordering::Relaxed) { + return Err("ffmpeg 转换已取消".to_string()); + } if m3u8_path.exists() { return Ok(m3u8_result); } @@ -178,9 +185,7 @@ pub fn convert_video_to_hls(path: &str) -> Result { // 记录 PID 和临时目录,以便在取消时终止进程 { - let mut guard = FFMPEG_PROCESS - .lock() - .unwrap_or_else(|e| e.into_inner()); + let mut guard = FFMPEG_PROCESS.lock().unwrap_or_else(|e| e.into_inner()); *guard = Some((child.id(), temp_dir.clone())); } @@ -190,9 +195,7 @@ pub fn convert_video_to_hls(path: &str) -> Result { let status = child.wait(); { - let mut guard = FFMPEG_PROCESS - .lock() - .unwrap_or_else(|e| e.into_inner()); + 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; @@ -218,6 +221,11 @@ pub fn convert_video_to_hls(path: &str) -> Result { // 边转边播:等待首个播放列表文件生成后立即返回给前端。 for _ in 0..120 { + if FFMPEG_CANCELLED.load(Ordering::Relaxed) { + log::info!("ffmpeg 转换已取消,提前结束等待"); + let _ = std::fs::remove_dir_all(&temp_dir); + return Err("ffmpeg 转换已取消".to_string()); + } if m3u8_path.exists() { log::info!("m3u8 已就绪,开始边转边播: {:?}", m3u8_path); return Ok(m3u8_result); @@ -267,6 +275,7 @@ fn kill_ffmpeg_process() { /// 取消正在进行的 ffmpeg 视频转换,清理临时文件。 pub fn cancel_video_conversion() { + FFMPEG_CANCELLED.store(true, Ordering::Relaxed); kill_ffmpeg_process(); } diff --git a/src/views/preview/video.vue b/src/views/preview/video.vue index 7bb2a4c..c85de2e 100644 --- a/src/views/preview/video.vue +++ b/src/views/preview/video.vue @@ -2,6 +2,8 @@ import { onMounted, onUnmounted, ref } from 'vue' import { useRoute } from 'vue-router' +import { info } from '@tauri-apps/plugin-log' + import Player, { I18N } from 'xgplayer' import 'xgplayer/dist/index.min.css' import ZH from 'xgplayer/es/lang/zh-cn' @@ -10,8 +12,32 @@ import type { FileInfo } from '@/utils/typescript' import LayoutPreview from '@/components/layout-preview.vue' import { convertFileSrc, invoke } from '@tauri-apps/api/core' import { load } from '@tauri-apps/plugin-store' +import { getCurrentWindow } from '@tauri-apps/api/window' const route = useRoute() +let unlistenWindowClose: (() => void) | null = null +let disposed = false + +const cleanupPlayer = () => { + if (player !== null) { + player.destroy() + player = null + } +} + +const listenWindowClose = async () => { + const currentWindow = getCurrentWindow() + unlistenWindowClose = await currentWindow.onCloseRequested(() => { + disposed = true + // 清理逻辑 + info('触发 onCloseRequested 生命周期,正在清理资源...') + // 如果窗口关闭或切换文件时转码仍在进行,通知后端终止 ffmpeg 进程 + + invoke('cancel_video_conversion').catch(err => console.error('停止 ffmpeg 转换失败:', err)) + cleanupPlayer() + // 不阻止则继续关闭 + }) +} // 启用中文 I18N.use(ZH) @@ -55,17 +81,9 @@ const initPlayer = (url: string, isHls = false) => { } } -onUnmounted(() => { - // 如果窗口关闭或切换文件时转码仍在进行,通知后端终止 ffmpeg 进程 - if (converting.value) { - invoke('cancel_video_conversion').catch(err => console.error('停止 ffmpeg 转换失败:', err)) - } - if (player !== null) { - player.destroy() - } -}) - onMounted(async () => { + await listenWindowClose() + fileInfo.value = route.query as unknown as FileInfo const filePath = fileInfo.value.path @@ -79,11 +97,17 @@ onMounted(async () => { convertError.value = null try { const m3u8Path = await invoke('convert_video_to_hls', { path: filePath }) + if (disposed) { + return + } console.log('视频转换完成,m3u8 文件路径:', m3u8Path) const m3u8Url = convertFileSrc(m3u8Path) console.log('视频转换完成,开始播放...', m3u8Url) initPlayer(m3u8Url, true) } catch (e: unknown) { + if (disposed) { + return + } convertError.value = e instanceof Error ? e.message : String(e) // 回退到直接播放 initPlayer(convertFileSrc(filePath), false) @@ -95,6 +119,16 @@ onMounted(async () => { initPlayer(convertFileSrc(filePath), false) } }) + +onUnmounted(() => { + disposed = true + invoke('cancel_video_conversion').catch(err => console.error('停止 ffmpeg 转换失败:', err)) + cleanupPlayer() + if (unlistenWindowClose !== null) { + unlistenWindowClose() + unlistenWindowClose = null + } +})