0.2
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
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",
|
"$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",
|
||||||
|
|||||||
Reference in New Issue
Block a user