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