diff --git a/Cargo.toml b/Cargo.toml index 94ef78f..7b04962 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "tauri-video01" -version = "0.1.0" -description = "A Tauri App" -authors = ["you"] -edition = "2021" +version = "0.2.0" +description = "A Tauri Video App" +authors = ["sinus@sasedev.net"] +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/frontend/index.html b/frontend/index.html index e7997ad..4d24573 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@
-

POC 1A - Audio Recording

+

POC 1 - Recording

Audio

@@ -22,6 +22,18 @@
Ready.
+ +
+

Video

+ +
+ + +
+ +
Ready.
+
diff --git a/frontend/main.ts b/frontend/main.ts index 1b1683c..6fd25bf 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -4,23 +4,44 @@ const startAudioBtn = document.querySelector("#start-audio-bt const stopAudioBtn = document.querySelector("#stop-audio-btn"); const audioStatus = document.querySelector("#audio-status"); -if (startAudioBtn === null || stopAudioBtn === null || audioStatus === null) { +const startVideoBtn = document.querySelector("#start-video-btn"); +const stopVideoBtn = document.querySelector("#stop-video-btn"); +const videoStatus = document.querySelector("#video-status"); + +if ( + startAudioBtn === null || + stopAudioBtn === null || + audioStatus === null || + startVideoBtn === null || + stopVideoBtn === null || + videoStatus === null +) { throw new Error("missing UI elements"); } function setAudioStatus(message: string): void { - if (audioStatus) { + if (audioStatus) audioStatus.textContent = message; - } } function setAudioButtons(isRecording: boolean): void { - if (startAudioBtn) { + if (startAudioBtn) startAudioBtn.disabled = isRecording; - } - if (stopAudioBtn) { + if (stopAudioBtn) stopAudioBtn.disabled = !isRecording; - } +} + +function setVideoStatus(message: string): void { + + if (videoStatus) + videoStatus.textContent = message; +} + +function setVideoButtons(isRecording: boolean): void { + if (startVideoBtn) + startVideoBtn.disabled = isRecording; + if (stopVideoBtn) + stopVideoBtn.disabled = !isRecording; } startAudioBtn.addEventListener("click", async () => { @@ -48,3 +69,29 @@ stopAudioBtn.addEventListener("click", async () => { setAudioButtons(true); } }); + +startVideoBtn.addEventListener("click", async () => { + startVideoBtn.disabled = true; + + try { + const path = await invoke("start_video_recording"); + setVideoStatus(`Video recording started.\nOutput: ${path}`); + setVideoButtons(true); + } catch (error) { + setVideoStatus(`Start video failed.\n${String(error)}`); + setVideoButtons(false); + } +}); + +stopVideoBtn.addEventListener("click", async () => { + stopVideoBtn.disabled = true; + + try { + const path = await invoke("stop_video_recording"); + setVideoStatus(`Video recording stopped.\nSaved file: ${path}`); + setVideoButtons(false); + } catch (error) { + setVideoStatus(`Stop video failed.\n${String(error)}`); + setVideoButtons(true); + } +}); \ No newline at end of file diff --git a/frontend/styles.css b/frontend/styles.css index e02ec3a..9545957 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -47,4 +47,4 @@ pre { border-radius: 8px; padding: 12px; white-space: pre-wrap; -} \ No newline at end of file +} diff --git a/package.json b/package.json index 69b16b3..23820d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tauri-video01", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/app_state.rs b/src/app_state.rs index de405a1..8e65e7c 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -16,14 +16,32 @@ impl AudioRecorderState { } } +pub struct VideoRecorderState { + pub is_recording: bool, + pub output_path: Option, + pub pipeline: Option, +} + +impl VideoRecorderState { + pub fn new() -> Self { + Self { + is_recording: false, + output_path: None, + pipeline: None, + } + } +} + pub struct AppState { pub audio: std::sync::Mutex, + pub video: std::sync::Mutex, } impl AppState { pub fn new() -> Self { Self { audio: std::sync::Mutex::new(AudioRecorderState::new()), + video: std::sync::Mutex::new(VideoRecorderState::new()), } } } diff --git a/src/commands.rs b/src/commands.rs index b22ed54..8d0c820 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2,11 +2,10 @@ use crate::app_state::AppState; use crate::media_audio; +use crate::media_video; #[tauri::command] -pub async fn start_audio_recording( - state: tauri::State<'_, AppState>, -) -> Result { +pub async fn start_audio_recording(state: tauri::State<'_, AppState>) -> Result { let result = media_audio::start_audio_recording(&state); match result { Ok(path) => Ok(path), @@ -15,12 +14,28 @@ pub async fn start_audio_recording( } #[tauri::command] -pub async fn stop_audio_recording( - state: tauri::State<'_, AppState>, -) -> Result { +pub async fn stop_audio_recording(state: tauri::State<'_, AppState>) -> Result { let result = media_audio::stop_audio_recording(&state); match result { Ok(path) => Ok(path), Err(error) => Err(error.to_user_message()), } } + +#[tauri::command] +pub async fn start_video_recording(state: tauri::State<'_, AppState>) -> Result { + let result = media_video::start_video_recording(&state); + match result { + Ok(path) => Ok(path), + Err(error) => Err(error.to_user_message()), + } +} + +#[tauri::command] +pub async fn stop_video_recording(state: tauri::State<'_, AppState>) -> Result { + let result = media_video::stop_video_recording(&state); + match result { + Ok(path) => Ok(path), + Err(error) => Err(error.to_user_message()), + } +} diff --git a/src/error.rs b/src/error.rs index 9dbe3db..1cbb21d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,21 +2,17 @@ #[derive(Debug)] pub enum AppError { - GstInit(String), Gst(String), Io(String), State(String), - InvalidInput(String), } impl AppError { pub fn to_user_message(&self) -> String { match self { - AppError::GstInit(message) => format!("gstreamer init error: {message}"), AppError::Gst(message) => format!("gstreamer error: {message}"), AppError::Io(message) => format!("io error: {message}"), AppError::State(message) => format!("state error: {message}"), - AppError::InvalidInput(message) => format!("invalid input: {message}"), } } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 1debd3f..705445f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ mod app_state; mod commands; mod error; mod media_audio; +mod media_video; fn init_tracing() { let subscriber_result = tracing_subscriber::fmt() @@ -34,7 +35,9 @@ pub fn run() { .manage(app_state::AppState::new()) .invoke_handler(tauri::generate_handler![ commands::start_audio_recording, - commands::stop_audio_recording + commands::stop_audio_recording, + commands::start_video_recording, + commands::stop_video_recording, ]); let run_result = builder.run(tauri::generate_context!()); if let Err(error) = run_result { diff --git a/src/media_audio.rs b/src/media_audio.rs index d70a589..f2670da 100644 --- a/src/media_audio.rs +++ b/src/media_audio.rs @@ -12,7 +12,7 @@ fn build_output_dir() -> Result { return Err(AppError::Io("unable to resolve local data directory".to_string())); }; - base_dir.push("tauri-gst-record"); + base_dir.push("tauri-video01"); base_dir.push("records"); let create_dir_result = std::fs::create_dir_all(&base_dir); diff --git a/src/media_video.rs b/src/media_video.rs new file mode 100644 index 0000000..801a9a3 --- /dev/null +++ b/src/media_video.rs @@ -0,0 +1,177 @@ +// file: src/media_video.rs + +use crate::app_state::AppState; +use crate::error::AppError; +use gst::prelude::*; +use gstreamer as gst; + +fn build_output_dir() -> Result { + let app_data_dir = dirs::data_local_dir(); + + let Some(mut base_dir) = app_data_dir else { + return Err(AppError::Io( + "unable to resolve local data directory".to_string(), + )); + }; + + base_dir.push("tauri-video01"); + base_dir.push("records"); + + let create_dir_result = std::fs::create_dir_all(&base_dir); + if let Err(error) = create_dir_result { + return Err(AppError::Io(format!( + "unable to create output directory '{}': {error}", + base_dir.display() + ))); + } + + Ok(base_dir) +} + +fn build_output_path() -> Result { + let build_dir_result = build_output_dir(); + let mut dir = match build_dir_result { + Ok(value) => value, + Err(error) => return Err(error), + }; + + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); + dir.push(format!("video-{timestamp}.webm")); + Ok(dir) +} + +fn build_pipeline(output_path: &std::path::Path) -> Result { + let location = output_path + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + + let pipeline_str = format!( + "autovideosrc ! videoconvert ! queue ! vp8enc deadline=1 ! webmmux ! filesink location=\"{location}\"" + ); + + tracing::info!(pipeline = %pipeline_str, "building video pipeline"); + + let element_result = gst::parse::launch(&pipeline_str); + let element = match element_result { + Ok(value) => value, + Err(error) => { + return Err(AppError::Gst(format!("unable to parse pipeline: {error}"))); + } + }; + + let downcast_result = element.downcast::(); + match downcast_result { + Ok(pipeline) => Ok(pipeline), + Err(_) => Err(AppError::Gst( + "parsed element is not a pipeline".to_string(), + )), + } +} + +pub fn start_video_recording(state: &tauri::State<'_, AppState>) -> Result { + let mut guard = match state.video.lock() { + Ok(value) => value, + Err(_) => { + return Err(AppError::State("video state lock poisoned".to_string())); + } + }; + + if guard.is_recording { + return Err(AppError::State( + "video recording is already running".to_string(), + )); + } + + let output_path = match build_output_path() { + Ok(value) => value, + Err(error) => return Err(error), + }; + + let pipeline = match build_pipeline(&output_path) { + Ok(value) => value, + Err(error) => return Err(error), + }; + + let set_state_result = pipeline.set_state(gst::State::Playing); + match set_state_result { + Ok(_) => {} + Err(error) => { + return Err(AppError::Gst(format!( + "unable to set pipeline to playing: {error:?}" + ))); + } + } + + tracing::info!(path = %output_path.display(), "video recording started"); + + guard.is_recording = true; + guard.output_path = Some(output_path.clone()); + guard.pipeline = Some(pipeline); + + Ok(output_path.display().to_string()) +} + +pub fn stop_video_recording(state: &tauri::State<'_, AppState>) -> Result { + let mut guard = match state.video.lock() { + Ok(value) => value, + Err(_) => { + return Err(AppError::State("video state lock poisoned".to_string())); + } + }; + + if !guard.is_recording { + return Err(AppError::State( + "video recording is not running".to_string(), + )); + } + + let pipeline = match guard.pipeline.take() { + Some(value) => value, + None => { + return Err(AppError::State( + "missing video pipeline while recording flag is set".to_string(), + )); + } + }; + + let output_path = match guard.output_path.clone() { + Some(value) => value, + None => { + return Err(AppError::State( + "missing video output path while recording flag is set".to_string(), + )); + } + }; + + let send_event_result = pipeline.send_event(gst::event::Eos::new()); + if !send_event_result { + tracing::warn!("failed to send EOS event to video pipeline"); + } + + let bus = pipeline.bus(); + if let Some(bus_ref) = bus { + let _ = bus_ref.timed_pop_filtered( + gst::ClockTime::from_seconds(5), + &[gst::MessageType::Eos, gst::MessageType::Error], + ); + } + + let set_state_result = pipeline.set_state(gst::State::Null); + match set_state_result { + Ok(_) => {} + Err(error) => { + return Err(AppError::Gst(format!( + "unable to set video pipeline to null: {error:?}" + ))); + } + } + + tracing::info!(path = %output_path.display(), "video recording stopped"); + + guard.is_recording = false; + guard.output_path = None; + guard.pipeline = None; + + Ok(output_path.display().to_string()) +} diff --git a/tauri.conf.json b/tauri.conf.json index 58b26e8..9e3842b 100644 --- a/tauri.conf.json +++ b/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "tauri-video01", - "version": "0.1.0", + "version": "0.2.0", "identifier": "com.sinus.tauri-video01", "build": { "beforeDevCommand": "npm run dev",