commit db9201e2ed57cbb598e744f15dc83c5b12fcc626 Author: SinuS Von SifriduS Date: Mon Mar 30 11:04:04 2026 +0200 0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2928f85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +.settings +.project +.idea + +Cargo.lock +package-lock.json +/gen/ + +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5cd7519 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tauri-video02" +version = "0.1.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 + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "tauri_video02_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +base64 = { version = "^0.22", features = [] } +chrono = { version = "^0.4", features = ["clock", "serde"] } +dirs = { version = "^6.0", features = [] } +gstreamer = { version = "^0.25", features = ["serde"] } +gstreamer-app = "0.25.0" +serde = { version = "^1.0", features = ["derive"] } +serde_json = { version = "^1.0", features = [] } +tauri = { version = "^2.10", features = ["default"] } +tauri-plugin-opener = { version = "^2.5", features = [] } +tracing = { version = "^0.1", features = ["async-await", "log"] } +tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..b381dcf --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Tauri + Vanilla TS + +This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript. + +## Recommended IDE Setup + +- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/capabilities/default.json b/capabilities/default.json new file mode 100644 index 0000000..4cdbf49 --- /dev/null +++ b/capabilities/default.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default" + ] +} diff --git a/frontend/assets/tauri.svg b/frontend/assets/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/frontend/assets/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/assets/typescript.svg b/frontend/assets/typescript.svg new file mode 100644 index 0000000..30a5edd --- /dev/null +++ b/frontend/assets/typescript.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/frontend/assets/vite.svg b/frontend/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d553262 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,44 @@ + + + + + + +Tauri GST Record + Preview + + + +
+

POC 2 - Recording + Preview

+ +
+

Audio

+ +
+ + +
+ +
Ready.
+
+ +
+

Video

