mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-24 20:15:34 +08:00
feat: special audio file for waveform (#226)
This commit is contained in:
@@ -183,6 +183,68 @@ pub async fn trim_video(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract a sample audio from the video file for waveform display
|
||||||
|
pub async fn extract_audio_sample(file: &Path) -> Result<PathBuf, String> {
|
||||||
|
// ffmpeg -i fixed_\[30655592\]1742887114_0325084106_81.5.mp4 -ar 16000 test.wav
|
||||||
|
log::info!("Extract audio sample task start: {}", file.display());
|
||||||
|
let output_path = file.with_extension("opus");
|
||||||
|
let mut extract_error = None;
|
||||||
|
|
||||||
|
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
|
||||||
|
let child = ffmpeg_process
|
||||||
|
.args(["-i", file.to_str().unwrap()])
|
||||||
|
.args(["-c:a", "libopus"])
|
||||||
|
.args(["-ar", "16000"])
|
||||||
|
.args(["-ac", "1"])
|
||||||
|
.args(["-vn"])
|
||||||
|
.args(["-b:a", "12k"])
|
||||||
|
.args(["-vbr", "on"])
|
||||||
|
.args(["-compression_level", "10"])
|
||||||
|
.args([output_path.to_str().unwrap()])
|
||||||
|
.args(["-y"])
|
||||||
|
.args(["-progress", "pipe:2"])
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn();
|
||||||
|
|
||||||
|
if let Err(e) = child {
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = child.unwrap();
|
||||||
|
let stderr = child.stderr.take().unwrap();
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
let mut parser = FfmpegLogParser::new(reader);
|
||||||
|
while let Ok(event) = parser.parse_next_event().await {
|
||||||
|
match event {
|
||||||
|
FfmpegEvent::Error(e) => {
|
||||||
|
log::error!("Extract audio sample error: {e}");
|
||||||
|
extract_error = Some(e.to_string());
|
||||||
|
}
|
||||||
|
FfmpegEvent::LogEOF => break,
|
||||||
|
FfmpegEvent::Progress(p) => {
|
||||||
|
log::info!("Extract audio sample progress: {}", p.time);
|
||||||
|
}
|
||||||
|
FfmpegEvent::Log(_level, _content) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = child.wait().await {
|
||||||
|
log::error!("Extract audio sample error: {e}");
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(error) = extract_error {
|
||||||
|
log::error!("Extract audio sample error: {error}");
|
||||||
|
Err(error)
|
||||||
|
} else {
|
||||||
|
log::info!("Extract audio sample task end: {}", output_path.display());
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
pub async fn extract_audio_chunks(file: &Path, format: &str) -> Result<PathBuf, String> {
|
pub async fn extract_audio_chunks(file: &Path, format: &str) -> Result<PathBuf, String> {
|
||||||
// ffmpeg -i fixed_\[30655190\]1742887114_0325084106_81.5.mp4 -ar 16000 test.wav
|
// ffmpeg -i fixed_\[30655190\]1742887114_0325084106_81.5.mp4 -ar 16000 test.wav
|
||||||
log::info!("Extract audio task start: {}", file.display());
|
log::info!("Extract audio task start: {}", file.display());
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ async fn clip_range_inner(
|
|||||||
if cover_generate_ffmpeg {
|
if cover_generate_ffmpeg {
|
||||||
ffmpeg::generate_thumbnail(&file, 0.0).await?;
|
ffmpeg::generate_thumbnail(&file, 0.0).await?;
|
||||||
}
|
}
|
||||||
|
let _ = crate::ffmpeg::extract_audio_sample(&file).await?;
|
||||||
// get filename from path
|
// get filename from path
|
||||||
let filename = Path::new(&file)
|
let filename = Path::new(&file)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -714,6 +715,8 @@ pub async fn delete_video(state: state_type!(), id: i64) -> Result<(), String> {
|
|||||||
let _ = tokio::fs::remove_file(wav_path).await;
|
let _ = tokio::fs::remove_file(wav_path).await;
|
||||||
let mp3_path = file.with_extension("mp3");
|
let mp3_path = file.with_extension("mp3");
|
||||||
let _ = tokio::fs::remove_file(mp3_path).await;
|
let _ = tokio::fs::remove_file(mp3_path).await;
|
||||||
|
let opus_path = file.with_extension("opus");
|
||||||
|
let _ = tokio::fs::remove_file(opus_path).await;
|
||||||
let cover_path = Path::new(&config.output).join(&video.cover);
|
let cover_path = Path::new(&config.output).join(&video.cover);
|
||||||
let _ = tokio::fs::remove_file(cover_path).await;
|
let _ = tokio::fs::remove_file(cover_path).await;
|
||||||
|
|
||||||
@@ -1452,3 +1455,14 @@ pub async fn get_import_progress(
|
|||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "gui", tauri::command)]
|
||||||
|
pub async fn generate_audio_sample(state: state_type!(), video_id: i64) -> Result<(), String> {
|
||||||
|
let video = state.db.get_video(video_id).await?;
|
||||||
|
let video_path = Path::new(&state.config.read().await.output).join(&video.file);
|
||||||
|
let opus_path = video_path.with_extension("opus");
|
||||||
|
if !opus_path.exists() {
|
||||||
|
let _ = crate::ffmpeg::extract_audio_sample(&video_path).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ use crate::{
|
|||||||
utils::{console_log, get_disk_info, list_folder, sanitize_filename_advanced, DiskInfo},
|
utils::{console_log, get_disk_info, list_folder, sanitize_filename_advanced, DiskInfo},
|
||||||
video::{
|
video::{
|
||||||
batch_import_external_videos, cancel, clip_range, clip_video, delete_video,
|
batch_import_external_videos, cancel, clip_range, clip_video, delete_video,
|
||||||
encode_video_subtitle, generate_video_subtitle, generic_ffmpeg_command, get_all_videos,
|
encode_video_subtitle, generate_audio_sample, generate_video_subtitle,
|
||||||
get_file_size, get_import_progress, get_video, get_video_cover, get_video_subtitle,
|
generic_ffmpeg_command, get_all_videos, get_file_size, get_import_progress, get_video,
|
||||||
get_video_typelist, get_videos, import_external_video, update_video_cover,
|
get_video_cover, get_video_subtitle, get_video_typelist, get_videos,
|
||||||
update_video_note, update_video_subtitle, upload_procedure,
|
import_external_video, update_video_cover, update_video_note, update_video_subtitle,
|
||||||
|
upload_procedure,
|
||||||
},
|
},
|
||||||
AccountInfo,
|
AccountInfo,
|
||||||
},
|
},
|
||||||
@@ -756,6 +757,20 @@ async fn handler_get_video(
|
|||||||
Ok(Json(ApiResponse::success(video)))
|
Ok(Json(ApiResponse::success(video)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GenerateAudioSampleRequest {
|
||||||
|
video_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handler_generate_audio_sample(
|
||||||
|
state: axum::extract::State<State>,
|
||||||
|
Json(param): Json<GenerateAudioSampleRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, ApiError> {
|
||||||
|
generate_audio_sample(state.0, param.video_id).await?;
|
||||||
|
Ok(Json(ApiResponse::success(())))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct GetVideosRequest {
|
struct GetVideosRequest {
|
||||||
@@ -1816,6 +1831,10 @@ pub async fn start_api_server(state: State) {
|
|||||||
// Video commands
|
// Video commands
|
||||||
.route("/api/clip_range", post(handler_clip_range))
|
.route("/api/clip_range", post(handler_clip_range))
|
||||||
.route("/api/get_video", post(handler_get_video))
|
.route("/api/get_video", post(handler_get_video))
|
||||||
|
.route(
|
||||||
|
"/api/generate_audio_sample",
|
||||||
|
post(handler_generate_audio_sample),
|
||||||
|
)
|
||||||
.route("/api/get_videos", post(handler_get_videos))
|
.route("/api/get_videos", post(handler_get_videos))
|
||||||
.route("/api/get_video_cover", post(handler_get_video_cover))
|
.route("/api/get_video_cover", post(handler_get_video_cover))
|
||||||
.route("/api/get_all_videos", post(handler_get_all_videos))
|
.route("/api/get_all_videos", post(handler_get_all_videos))
|
||||||
|
|||||||
@@ -691,6 +691,7 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
|
|||||||
crate::handlers::video::clip_video,
|
crate::handlers::video::clip_video,
|
||||||
crate::handlers::video::get_file_size,
|
crate::handlers::video::get_file_size,
|
||||||
crate::handlers::video::get_import_progress,
|
crate::handlers::video::get_import_progress,
|
||||||
|
crate::handlers::video::generate_audio_sample,
|
||||||
crate::handlers::task::get_tasks,
|
crate::handlers::task::get_tasks,
|
||||||
crate::handlers::task::delete_task,
|
crate::handlers::task::delete_task,
|
||||||
crate::handlers::utils::show_in_folder,
|
crate::handlers::utils::show_in_folder,
|
||||||
|
|||||||
@@ -1437,6 +1437,7 @@ impl RecorderManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = crate::ffmpeg::generate_thumbnail(Path::new(&output_path), 0.0).await;
|
let _ = crate::ffmpeg::generate_thumbnail(Path::new(&output_path), 0.0).await;
|
||||||
|
let _ = crate::ffmpeg::extract_audio_sample(Path::new(&output_path)).await;
|
||||||
|
|
||||||
let video = self
|
let video = self
|
||||||
.db
|
.db
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ pub async fn start_static_server(
|
|||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.layer(cors)
|
|
||||||
.nest_service("/output", ServeDir::new(output_path))
|
.nest_service("/output", ServeDir::new(output_path))
|
||||||
.nest_service("/cache", ServeDir::new(cache_path));
|
.nest_service("/cache", ServeDir::new(cache_path))
|
||||||
|
.layer(cors);
|
||||||
|
|
||||||
if let Err(e) = axum::serve(listener, router).await {
|
if let Err(e) = axum::serve(listener, router).await {
|
||||||
log::error!("Server error: {}", e);
|
log::error!("Server error: {}", e);
|
||||||
|
|||||||
@@ -153,14 +153,14 @@
|
|||||||
isWaveformLoading = true;
|
isWaveformLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
createWaveSurfer();
|
await createWaveSurfer();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize WaveSurfer.js:", error);
|
console.error("Failed to initialize WaveSurfer.js:", error);
|
||||||
isWaveformLoading = false;
|
isWaveformLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWaveSurfer() {
|
async function createWaveSurfer() {
|
||||||
// 使用更稳定的容器查找方式
|
// 使用更稳定的容器查找方式
|
||||||
const container = document.querySelector(
|
const container = document.querySelector(
|
||||||
"[data-waveform-container]"
|
"[data-waveform-container]"
|
||||||
@@ -203,9 +203,13 @@
|
|||||||
plugins: [],
|
plugins: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("WaveSurfer created, loading file:", video.file);
|
|
||||||
// 加载音频
|
// 加载音频
|
||||||
wavesurfer.load(video.file);
|
await invoke("generate_audio_sample", {
|
||||||
|
videoId: video.id,
|
||||||
|
});
|
||||||
|
let opus_file = video.file.replace(".mp4", ".opus");
|
||||||
|
console.log("WaveSurfer created, loading file:", opus_file);
|
||||||
|
wavesurfer.load(opus_file);
|
||||||
|
|
||||||
// 监听加载完成
|
// 监听加载完成
|
||||||
wavesurfer.on("ready", () => {
|
wavesurfer.on("ready", () => {
|
||||||
@@ -1738,7 +1742,9 @@
|
|||||||
on:wheel|preventDefault={handleWheel}
|
on:wheel|preventDefault={handleWheel}
|
||||||
>
|
>
|
||||||
{#if isWaveformLoading}
|
{#if isWaveformLoading}
|
||||||
<div class="flex items-center space-x-2 text-gray-400 w-full">
|
<div
|
||||||
|
class="flex items-center justify-center gap-2 text-gray-400 w-full h-full text-center"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="animate-spin h-4 w-4"
|
class="animate-spin h-4 w-4"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
Reference in New Issue
Block a user