This commit is contained in:
2026-03-30 10:23:29 +02:00
parent 1272f2c44e
commit 966162a934
12 changed files with 295 additions and 27 deletions

View File

@@ -1,9 +1,9 @@
[package] [package]
name = "tauri-video01" name = "tauri-video01"
version = "0.1.0" version = "0.2.0"
description = "A Tauri App" description = "A Tauri Video App"
authors = ["you"] authors = ["sinus@sasedev.net"]
edition = "2021" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -9,7 +9,7 @@
<body> <body>
<main class="container"> <main class="container">
<h1>POC 1A - Audio Recording</h1> <h1>POC 1 - Recording</h1>
<section class="card"> <section class="card">
<h2>Audio</h2> <h2>Audio</h2>
@@ -22,6 +22,18 @@
<pre id="audio-status">Ready.</pre> <pre id="audio-status">Ready.</pre>
</section> </section>
<section class="card">
<h2>Video</h2>
<div class="actions">
<button id="start-video-btn" type="button">Start video</button>
<button id="stop-video-btn" type="button" disabled>Stop
video</button>
</div>
<pre id="video-status">Ready.</pre>
</section>
</main> </main>
<script type="module" src="main.ts" defer></script> <script type="module" src="main.ts" defer></script>
</body> </body>

View File

@@ -4,23 +4,44 @@ const startAudioBtn = document.querySelector<HTMLButtonElement>("#start-audio-bt
const stopAudioBtn = document.querySelector<HTMLButtonElement>("#stop-audio-btn"); const stopAudioBtn = document.querySelector<HTMLButtonElement>("#stop-audio-btn");
const audioStatus = document.querySelector<HTMLElement>("#audio-status"); const audioStatus = document.querySelector<HTMLElement>("#audio-status");
if (startAudioBtn === null || stopAudioBtn === null || audioStatus === null) { const startVideoBtn = document.querySelector<HTMLButtonElement>("#start-video-btn");
const stopVideoBtn = document.querySelector<HTMLButtonElement>("#stop-video-btn");
const videoStatus = document.querySelector<HTMLElement>("#video-status");
if (
startAudioBtn === null ||
stopAudioBtn === null ||
audioStatus === null ||
startVideoBtn === null ||
stopVideoBtn === null ||
videoStatus === null
) {
throw new Error("missing UI elements"); throw new Error("missing UI elements");
} }
function setAudioStatus(message: string): void { function setAudioStatus(message: string): void {
if (audioStatus) { if (audioStatus)
audioStatus.textContent = message; audioStatus.textContent = message;
}
} }
function setAudioButtons(isRecording: boolean): void { function setAudioButtons(isRecording: boolean): void {
if (startAudioBtn) { if (startAudioBtn)
startAudioBtn.disabled = isRecording; startAudioBtn.disabled = isRecording;
} if (stopAudioBtn)
if (stopAudioBtn) {
stopAudioBtn.disabled = !isRecording; 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 () => { startAudioBtn.addEventListener("click", async () => {
@@ -48,3 +69,29 @@ stopAudioBtn.addEventListener("click", async () => {
setAudioButtons(true); setAudioButtons(true);
} }
}); });
startVideoBtn.addEventListener("click", async () => {
startVideoBtn.disabled = true;
try {
const path = await invoke<string>("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<string>("stop_video_recording");
setVideoStatus(`Video recording stopped.\nSaved file: ${path}`);
setVideoButtons(false);
} catch (error) {
setVideoStatus(`Stop video failed.\n${String(error)}`);
setVideoButtons(true);
}
});

View File

@@ -1,7 +1,7 @@
{ {
"name": "tauri-video01", "name": "tauri-video01",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -16,14 +16,32 @@ impl AudioRecorderState {
} }
} }
pub struct VideoRecorderState {
pub is_recording: bool,
pub output_path: Option<std::path::PathBuf>,
pub pipeline: Option<gstreamer::Pipeline>,
}
impl VideoRecorderState {
pub fn new() -> Self {
Self {
is_recording: false,
output_path: None,
pipeline: None,
}
}
}
pub struct AppState { pub struct AppState {
pub audio: std::sync::Mutex<AudioRecorderState>, pub audio: std::sync::Mutex<AudioRecorderState>,
pub video: std::sync::Mutex<VideoRecorderState>,
} }
impl AppState { impl AppState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
audio: std::sync::Mutex::new(AudioRecorderState::new()), audio: std::sync::Mutex::new(AudioRecorderState::new()),
video: std::sync::Mutex::new(VideoRecorderState::new()),
} }
} }
} }