+ +
+ + +
+ +
+ Video preview +
+ +
Ready.
+
+
+ + + diff --git a/frontend/main.ts b/frontend/main.ts new file mode 100644 index 0000000..f906cfb --- /dev/null +++ b/frontend/main.ts @@ -0,0 +1,145 @@ +import { invoke } from "@tauri-apps/api/core"; + +const startAudioBtn = document.querySelector("#start-audio-btn"); +const stopAudioBtn = document.querySelector("#stop-audio-btn"); +const audioStatus = document.querySelector("#audio-status"); + +const startVideoBtn = document.querySelector("#start-video-btn"); +const stopVideoBtn = document.querySelector("#stop-video-btn"); +const videoStatus = document.querySelector("#video-status"); +const videoPreview = document.querySelector("#video-preview"); + +if ( + startAudioBtn === null || + stopAudioBtn === null || + audioStatus === null || + startVideoBtn === null || + stopVideoBtn === null || + videoStatus === null || + videoPreview === null +) { + throw new Error("missing UI elements"); +} + +let previewTimer: number | null = null; +let previewRequestInFlight = false; + +function setAudioStatus(message: string): void { + if (audioStatus) + audioStatus.textContent = message; +} + +function setAudioButtons(isRecording: boolean): void { + if (startAudioBtn) + startAudioBtn.disabled = isRecording; + 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; +} + +async function refreshPreviewFrame(): Promise { + if (previewRequestInFlight) { + return; + } + + previewRequestInFlight = true; + + try { + const encoded = await invoke("get_video_preview_frame_base64"); + + if (encoded !== null && encoded.length > 0 && videoPreview) { + videoPreview.src = `data:image/jpeg;base64,${encoded}`; + } + } catch (error) { + console.error("preview refresh failed", error); + } finally { + previewRequestInFlight = false; + } +} + +function startPreviewPolling(): void { + if (previewTimer !== null) { + return; + } + + previewTimer = window.setInterval(() => { + void refreshPreviewFrame(); + }, 200); +} + +function stopPreviewPolling(): void { + if (previewTimer !== null) { + window.clearInterval(previewTimer); + previewTimer = null; + } + + previewRequestInFlight = false; + if (videoPreview) + videoPreview.removeAttribute("src"); +} + +startAudioBtn.addEventListener("click", async () => { + startAudioBtn.disabled = true; + + try { + const path = await invoke("start_audio_recording"); + setAudioStatus(`Audio recording started.\nOutput: ${path}`); + setAudioButtons(true); + } catch (error) { + setAudioStatus(`Start audio failed.\n${String(error)}`); + setAudioButtons(false); + } +}); + +stopAudioBtn.addEventListener("click", async () => { + stopAudioBtn.disabled = true; + + try { + const path = await invoke("stop_audio_recording"); + setAudioStatus(`Audio recording stopped.\nSaved file: ${path}`); + setAudioButtons(false); + } catch (error) { + setAudioStatus(`Stop audio failed.\n${String(error)}`); + setAudioButtons(true); + } +}); + +startVideoBtn.addEventListener("click", async () => { + startVideoBtn.disabled = true; + + try { + const path = await invoke("start_video_recording"); + setVideoStatus(`Video recording started.\nOutput: ${path}`); + setVideoButtons(true); + startPreviewPolling(); + } catch (error) { + setVideoStatus(`Start video failed.\n${String(error)}`); + setVideoButtons(false); + stopPreviewPolling(); + } +}); + +stopVideoBtn.addEventListener("click", async () => { + stopVideoBtn.disabled = true; + + try { + const path = await invoke("stop_video_recording"); + setVideoStatus(`Video recording stopped.\nSaved file: ${path}`); + setVideoButtons(false); + stopPreviewPolling(); + } catch (error) { + setVideoStatus(`Stop video failed.\n${String(error)}`); + setVideoButtons(true); + } +}); \ No newline at end of file diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..181a556 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,71 @@ +:root { + color-scheme: dark; + font-family: Arial, Helvetica, sans-serif; +} + +body { + margin: 0; + background: #111827; + color: #f9fafb; +} + +.container { + max-width: 980px; + margin: 0 auto; + padding: 24px; +} + +.card { + background: #1f2937; + border: 1px solid #374151; + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; +} + +.actions { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +button { + border: 0; + border-radius: 8px; + padding: 10px 16px; + cursor: pointer; + font-size: 16px; +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +.preview-wrap { + width: 100%; + aspect-ratio: 16/9; + background: #0b1220; + border: 1px solid #374151; + border-radius: 8px; + overflow: hidden; + margin-bottom: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.preview-wrap img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +pre { + background: #0b1220; + border: 1px solid #374151; + border-radius: 8px; + padding: 12px; + white-space: pre-wrap; +} diff --git a/icons/128x128.png b/icons/128x128.png new file mode 100644 index 0000000..6be5e50 Binary files /dev/null and b/icons/128x128.png differ diff --git a/icons/128x128@2x.png b/icons/128x128@2x.png new file mode 100644 index 0000000..e81bece Binary files /dev/null and b/icons/128x128@2x.png differ diff --git a/icons/32x32.png b/icons/32x32.png new file mode 100644 index 0000000..a437dd5 Binary files /dev/null and b/icons/32x32.png differ diff --git a/icons/Square107x107Logo.png b/icons/Square107x107Logo.png new file mode 100644 index 0000000..0ca4f27 Binary files /dev/null and b/icons/Square107x107Logo.png differ diff --git a/icons/Square142x142Logo.png b/icons/Square142x142Logo.png new file mode 100644 index 0000000..b81f820 Binary files /dev/null and b/icons/Square142x142Logo.png differ diff --git a/icons/Square150x150Logo.png b/icons/Square150x150Logo.png new file mode 100644 index 0000000..624c7bf Binary files /dev/null and b/icons/Square150x150Logo.png differ diff --git a/icons/Square284x284Logo.png b/icons/Square284x284Logo.png new file mode 100644 index 0000000..c021d2b Binary files /dev/null and b/icons/Square284x284Logo.png differ diff --git a/icons/Square30x30Logo.png b/icons/Square30x30Logo.png new file mode 100644 index 0000000..6219700 Binary files /dev/null and b/icons/Square30x30Logo.png differ diff --git a/icons/Square310x310Logo.png b/icons/Square310x310Logo.png new file mode 100644 index 0000000..f9bc048 Binary files /dev/null and b/icons/Square310x310Logo.png differ diff --git a/icons/Square44x44Logo.png b/icons/Square44x44Logo.png new file mode 100644 index 0000000..d5fbfb2 Binary files /dev/null and b/icons/Square44x44Logo.png differ diff --git a/icons/Square71x71Logo.png b/icons/Square71x71Logo.png new file mode 100644 index 0000000..63440d7 Binary files /dev/null and b/icons/Square71x71Logo.png differ diff --git a/icons/Square89x89Logo.png b/icons/Square89x89Logo.png new file mode 100644 index 0000000..f3f705a Binary files /dev/null and b/icons/Square89x89Logo.png differ diff --git a/icons/StoreLogo.png b/icons/StoreLogo.png new file mode 100644 index 0000000..4556388 Binary files /dev/null and b/icons/StoreLogo.png differ diff --git a/icons/icon.icns b/icons/icon.icns new file mode 100644 index 0000000..12a5bce Binary files /dev/null and b/icons/icon.icns differ diff --git a/icons/icon.ico b/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/icons/icon.ico differ diff --git a/icons/icon.png b/icons/icon.png new file mode 100644 index 0000000..e1cd261 Binary files /dev/null and b/icons/icon.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..6b75b5e --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "tauri-video02", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.10", + "@tauri-apps/plugin-opener": "^2.5" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.10", + "@types/node": "^25.3", + "sass-embedded": "^1.97", + "typescript": "^5.9", + "vite": "npm:rolldown-vite@^7.3" + } +} diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..8e37cc7 --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,61 @@ +// file: src/app_state.rs + +pub struct AudioRecorderState { + pub is_recording: bool, + pub output_path: Option, + pub pipeline: Option, +} + +impl AudioRecorderState { + pub fn new() -> Self { + Self { + is_recording: false, + output_path: None, + pipeline: None, + } + } +} + +pub struct VideoRecorderState { + pub is_recording: bool, + pub output_path: Option, + pub pipeline: Option, +} + +impl VideoRecorderState { + pub fn new() -> Self { + Self { + is_recording: false, + output_path: None, + pipeline: None, + } + } +} + +pub struct PreviewState { + pub latest_jpeg: std::sync::Arc>>>, +} + +impl PreviewState { + pub fn new() -> Self { + Self { + latest_jpeg: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } +} + +pub struct AppState { + pub audio: std::sync::Mutex, + pub video: std::sync::Mutex, + pub preview: PreviewState, +} + +impl AppState { + pub fn new() -> Self { + Self { + audio: std::sync::Mutex::new(AudioRecorderState::new()), + video: std::sync::Mutex::new(VideoRecorderState::new()), + preview: PreviewState::new(), + } + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..8047688 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,52 @@ +// file: src/commands.rs + +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 { + let result = media_audio::start_audio_recording(&state); + match result { + Ok(path) => Ok(path), + Err(error) => Err(error.to_user_message()), + } +} + +#[tauri::command] +pub async fn stop_audio_recording(state: tauri::State<'_, AppState>) -> Result { + 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 { + 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 { + let result = media_video::stop_video_recording(&state); + match result { + Ok(path) => Ok(path), + Err(error) => Err(error.to_user_message()), + } +} + +#[tauri::command] +pub async fn get_video_preview_frame_base64( + state: tauri::State<'_, AppState>, +) -> Result, String> { + let result = media_video::get_video_preview_frame_base64(&state); + match result { + Ok(value) => Ok(value), + Err(error) => Err(error.to_user_message()), + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..1cbb21d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,18 @@ +// file: src/error.rs + +#[derive(Debug)] +pub enum AppError { + Gst(String), + Io(String), + State(String), +} + +impl AppError { + pub fn to_user_message(&self) -> String { + match self { + AppError::Gst(message) => format!("gstreamer error: {message}"), + AppError::Io(message) => format!("io error: {message}"), + AppError::State(message) => format!("state error: {message}"), + } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5f30229 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,49 @@ +// file: src/lib.rs + +mod app_state; +mod commands; +mod error; +mod media_audio; +mod media_video; + +fn init_tracing() { + let subscriber_result = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .try_init(); + + if let Err(error) = subscriber_result { + eprintln!("failed to init tracing: {error}"); + } +} + +fn init_gstreamer() { + let init_result = gstreamer::init(); + if let Err(error) = init_result { + panic!("failed to init gstreamer: {error}"); + } +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + init_tracing(); + init_gstreamer(); + + let builder = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .manage(app_state::AppState::new()) + .invoke_handler(tauri::generate_handler![ + commands::start_audio_recording, + commands::stop_audio_recording, + commands::start_video_recording, + commands::stop_video_recording, + commands::get_video_preview_frame_base64, + ]); + + let run_result = builder.run(tauri::generate_context!()); + if let Err(error) = run_result { + tracing::error!(%error, "tauri run failed"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ae7c552 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,8 @@ +// file: src/main.rs + +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri_video02_lib::run() +} diff --git a/src/media_audio.rs b/src/media_audio.rs new file mode 100644 index 0000000..2ffc90e --- /dev/null +++ b/src/media_audio.rs @@ -0,0 +1,165 @@ +// file: src/media_audio.rs + +use crate::app_state::AppState; +use crate::error::AppError; +use gstreamer as gst; +use gst::prelude::*; + +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 mut dir = build_output_dir()?; + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); + dir.push(format!("audio-{timestamp}.wav")); + Ok(dir) +} + +fn build_pipeline(output_path: &std::path::Path) -> Result { + let location = output_path.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\""); + let pipeline_str = format!( + "autoaudiosrc ! audioconvert ! audioresample ! wavenc ! filesink location=\"{location}\"" + ); + + tracing::info!(pipeline = %pipeline_str, "building audio 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(), + )), + } +} + +pub fn start_audio_recording(state: &tauri::State<'_, AppState>) -> Result { + let mut guard = match state.audio.lock() { + Ok(value) => value, + Err(_) => { + return Err(AppError::State( + "audio state lock poisoned".to_string(), + )); + } + }; + + if guard.is_recording { + return Err(AppError::State( + "audio recording is already running".to_string(), + )); + } + + let output_path = build_output_path()?; + let pipeline = build_pipeline(&output_path)?; + + 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(), "audio 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_audio_recording(state: &tauri::State<'_, AppState>) -> Result { + let mut guard = match state.audio.lock() { + Ok(value) => value, + Err(_) => { + return Err(AppError::State( + "audio state lock poisoned".to_string(), + )); + } + }; + + if !guard.is_recording { + return Err(AppError::State( + "audio recording is not running".to_string(), + )); + } + + let pipeline = match guard.pipeline.take() { + Some(value) => value, + None => { + return Err(AppError::State( + "missing 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 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 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 pipeline to null: {error:?}" + ))); + } + } + + tracing::info!(path = %output_path.display(), "audio recording stopped"); + + guard.is_recording = false; + guard.output_path = None; + guard.pipeline = None; + + Ok(output_path.display().to_string()) +} diff --git a/src/media_video.rs b/src/media_video.rs new file mode 100644 index 0000000..2304b11 --- /dev/null +++ b/src/media_video.rs @@ -0,0 +1,292 @@ +// 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 emit-signals=false 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(); + + 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 { + return Ok(None); + }; + + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + Ok(Some(encoded)) +} diff --git a/tauri.conf.json b/tauri.conf.json new file mode 100644 index 0000000..0afed27 --- /dev/null +++ b/tauri.conf.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "tauri-video02", + "version": "0.1.0", + "identifier": "com.sinus.tauri-video02", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "./dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "tauri-video02", + "width": 800, + "height": 600 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f1e1928 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": "./frontend" + }, + "include": [ + "frontend" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..79531e8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, normalizePath } from "vite"; +import { NodePackageImporter } from "sass-embedded"; +import { resolve } from 'path'; + +const host = process.env.TAURI_DEV_HOST; + +// https://vite.dev/config/ +export default defineConfig(async () => ({ + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + root: 'frontend', // Set this to your frontend directory + publicDir: 'public', + build: { + outDir: './dist', // Output directory for the build + emptyOutDir: true, + rollupOptions: { + input: { + "main": normalizePath(resolve(__dirname, 'frontend/index.html')) + }, + output: { + entryFileNames: 'js/[name]-[hash].js', + chunkFileNames: 'js/chunks/[name]-[hash].js', + assetFileNames: (assetInfo) => { + const originalName = assetInfo.name ?? ''; + const ext = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase(); + if (ext === 'js') { + return 'js/[name]-[hash][extname]'; + } + // CSS + if (ext === 'css') { + return 'css/[name]-[hash][extname]'; + } + if (['eot', 'otf', 'ttf', 'woff', 'woff2'].includes(ext)) { + return 'fonts/[name]-[hash][extname]'; + } + if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'].includes(ext)) { + return 'imgs/[name][extname]'; + } + if (['mp4', 'webm'].includes(ext)) { + return 'videos/[name][extname]'; + } + return 'otherassets/[name][extname]'; + }, + }, + }, + minify: true, + sourcemap: false, + cssCodeSplit: true + }, + css: { + preprocessorOptions: { + scss: { + api: 'modern-compiler', + importers: [new NodePackageImporter()], + } + } + }, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + ignored: ["**/src/**"], + }, + }, +}));