// 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 { 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 { 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!( 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::(); 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>>>, ) -> 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::(); 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 { 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 { 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, 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)) }