mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-24 20:15:34 +08:00
feat: smart flv conversion with progress (#155)
* fix: auto-close batch delete dialog after completion * feat: smart FLV conversion with detailed progress and better UX - Intelligent FLV→MP4 conversion (lossless stream copy + high-quality fallback) - Real-time import progress with percentage tracking - Smart file size display (auto GB/MB units) - Optimized thumbnail generation and network file handling * refactor: reorganize FFmpeg functions and fix network detection - Move FFmpeg functions from video.rs to ffmpeg.rs - Fix Windows drive letters misidentified as network paths - Improve network vs local file detection logic * fix: delete thumbnails when removing cliped videos
This commit is contained in:
@@ -985,6 +985,165 @@ pub async fn generate_thumbnail(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 解析FFmpeg时间字符串为秒数 (格式: "HH:MM:SS.mmm")
|
||||
pub fn parse_ffmpeg_time(time_str: &str) -> Result<f64, String> {
|
||||
let parts: Vec<&str> = time_str.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!("Invalid time format: {}", time_str));
|
||||
}
|
||||
|
||||
let hours: f64 = parts[0].parse().map_err(|_| format!("Invalid hours: {}", parts[0]))?;
|
||||
let minutes: f64 = parts[1].parse().map_err(|_| format!("Invalid minutes: {}", parts[1]))?;
|
||||
let seconds: f64 = parts[2].parse().map_err(|_| format!("Invalid seconds: {}", parts[2]))?;
|
||||
|
||||
Ok(hours * 3600.0 + minutes * 60.0 + seconds)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 执行FFmpeg转换的通用函数
|
||||
pub async fn execute_ffmpeg_conversion(
|
||||
mut cmd: tokio::process::Command,
|
||||
total_duration: f64,
|
||||
reporter: &ProgressReporter,
|
||||
mode_name: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Stdio;
|
||||
use tokio::io::BufReader;
|
||||
use async_ffmpeg_sidecar::event::FfmpegEvent;
|
||||
use async_ffmpeg_sidecar::log_parser::FfmpegLogParser;
|
||||
|
||||
let mut child = cmd
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动FFmpeg进程失败: {}", e))?;
|
||||
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut parser = FfmpegLogParser::new(reader);
|
||||
|
||||
let mut conversion_error = None;
|
||||
while let Ok(event) = parser.parse_next_event().await {
|
||||
match event {
|
||||
FfmpegEvent::Progress(p) => {
|
||||
if total_duration > 0.0 {
|
||||
// 解析时间字符串为浮点数 (格式: "HH:MM:SS.mmm")
|
||||
if let Ok(current_time) = parse_ffmpeg_time(&p.time) {
|
||||
let progress = (current_time / total_duration * 100.0).min(100.0);
|
||||
reporter.update(&format!("正在转换视频格式... {:.1}% ({})", progress, mode_name));
|
||||
} else {
|
||||
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
|
||||
}
|
||||
} else {
|
||||
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
|
||||
}
|
||||
}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(level, content) => {
|
||||
if matches!(level, async_ffmpeg_sidecar::event::LogLevel::Error) && content.contains("Error") {
|
||||
conversion_error = Some(content);
|
||||
}
|
||||
}
|
||||
FfmpegEvent::Error(e) => {
|
||||
conversion_error = Some(e);
|
||||
}
|
||||
_ => {} // 忽略其他事件类型
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| format!("等待FFmpeg进程失败: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
let error_msg = conversion_error.unwrap_or_else(|| format!("FFmpeg退出码: {}", status.code().unwrap_or(-1)));
|
||||
return Err(format!("视频格式转换失败 ({}): {}", mode_name, error_msg));
|
||||
}
|
||||
|
||||
reporter.update(&format!("视频格式转换完成 100% ({})", mode_name));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 尝试流复制转换(无损,速度快)
|
||||
pub async fn try_stream_copy_conversion(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 获取视频时长以计算进度
|
||||
let metadata = extract_video_metadata(source).await?;
|
||||
let total_duration = metadata.duration;
|
||||
|
||||
reporter.update("正在转换视频格式... 0% (无损模式)");
|
||||
|
||||
// 构建ffmpeg命令 - 流复制模式
|
||||
let mut cmd = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
|
||||
cmd.args([
|
||||
"-i", &source.to_string_lossy(),
|
||||
"-c:v", "copy", // 直接复制视频流,零损失
|
||||
"-c:a", "copy", // 直接复制音频流,零损失
|
||||
"-avoid_negative_ts", "make_zero", // 修复时间戳问题
|
||||
"-movflags", "+faststart", // 优化web播放
|
||||
"-progress", "pipe:2", // 输出进度到stderr
|
||||
"-y", // 覆盖输出文件
|
||||
&dest.to_string_lossy(),
|
||||
]);
|
||||
|
||||
execute_ffmpeg_conversion(cmd, total_duration, reporter, "无损转换").await
|
||||
}
|
||||
|
||||
// 高质量重编码转换(兼容性好,质量高)
|
||||
pub async fn try_high_quality_conversion(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 获取视频时长以计算进度
|
||||
let metadata = extract_video_metadata(source).await?;
|
||||
let total_duration = metadata.duration;
|
||||
|
||||
reporter.update("正在转换视频格式... 0% (高质量模式)");
|
||||
|
||||
// 构建ffmpeg命令 - 高质量重编码
|
||||
let mut cmd = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
|
||||
cmd.args([
|
||||
"-i", &source.to_string_lossy(),
|
||||
"-c:v", "libx264", // H.264编码器
|
||||
"-preset", "slow", // 慢速预设,更好的压缩效率
|
||||
"-crf", "18", // 高质量设置 (18-23范围,越小质量越高)
|
||||
"-c:a", "aac", // AAC音频编码器
|
||||
"-b:a", "192k", // 高音频码率
|
||||
"-avoid_negative_ts", "make_zero", // 修复时间戳问题
|
||||
"-movflags", "+faststart", // 优化web播放
|
||||
"-progress", "pipe:2", // 输出进度到stderr
|
||||
"-y", // 覆盖输出文件
|
||||
&dest.to_string_lossy(),
|
||||
]);
|
||||
|
||||
execute_ffmpeg_conversion(cmd, total_duration, reporter, "高质量转换").await
|
||||
}
|
||||
|
||||
// 带进度的视频格式转换函数(智能质量保持策略)
|
||||
pub async fn convert_video_format(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 先尝试stream copy(无损转换),如果失败则使用高质量重编码
|
||||
match try_stream_copy_conversion(source, dest, reporter).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(stream_copy_error) => {
|
||||
reporter.update("流复制失败,使用高质量重编码模式...");
|
||||
log::warn!("Stream copy failed: {}, falling back to re-encoding", stream_copy_error);
|
||||
try_high_quality_conversion(source, dest, reporter).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tests
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -11,6 +11,67 @@ use crate::subtitle_generator::item_to_srt;
|
||||
use chrono::{Local, Utc};
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::io::{Read, Write};
|
||||
use std::fs::File;
|
||||
|
||||
// 检测是否为网络协议路径(排除Windows盘符)
|
||||
fn is_network_protocol(path_str: &str) -> bool {
|
||||
// 常见的网络协议
|
||||
let network_protocols = [
|
||||
"ftp://", "sftp://", "ftps://",
|
||||
"http://", "https://",
|
||||
"smb://", "cifs://",
|
||||
"nfs://", "afp://",
|
||||
"ssh://", "scp://",
|
||||
];
|
||||
|
||||
// 检查是否以网络协议开头
|
||||
for protocol in &network_protocols {
|
||||
if path_str.to_lowercase().starts_with(protocol) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 排除Windows盘符格式 (如 C:/, D:/, E:/ 等)
|
||||
if cfg!(windows) {
|
||||
// 检查是否为Windows盘符格式:单字母 + : + /
|
||||
if path_str.len() >= 3 {
|
||||
let chars: Vec<char> = path_str.chars().collect();
|
||||
if chars.len() >= 3 &&
|
||||
chars[0].is_ascii_alphabetic() &&
|
||||
chars[1] == ':' &&
|
||||
(chars[2] == '/' || chars[2] == '\\') {
|
||||
return false; // 这是Windows盘符,不是网络路径
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// 判断是否需要转换视频格式
|
||||
fn should_convert_video_format(extension: &str) -> bool {
|
||||
// FLV格式在现代浏览器中播放兼容性差,需要转换为MP4
|
||||
matches!(extension.to_lowercase().as_str(), "flv")
|
||||
}
|
||||
|
||||
// 获取最佳缩略图时间点
|
||||
fn get_optimal_thumbnail_timestamp(duration: f64) -> f64 {
|
||||
// 根据视频长度选择最佳时间点
|
||||
if duration <= 10.0 {
|
||||
// 短视频(10秒以内):选择1/3位置,避免开头黑屏
|
||||
duration / 3.0
|
||||
} else if duration <= 60.0 {
|
||||
// 1分钟以内:选择第3秒
|
||||
3.0
|
||||
} else if duration <= 300.0 {
|
||||
// 5分钟以内:选择第5秒
|
||||
5.0
|
||||
} else {
|
||||
// 长视频:选择第10秒,确保跳过开头可能的黑屏/logo
|
||||
10.0
|
||||
}
|
||||
}
|
||||
|
||||
use crate::state::State;
|
||||
use crate::state_type;
|
||||
@@ -26,6 +87,207 @@ struct ImportedVideoMetadata {
|
||||
resolution: Option<String>,
|
||||
}
|
||||
|
||||
// 带进度的文件复制函数
|
||||
async fn copy_file_with_progress(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
let mut source_file = File::open(source).map_err(|e| format!("无法打开源文件: {}", e))?;
|
||||
let mut dest_file = File::create(dest).map_err(|e| format!("无法创建目标文件: {}", e))?;
|
||||
|
||||
let total_size = source_file.metadata().map_err(|e| format!("无法获取文件大小: {}", e))?.len();
|
||||
let mut copied = 0u64;
|
||||
|
||||
// 根据文件大小调整缓冲区大小
|
||||
let buffer_size = if total_size > 100 * 1024 * 1024 { // 100MB以上
|
||||
1024 * 1024 // 1MB buffer for large files
|
||||
} else if total_size > 10 * 1024 * 1024 { // 10MB-100MB
|
||||
256 * 1024 // 256KB buffer
|
||||
} else {
|
||||
64 * 1024 // 64KB buffer for small files
|
||||
};
|
||||
|
||||
let mut buffer = vec![0u8; buffer_size];
|
||||
|
||||
let mut last_reported_percent = 0;
|
||||
|
||||
loop {
|
||||
let bytes_read = source_file.read(&mut buffer).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
dest_file.write_all(&buffer[..bytes_read]).map_err(|e| format!("写入文件失败: {}", e))?;
|
||||
copied += bytes_read as u64;
|
||||
|
||||
// 计算进度百分比,只在变化时更新
|
||||
let percent = if total_size > 0 {
|
||||
((copied as f64 / total_size as f64) * 100.0) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// 根据文件大小调整报告频率
|
||||
let report_threshold = if total_size > 100 * 1024 * 1024 { // 100MB以上
|
||||
1 // 每1%报告一次
|
||||
} else if total_size > 10 * 1024 * 1024 { // 10MB-100MB
|
||||
2 // 每2%报告一次
|
||||
} else {
|
||||
5 // 小文件每5%报告一次
|
||||
};
|
||||
|
||||
if percent != last_reported_percent && (percent % report_threshold == 0 || percent == 100) {
|
||||
reporter.update(&format!("正在复制视频文件... {}%", percent));
|
||||
last_reported_percent = percent;
|
||||
}
|
||||
}
|
||||
|
||||
dest_file.flush().map_err(|e| format!("刷新文件缓冲区失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 智能边拷贝边转换函数(针对网络文件优化)
|
||||
async fn copy_and_convert_with_progress(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
need_conversion: bool,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
if !need_conversion {
|
||||
// 非转换文件直接使用原有拷贝逻辑
|
||||
return copy_file_with_progress(source, dest, reporter).await;
|
||||
}
|
||||
|
||||
// 检查源文件是否在网络位置(启发式判断)
|
||||
let source_str = source.to_string_lossy();
|
||||
let is_network_source = source_str.starts_with("\\\\") || // UNC path (Windows网络共享)
|
||||
is_network_protocol(&source_str); // 网络协议但排除Windows盘符
|
||||
|
||||
if is_network_source {
|
||||
// 网络文件:先复制到本地临时位置,再转换
|
||||
reporter.update("检测到网络文件,使用先复制后转换策略...");
|
||||
copy_then_convert_strategy(source, dest, reporter).await
|
||||
} else {
|
||||
// 本地文件:直接转换(更高效)
|
||||
reporter.update("检测到本地文件,使用直接转换策略...");
|
||||
ffmpeg::convert_video_format(source, dest, reporter).await
|
||||
}
|
||||
}
|
||||
|
||||
// 网络文件处理策略:先复制到本地临时位置,再转换
|
||||
async fn copy_then_convert_strategy(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 创建临时文件路径
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_filename = format!("temp_video_{}.{}",
|
||||
chrono::Utc::now().timestamp(),
|
||||
source.extension().and_then(|e| e.to_str()).unwrap_or("tmp"));
|
||||
let temp_path = temp_dir.join(&temp_filename);
|
||||
|
||||
// 确保临时目录存在
|
||||
if let Some(parent) = temp_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建临时目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 第一步:将网络文件复制到本地临时位置(使用优化的缓冲区)
|
||||
reporter.update("第1步:从网络复制文件到本地临时位置...");
|
||||
copy_file_with_network_optimization(source, &temp_path, reporter).await?;
|
||||
|
||||
// 第二步:从本地临时文件转换到目标位置
|
||||
reporter.update("第2步:从临时文件转换到目标格式...");
|
||||
let convert_result = ffmpeg::convert_video_format(&temp_path, dest, reporter).await;
|
||||
|
||||
// 清理临时文件
|
||||
if temp_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&temp_path) {
|
||||
log::warn!("删除临时文件失败: {} - {}", temp_path.display(), e);
|
||||
} else {
|
||||
log::info!("已清理临时文件: {}", temp_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
convert_result
|
||||
}
|
||||
|
||||
// 针对网络文件优化的复制函数
|
||||
async fn copy_file_with_network_optimization(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
let mut source_file = File::open(source).map_err(|e| format!("无法打开网络源文件: {}", e))?;
|
||||
let mut dest_file = File::create(dest).map_err(|e| format!("无法创建本地临时文件: {}", e))?;
|
||||
|
||||
let total_size = source_file.metadata().map_err(|e| format!("无法获取文件大小: {}", e))?.len();
|
||||
let mut copied = 0u64;
|
||||
|
||||
// 网络文件使用更大的缓冲区以减少网络请求次数
|
||||
let buffer_size = if total_size > 1024 * 1024 * 1024 { // >1GB
|
||||
8 * 1024 * 1024 // 8MB buffer for very large files
|
||||
} else if total_size > 100 * 1024 * 1024 { // >100MB
|
||||
4 * 1024 * 1024 // 4MB buffer for large files
|
||||
} else {
|
||||
2 * 1024 * 1024 // 2MB buffer for network files
|
||||
};
|
||||
|
||||
let mut buffer = vec![0u8; buffer_size];
|
||||
let mut last_reported_percent = 0;
|
||||
let mut consecutive_errors = 0;
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
loop {
|
||||
match source_file.read(&mut buffer) {
|
||||
Ok(bytes_read) => {
|
||||
if bytes_read == 0 {
|
||||
break; // 文件读取完成
|
||||
}
|
||||
|
||||
// 重置错误计数
|
||||
consecutive_errors = 0;
|
||||
|
||||
dest_file.write_all(&buffer[..bytes_read]).map_err(|e| format!("写入临时文件失败: {}", e))?;
|
||||
copied += bytes_read as u64;
|
||||
|
||||
// 计算并报告进度
|
||||
let percent = if total_size > 0 {
|
||||
((copied as f64 / total_size as f64) * 100.0) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// 网络文件更频繁地报告进度
|
||||
if percent != last_reported_percent {
|
||||
reporter.update(&format!("正在从网络复制文件... {}% ({:.1}MB/{:.1}MB)",
|
||||
percent,
|
||||
copied as f64 / (1024.0 * 1024.0),
|
||||
total_size as f64 / (1024.0 * 1024.0)));
|
||||
last_reported_percent = percent;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
log::warn!("网络读取错误 (尝试 {}/{}): {}", consecutive_errors, MAX_RETRIES, e);
|
||||
|
||||
if consecutive_errors >= MAX_RETRIES {
|
||||
return Err(format!("网络文件读取失败,已重试{}次: {}", MAX_RETRIES, e));
|
||||
}
|
||||
|
||||
// 等待一小段时间后重试
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
reporter.update(&format!("网络连接中断,正在重试... ({}/{})", consecutive_errors, MAX_RETRIES));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dest_file.flush().map_err(|e| format!("刷新临时文件缓冲区失败: {}", e))?;
|
||||
reporter.update("网络文件复制完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct ClipMetadata {
|
||||
parent_video_id: i64,
|
||||
@@ -356,27 +618,56 @@ pub async fn get_video_cover(state: state_type!(), id: i64) -> Result<String, St
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn delete_video(state: state_type!(), id: i64) -> Result<(), String> {
|
||||
// get video info from dbus
|
||||
// get video info from db
|
||||
let video = state.db.get_video(id).await?;
|
||||
let config = state.config.read().await;
|
||||
|
||||
// delete video from db
|
||||
state.db.delete_video(id).await?;
|
||||
|
||||
// delete video files
|
||||
let filepath = Path::new(state.config.read().await.output.as_str()).join(&video.file);
|
||||
let filepath = Path::new(&config.output).join(&video.file);
|
||||
let file = Path::new(&filepath);
|
||||
let _ = std::fs::remove_file(file);
|
||||
if let Err(e) = std::fs::remove_file(file) {
|
||||
log::warn!("删除视频文件失败: {} - {}", file.display(), e);
|
||||
} else {
|
||||
log::info!("已删除视频文件: {}", file.display());
|
||||
}
|
||||
|
||||
// delete srt file
|
||||
// delete subtitle files
|
||||
let srt_path = file.with_extension("srt");
|
||||
let _ = std::fs::remove_file(srt_path);
|
||||
// delete wav file
|
||||
let wav_path = file.with_extension("wav");
|
||||
let _ = std::fs::remove_file(wav_path);
|
||||
// delete mp3 file
|
||||
let mp3_path = file.with_extension("mp3");
|
||||
let _ = std::fs::remove_file(mp3_path);
|
||||
|
||||
// delete thumbnail file based on video type
|
||||
delete_video_thumbnail(&config.output, &video).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 根据视频类型删除对应的缩略图文件
|
||||
async fn delete_video_thumbnail(output_dir: &str, video: &VideoRow) {
|
||||
if video.cover.is_empty() {
|
||||
return; // 没有缩略图,无需删除
|
||||
}
|
||||
|
||||
// 构建缩略图完整路径
|
||||
let thumbnail_path = Path::new(output_dir).join(&video.cover);
|
||||
|
||||
if thumbnail_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&thumbnail_path) {
|
||||
log::warn!("删除缩略图失败: {} - {}", thumbnail_path.display(), e);
|
||||
} else {
|
||||
log::info!("已删除缩略图: {}", thumbnail_path.display());
|
||||
}
|
||||
} else {
|
||||
log::debug!("缩略图文件不存在: {}", thumbnail_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn get_video_typelist(
|
||||
state: state_type!(),
|
||||
@@ -636,12 +927,20 @@ pub async fn generic_ffmpeg_command(
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn import_external_video(
|
||||
state: state_type!(),
|
||||
event_id: String,
|
||||
file_path: String,
|
||||
title: String,
|
||||
_original_name: String,
|
||||
size: i64,
|
||||
room_id: u64,
|
||||
) -> Result<VideoRow, String> {
|
||||
// 设置进度事件发射器
|
||||
#[cfg(feature = "gui")]
|
||||
let emitter = EventEmitter::new(state.app_handle.clone());
|
||||
#[cfg(feature = "headless")]
|
||||
let emitter = EventEmitter::new(state.progress_manager.get_event_sender());
|
||||
let reporter = ProgressReporter::new(&emitter, &event_id).await?;
|
||||
|
||||
let source_path = Path::new(&file_path);
|
||||
|
||||
// 验证文件存在
|
||||
@@ -649,7 +948,8 @@ pub async fn import_external_video(
|
||||
return Err("文件不存在".to_string());
|
||||
}
|
||||
|
||||
// 获取视频元数据
|
||||
// 步骤1: 获取视频元数据
|
||||
reporter.update("正在提取视频元数据...");
|
||||
let metadata = ffmpeg::extract_video_metadata(source_path).await?;
|
||||
|
||||
// 生成目标文件名
|
||||
@@ -663,25 +963,45 @@ pub async fn import_external_video(
|
||||
let extension = source_path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("mp4");
|
||||
let target_filename = format!("imported_{}_{}.{}", timestamp,
|
||||
let mut target_filename = format!("imported_{}_{}.{}", timestamp,
|
||||
sanitize_filename(&title), extension);
|
||||
let target_path = output_dir.join(&target_filename);
|
||||
|
||||
// 复制文件到目标位置
|
||||
std::fs::copy(source_path, &target_path).map_err(|e| e.to_string())?;
|
||||
// 步骤2: 智能复制或转换文件到目标位置
|
||||
let need_conversion = should_convert_video_format(&extension);
|
||||
let final_target_path = if need_conversion {
|
||||
// FLV文件需要转换为MP4
|
||||
let mp4_filename = format!("imported_{}_{}.mp4", timestamp,
|
||||
sanitize_filename(&title));
|
||||
let mp4_target_path = output_dir.join(&mp4_filename);
|
||||
|
||||
reporter.update("准备转换视频格式 (FLV → MP4)...");
|
||||
// 使用智能转换函数,自动检测网络优化
|
||||
copy_and_convert_with_progress(source_path, &mp4_target_path, true, &reporter).await?;
|
||||
|
||||
// 更新最终文件名和路径
|
||||
target_filename = mp4_filename;
|
||||
mp4_target_path
|
||||
} else {
|
||||
// 其他格式使用智能拷贝
|
||||
copy_and_convert_with_progress(source_path, &target_path, false, &reporter).await?;
|
||||
target_path
|
||||
};
|
||||
|
||||
// 生成缩略图
|
||||
// 步骤3: 生成缩略图
|
||||
reporter.update("正在生成视频缩略图...");
|
||||
let thumbnail_dir = Path::new(&config.output).join("thumbnails").join("imported");
|
||||
if !thumbnail_dir.exists() {
|
||||
std::fs::create_dir_all(&thumbnail_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let thumbnail_filename = format!("{}.jpg",
|
||||
target_path.file_stem().unwrap().to_str().unwrap());
|
||||
final_target_path.file_stem().unwrap().to_str().unwrap());
|
||||
let thumbnail_path = thumbnail_dir.join(&thumbnail_filename);
|
||||
|
||||
// 生成缩略图,如果失败则使用默认封面
|
||||
let cover_path = match ffmpeg::generate_thumbnail(&target_path, &thumbnail_path, metadata.duration / 2.0).await {
|
||||
// 生成缩略图,使用智能时间点选择
|
||||
let thumbnail_timestamp = get_optimal_thumbnail_timestamp(metadata.duration);
|
||||
let cover_path = match ffmpeg::generate_thumbnail(&final_target_path, &thumbnail_path, thumbnail_timestamp).await {
|
||||
Ok(_) => format!("thumbnails/imported/{}", thumbnail_filename),
|
||||
Err(e) => {
|
||||
log::warn!("生成缩略图失败: {}", e);
|
||||
@@ -689,6 +1009,9 @@ pub async fn import_external_video(
|
||||
}
|
||||
};
|
||||
|
||||
// 步骤4: 保存到数据库
|
||||
reporter.update("正在保存视频信息...");
|
||||
|
||||
// 构建导入视频的元数据
|
||||
let import_metadata = ImportedVideoMetadata {
|
||||
original_path: file_path.clone(),
|
||||
@@ -707,7 +1030,7 @@ pub async fn import_external_video(
|
||||
title,
|
||||
file: format!("imported/{}", target_filename), // 包含完整相对路径
|
||||
length: metadata.duration as i64,
|
||||
size: target_path.metadata().map_err(|e| e.to_string())?.len() as i64,
|
||||
size: final_target_path.metadata().map_err(|e| e.to_string())?.len() as i64,
|
||||
status: 1, // 导入完成
|
||||
cover: cover_path,
|
||||
desc: serde_json::to_string(&import_metadata).unwrap_or_default(),
|
||||
@@ -719,12 +1042,16 @@ pub async fn import_external_video(
|
||||
|
||||
let result = state.db.add_video(&video).await?;
|
||||
|
||||
// 完成进度通知
|
||||
reporter.finish(true, "视频导入完成").await;
|
||||
|
||||
// 发送通知消息
|
||||
state.db.new_message(
|
||||
"视频导入完成",
|
||||
&format!("成功导入视频:{}", result.title),
|
||||
).await?;
|
||||
|
||||
log::info!("导入视频成功: {} -> {}", file_path, result.file);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -847,8 +1174,10 @@ async fn clip_video_inner(
|
||||
};
|
||||
let thumbnail_path = thumbnail_dir.join(&clip_thumbnail_filename);
|
||||
|
||||
// 生成缩略图,如果失败则使用默认封面
|
||||
let clip_cover_path = match ffmpeg::generate_thumbnail(&output_path, &thumbnail_path, (end_time - start_time) / 2.0).await {
|
||||
// 生成缩略图,选择切片开头的合理位置
|
||||
let clip_duration = end_time - start_time;
|
||||
let clip_thumbnail_timestamp = get_optimal_thumbnail_timestamp(clip_duration);
|
||||
let clip_cover_path = match ffmpeg::generate_thumbnail(&output_path, &thumbnail_path, clip_thumbnail_timestamp).await {
|
||||
Ok(_) => format!("thumbnails/clips/{}", clip_thumbnail_filename),
|
||||
Err(e) => {
|
||||
log::warn!("生成切片缩略图失败: {}", e);
|
||||
|
||||
@@ -930,7 +930,7 @@ async fn handler_import_external_video(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<ImportExternalVideoRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
import_external_video(state.0, param.file_path.clone(), param.title, param.original_name, param.size, param.room_id).await?;
|
||||
import_external_video(state.0, param.event_id.clone(), param.file_path.clone(), param.title, param.original_name, param.size, param.room_id).await?;
|
||||
Ok(Json(ApiResponse::success(param.event_id)))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { invoke, TAURI_ENV, ENDPOINT } from "../lib/invoker";
|
||||
import { invoke, TAURI_ENV, ENDPOINT, listen } from "../lib/invoker";
|
||||
import { Upload, X, CheckCircle } from "lucide-svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { ProgressUpdate, ProgressFinished } from "./interface";
|
||||
|
||||
export let showDialog = false;
|
||||
export let roomId: number | null = null;
|
||||
@@ -18,6 +19,60 @@
|
||||
let uploadProgress = 0;
|
||||
let dragOver = false;
|
||||
let fileInput: HTMLInputElement;
|
||||
let importProgress = "";
|
||||
let currentImportEventId: string | null = null;
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(sizeInBytes: number): string {
|
||||
if (sizeInBytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const k = 1024;
|
||||
let unitIndex = 0;
|
||||
let size = sizeInBytes;
|
||||
|
||||
// 找到合适的单位
|
||||
while (size >= k && unitIndex < units.length - 1) {
|
||||
size /= k;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// 对于GB以上,显示2位小数;MB显示2位小数;KB及以下显示1位小数
|
||||
const decimals = unitIndex >= 3 ? 2 : (unitIndex >= 2 ? 2 : 1);
|
||||
|
||||
return size.toFixed(decimals) + " " + units[unitIndex];
|
||||
}
|
||||
|
||||
// 进度监听器
|
||||
const progressUpdateListener = listen<ProgressUpdate>('progress-update', (e) => {
|
||||
if (e.payload.id === currentImportEventId) {
|
||||
importProgress = e.payload.content;
|
||||
}
|
||||
});
|
||||
|
||||
const progressFinishedListener = listen<ProgressFinished>('progress-finished', (e) => {
|
||||
if (e.payload.id === currentImportEventId) {
|
||||
if (e.payload.success) {
|
||||
// 导入成功,关闭对话框并刷新列表
|
||||
showDialog = false;
|
||||
selectedFilePath = null;
|
||||
selectedFileName = "";
|
||||
selectedFileSize = 0;
|
||||
videoTitle = "";
|
||||
dispatch("imported");
|
||||
} else {
|
||||
alert("导入失败: " + e.payload.message);
|
||||
}
|
||||
importing = false;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
progressUpdateListener?.then(fn => fn());
|
||||
progressFinishedListener?.then(fn => fn());
|
||||
});
|
||||
|
||||
async function handleFileSelect() {
|
||||
if (TAURI_ENV) {
|
||||
@@ -151,8 +206,12 @@
|
||||
if (!selectedFilePath) return;
|
||||
|
||||
importing = true;
|
||||
importProgress = "准备导入...";
|
||||
|
||||
try {
|
||||
const eventId = "import_" + Date.now();
|
||||
currentImportEventId = eventId;
|
||||
|
||||
await invoke("import_external_video", {
|
||||
eventId: eventId,
|
||||
filePath: selectedFilePath,
|
||||
@@ -162,20 +221,13 @@
|
||||
roomId: roomId || 0
|
||||
});
|
||||
|
||||
// 导入成功,关闭对话框并刷新列表
|
||||
showDialog = false;
|
||||
selectedFilePath = null;
|
||||
selectedFileName = "";
|
||||
selectedFileSize = 0;
|
||||
videoTitle = "";
|
||||
dispatch("imported");
|
||||
// 注意:成功处理移到了progressFinishedListener中
|
||||
} catch (error) {
|
||||
console.error("导入失败:", error);
|
||||
alert("导入失败: " + error);
|
||||
} finally {
|
||||
importing = false;
|
||||
uploading = false;
|
||||
uploadProgress = 0;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +239,9 @@
|
||||
videoTitle = "";
|
||||
uploading = false;
|
||||
uploadProgress = 0;
|
||||
importing = false;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
@@ -251,7 +306,7 @@
|
||||
<CheckCircle class="w-12 h-12 text-green-500 mx-auto" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-900 dark:text-white font-medium">{selectedFileName}</p>
|
||||
<p class="text-xs text-gray-500">大小: {(selectedFileSize / 1024 / 1024).toFixed(2)} MB</p>
|
||||
<p class="text-xs text-gray-500">大小: {formatFileSize(selectedFileSize)}</p>
|
||||
<p class="text-xs text-gray-400 break-all" title={selectedFilePath}>{selectedFilePath}</p>
|
||||
<button
|
||||
on:click={() => {
|
||||
@@ -327,9 +382,15 @@
|
||||
<button
|
||||
on:click={startImport}
|
||||
disabled={!selectedFilePath || importing || !videoTitle.trim() || uploading}
|
||||
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
{importing ? "导入中..." : "开始导入"}
|
||||
{#if importing}
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{importing ? (importProgress || "导入中...") : "开始导入"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,6 +238,8 @@
|
||||
}
|
||||
selectedVideos.clear();
|
||||
await loadVideos();
|
||||
showDeleteConfirm = false;
|
||||
videoToDelete = null;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete selected videos:", error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user