0.2
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<body>
|
||||
<main class="container">
|
||||
<h1>POC 1A - Audio Recording</h1>
|
||||
<h1>POC 1 - Recording</h1>
|
||||
|
||||
<section class="card">
|
||||
<h2>Audio</h2>
|
||||
@@ -22,6 +22,18 @@
|
||||
|
||||
<pre id="audio-status">Ready.</pre>
|
||||
</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>
|
||||
<script type="module" src="main.ts" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -4,23 +4,44 @@ const startAudioBtn = document.querySelector<HTMLButtonElement>("#start-audio-bt
|
||||
const stopAudioBtn = document.querySelector<HTMLButtonElement>("#stop-audio-btn");
|
||||
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");
|
||||
}
|
||||
|
||||
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<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);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tauri-video01",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 audio: std::sync::Mutex<AudioRecorderState>,
|
||||
pub video: std::sync::Mutex<VideoRecorderState>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
audio: std::sync::Mutex::new(AudioRecorderState::new()),
|
||||
video: std::sync::Mutex::new(VideoRecorderState::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String> {
|
||||
pub async fn start_audio_recording(state: tauri::State<'_, AppState>) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
pub async fn stop_audio_recording(state: tauri::State<'_, AppState>) -> Result<String, String> {
|
||||
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<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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
177
src/media_video.rs
Normal file
177
src/media_video.rs
Normal 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())
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user