v0.2.0 working
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tauri-video02"
|
name = "tauri-video02"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
description = "A Tauri Video App"
|
description = "A Tauri Video App"
|
||||||
authors = ["sinus@sasedev.net"]
|
authors = ["sinus@sasedev.net"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -29,3 +29,4 @@ tauri = { version = "^2.10", features = ["default"] }
|
|||||||
tauri-plugin-opener = { version = "^2.5", features = [] }
|
tauri-plugin-opener = { version = "^2.5", features = [] }
|
||||||
tracing = { version = "^0.1", features = ["async-await", "log"] }
|
tracing = { version = "^0.1", features = ["async-await", "log"] }
|
||||||
tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] }
|
tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] }
|
||||||
|
ts-rs = { version = "^12.0", features = [] }
|
||||||
|
|||||||
@@ -14,15 +14,22 @@
|
|||||||
<h1>POC 2 - Recording + Preview</h1>
|
<h1>POC 2 - Recording + Preview</h1>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Audio</h2>
|
<h2>Video Preview</h2>
|
||||||
|
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<img id="video-preview" alt="Video preview" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Audio + Video</h2>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="start-audio-btn" type="button">Start audio</button>
|
<button id="start-av-btn" type="button">Start AV</button>
|
||||||
<button id="stop-audio-btn" type="button" disabled>Stop
|
<button id="stop-av-btn" type="button" disabled>Stop AV</button>
|
||||||
audio</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pre id="audio-status">Ready.</pre>
|
<pre id="av-status">Ready.</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -34,11 +41,19 @@
|
|||||||
video</button>
|
video</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="preview-wrap">
|
<pre id="video-status">Ready.</pre>
|
||||||
<img id="video-preview" alt="Video preview" />
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Audio</h2>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="start-audio-btn" type="button">Start audio</button>
|
||||||
|
<button id="stop-audio-btn" type="button" disabled>Stop
|
||||||
|
audio</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pre id="video-status">Ready.</pre>
|
<pre id="audio-status">Ready.</pre>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="main.ts" defer></script>
|
<script type="module" src="main.ts" defer></script>
|
||||||
|
|||||||
221
frontend/main.ts
221
frontend/main.ts
@@ -1,4 +1,9 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { AvStartResponse } from './ts/bindings/AvStartResponse';
|
||||||
|
import { AvStopResponse } from './ts/bindings/AvStopResponse';
|
||||||
|
|
||||||
|
type Mode = "idle" | "audio" | "video" | "av";
|
||||||
|
|
||||||
const startAudioBtn = document.querySelector<HTMLButtonElement>("#start-audio-btn");
|
const startAudioBtn = document.querySelector<HTMLButtonElement>("#start-audio-btn");
|
||||||
const stopAudioBtn = document.querySelector<HTMLButtonElement>("#stop-audio-btn");
|
const stopAudioBtn = document.querySelector<HTMLButtonElement>("#stop-audio-btn");
|
||||||
@@ -7,7 +12,12 @@ const audioStatus = document.querySelector<HTMLElement>("#audio-status");
|
|||||||
const startVideoBtn = document.querySelector<HTMLButtonElement>("#start-video-btn");
|
const startVideoBtn = document.querySelector<HTMLButtonElement>("#start-video-btn");
|
||||||
const stopVideoBtn = document.querySelector<HTMLButtonElement>("#stop-video-btn");
|
const stopVideoBtn = document.querySelector<HTMLButtonElement>("#stop-video-btn");
|
||||||
const videoStatus = document.querySelector<HTMLElement>("#video-status");
|
const videoStatus = document.querySelector<HTMLElement>("#video-status");
|
||||||
const videoPreview = document.querySelector<HTMLImageElement>("#video-preview");
|
|
||||||
|
const startAvBtn = document.querySelector<HTMLButtonElement>("#start-av-btn");
|
||||||
|
const stopAvBtn = document.querySelector<HTMLButtonElement>("#stop-av-btn");
|
||||||
|
const avStatus = document.querySelector<HTMLElement>("#av-status");
|
||||||
|
|
||||||
|
const previewElement = document.querySelector<HTMLImageElement>("#video-preview");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
startAudioBtn === null ||
|
startAudioBtn === null ||
|
||||||
@@ -16,19 +26,34 @@ if (
|
|||||||
startVideoBtn === null ||
|
startVideoBtn === null ||
|
||||||
stopVideoBtn === null ||
|
stopVideoBtn === null ||
|
||||||
videoStatus === null ||
|
videoStatus === null ||
|
||||||
videoPreview === null
|
startAvBtn === null ||
|
||||||
|
stopAvBtn === null ||
|
||||||
|
avStatus === null ||
|
||||||
|
previewElement === null
|
||||||
) {
|
) {
|
||||||
throw new Error("missing UI elements");
|
throw new Error("missing UI elements");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentMode: Mode = "idle";
|
||||||
let previewTimer: number | null = null;
|
let previewTimer: number | null = null;
|
||||||
let previewRequestInFlight = false;
|
let previewRequestInFlight = false;
|
||||||
|
let previewObjectUrl: string | null = null;
|
||||||
|
|
||||||
function setAudioStatus(message: string): void {
|
function setAudioStatus(message: string): void {
|
||||||
if (audioStatus)
|
if (audioStatus)
|
||||||
audioStatus.textContent = message;
|
audioStatus.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setVideoStatus(message: string): void {
|
||||||
|
if (videoStatus)
|
||||||
|
videoStatus.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAvStatus(message: string): void {
|
||||||
|
if (avStatus)
|
||||||
|
avStatus.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
function setAudioButtons(isRecording: boolean): void {
|
function setAudioButtons(isRecording: boolean): void {
|
||||||
if (startAudioBtn)
|
if (startAudioBtn)
|
||||||
startAudioBtn.disabled = isRecording;
|
startAudioBtn.disabled = isRecording;
|
||||||
@@ -36,11 +61,6 @@ function setAudioButtons(isRecording: boolean): void {
|
|||||||
stopAudioBtn.disabled = !isRecording;
|
stopAudioBtn.disabled = !isRecording;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVideoStatus(message: string): void {
|
|
||||||
if (videoStatus)
|
|
||||||
videoStatus.textContent = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVideoButtons(isRecording: boolean): void {
|
function setVideoButtons(isRecording: boolean): void {
|
||||||
if (startVideoBtn)
|
if (startVideoBtn)
|
||||||
startVideoBtn.disabled = isRecording;
|
startVideoBtn.disabled = isRecording;
|
||||||
@@ -48,11 +68,92 @@ function setVideoButtons(isRecording: boolean): void {
|
|||||||
stopVideoBtn.disabled = !isRecording;
|
stopVideoBtn.disabled = !isRecording;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAvButtons(isRecording: boolean): void {
|
||||||
|
if (startAvBtn)
|
||||||
|
startAvBtn.disabled = isRecording;
|
||||||
|
if (stopAvBtn)
|
||||||
|
stopAvBtn.disabled = !isRecording;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAllButtonsForMode(mode: Mode): void {
|
||||||
|
if (mode === "idle") {
|
||||||
|
setAudioButtons(false);
|
||||||
|
setVideoButtons(false);
|
||||||
|
setAvButtons(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudioButtons(mode === "audio");
|
||||||
|
setVideoButtons(mode === "video");
|
||||||
|
setAvButtons(mode === "av");
|
||||||
|
|
||||||
|
if (mode !== "audio") {
|
||||||
|
if (stopAudioBtn)
|
||||||
|
stopAudioBtn.disabled = true;
|
||||||
|
if (startAudioBtn)
|
||||||
|
startAudioBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode !== "video") {
|
||||||
|
if (stopVideoBtn)
|
||||||
|
stopVideoBtn.disabled = true;
|
||||||
|
if (startVideoBtn)
|
||||||
|
startVideoBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode !== "av") {
|
||||||
|
if (stopAvBtn)
|
||||||
|
stopAvBtn.disabled = true;
|
||||||
|
if (startAvBtn)
|
||||||
|
startAvBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrentMode(mode: Mode): void {
|
||||||
|
currentMode = mode;
|
||||||
|
setAllButtonsForMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
const binary = window.atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
while (index < binary.length) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreviewImageFromBase64(encoded: string): void {
|
||||||
|
const bytes = base64ToUint8Array(encoded);
|
||||||
|
const copied = new Uint8Array(bytes.byteLength);
|
||||||
|
|
||||||
|
copied.set(bytes);
|
||||||
|
|
||||||
|
const blob = new Blob([copied.buffer], { type: "image/jpeg" });
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
if (previewObjectUrl !== null) {
|
||||||
|
URL.revokeObjectURL(previewObjectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewObjectUrl = objectUrl;
|
||||||
|
if (previewElement)
|
||||||
|
previewElement.src = objectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshPreviewFrame(): Promise<void> {
|
async function refreshPreviewFrame(): Promise<void> {
|
||||||
if (previewRequestInFlight) {
|
if (previewRequestInFlight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentMode !== "video" && currentMode !== "av") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
previewRequestInFlight = true;
|
previewRequestInFlight = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -73,8 +174,6 @@ function startPreviewPolling(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("starting preview polling");
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
void refreshPreviewFrame();
|
void refreshPreviewFrame();
|
||||||
}, 120);
|
}, 120);
|
||||||
@@ -91,97 +190,133 @@ function stopPreviewPolling(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
previewRequestInFlight = false;
|
previewRequestInFlight = false;
|
||||||
if (videoPreview)
|
|
||||||
videoPreview.removeAttribute("src");
|
|
||||||
|
|
||||||
if (previewObjectUrl !== null) {
|
if (previewObjectUrl !== null) {
|
||||||
URL.revokeObjectURL(previewObjectUrl);
|
URL.revokeObjectURL(previewObjectUrl);
|
||||||
previewObjectUrl = null;
|
previewObjectUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previewElement)
|
||||||
|
previewElement.removeAttribute("src");
|
||||||
}
|
}
|
||||||
|
|
||||||
startAudioBtn.addEventListener("click", async () => {
|
startAudioBtn.addEventListener("click", async () => {
|
||||||
startAudioBtn.disabled = true;
|
if (currentMode !== "idle") {
|
||||||
|
setAudioStatus("Another mode is already running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentMode("audio");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const path = await invoke<string>("start_audio_recording");
|
const path = await invoke<string>("start_audio_recording");
|
||||||
setAudioStatus(`Audio recording started.\nOutput: ${path}`);
|
setAudioStatus(`Audio recording started.\nOutput: ${path}`);
|
||||||
setAudioButtons(true);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAudioStatus(`Start audio failed.\n${String(error)}`);
|
setAudioStatus(`Start audio failed.\n${String(error)}`);
|
||||||
setAudioButtons(false);
|
setCurrentMode("idle");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stopAudioBtn.addEventListener("click", async () => {
|
stopAudioBtn.addEventListener("click", async () => {
|
||||||
|
if (currentMode !== "audio") {
|
||||||
|
setAudioStatus("Audio mode is not running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
stopAudioBtn.disabled = true;
|
stopAudioBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const path = await invoke<string>("stop_audio_recording");
|
const path = await invoke<string>("stop_audio_recording");
|
||||||
setAudioStatus(`Audio recording stopped.\nSaved file: ${path}`);
|
setAudioStatus(`Audio recording stopped.\nSaved file: ${path}`);
|
||||||
setAudioButtons(false);
|
setCurrentMode("idle");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAudioStatus(`Stop audio failed.\n${String(error)}`);
|
setAudioStatus(`Stop audio failed.\n${String(error)}`);
|
||||||
setAudioButtons(true);
|
setCurrentMode("audio");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
startVideoBtn.addEventListener("click", async () => {
|
startVideoBtn.addEventListener("click", async () => {
|
||||||
startVideoBtn.disabled = true;
|
if (currentMode !== "idle") {
|
||||||
|
setVideoStatus("Another mode is already running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentMode("video");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const path = await invoke<string>("start_video_recording");
|
const path = await invoke<string>("start_video_recording");
|
||||||
setVideoStatus(`Video recording started.\nOutput: ${path}`);
|
setVideoStatus(`Video recording started.\nOutput: ${path}`);
|
||||||
setVideoButtons(true);
|
|
||||||
startPreviewPolling();
|
startPreviewPolling();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVideoStatus(`Start video failed.\n${String(error)}`);
|
setVideoStatus(`Start video failed.\n${String(error)}`);
|
||||||
setVideoButtons(false);
|
|
||||||
stopPreviewPolling();
|
stopPreviewPolling();
|
||||||
|
setCurrentMode("idle");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stopVideoBtn.addEventListener("click", async () => {
|
stopVideoBtn.addEventListener("click", async () => {
|
||||||
|
if (currentMode !== "video") {
|
||||||
|
setVideoStatus("Video mode is not running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
stopVideoBtn.disabled = true;
|
stopVideoBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const path = await invoke<string>("stop_video_recording");
|
const path = await invoke<string>("stop_video_recording");
|
||||||
setVideoStatus(`Video recording stopped.\nSaved file: ${path}`);
|
setVideoStatus(`Video recording stopped.\nSaved file: ${path}`);
|
||||||
setVideoButtons(false);
|
|
||||||
stopPreviewPolling();
|
stopPreviewPolling();
|
||||||
|
setCurrentMode("idle");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVideoStatus(`Stop video failed.\n${String(error)}`);
|
setVideoStatus(`Stop video failed.\n${String(error)}`);
|
||||||
setVideoButtons(true);
|
setCurrentMode("video");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let previewObjectUrl: string | null = null;
|
startAvBtn.addEventListener("click", async () => {
|
||||||
|
if (currentMode !== "idle") {
|
||||||
function base64ToUint8Array(base64: string): Uint8Array {
|
setAvStatus("Another mode is already running.");
|
||||||
const binary = window.atob(base64);
|
return;
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
|
|
||||||
for (let index = 0;index < binary.length;index += 1) {
|
|
||||||
bytes[index] = binary.charCodeAt(index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytes;
|
setCurrentMode("av");
|
||||||
}
|
|
||||||
|
|
||||||
function updatePreviewImageFromBase64(encoded: string): void {
|
try {
|
||||||
const bytes = base64ToUint8Array(encoded);
|
const result = await invoke<AvStartResponse>("start_av_recording");
|
||||||
const copied = new Uint8Array(bytes.byteLength);
|
|
||||||
|
|
||||||
copied.set(bytes);
|
setAvStatus(
|
||||||
|
`AV recording started.\nAudio: ${result.audio_path}\nVideo: ${result.video_path}`
|
||||||
|
);
|
||||||
|
|
||||||
const blob = new Blob([copied.buffer], { type: "image/jpeg" });
|
startPreviewPolling();
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
} catch (error) {
|
||||||
|
setAvStatus(`Start AV failed.\n${String(error)}`);
|
||||||
|
stopPreviewPolling();
|
||||||
|
setCurrentMode("idle");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (previewObjectUrl !== null) {
|
stopAvBtn.addEventListener("click", async () => {
|
||||||
URL.revokeObjectURL(previewObjectUrl);
|
if (currentMode !== "av") {
|
||||||
|
setAvStatus("AV mode is not running.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
previewObjectUrl = objectUrl;
|
stopAvBtn.disabled = true;
|
||||||
if (videoPreview)
|
|
||||||
videoPreview.src = objectUrl;
|
try {
|
||||||
}
|
const result = await invoke<AvStopResponse>("stop_av_recording");
|
||||||
|
|
||||||
|
setAvStatus(
|
||||||
|
`AV recording stopped.\nAudio: ${result.audio_path}\nVideo: ${result.video_path}`
|
||||||
|
);
|
||||||
|
|
||||||
|
stopPreviewPolling();
|
||||||
|
setCurrentMode("idle");
|
||||||
|
} catch (error) {
|
||||||
|
setAvStatus(`Stop AV failed.\n${String(error)}`);
|
||||||
|
setCurrentMode("av");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentMode("idle");
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|||||||
3
frontend/ts/bindings/AvStartResponse.ts
Normal file
3
frontend/ts/bindings/AvStartResponse.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type AvStartResponse = { audio_path: string, video_path: string, };
|
||||||
3
frontend/ts/bindings/AvStopResponse.ts
Normal file
3
frontend/ts/bindings/AvStopResponse.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type AvStopResponse = { audio_path: string, video_path: string, };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tauri-video02",
|
"name": "tauri-video02",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -44,10 +44,23 @@ impl PreviewState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct AvRecorderState {
|
||||||
|
pub is_recording: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AvRecorderState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
is_recording: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub audio: std::sync::Mutex<AudioRecorderState>,
|
pub audio: std::sync::Mutex<AudioRecorderState>,
|
||||||
pub video: std::sync::Mutex<VideoRecorderState>,
|
pub video: std::sync::Mutex<VideoRecorderState>,
|
||||||
pub preview: PreviewState,
|
pub preview: PreviewState,
|
||||||
|
pub av: std::sync::Mutex<AvRecorderState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -56,6 +69,7 @@ impl AppState {
|
|||||||
audio: std::sync::Mutex::new(AudioRecorderState::new()),
|
audio: std::sync::Mutex::new(AudioRecorderState::new()),
|
||||||
video: std::sync::Mutex::new(VideoRecorderState::new()),
|
video: std::sync::Mutex::new(VideoRecorderState::new()),
|
||||||
preview: PreviewState::new(),
|
preview: PreviewState::new(),
|
||||||
|
av: std::sync::Mutex::new(AvRecorderState::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::media_audio;
|
use crate::media_audio;
|
||||||
|
use crate::media_av;
|
||||||
use crate::media_video;
|
use crate::media_video;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -50,3 +51,25 @@ pub async fn get_video_preview_frame_base64(
|
|||||||
Err(error) => Err(error.to_user_message()),
|
Err(error) => Err(error.to_user_message()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_av_recording(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<media_av::AvStartResponse, String> {
|
||||||
|
let result = media_av::start_av_recording(&state);
|
||||||
|
match result {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(error) => Err(error.to_user_message()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_av_recording(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<media_av::AvStopResponse, String> {
|
||||||
|
let result = media_av::stop_av_recording(&state);
|
||||||
|
match result {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(error) => Err(error.to_user_message()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod app_state;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod error;
|
mod error;
|
||||||
mod media_audio;
|
mod media_audio;
|
||||||
|
mod media_av;
|
||||||
mod media_video;
|
mod media_video;
|
||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
@@ -40,6 +41,8 @@ pub fn run() {
|
|||||||
commands::start_video_recording,
|
commands::start_video_recording,
|
||||||
commands::stop_video_recording,
|
commands::stop_video_recording,
|
||||||
commands::get_video_preview_frame_base64,
|
commands::get_video_preview_frame_base64,
|
||||||
|
commands::start_av_recording,
|
||||||
|
commands::stop_av_recording,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let run_result = builder.run(tauri::generate_context!());
|
let run_result = builder.run(tauri::generate_context!());
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use gstreamer as gst;
|
|
||||||
use gst::prelude::*;
|
use gst::prelude::*;
|
||||||
|
use gstreamer as gst;
|
||||||
|
|
||||||
fn build_output_dir() -> Result<std::path::PathBuf, AppError> {
|
fn build_output_dir() -> Result<std::path::PathBuf, AppError> {
|
||||||
let app_data_dir = dirs::data_local_dir();
|
let app_data_dir = dirs::data_local_dir();
|
||||||
|
|
||||||
let Some(mut base_dir) = app_data_dir else {
|
let Some(mut base_dir) = app_data_dir else {
|
||||||
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-video02");
|
base_dir.push("tauri-video02");
|
||||||
@@ -27,16 +29,26 @@ fn build_output_dir() -> Result<std::path::PathBuf, AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_output_path() -> Result<std::path::PathBuf, AppError> {
|
fn build_output_path() -> Result<std::path::PathBuf, AppError> {
|
||||||
let mut dir = build_output_dir()?;
|
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();
|
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
|
||||||
dir.push(format!("audio-{timestamp}.wav"));
|
dir.push(format!("audio-{timestamp}.wav"));
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_pipeline(output_path: &std::path::Path) -> Result<gst::Pipeline, AppError> {
|
fn build_pipeline(output_path: &std::path::Path) -> Result<gst::Pipeline, AppError> {
|
||||||
let location = output_path.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\"");
|
let location = output_path
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('"', "\\\"");
|
||||||
|
|
||||||
let pipeline_str = format!(
|
let pipeline_str = format!(
|
||||||
"autoaudiosrc ! audioconvert ! audioresample ! wavenc ! filesink location=\"{location}\""
|
"autoaudiosrc ! audioconvert ! audioresample ! wavenc ! filesink location=\"{}\"",
|
||||||
|
location
|
||||||
);
|
);
|
||||||
|
|
||||||
tracing::info!(pipeline = %pipeline_str, "building audio pipeline");
|
tracing::info!(pipeline = %pipeline_str, "building audio pipeline");
|
||||||
@@ -45,9 +57,7 @@ fn build_pipeline(output_path: &std::path::Path) -> Result<gst::Pipeline, AppErr
|
|||||||
let element = match element_result {
|
let element = match element_result {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(AppError::Gst(format!(
|
return Err(AppError::Gst(format!("unable to parse pipeline: {error}")));
|
||||||
"unable to parse pipeline: {error}"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,9 +74,7 @@ pub fn start_audio_recording(state: &tauri::State<'_, AppState>) -> Result<Strin
|
|||||||
let mut guard = match state.audio.lock() {
|
let mut guard = match state.audio.lock() {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Err(AppError::State(
|
return Err(AppError::State("audio state lock poisoned".to_string()));
|
||||||
"audio state lock poisoned".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,8 +84,15 @@ pub fn start_audio_recording(state: &tauri::State<'_, AppState>) -> Result<Strin
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let output_path = build_output_path()?;
|
let output_path = match build_output_path() {
|
||||||
let pipeline = build_pipeline(&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);
|
let set_state_result = pipeline.set_state(gst::State::Playing);
|
||||||
match set_state_result {
|
match set_state_result {
|
||||||
@@ -102,9 +117,7 @@ pub fn stop_audio_recording(state: &tauri::State<'_, AppState>) -> Result<String
|
|||||||
let mut guard = match state.audio.lock() {
|
let mut guard = match state.audio.lock() {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Err(AppError::State(
|
return Err(AppError::State("audio state lock poisoned".to_string()));
|
||||||
"audio state lock poisoned".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,7 +147,7 @@ pub fn stop_audio_recording(state: &tauri::State<'_, AppState>) -> Result<String
|
|||||||
|
|
||||||
let send_event_result = pipeline.send_event(gst::event::Eos::new());
|
let send_event_result = pipeline.send_event(gst::event::Eos::new());
|
||||||
if !send_event_result {
|
if !send_event_result {
|
||||||
tracing::warn!("failed to send EOS event to pipeline");
|
tracing::warn!("failed to send EOS event to audio pipeline");
|
||||||
}
|
}
|
||||||
|
|
||||||
let bus = pipeline.bus();
|
let bus = pipeline.bus();
|
||||||
|
|||||||
120
src/media_av.rs
Normal file
120
src/media_av.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// file: src/media_av.rs
|
||||||
|
|
||||||
|
use crate::app_state::AppState;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::media_audio;
|
||||||
|
use crate::media_video;
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, ts_rs::TS)]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/")]
|
||||||
|
pub struct AvStartResponse {
|
||||||
|
pub audio_path: String,
|
||||||
|
pub video_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, ts_rs::TS)]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/")]
|
||||||
|
pub struct AvStopResponse {
|
||||||
|
pub audio_path: String,
|
||||||
|
pub video_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_av_recording(state: &tauri::State<'_, AppState>) -> Result<AvStartResponse, AppError> {
|
||||||
|
let mut av_guard = match state.av.lock() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(AppError::State("av state lock poisoned".to_string()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if av_guard.is_recording {
|
||||||
|
return Err(AppError::State(
|
||||||
|
"av recording is already running".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_guard_result = state.audio.lock();
|
||||||
|
match audio_guard_result {
|
||||||
|
Ok(audio_guard) => {
|
||||||
|
if audio_guard.is_recording {
|
||||||
|
return Err(AppError::State("audio already running".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(AppError::State("audio state lock poisoned".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let video_guard_result = state.video.lock();
|
||||||
|
match video_guard_result {
|
||||||
|
Ok(video_guard) => {
|
||||||
|
if video_guard.is_recording {
|
||||||
|
return Err(AppError::State("video already running".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(AppError::State("video state lock poisoned".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_start_result = media_audio::start_audio_recording(state);
|
||||||
|
let audio_path = match audio_start_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let video_start_result = media_video::start_video_recording(state);
|
||||||
|
let video_path = match video_start_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let rollback_result = media_audio::stop_audio_recording(state);
|
||||||
|
if let Err(rollback_error) = rollback_result {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %rollback_error.to_user_message(),
|
||||||
|
"failed to rollback audio after video start failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
av_guard.is_recording = true;
|
||||||
|
|
||||||
|
Ok(AvStartResponse {
|
||||||
|
audio_path,
|
||||||
|
video_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_av_recording(state: &tauri::State<'_, AppState>) -> Result<AvStopResponse, AppError> {
|
||||||
|
let mut av_guard = match state.av.lock() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(AppError::State("av state lock poisoned".to_string()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !av_guard.is_recording {
|
||||||
|
return Err(AppError::State("av recording is not running".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_stop_result = media_audio::stop_audio_recording(state);
|
||||||
|
let audio_path = match audio_stop_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let video_stop_result = media_video::stop_video_recording(state);
|
||||||
|
let video_path = match video_stop_result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
av_guard.is_recording = false;
|
||||||
|
|
||||||
|
Ok(AvStopResponse {
|
||||||
|
audio_path,
|
||||||
|
video_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -123,8 +123,6 @@ fn attach_preview_callbacks(
|
|||||||
|
|
||||||
let bytes = map.as_slice().to_vec();
|
let bytes = map.as_slice().to_vec();
|
||||||
|
|
||||||
tracing::info!(size = bytes.len(), "preview sample received");
|
|
||||||
|
|
||||||
let lock_result = preview_store.lock();
|
let lock_result = preview_store.lock();
|
||||||
match lock_result {
|
match lock_result {
|
||||||
Ok(mut guard) => {
|
Ok(mut guard) => {
|
||||||
@@ -286,12 +284,9 @@ pub fn get_video_preview_frame_base64(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let Some(bytes) = guard.as_ref() else {
|
let Some(bytes) = guard.as_ref() else {
|
||||||
tracing::info!("preview frame requested but none is available yet");
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!(size = bytes.len(), "preview frame requested and returned");
|
|
||||||
|
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
Ok(Some(encoded))
|
Ok(Some(encoded))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "tauri-video02",
|
"productName": "tauri-video02",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"identifier": "com.sinus.tauri-video02",
|
"identifier": "com.sinus.tauri-video02",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user