Compare commits

...

7 Commits

Author SHA1 Message Date
Xinrea
8ab4b7d693 bump version to 2.11.3 2025-08-14 00:13:09 +08:00
Xinrea
ce2f097d32 fix: adjust body size limit for video importing 2025-08-14 00:12:02 +08:00
Xinrea
f7575cd327 bump version to 2.11.2 2025-08-10 21:35:21 +08:00
Xinrea
8634c6a211 fix: always start a new recording when update entries errror 2025-08-10 21:34:41 +08:00
Xinrea
b070013efc bump version to 2.11.1 2025-08-10 20:56:48 +08:00
Eeeeep4
d2d9112f6c 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
2025-08-10 17:00:31 +08:00
Xinrea
9fea18f2de ci/cd: remove unused rust-cache 2025-08-10 10:17:09 +08:00
11 changed files with 811 additions and 187 deletions

View File

@@ -59,12 +59,6 @@ jobs:
if: matrix.platform == 'windows-latest' && matrix.features == 'cuda'
uses: Jimver/cuda-toolkit@v0.2.24
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
shared-key: ${{ matrix.platform }}
- name: Setup ffmpeg
if: matrix.platform == 'windows-latest'
working-directory: ./

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
/target/
# Editor directories and files
.vscode/*

View File

@@ -1,7 +1,7 @@
{
"name": "bili-shadowreplay",
"private": true,
"version": "2.11.0",
"version": "2.11.3",
"type": "module",
"scripts": {
"dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@@ -537,7 +537,7 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bili-shadowreplay"
version = "2.11.0"
version = "2.11.3"
dependencies = [
"async-ffmpeg-sidecar",
"async-std",

View File

@@ -4,7 +4,7 @@ resolver = "2"
[package]
name = "bili-shadowreplay"
version = "2.11.0"
version = "2.11.3"
description = "BiliBili ShadowReplay"
authors = ["Xinrea"]
license = ""

View File

@@ -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 {

View File

@@ -10,8 +10,67 @@ use crate::recorder_manager::ClipRangeParams;
use crate::subtitle_generator::item_to_srt;
use chrono::{Local, Utc};
use serde_json::json;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
// 检测是否为网络协议路径排除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 +85,241 @@ 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 +650,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 file = Path::new(&filepath);
let _ = std::fs::remove_file(file);
// delete srt file
// delete video files
let filepath = Path::new(&config.output).join(&video.file);
let file = Path::new(&filepath);
if let Err(e) = std::fs::remove_file(file) {
log::warn!("删除视频文件失败: {} - {}", file.display(), e);
} else {
log::info!("已删除视频文件: {}", file.display());
}
// 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!(),
@@ -576,13 +899,12 @@ async fn encode_video_subtitle_inner(
let video = state.db.get_video(id).await?;
let config = state.config.read().await;
let filepath = Path::new(&config.output).join(&video.file);
// 查找字幕文件:对于切片视频,需要查找原视频的字幕文件
let subtitle_path = find_subtitle_file(state, &video, &filepath).await?;
let output_file =
ffmpeg::encode_video_subtitle(reporter, &filepath, &subtitle_path, srt_style)
.await?;
ffmpeg::encode_video_subtitle(reporter, &filepath, &subtitle_path, srt_style).await?;
// 构建正确的相对路径:如果原文件在子目录中,保持相同的目录结构
let relative_output_file = if let Some((parent_dir, _)) = video.file.rsplit_once('/') {
@@ -636,59 +958,102 @@ 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);
// 验证文件存在
if !source_path.exists() {
return Err("文件不存在".to_string());
}
// 获取视频元数据
// 步骤1: 获取视频元数据
reporter.update("正在提取视频元数据...");
let metadata = ffmpeg::extract_video_metadata(source_path).await?;
// 生成目标文件名
let config = state.config.read().await;
let output_dir = Path::new(&config.output).join("imported");
if !output_dir.exists() {
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
}
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
let extension = source_path.extension()
let extension = source_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("mp4");
let target_filename = format!("imported_{}_{}.{}", timestamp,
sanitize_filename(&title), extension);
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())?;
// 生成缩略图
let thumbnail_dir = Path::new(&config.output).join("thumbnails").join("imported");
// 步骤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());
let thumbnail_filename = format!(
"{}.jpg",
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 {
Ok(_) => format!("thumbnails/imported/{}", thumbnail_filename),
Err(e) => {
log::warn!("生成缩略图失败: {}", e);
"".to_string() // 使用空字符串,前端会显示默认图标
}
};
// 生成缩略图,使用智能时间点选择
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);
"".to_string() // 使用空字符串,前端会显示默认图标
}
};
// 步骤4: 保存到数据库
reporter.update("正在保存视频信息...");
// 构建导入视频的元数据
let import_metadata = ImportedVideoMetadata {
original_path: file_path.clone(),
@@ -698,16 +1063,19 @@ pub async fn import_external_video(
duration: metadata.duration,
resolution: Some(format!("{}x{}", metadata.width, metadata.height)),
};
// 添加到数据库
let video = VideoRow {
id: 0,
room_id, // 使用传入的 room_id
room_id, // 使用传入的 room_id
platform: "imported".to_string(), // 使用 platform 字段标识
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(),
@@ -716,15 +1084,19 @@ pub async fn import_external_video(
area: 0,
created_at: Utc::now().to_rfc3339(),
};
let result = state.db.add_video(&video).await?;
// 完成进度通知
reporter.finish(true, "视频导入完成").await;
// 发送通知消息
state.db.new_message(
"视频导入完成",
&format!("成功导入视频:{}", result.title),
).await?;
state
.db
.new_message("视频导入完成", &format!("成功导入视频:{}", result.title))
.await?;
log::info!("导入视频成功: {} -> {}", file_path, result.file);
Ok(result)
}
@@ -740,18 +1112,18 @@ pub async fn clip_video(
) -> Result<VideoRow, String> {
// 获取父视频信息
let parent_video = state.db.get_video(parent_video_id).await?;
// 检查是否为正在录制的视频
if parent_video.status == -1 {
return Err("正在录制的视频无法进行切片".to_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 task = TaskRow {
id: event_id.clone(),
@@ -763,27 +1135,41 @@ pub async fn clip_video(
"start_time": start_time,
"end_time": end_time,
"clip_title": clip_title,
}).to_string(),
})
.to_string(),
created_at: Utc::now().to_rfc3339(),
};
state.db.add_task(&task).await?;
match clip_video_inner(&state, &reporter, parent_video, start_time, end_time, clip_title).await {
match clip_video_inner(
&state,
&reporter,
parent_video,
start_time,
end_time,
clip_title,
)
.await
{
Ok(video) => {
reporter.finish(true, "切片完成").await;
state.db.update_task(&event_id, "success", "切片完成", None).await?;
state
.db
.update_task(&event_id, "success", "切片完成", None)
.await?;
Ok(video)
}
Err(e) => {
reporter.finish(false, &format!("切片失败: {}", e)).await;
state.db.update_task(&event_id, "failed", &format!("切片失败: {}", e), None).await?;
state
.db
.update_task(&event_id, "failed", &format!("切片失败: {}", e), None)
.await?;
Err(e)
}
}
}
async fn clip_video_inner(
state: &state_type!(),
reporter: &ProgressReporter,
@@ -793,36 +1179,36 @@ async fn clip_video_inner(
clip_title: String,
) -> Result<VideoRow, String> {
let config = state.config.read().await;
// 构建输入文件路径
let input_path = Path::new(&config.output)
.join(&parent_video.file);
let input_path = Path::new(&config.output).join(&parent_video.file);
if !input_path.exists() {
return Err("原视频文件不存在".to_string());
}
// 统一的输出目录clips
let output_dir = Path::new(&config.output).join("clips");
if !output_dir.exists() {
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
}
let timestamp = Local::now().format("%Y%m%d%H%M").to_string();
let extension = input_path.extension()
let extension = input_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("mp4");
// 获取原文件名(不含扩展名)
let original_filename = input_path.file_stem()
let original_filename = input_path
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("video");
// 生成新的文件名格式:原文件名[clip][时间戳].扩展名
let output_filename = format!("{}[clip][{}].{}",
original_filename, timestamp, extension);
let output_filename = format!("{}[clip][{}].{}", original_filename, timestamp, extension);
let output_path = output_dir.join(&output_filename);
// 执行切片
reporter.update("开始切片处理");
ffmpeg::clip_from_video_file(
@@ -831,31 +1217,38 @@ async fn clip_video_inner(
&output_path,
start_time,
end_time - start_time,
).await?;
)
.await?;
// 生成缩略图
let thumbnail_dir = Path::new(&config.output).join("thumbnails").join("clips");
if !thumbnail_dir.exists() {
std::fs::create_dir_all(&thumbnail_dir).map_err(|e| e.to_string())?;
}
// 生成缩略图文件名,确保路径安全
let clip_thumbnail_filename = if let Some(stem) = output_path.file_stem().and_then(|s| s.to_str()) {
format!("{}.jpg", stem)
} else {
format!("thumbnail_{}.jpg", timestamp)
};
let clip_thumbnail_filename =
if let Some(stem) = output_path.file_stem().and_then(|s| s.to_str()) {
format!("{}.jpg", stem)
} else {
format!("thumbnail_{}.jpg", timestamp)
};
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 {
Ok(_) => format!("thumbnails/clips/{}", clip_thumbnail_filename),
Err(e) => {
log::warn!("生成切片缩略图失败: {}", e);
"".to_string() // 使用空字符串,前端会显示默认图标
}
};
// 生成缩略图,选择切片开头的合理位置
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);
"".to_string() // 使用空字符串,前端会显示默认图标
}
};
// 构建统一的切片元数据
let clip_metadata = ClipMetadata {
parent_video_id: parent_video.id,
@@ -865,10 +1258,10 @@ async fn clip_video_inner(
original_platform: parent_video.platform.clone(),
original_room_id: parent_video.room_id,
};
// 获取输出文件信息
let file_metadata = output_path.metadata().map_err(|e| e.to_string())?;
// 添加到数据库 - 统一使用 "clip" 平台类型
let clip_video = VideoRow {
id: 0,
@@ -886,15 +1279,15 @@ async fn clip_video_inner(
area: parent_video.area,
created_at: Local::now().to_rfc3339(),
};
let result = state.db.add_video(&clip_video).await?;
// 发送通知消息
state.db.new_message(
"视频切片完成",
&format!("生成切片:{}", result.title),
).await?;
state
.db
.new_message("视频切片完成", &format!("生成切片:{}", result.title))
.await?;
Ok(result)
}
@@ -918,14 +1311,14 @@ async fn find_subtitle_file(
if local_subtitle.exists() {
return Ok(local_subtitle);
}
// 如果是切片视频,尝试查找原视频的字幕文件
if video.platform == "clip" && !video.desc.is_empty() {
// 解析切片元数据获取父视频ID
if let Ok(metadata) = serde_json::from_str::<ClipMetadata>(&video.desc) {
if let Ok(parent_video) = state.db.get_video(metadata.parent_video_id).await {
let parent_filepath = Path::new(&state.config.read().await.output)
.join(&parent_video.file);
let parent_filepath =
Path::new(&state.config.read().await.output).join(&parent_video.file);
let parent_subtitle = parent_filepath.with_extension("srt");
if parent_subtitle.exists() {
return Ok(parent_subtitle);
@@ -933,20 +1326,18 @@ async fn find_subtitle_file(
}
}
}
// 如果都找不到返回默认路径即使文件不存在让ffmpeg处理错误
Ok(local_subtitle)
}
// 获取文件大小
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
let path = Path::new(&file_path);
match std::fs::metadata(path) {
Ok(metadata) => Ok(metadata.len()),
Err(e) => Err(format!("无法获取文件信息: {}", e))
Err(e) => Err(format!("无法获取文件信息: {}", e)),
}
}

View File

@@ -30,10 +30,10 @@ use crate::{
task::{delete_task, get_tasks},
utils::{console_log, get_disk_info, list_folder, DiskInfo},
video::{
cancel, clip_range, clip_video, delete_video, encode_video_subtitle, generate_video_subtitle,
generic_ffmpeg_command, get_all_videos, get_file_size, get_video, get_video_cover, get_video_subtitle,
get_video_typelist, get_videos, import_external_video, update_video_cover, update_video_subtitle,
upload_procedure,
cancel, clip_range, clip_video, delete_video, encode_video_subtitle,
generate_video_subtitle, generic_ffmpeg_command, get_all_videos, get_file_size,
get_video, get_video_cover, get_video_subtitle, get_video_typelist, get_videos,
import_external_video, update_video_cover, update_video_subtitle, upload_procedure,
},
AccountInfo,
},
@@ -52,7 +52,7 @@ use crate::{
};
use axum::{extract::Query, response::sse};
use axum::{
extract::{DefaultBodyLimit, Json, Path, Multipart},
extract::{DefaultBodyLimit, Json, Multipart, Path},
http::StatusCode,
response::{IntoResponse, Sse},
routing::{get, post},
@@ -806,14 +806,14 @@ async fn handler_image_base64(
Ok(cover) => cover,
Err(_) => return Err(StatusCode::NOT_FOUND),
};
// 检查是否是base64数据URL
if cover.starts_with("data:image/") {
if let Some(base64_start) = cover.find("base64,") {
let base64_data = &cover[base64_start + 7..]; // 跳过 "base64,"
let base64_data = &cover[base64_start + 7..]; // 跳过 "base64,"
// 解码base64数据
use base64::{Engine as _, engine::general_purpose};
use base64::{engine::general_purpose, Engine as _};
if let Ok(image_data) = general_purpose::STANDARD.decode(base64_data) {
// 确定MIME类型
let content_type = if cover.contains("data:image/png") {
@@ -827,8 +827,9 @@ async fn handler_image_base64(
} else {
"image/png" // 默认
};
let mut response = axum::response::Response::new(axum::body::Body::from(image_data));
let mut response =
axum::response::Response::new(axum::body::Body::from(image_data));
let headers = response.headers_mut();
headers.insert(
axum::http::header::CONTENT_TYPE,
@@ -838,12 +839,12 @@ async fn handler_image_base64(
axum::http::header::CACHE_CONTROL,
"public, max-age=3600".parse().unwrap(),
);
return Ok(response);
}
}
}
Err(StatusCode::NOT_FOUND)
}
@@ -930,7 +931,16 @@ 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)))
}
@@ -955,7 +965,8 @@ async fn handler_clip_video(
param.start_time,
param.end_time,
param.clip_title,
).await?;
)
.await?;
Ok(Json(ApiResponse::success(param.event_id)))
}
@@ -1145,7 +1156,7 @@ async fn handler_upload_file(
while let Some(field) = multipart.next_field().await.map_err(|e| e.to_string())? {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"file" => {
file_name = field.file_name().unwrap_or("unknown").to_string();
@@ -1180,23 +1191,25 @@ async fn handler_upload_file(
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("upload");
let unique_filename = if extension.is_empty() {
format!("{}_{}", base_name, timestamp)
} else {
format!("{}_{}.{}", base_name, timestamp, extension)
};
let file_path = upload_dir.join(&unique_filename);
// 写入文件
tokio::fs::write(&file_path, &file_data).await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &file_data)
.await
.map_err(|e| e.to_string())?;
let file_size = file_data.len() as u64;
let file_path_str = file_path.to_string_lossy().to_string();
log::info!("File uploaded: {} ({} bytes)", file_path_str, file_size);
Ok(Json(ApiResponse::success(FileUploadResponse {
file_path: file_path_str,
file_name: unique_filename,
@@ -1353,9 +1366,19 @@ async fn handler_output(
);
// Only set Content-Disposition for non-media files to allow inline playback/display
if !matches!(content_type,
"video/mp4" | "video/webm" | "video/x-m4v" | "video/x-matroska" | "video/x-msvideo" |
"image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/svg+xml") {
if !matches!(
content_type,
"video/mp4"
| "video/webm"
| "video/x-m4v"
| "video/x-matroska"
| "video/x-msvideo"
| "image/jpeg"
| "image/png"
| "image/gif"
| "image/webp"
| "image/svg+xml"
) {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
@@ -1447,6 +1470,8 @@ async fn handler_sse(
)
}
const MAX_BODY_SIZE: usize = 10 * 1024 * 1024 * 1024;
pub async fn start_api_server(state: State) {
let cors = CorsLayer::new()
.allow_origin(Any)
@@ -1595,7 +1620,7 @@ pub async fn start_api_server(state: State) {
let router = app
.layer(cors)
.layer(DefaultBodyLimit::max(20 * 1024 * 1024))
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
.with_state(state);
let addr = "0.0.0.0:3000";

View File

@@ -23,7 +23,6 @@ use errors::BiliClientError;
use m3u8_rs::{Playlist, QuotedOrUnquoted, VariantStream};
use regex::Regex;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::fs::File;
@@ -62,7 +61,6 @@ pub struct BiliRecorder {
cover: Arc<RwLock<Option<String>>>,
entry_store: Arc<RwLock<Option<EntryStore>>>,
is_recording: Arc<RwLock<bool>>,
force_update: Arc<AtomicBool>,
last_update: Arc<RwLock<i64>>,
quit: Arc<Mutex<bool>>,
live_stream: Arc<RwLock<Option<BiliStream>>>,
@@ -144,7 +142,6 @@ impl BiliRecorder {
live_id: Arc::new(RwLock::new(String::new())),
cover: Arc::new(RwLock::new(cover)),
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
force_update: Arc::new(AtomicBool::new(false)),
quit: Arc::new(Mutex::new(false)),
live_stream: Arc::new(RwLock::new(None)),
danmu_storage: Arc::new(RwLock::new(None)),
@@ -332,34 +329,27 @@ impl BiliRecorder {
let stream = new_stream.unwrap();
let should_update_stream = self.live_stream.read().await.is_none()
|| self.force_update.load(Ordering::Relaxed);
if should_update_stream {
self.force_update.store(false, Ordering::Relaxed);
let new_stream = self.fetch_real_stream(&stream).await;
if new_stream.is_err() {
log::error!(
"[{}]Fetch real stream failed: {}",
self.room_id,
new_stream.err().unwrap()
);
return true;
}
let new_stream = new_stream.unwrap();
*self.live_stream.write().await = Some(new_stream);
*self.last_update.write().await = Utc::now().timestamp();
log::info!(
"[{}]Update to a new stream: {:?} => {}",
let new_stream = self.fetch_real_stream(&stream).await;
if new_stream.is_err() {
log::error!(
"[{}]Fetch real stream failed: {}",
self.room_id,
self.live_stream.read().await.clone(),
stream
new_stream.err().unwrap()
);
return true;
}
let new_stream = new_stream.unwrap();
*self.live_stream.write().await = Some(new_stream);
*self.last_update.write().await = Utc::now().timestamp();
log::info!(
"[{}]Update to a new stream: {:?} => {}",
self.room_id,
self.live_stream.read().await.clone(),
stream
);
true
}
Err(e) => {
@@ -579,7 +569,6 @@ impl BiliRecorder {
let current_stream = current_stream.unwrap();
let parsed = self.get_playlist().await;
if parsed.is_err() {
self.force_update.store(true, Ordering::Relaxed);
return Err(parsed.err().unwrap());
}
@@ -1065,8 +1054,7 @@ impl BiliRecorder {
.as_ref()
.is_some_and(|s| s.expire - Utc::now().timestamp() < pre_offset as i64)
{
log::info!("Stream is nearly expired, force update");
self.force_update.store(true, Ordering::Relaxed);
log::info!("Stream is nearly expired");
return Err(super::errors::RecorderError::StreamExpired {
stream: current_stream.unwrap(),
});
@@ -1153,7 +1141,10 @@ impl super::Recorder for BiliRecorder {
}
}
}
// whatever error happened during update entries, reset to start another recording.
*self_clone.is_recording.write().await = false;
self_clone.reset().await;
// go check status again after random 2-5 secs
let secs = rand::random::<u64>() % 4 + 2;
tokio::time::sleep(Duration::from_secs(

View File

@@ -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>

View File

@@ -238,6 +238,8 @@
}
selectedVideos.clear();
await loadVideos();
showDeleteConfirm = false;
videoToDelete = null;
} catch (error) {
console.error("Failed to delete selected videos:", error);
}