View File

@@ -2,11 +2,10 @@
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::media_audio; use crate::media_audio;
use crate::media_video;
#[tauri::command] #[tauri::command]
pub async fn start_audio_recording( pub async fn start_audio_recording(state: tauri::State<'_, AppState>) -> Result<String, String> {
state: tauri::State<'_, AppState>,
) -> Result<String, String> {
let result = media_audio::start_audio_recording(&state); let result = media_audio::start_audio_recording(&state);
match result { match result {
Ok(path) => Ok(path), Ok(path) => Ok(path),
@@ -15,12 +14,28 @@ pub async fn start_audio_recording(
} }
#[tauri::command] #[tauri::command]
pub async fn stop_audio_recording( pub async fn stop_audio_recording(state: tauri::State<'_, AppState>) -> Result<String, String> {
state: tauri::State<'_, AppState>,
) -> Result<String, String> {
let result = media_audio::stop_audio_recording(&state); let result = media_audio::stop_audio_recording(&state);
match result { match result {
Ok(path) => Ok(path), Ok(path) => Ok(path),
Err(error) => Err(error.to_user_message()), Err(error) => Err(error.to_user_message()),
} }
} }
#[tauri::command]
pub async fn start_video_recording(state: tauri::State<'_, AppState>) -> Result<String, String> {
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<String, String> {
let result = media_video::stop_video_recording(&state);
match result {
Ok(path) => Ok(path),
Err(error) => Err(error.to_user_message()),
}
}

View File

@@ -2,21 +2,17 @@
#[derive(Debug)] #[derive(Debug)]
pub enum AppError { pub enum AppError {
GstInit(String),
Gst(String), Gst(String),
Io(String), Io(String),
State(String), State(String),
InvalidInput(String),
} }
impl AppError { impl AppError {
pub fn to_user_message(&self) -> String { pub fn to_user_message(&self) -> String {
match self { match self {
AppError::GstInit(message) => format!("gstreamer init error: {message}"),
AppError::Gst(message) => format!("gstreamer error: {message}"), AppError::Gst(message) => format!("gstreamer error: {message}"),
AppError::Io(message) => format!("io error: {message}"), AppError::Io(message) => format!("io error: {message}"),
AppError::State(message) => format!("state error: {message}"), AppError::State(message) => format!("state error: {message}"),
AppError::InvalidInput(message) => format!("invalid input: {message}"),
} }
} }
} }

View File

@@ -4,6 +4,7 @@ mod app_state;
mod commands; mod commands;
mod error; mod error;
mod media_audio; mod media_audio;
mod media_video;
fn init_tracing() { fn init_tracing() {
let subscriber_result = tracing_subscriber::fmt() let subscriber_result = tracing_subscriber::fmt()
@@ -34,7 +35,9 @@ pub fn run() {
.manage(app_state::AppState::new()) .manage(app_state::AppState::new())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::start_audio_recording, 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!()); let run_result = builder.run(tauri::generate_context!());
if let Err(error) = run_result { if let Err(error) = run_result {

View File

@@ -12,7 +12,7 @@ fn build_output_dir() -> Result<std::path::PathBuf, AppError> {
return Err(AppError::Io("unable to resolve local data directory".to_string())); 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"); base_dir.push("records");
let create_dir_result = std::fs::create_dir_all(&base_dir); let create_dir_result = std::fs::create_dir_all(&base_dir);

177
src/media_video.rs Normal file
View File

@@ -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<std::path::PathBuf, AppError> {
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<std::path::PathBuf, AppError> {
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<gst::Pipeline, AppError> {
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::<gst::Pipeline>();
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<String, AppError> {
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<String, AppError> {
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())
}

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "tauri-video01", "productName": "tauri-video01",
"version": "0.1.0", "version": "0.2.0",
"identifier": "com.sinus.tauri-video01", "identifier": "com.sinus.tauri-video01",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",