298 lines
8.8 KiB
Rust
298 lines
8.8 KiB
Rust
// file: src/media_video.rs
|
|
|
|
use crate::app_state::AppState;
|
|
use crate::error::AppError;
|
|
use gst::prelude::*;
|
|
use gstreamer as gst;
|
|
use gstreamer_app as gst_app;
|
|
|
|
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-video02");
|
|
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!(
|
|
concat!(
|
|
"autovideosrc ! videoconvert ! tee name=t ",
|
|
"t. ! queue ! vp8enc deadline=1 ! webmmux ! filesink location=\"{}\" ",
|
|
"t. ! queue leaky=downstream max-size-buffers=1 ! videoconvert ! ",
|
|
"jpegenc quality=80 ! appsink name=preview_sink max-buffers=1 drop=true sync=false"
|
|
),
|
|
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(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn attach_preview_callbacks(
|
|
pipeline: &gst::Pipeline,
|
|
preview_store: std::sync::Arc<std::sync::Mutex<Option<Vec<u8>>>>,
|
|
) -> Result<(), AppError> {
|
|
let element_option = pipeline.by_name("preview_sink");
|
|
let Some(element) = element_option else {
|
|
return Err(AppError::Gst(
|
|
"preview sink not found in video pipeline".to_string(),
|
|
));
|
|
};
|
|
|
|
let downcast_result = element.downcast::<gst_app::AppSink>();
|
|
let app_sink = match downcast_result {
|
|
Ok(value) => value,
|
|
Err(_) => {
|
|
return Err(AppError::Gst("preview sink is not an appsink".to_string()));
|
|
}
|
|
};
|
|
|
|
let callbacks = gst_app::AppSinkCallbacks::builder()
|
|
.new_sample(move |sink| {
|
|
let sample_result = sink.pull_sample();
|
|
let sample = match sample_result {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "failed to pull preview sample");
|
|
return Err(gst::FlowError::Error);
|
|
}
|
|
};
|
|
|
|
let buffer_option = sample.buffer();
|
|
let Some(buffer_ref) = buffer_option else {
|
|
tracing::warn!("preview sample without buffer");
|
|
return Ok(gst::FlowSuccess::Ok);
|
|
};
|
|
|
|
let map_result = buffer_ref.map_readable();
|
|
let map = match map_result {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "failed to map preview buffer");
|
|
return Err(gst::FlowError::Error);
|
|
}
|
|
};
|
|
|
|
let bytes = map.as_slice().to_vec();
|
|
|
|
tracing::info!(size = bytes.len(), "preview sample received");
|
|
|
|
let lock_result = preview_store.lock();
|
|
match lock_result {
|
|
Ok(mut guard) => {
|
|
*guard = Some(bytes);
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!("preview state lock poisoned");
|
|
return Err(gst::FlowError::Error);
|
|
}
|
|
}
|
|
|
|
Ok(gst::FlowSuccess::Ok)
|
|
})
|
|
.build();
|
|
|
|
app_sink.set_callbacks(callbacks);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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 preview_lock_result = state.preview.latest_jpeg.lock();
|
|
match preview_lock_result {
|
|
Ok(mut preview_guard) => {
|
|
*preview_guard = None;
|
|
}
|
|
Err(_) => {
|
|
return Err(AppError::State("preview state lock poisoned".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 attach_result = attach_preview_callbacks(&pipeline, state.preview.latest_jpeg.clone());
|
|
if let Err(error) = attach_result {
|
|
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:?}"
|
|
)));
|
|
}
|
|
}
|
|
|
|
let preview_lock_result = state.preview.latest_jpeg.lock();
|
|
if let Ok(mut preview_guard) = preview_lock_result {
|
|
*preview_guard = None;
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
pub fn get_video_preview_frame_base64(
|
|
state: &tauri::State<'_, AppState>,
|
|
) -> Result<Option<String>, AppError> {
|
|
use base64::Engine;
|
|
|
|
let lock_result = state.preview.latest_jpeg.lock();
|
|
let guard = match lock_result {
|
|
Ok(value) => value,
|
|
Err(_) => {
|
|
return Err(AppError::State("preview state lock poisoned".to_string()));
|
|
}
|
|
};
|
|
|
|
let Some(bytes) = guard.as_ref() else {
|
|
tracing::info!("preview frame requested but none is available yet");
|
|
return Ok(None);
|
|
};
|
|
|
|
tracing::info!(size = bytes.len(), "preview frame requested and returned");
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
|
Ok(Some(encoded))
|
|
}
|