feat: special audio file for waveform (#226)

This commit is contained in:
Xinrea
2025-11-11 23:48:30 +08:00
committed by GitHub
parent 694dfcf701
commit 1758d1dd2d
7 changed files with 114 additions and 11 deletions

View File

@@ -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());

View File

@@ -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(())
}

View File

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

View File

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

View File

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

View File

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

View File

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