diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99d3e017..1fc2ae1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,4 @@ The video segments are stored in your app data directory, under the folder `so.c ### Notes for development on Windows: - - -Required dlls: `avutil`, `avformat`, `avcodec`, `avdevice`. -Put the full version of ffmpeg for the target arch into `target/binaries`. +Requirements: llvm, clang and VCPKG are required for compiling ffmpeg-sys. diff --git a/Cargo.lock b/Cargo.lock index abc0a223..ea66481a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -960,11 +960,15 @@ dependencies = [ name = "cap-utils" version = "0.1.0" dependencies = [ + "flume 0.11.0", "futures", "nix 0.29.0", + "serde", + "serde_json", "tokio", "uuid", "windows 0.58.0", + "windows-sys 0.52.0", ] [[package]] @@ -1955,6 +1959,7 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-oauth", "tauri-plugin-os", + "tauri-plugin-positioner", "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-single-instance", @@ -7042,9 +7047,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cdaf6701ee5efc83161cf41004aa5aec4d255c9fb2d2b11fe518fe506acc588" +checksum = "a1a1edf18000f02903a7c2e5997fb89aca455ecbc0acc15c6535afbb883be223" dependencies = [ "anyhow", "dunce", @@ -7065,9 +7070,9 @@ dependencies = [ [[package]] name = "tauri-plugin-global-shortcut" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c15fb7f5e4c80a73ce97217dcff27e423f496178cbcb87e13b4efe99eebb550" +checksum = "00f646a09511e8d283267dcdaa08c2ef27c4116bf271d9114849d9ca215606c3" dependencies = [ "global-hotkey", "log", @@ -7075,14 +7080,14 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 1.0.63", + "thiserror 2.0.6", ] [[package]] name = "tauri-plugin-http" -version = "2.0.4" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6f26c4e715b50f06e3fde65cda1d805dae23f04869a01380c4cf8708dbb296" +checksum = "e62a9bde54d6a0218b63f5a248f02056ad4316ba6ad81dfb9e4f73715df5deb1" dependencies = [ "data-url", "http", @@ -7102,9 +7107,9 @@ dependencies = [ [[package]] name = "tauri-plugin-notification" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef492a2d19b6376bb4c9e0c4fab3f3bf8a220ea112d24f35027b737ff55de20c" +checksum = "46ab803095f14ac6521fdb6477210a49e86fed6623c3c97d8e4b2b35e045e922" dependencies = [ "log", "notify-rust", @@ -7114,7 +7119,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 1.0.63", + "thiserror 2.0.6", "time", "url", ] @@ -7135,9 +7140,9 @@ dependencies = [ [[package]] name = "tauri-plugin-os" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc5f23a86f37687c7f4fecfdc706b279087bc44f7a46702f7307ff1551ee03a" +checksum = "dda2d571a9baf0664c1f2088db227e3072f9028602fafa885deade7547c3b738" dependencies = [ "gethostname 0.5.0", "log", @@ -7148,7 +7153,22 @@ dependencies = [ "sys-locale", "tauri", "tauri-plugin", - "thiserror 1.0.63", + "thiserror 2.0.6", +] + +[[package]] +name = "tauri-plugin-positioner" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c95b371d489bee3d1be5e5bd1538080ad408317fcc2d8546d24b290249f7bb5" +dependencies = [ + "log", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.6", ] [[package]] @@ -7163,9 +7183,9 @@ dependencies = [ [[package]] name = "tauri-plugin-shell" -version = "2.0.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad7880c5586b6b2104be451e3d7fc0f3800c84bda69e9ba81c828f87cb34267" +checksum = "bb2c50a63e60fb8925956cc5b7569f4b750ac197a4d39f13b8dd46ea8e2bad79" dependencies = [ "encoding_rs", "log", @@ -7178,7 +7198,7 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 1.0.63", + "thiserror 2.0.6", "tokio", ] @@ -7199,25 +7219,25 @@ dependencies = [ [[package]] name = "tauri-plugin-store" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9a580be53f04bb62422d239aa798e88522877f58a0d4a0e745f030055a51bb4" +checksum = "1c0c08fae6995909f5e9a0da6038273b750221319f2c0f3b526d6de1cde21505" dependencies = [ "dunce", - "log", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 1.0.63", + "thiserror 2.0.6", "tokio", + "tracing", ] [[package]] name = "tauri-plugin-updater" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ba9adaede60b0df5e0764692c6ac176eb133aade95d326bddeb968ad793320" +checksum = "b7351014c140906bcfff59d96e04b1170c8f602557f40eb37f7de356d4e7067b" dependencies = [ "base64 0.22.1", "dirs", @@ -7245,9 +7265,9 @@ dependencies = [ [[package]] name = "tauri-plugin-window-state" -version = "2.0.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683c8764751fbbcebf3a594bcee24cf84c62773fa0080d1b40fc80698472421e" +checksum = "234dd891cc7960fa28f93ea911f3e0d9ce8375ebf9ff303831bdd7a3443d5714" dependencies = [ "bitflags 2.6.0", "log", @@ -7255,7 +7275,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 1.0.63", + "thiserror 2.0.6", ] [[package]] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e6a708a1..677ba076 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@cap/desktop", "type": "module", "scripts": { - "dev": "RUST_BACKTRACE=1 dotenv -e ../../.env -- pnpm run preparescript && tauri dev", + "dev": "cross-env RUST_BACKTRACE=1 dotenv -e ../../.env -- pnpm run preparescript && tauri dev", "build:tauri": "dotenv -e ../../.env -- pnpm run preparescript && tauri build", "preparescript": "node scripts/prepare.js", "localdev": "dotenv -e ../../.env -- vinxi dev --port 3001", @@ -64,6 +64,7 @@ "@tauri-apps/cli": ">=2.1.0", "@total-typescript/ts-reset": "^0.6.1", "@types/dom-webcodecs": "^0.1.11", + "cross-env": "^7.0.3", "typescript": "^5.7.2", "vite": "^5.4.3", "vite-tsconfig-paths": "^5.0.1" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8f7fc3ff..a06751cc 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -26,18 +26,19 @@ tauri = { workspace = true, features = [ ] } tauri-specta = { version = "=2.0.0-rc.20", features = ["derive", "typescript"] } tauri-plugin-dialog = "2.0.0" -tauri-plugin-fs = "2.0.0-rc.0" -tauri-plugin-global-shortcut = "2.0.1" -tauri-plugin-http = "2.0.4" -tauri-plugin-notification = "2.0.1" -tauri-plugin-os = "2.0.0" +tauri-plugin-fs = "2.2.0" +tauri-plugin-global-shortcut = "2.1.0" +tauri-plugin-http = "2.1.0" +tauri-plugin-notification = "2.1.0" +tauri-plugin-os = "2.1.0" tauri-plugin-process = "2.0.1" -tauri-plugin-shell = "2.0.0" +tauri-plugin-shell = "2.1.0" tauri-plugin-single-instance = "2.0.1" -tauri-plugin-store = "2.0.0" -tauri-plugin-updater = "2.1.0" +tauri-plugin-store = "2.2.0" +tauri-plugin-updater = "2.2.0" tauri-plugin-oauth = { git = "https://github.com/FabianLars/tauri-plugin-oauth", branch = "v2" } -tauri-plugin-window-state = "2.0.2" +tauri-plugin-window-state = "2.2.0" +tauri-plugin-positioner = "2.2.0" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index fc022ef4..abcfb7b3 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -16,19 +16,42 @@ pub async fn export_video( force: bool, use_custom_muxer: bool, ) -> Result { - let VideoRecordingMetadata { duration, .. } = - get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)) + let screen_metadata = + match get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)).await { + Ok(meta) => meta, + Err(e) => { + sentry::capture_message( + &format!("Failed to get video metadata: {}", e), + sentry::Level::Error, + ); + return Err( + "Failed to read video metadata. The recording may be from an incompatible version." + .to_string(), + ); + } + }; + + // Get camera metadata if it exists + let camera_metadata = + get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Camera)) .await - .unwrap(); + .ok(); + + // Use the longer duration between screen and camera + let duration = screen_metadata.duration.max( + camera_metadata + .map(|m| m.duration) + .unwrap_or(screen_metadata.duration), + ); - // 30 FPS (calculated for output video) - let total_frames = (duration * 30.0).round() as u32; + // Calculate total frames with ceiling to ensure we don't exceed 100% + let total_frames = ((duration * 30.0).ceil() as u32).max(1); let editor_instance = upsert_editor_instance(&app, video_id.clone()).await; let output_path = editor_instance.meta().output_path(); - // If the file exists, return it immediately + // If the file exists and we're not forcing a re-render, return it if output_path.exists() && !force { return Ok(output_path); } @@ -37,14 +60,25 @@ pub async fn export_video( .send(RenderProgress::EstimatedTotalFrames { total_frames }) .ok(); + // Create a modified project configuration that accounts for different video lengths + let mut modified_project = project.clone(); + if let Some(timeline) = &mut modified_project.timeline { + // Ensure timeline duration matches the longest video + for segment in timeline.segments.iter_mut() { + if segment.end > duration { + segment.end = duration; + } + } + } + let exporter = cap_export::Exporter::new( - project, + modified_project, output_path.clone(), move |frame_index| { + // Ensure progress never exceeds total frames + let current_frame = (frame_index + 1).min(total_frames); progress - .send(RenderProgress::FrameRendered { - current_frame: frame_index + 1, - }) + .send(RenderProgress::FrameRendered { current_frame }) .ok(); }, editor_instance.project_path.clone(), @@ -57,17 +91,20 @@ pub async fn export_video( e.to_string() })?; - if use_custom_muxer { + let result = if use_custom_muxer { exporter.export_with_custom_muxer().await } else { exporter.export_with_ffmpeg_cli().await - } - .map_err(|e| { - sentry::capture_message(&e.to_string(), sentry::Level::Error); - e.to_string() - })?; + }; - ShowCapWindow::PrevRecordings.show(&app).ok(); - - Ok(output_path) + match result { + Ok(_) => { + ShowCapWindow::PrevRecordings.show(&app).ok(); + Ok(output_path) + } + Err(e) => { + sentry::capture_message(&e.to_string(), sentry::Level::Error); + Err(e.to_string()) + } + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c2a8afae..9f450d78 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -88,6 +88,7 @@ pub struct App { pub enum VideoType { Screen, Output, + Camera, } #[derive(Serialize, Deserialize, specta::Type)] @@ -952,6 +953,17 @@ async fn get_video_metadata( let paths = match video_type { Some(VideoType::Screen) => content_paths(&project_path, &meta), + Some(VideoType::Camera) => match &meta.content { + Content::SingleSegment { segment } => segment + .camera + .as_ref() + .map_or(vec![], |c| vec![segment.path(&meta, &c.path)]), + Content::MultipleSegments { inner } => inner + .segments + .iter() + .filter_map(|s| s.camera.as_ref().map(|c| inner.path(&meta, &c.path))) + .collect(), + }, Some(VideoType::Output) | None => { let output_video_path = project_path.join("output").join("result.mp4"); println!("Using output video path: {:?}", output_video_path); @@ -1039,7 +1051,7 @@ fn focus_captures_panel(app: AppHandle) { #[derive(Serialize, Deserialize, specta::Type, Clone)] #[serde(tag = "type")] -enum RenderProgress { +pub enum RenderProgress { Starting { total_frames: u32 }, EstimatedTotalFrames { total_frames: u32 }, FrameRendered { current_frame: u32 }, @@ -1896,21 +1908,8 @@ pub async fn run() { #[cfg(target_os = "macos")] { - builder = builder.plugin(tauri_nspanel::init()); - } - - builder - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_store::Builder::new().build()) - .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_oauth::init()) - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_deep_link::init()) - .plugin( + builder = builder.plugin(tauri_nspanel::init()).plugin( + // TODO(Ilya): Also enable for Windows when Tao is updated to `0.31.0` tauri_plugin_window_state::Builder::new() .with_state_flags({ use tauri_plugin_window_state::StateFlags; @@ -1930,8 +1929,21 @@ pub async fn run() { _ => label, }) .build(), - ) + ); + } + + builder + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_oauth::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_notification::init()) .plugin(flags::plugin::init()) + .plugin(tauri_plugin_deep_link::init()) .invoke_handler({ let handler = specta_builder.invoke_handler(); @@ -2091,25 +2103,27 @@ pub async fn run() { match event { WindowEvent::Destroyed => { - match CapWindowId::from_str(label).unwrap() { - CapWindowId::Main => { - if let Some(w) = CapWindowId::Camera.get(app) { - w.close().ok(); + if let Ok(window_id) = CapWindowId::from_str(label) { + match window_id { + CapWindowId::Main => { + if let Some(w) = CapWindowId::Camera.get(app) { + w.close().ok(); + } } - } - CapWindowId::Editor { project_id } => { - let app_handle = app.clone(); - tokio::spawn(async move { - let _ = remove_editor_instance(&app_handle, project_id).await; - tokio::task::yield_now().await; - }); - } - CapWindowId::Settings | CapWindowId::Upgrade => { - // Don't quit the app when settings or upgrade window is closed - return; - } - _ => {} - }; + CapWindowId::Editor { project_id } => { + let app_handle = app.clone(); + tokio::spawn(async move { + let _ = remove_editor_instance(&app_handle, project_id).await; + tokio::task::yield_now().await; + }); + } + CapWindowId::Settings | CapWindowId::Upgrade => { + // Don't quit the app when settings or upgrade window is closed + return; + } + _ => {} + }; + } if let Some(settings) = GeneralSettingsStore::get(app).unwrap_or(None) { if settings.hide_dock_icon @@ -2123,11 +2137,13 @@ pub async fn run() { } } } + #[cfg(target_os = "macos")] WindowEvent::Focused(focused) if *focused => { - if CapWindowId::from_str(label).unwrap().activates_dock() { - #[cfg(target_os = "macos")] - app.set_activation_policy(tauri::ActivationPolicy::Regular) - .ok(); + if let Ok(window_id) = CapWindowId::from_str(label) { + if window_id.activates_dock() { + app.set_activation_policy(tauri::ActivationPolicy::Regular) + .ok(); + } } } _ => {} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index d4e977f3..ca0e0264 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -1,4 +1,5 @@ #![allow(unused_mut)] +#![allow(unused_imports)] use crate::{fake_window, general_settings::AppTheme}; use cap_flags::FLAGS; @@ -9,8 +10,12 @@ use tauri::{ AppHandle, LogicalPosition, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, Wry, }; +#[cfg(target_os = "macos")] const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition = LogicalPosition::new(12.0, 12.0); +#[cfg(target_os = "windows")] +const WIN_WSCAPTION_WSTHICKFRAME_LOGICAL_SIZE: tauri::LogicalSize = tauri::LogicalSize::new(12.0, 35.0); + #[derive(Clone)] pub enum CapWindowId { // Contains onboarding + permissions @@ -90,6 +95,7 @@ impl CapWindowId { app.get_webview_window(&label) } + #[cfg(target_os = "macos")] pub fn traffic_lights_position(&self) -> Option>> { match self { Self::Editor { .. } => Some(Some(LogicalPosition::new(20.0, 40.0))), @@ -99,10 +105,12 @@ impl CapWindowId { _ => Some(None), } } + pub fn min_size(&self) -> Option<(f64, f64)> { Some(match self { Self::Setup => (600.0, 600.0), Self::Main => (300.0, 360.0), + Self::Editor { .. } => (900.0, 800.0), Self::Settings => (600.0, 450.0), Self::Camera => (460.0, 920.0), _ => return None, @@ -151,6 +159,7 @@ impl ShowCapWindow { .resizable(false) .maximized(false) .maximizable(false) + .center() .build()?, Self::Settings { page } => self .window_builder( @@ -159,11 +168,13 @@ impl ShowCapWindow { ) .resizable(true) .maximized(false) + .center() .build()?, Self::Editor { project_id } => self .window_builder(app, format!("/editor?id={project_id}")) .inner_size(1150.0, 800.0) .maximizable(true) + .center() .build()?, Self::Upgrade => self .window_builder(app, "/upgrade") @@ -173,6 +184,7 @@ impl ShowCapWindow { .always_on_top(true) .maximized(false) .transparent(true) + .center() .build()?, Self::Camera { ws_port } => { const WINDOW_SIZE: f64 = 230.0 * 2.0; @@ -242,7 +254,12 @@ impl ShowCapWindow { if FLAGS.pause_resume { width += 32.0; } - let height = 40.0; + let mut height = 40.0; + #[cfg(target_os = "windows")] + { + width -= WIN_WSCAPTION_WSTHICKFRAME_LOGICAL_SIZE.width; + height -= WIN_WSCAPTION_WSTHICKFRAME_LOGICAL_SIZE.height; + } self.window_builder(app, "/in-progress-recording") .maximized(false) @@ -318,6 +335,44 @@ impl ShowCapWindow { window.hide().ok(); + // TODO(Ilya): Remove once Tao is updated to `0.31.0` + #[cfg(target_os = "windows")] + { + use tauri_plugin_positioner::{Position, WindowExt}; + + if matches!( + self, + Self::Setup + | Self::Main + | Self::Editor { .. } + | Self::Settings { .. } + | Self::Upgrade + ) { + let _ = window.move_window(Position::Center); + } + + if matches!(self, Self::InProgressRecording { .. }) { + let _ = window.move_window(Position::BottomCenter); + + if let Ok(outer_size) = window.outer_size() { + let screen_position = monitor.position(); + let window_size = tauri::PhysicalSize:: { + width: outer_size.width as i32, + height: outer_size.height as i32, + }; + let screen_size = tauri::PhysicalSize:: { + width: monitor.size().width as i32, + height: monitor.size().height as i32, + }; + + let _ = window.set_position(tauri::PhysicalPosition { + x: screen_position.x + ((screen_size.width / 2) - (window_size.width / 2)), + y: screen_size.height - (window_size.height - screen_position.y) - 120, + }); + } + } + } + #[cfg(target_os = "macos")] if let Some(position) = id.traffic_lights_position() { add_traffic_lights(&window, position); @@ -340,9 +395,32 @@ impl ShowCapWindow { .shadow(true); if let Some(min) = id.min_size() { + // TODO(Ilya): Remove once Tao is updated to `0.31.0` + // currently, undecorated windows with shadows get the invisible bounds of the titlebar and window frame added to the inner size + #[cfg(target_os = "windows")] + let size = if matches!( + self, + Self::Setup + | Self::Main + | Self::Editor { .. } + | Self::Settings { .. } + | Self::InProgressRecording { .. } + | Self::Upgrade + ) { + ( + min.0 - WIN_WSCAPTION_WSTHICKFRAME_LOGICAL_SIZE.width, + min.1 - WIN_WSCAPTION_WSTHICKFRAME_LOGICAL_SIZE.height, + ) + } else { + min + }; + + #[cfg(not(target_os = "windows"))] + let size = min; + builder = builder - .inner_size(min.0, min.1) - .min_inner_size(min.0, min.1); + .inner_size(size.0, size.1) + .min_inner_size(size.0, size.1); } #[cfg(target_os = "macos")] diff --git a/apps/desktop/src/components/titlebar/Titlebar.tsx b/apps/desktop/src/components/titlebar/Titlebar.tsx index 16019842..e26e1a8a 100644 --- a/apps/desktop/src/components/titlebar/Titlebar.tsx +++ b/apps/desktop/src/components/titlebar/Titlebar.tsx @@ -14,7 +14,7 @@ export default function Titlebar() { return (
) { local.class, focused() ? "*:text-black-transparent-80" : "*:text-black-transparent-40" )} - style={{ - "font-family": "Segoe Fluent Icons', 'Segoe MDL2 Assets" - }} {...otherProps} > -
-
- - + batch(() => { + setTitlebar("border", false); + setTitlebar("height", "4rem"); + setTitlebar("transparent", true); + setTitlebar( + "items", +
+
+
+ + +
-
- ); + ); + }); return ( <> @@ -235,6 +239,7 @@ import { save } from "@tauri-apps/plugin-dialog"; import { DEFAULT_PROJECT_CONFIG } from "./projectConfig"; import { createMutation } from "@tanstack/solid-query"; import { getRequestEvent } from "solid-js/web"; +import { checkIsUpgradedAndUpdate } from "~/utils/plans"; function ExportButton() { const { videoId, project, prettyName } = useEditorContext(); @@ -260,14 +265,26 @@ function ExportButton() { const progress = new Channel(); progress.onmessage = (p) => { if (p.type === "FrameRendered" && progressState.type === "saving") { - const percentComplete = Math.round( - (p.current_frame / (progressState.totalFrames || 1)) * 100 + const percentComplete = Math.min( + Math.round( + (p.current_frame / (progressState.totalFrames || 1)) * 100 + ), + 100 ); + setProgressState({ ...progressState, renderProgress: p.current_frame, message: `Rendering video - ${percentComplete}%`, }); + + // If rendering is complete, update to finalizing state + if (percentComplete === 100) { + setProgressState({ + ...progressState, + message: "Finalizing export...", + }); + } } if ( p.type === "EstimatedTotalFrames" && @@ -281,25 +298,30 @@ function ExportButton() { } }; - const videoPath = await commands.exportVideo( - videoId, - project, - progress, - true, - useCustomMuxer - ); - await commands.copyFileToPath(videoPath, path); + try { + const videoPath = await commands.exportVideo( + videoId, + project, + progress, + true, + useCustomMuxer + ); + await commands.copyFileToPath(videoPath, path); - setProgressState({ - type: "saving", - progress: 100, - message: "Saved successfully!", - mediaPath: path, - }); + setProgressState({ + type: "saving", + progress: 100, + message: "Saved successfully!", + mediaPath: path, + }); - setTimeout(() => { + setTimeout(() => { + setProgressState({ type: "idle" }); + }, 1500); + } catch (error) { setProgressState({ type: "idle" }); - }, 1500); + throw error; + } }, })); @@ -330,6 +352,13 @@ function ShareButton() { throw new Error("Recording metadata not available"); } + // Check for pro access first before starting the export + const isUpgraded = await checkIsUpgradedAndUpdate(); + if (!isUpgraded) { + await commands.showWindow("Upgrade"); + throw new Error("Upgrade required to share recordings"); + } + let unlisten: (() => void) | undefined; try { diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index d18f878c..07b0627b 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -172,7 +172,7 @@ export default function () { class="non-styled-move cursor-move flex items-center justify-center p-[0.25rem] border-l border-gray-400 dark:border-gray-200 hover:cursor-move" data-tauri-drag-region > - + ); diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index b0a6b2d6..83a1d636 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -375,14 +375,26 @@ export default function () { undefined && progressState.totalFrames ) { - return `${Math.min( + const progress = Math.min( Math.round( (progressState.renderProgress / progressState.totalFrames) * 100 ), 100 - )}%`; + ); + + // If we hit 100%, transition to the next stage + if (progress === 100 && progressState.type === "uploading") { + setProgressState({ + ...progressState, + stage: "uploading", + message: "Starting upload...", + uploadProgress: 0 + }); + } + + return `${progress}%`; } return progressState.message; diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index a8b14ca3..d669bc47 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -284,7 +284,7 @@ export type UploadProgress = { stage: string; progress: number; message: string export type UploadResult = { Success: string } | "NotAuthenticated" | "PlanCheckFailed" | "UpgradeRequired" export type Video = { duration: number; width: number; height: number } export type VideoRecordingMetadata = { duration: number; size: number } -export type VideoType = "screen" | "output" +export type VideoType = "screen" | "output" | "camera" export type XY = { x: T; y: T } export type ZoomMode = "auto" | { manual: { x: number; y: number } } export type ZoomSegment = { start: number; end: number; amount: number; mode: ZoomMode } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6bc463cc..dbdd2ebb 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1,8 +1,10 @@ use std::{sync::Arc, time::Instant}; use cap_media::frame_ws::WSFrame; -use cap_project::{BackgroundSource, ProjectConfiguration}; -use cap_rendering::{decoder::DecodedFrame, produce_frame, ProjectUniforms, RenderVideoConstants}; +use cap_project::{BackgroundSource, ProjectConfiguration, RecordingMeta}; +use cap_rendering::{ + decoder::DecodedFrame, produce_frame, ProjectRecordings, ProjectUniforms, RenderVideoConstants, +}; use tokio::{ sync::{mpsc, oneshot}, task::JoinHandle, @@ -26,6 +28,7 @@ pub struct Renderer { rx: mpsc::Receiver, frame_tx: flume::Sender, render_constants: Arc, + total_frames: u32, } pub struct RendererHandle { @@ -36,13 +39,28 @@ impl Renderer { pub fn spawn( render_constants: Arc, frame_tx: flume::Sender, + meta: &RecordingMeta, ) -> RendererHandle { + let recordings = ProjectRecordings::new(meta); + let mut max_duration = recordings.duration(); + + // Check camera duration if it exists + if let Some(camera_path) = meta.content.camera_path() { + if let Ok(camera_duration) = recordings.get_source_duration(&camera_path) { + max_duration = max_duration.max(camera_duration); + } + } + + let total_frames = (30_f64 * max_duration).ceil() as u32; + println!("Editor total frames: {total_frames}"); + let (tx, rx) = mpsc::channel(4); let this = Self { rx, frame_tx, render_constants, + total_frames, }; tokio::spawn(this.run()); @@ -61,7 +79,7 @@ impl Renderer { camera_frame, background, uniforms, - time, // Add this + time, finished, } => { if let Some(task) = frame_task.as_ref() { @@ -74,6 +92,7 @@ impl Renderer { let render_constants = self.render_constants.clone(); let frame_tx = self.frame_tx.clone(); + let total_frames = self.total_frames; frame_task = Some(tokio::spawn(async move { let frame = produce_frame( @@ -83,6 +102,7 @@ impl Renderer { cap_rendering::Background::from(background), &uniforms, time, + total_frames, ) .await .unwrap(); diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index e45084ba..e3a3cdf8 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -86,7 +86,11 @@ impl EditorInstance { .unwrap(), ); - let renderer = Arc::new(editor::Renderer::spawn(render_constants.clone(), frame_tx)); + let renderer = Arc::new(editor::Renderer::spawn( + render_constants.clone(), + frame_tx, + &meta, + )); let (preview_tx, preview_rx) = watch::channel(None); diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index a2791b0c..85612a07 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -5,6 +5,10 @@ description = "A modular multimedia processing library" edition = "2021" rust-version = "1.80" +[features] +default = [] +debug-logging = [] # Feature flag to control debug logging + [dependencies] cap-project = { path = "../project" } cap-flags = { path = "../flags" } @@ -21,7 +25,7 @@ serde = { version = "1", features = ["derive"] } specta.workspace = true tempfile = "3.12.0" thiserror.workspace = true -tracing = "0.1" +tracing = { version = "0.1", features = ["release_max_level_info"] } futures = "0.3.31" axum = "0.7.9" tokio.workspace = true diff --git a/crates/media/src/data.rs b/crates/media/src/data.rs index b06ba3a1..ead0c7eb 100644 --- a/crates/media/src/data.rs +++ b/crates/media/src/data.rs @@ -264,16 +264,35 @@ impl VideoInfo { pub fn wrap_frame(&self, data: &[u8], timestamp: i64, stride: usize) -> FFVideo { let mut frame = FFVideo::new(self.pixel_format, self.width, self.height); - frame.set_pts(Some(timestamp)); + let frame_stride = frame.stride(0) as usize; + let frame_height = self.height as usize; + + // Ensure we don't try to copy more data than we have if frame.stride(0) == self.width as usize { - frame.data_mut(0)[0..data.len()].copy_from_slice(data); + let copy_len = std::cmp::min(data.len(), frame.data_mut(0).len()); + frame.data_mut(0)[0..copy_len].copy_from_slice(&data[0..copy_len]); } else { - let ffmpeg_stride = frame.stride(0) as usize; - for (line, chunk) in data.chunks(stride).enumerate() { - frame.data_mut(0)[line * ffmpeg_stride..(line + 1) * ffmpeg_stride] - .copy_from_slice(&chunk[0..ffmpeg_stride]); + for line in 0..frame_height { + if line * stride >= data.len() { + break; // Stop if we run out of source data + } + + let src_start = line * stride; + let src_end = std::cmp::min(src_start + frame_stride, data.len()); + if src_end <= src_start { + break; // Stop if we can't get any more source data + } + + let dst_start = line * frame_stride; + let dst_end = dst_start + (src_end - src_start); + + // Only copy if we have enough destination space + if dst_end <= frame.data_mut(0).len() { + frame.data_mut(0)[dst_start..dst_end] + .copy_from_slice(&data[src_start..src_end]); + } } } diff --git a/crates/media/src/feeds/audio_input.rs b/crates/media/src/feeds/audio_input.rs index 49b53200..83fb834a 100644 --- a/crates/media/src/feeds/audio_input.rs +++ b/crates/media/src/feeds/audio_input.rs @@ -1,8 +1,8 @@ -use cpal::traits::{DeviceTrait, HostTrait}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{Device, InputCallbackInfo, SampleFormat, StreamConfig, SupportedStreamConfig}; use flume::{Receiver, Sender, TrySendError}; use indexmap::IndexMap; -use tracing::warn; +use tracing::{warn, error, info, debug}; use crate::{ data::{ffmpeg_sample_format_for, AudioInfo}, @@ -55,37 +55,52 @@ impl AudioInputFeed { } pub async fn init(selected_input: &str) -> Result { + info!("Initializing audio input feed with device: {}", selected_input); + let (device, config) = Self::list_devices() .swap_remove_entry(selected_input) .map(|(device_name, (device, config))| { - println!("Using audio device: {}", device_name); + info!("Using audio device: {} with config: {:?}", device_name, config); (device, config) }) - .unwrap(); + .ok_or_else(|| { + error!("Failed to find audio device: {}", selected_input); + MediaError::DeviceUnreachable(selected_input.to_string()) + })?; - let audio_info = AudioInfo::from_stream_config(&config)?; + let audio_info = AudioInfo::from_stream_config(&config).map_err(|e| { + error!("Failed to create audio info from stream config: {}", e); + e + })?; + + debug!("Created audio info: {:?}", audio_info); let (control_tx, control_rx) = flume::bounded(1); std::thread::spawn(|| start_capturing(device, config, control_rx)); + info!("Started audio capture thread"); Ok(Self { control_tx, audio_info, - // rx: samples_rx, }) } pub fn list_devices() -> AudioInputDeviceMap { + info!("Listing available audio input devices"); let host = cpal::default_host(); let mut device_map = IndexMap::new(); let get_usable_device = |device: Device| { device .supported_input_configs() - .map_err(|error| eprintln!("Error: {error}")) + .map_err(|error| { + error!("Error getting supported input configs for device: {}", error); + error + }) .ok() .and_then(|configs| { let mut configs = configs.collect::>(); + debug!("Found {} supported configs", configs.len()); configs.sort_by(|a, b| { b.sample_format() .sample_size() @@ -100,31 +115,33 @@ impl AudioInputFeed { .find(|c| ffmpeg_sample_format_for(c.sample_format()).is_some()) }) .and_then(|config| { - device - .name() - .ok() - .map(|name| (name, device, config.with_max_sample_rate())) + device.name().ok().map(|name| { + debug!("Found usable device: {} with config: {:?}", name, config); + (name, device, config.with_max_sample_rate()) + }) }) }; - if let Some((name, device, config)) = - host.default_input_device().and_then(get_usable_device) - { + if let Some((name, device, config)) = host.default_input_device().and_then(get_usable_device) { + info!("Found default input device: {}", name); device_map.insert(name, (device, config)); + } else { + warn!("No default input device found or it's not usable"); } match host.input_devices() { Ok(devices) => { for (name, device, config) in devices.filter_map(get_usable_device) { + debug!("Found additional device: {}", name); device_map.entry(name).or_insert((device, config)); } } Err(error) => { - eprintln!("Could not access audio input devices"); - eprintln!("{error}"); + error!("Could not access audio input devices: {}", error); } } + info!("Found {} usable audio input devices", device_map.len()); device_map } @@ -179,43 +196,64 @@ fn start_capturing( mut config: SupportedStreamConfig, control: Receiver, ) { + info!("Starting audio capture with device: {:?}, config: {:?}", device.name(), config); let mut senders: Vec = vec![]; loop { let (tx, rx) = flume::bounded(4); + info!("Building input stream with config: {:?}", config); let stream_config: StreamConfig = config.clone().into(); - let stream = device + let stream = match device .build_input_stream_raw( &stream_config, config.sample_format(), move |data, info| { - tx.send(AudioInputSamples { + if let Err(e) = tx.send(AudioInputSamples { data: data.bytes().to_vec(), format: data.sample_format(), info: info.clone(), - }) - .ok(); + }) { + error!("Failed to send audio samples: {}", e); + } + }, + |e| { + error!("Error in audio input stream: {}", e); }, - |_e| {}, None, - ) - .map_err(|error| { - eprintln!("Error while preparing audio capture: {error}"); - MediaError::TaskLaunch("Failed to start audio capture".into()) - }); + ) { + Ok(stream) => { + info!("Successfully built audio input stream"); + stream + } + Err(err) => { + error!("Failed to build audio input stream: {}", err); + // Sleep briefly to avoid tight error loop + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + // Try to play the stream + if let Err(e) = stream.play() { + error!("Failed to start audio stream playback: {}", e); + continue; + } + info!("Audio stream playback started"); loop { match control.try_recv() { Ok(AudioInputControl::Switch(name, response)) => { + info!("Switching audio device to: {}", name); // list_devices hangs if the stream isn't dropped drop(stream); let Some(items) = AudioInputFeed::list_devices().swap_remove_entry(&name).map( |(device_name, (device, config))| { - println!("Using audio device: {}", device_name); + info!("Switching to audio device: {} with config: {:?}", device_name, config); (device, config) }, ) else { + error!("Failed to find audio device: {}", name); response .send(Err(MediaError::DeviceUnreachable(name))) .unwrap(); @@ -229,13 +267,15 @@ fn start_capturing( break; } Ok(AudioInputControl::Shutdown) => { + info!("Received shutdown signal for audio capture"); return; } Ok(AudioInputControl::AttachSender(sender)) => { + info!("New audio sender attached"); senders.push(sender); } Err(flume::TryRecvError::Disconnected) => { - println!("Control receiver is unreachable! Shutting down"); + warn!("Control receiver is unreachable! Shutting down audio capture"); return; } Err(flume::TryRecvError::Empty) => { @@ -248,19 +288,22 @@ fn start_capturing( let mut to_remove = vec![]; for (i, sender) in senders.iter().enumerate() { if let Err(TrySendError::Disconnected(_)) = sender.try_send(data.clone()) { + warn!("Audio sender {} disconnected, will be removed", i); to_remove.push(i); }; } - for i in to_remove.into_iter().rev() { - senders.swap_remove(i); + if !to_remove.is_empty() { + debug!("Removing {} disconnected audio senders", to_remove.len()); + for i in to_remove.into_iter().rev() { + senders.swap_remove(i); + } } } Err(error) => { - warn!("Failed to capture audio sampels: {:?}", error); - // Optionally, add a small delay to avoid busy-waiting - std::thread::sleep(std::time::Duration::from_millis(10)); - continue; + error!("Failed to capture audio samples: {:?}", error); + // Break inner loop to recreate the stream + break; } } } diff --git a/crates/media/src/feeds/camera.rs b/crates/media/src/feeds/camera.rs index b479e9c9..e790d76c 100644 --- a/crates/media/src/feeds/camera.rs +++ b/crates/media/src/feeds/camera.rs @@ -1,12 +1,12 @@ use cap_gpu_converters::{NV12Input, NV12ToRGBA, UYVYToRGBA}; use ffmpeg::software::scaling; use flume::{Receiver, Sender, TryRecvError}; -use nokhwa::{utils::*, Camera}; +use nokhwa::{pixel_format::RgbFormat, utils::*, Camera}; use std::{ thread::{self, JoinHandle}, time::Instant, }; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, trace, warn}; use crate::{ data::{FFVideo, RawVideoFormat, VideoInfo}, @@ -59,7 +59,8 @@ impl CameraFeed { selected_camera: &str, rgba_data: Sender, ) -> Result { - println!("Selected camera: {:?}", selected_camera); + #[cfg(feature = "debug-logging")] + debug!("Initializing camera feed for: {}", selected_camera); let camera_info = find_camera(selected_camera)?; let (control, control_receiver) = flume::bounded(1); @@ -83,10 +84,7 @@ impl CameraFeed { .into_iter() .map(|i| i.human_name().to_string()) .collect::>(), - Err(e) => { - eprintln!("Failed to query cameras: {}", e); - Vec::new() - } + Err(_) => Vec::new() } } @@ -139,38 +137,42 @@ fn find_camera(selected_camera: &str) -> Result { } fn create_camera(info: &CameraInfo) -> Result { - dbg!(info); + #[cfg(feature = "debug-logging")] + debug!("Creating camera with info: {:?}", info); - // TODO: Make selected format more flexible - // let format = RequestedFormat::new::(RequestedFormatType::AbsoluteHighestResolution); let format = RequestedFormat::with_formats( - RequestedFormatType::ClosestIgnoringFormat { - resolution: Resolution { - width_x: 1920, - height_y: 1080, - }, - frame_rate: 30, - }, - &[FrameFormat::NV12], + RequestedFormatType::AbsoluteHighestFrameRate, + &[FrameFormat::YUYV], ); + + #[cfg(feature = "debug-logging")] + trace!("Requested camera format: {:?}", format); let index = info.index().clone(); #[cfg(target_os = "macos")] { let device = nokhwa_bindings_macos::AVCaptureDevice::new(&index).unwrap(); - let formats = device.supported_formats()?; - dbg!(formats); + if let Ok(formats) = device.supported_formats() { + #[cfg(feature = "debug-logging")] + trace!("Supported formats: {:?}", formats); + } } - Ok(Camera::new(index, format)?) + let camera = Camera::new(index, format)?; + + #[cfg(feature = "debug-logging")] + debug!("Created camera with format: {:?}", camera.camera_format()); + + Ok(camera) } fn find_and_create_camera(selected_camera: &String) -> Result<(CameraInfo, Camera), MediaError> { let info = find_camera(selected_camera)?; let camera = create_camera(&info)?; - dbg!(camera.camera_format()); + #[cfg(feature = "debug-logging")] + trace!("Camera format: {:?}", camera.camera_format()); Ok((info, camera)) } @@ -218,37 +220,23 @@ fn run_camera_feed( return; } - info!("Camera stream opened successfully"); - let mut converter = None; let mut ready_signal = Some(ready_signal); loop { match control.try_recv() { - Err(TryRecvError::Disconnected) => { - println!("Control receiver is unreachable! Shutting down"); - break; - } - Err(TryRecvError::Empty) => { - // No signal received, nothing to do - } - Ok(CameraControl::Shutdown) => { - println!("Shutdown request received."); - break; - } + Err(TryRecvError::Disconnected) => break, + Err(TryRecvError::Empty) => {}, + Ok(CameraControl::Shutdown) => break, Ok(CameraControl::AttachRawConsumer(rgba_sender)) => { - eprintln!("Attaching to a new pipeline consumer. Any previously attached consumer will be dropped"); maybe_raw_data = Some(rgba_sender); } Ok(CameraControl::Switch(camera_name, switch_result)) => { if maybe_raw_data.is_some() { switch_result.send(Err(MediaError::Any("Cannot switch cameras while the feed is attached to a running pipeline"))).unwrap(); } else { - println!("Switching camera to {camera_name}"); - match find_and_create_camera(&camera_name) { Err(error) => { - eprintln!("{error}"); switch_result.send(Err(error)).unwrap(); } Ok((new_info, mut new_camera)) => { @@ -256,17 +244,12 @@ fn run_camera_feed( let new_converter = FrameConverter::build(new_format); if new_camera.open_stream().is_ok() { - println!("Now using {camera_name}"); let _ = camera.stop_stream(); switch_result .send(Ok((new_info, new_converter.video_info))) .unwrap(); camera = new_camera; - // converter = new_converter; } else { - eprintln!( - "Unable to switch to {camera_name}. Still using previous camera" - ); switch_result .send(Err(MediaError::DeviceUnreachable(camera_name))) .unwrap(); @@ -309,9 +292,7 @@ fn run_camera_feed( *converter = FrameConverter::build(format); } - // TODO: Merge fix in nokhwa lib to use presentation timestamps from the system, like scap does let captured_at = Instant::now(); - let rgba_frame = converter.rgba(&raw_buffer); if dropping_send( @@ -325,8 +306,6 @@ fn run_camera_feed( ) .is_err() { - // TODO: Also allow changing the connection? - eprintln!("Camera preview has been disconnected. Shutting down feed"); break; } @@ -336,14 +315,12 @@ fn run_camera_feed( captured_at, }; if dropping_send(raw_data, frame).is_err() { - eprintln!("Raw data consumer has been disconnected."); maybe_raw_data = None; } } } Err(error) => { warn!("Failed to capture frame: {:?}", error); - // Optionally, add a small delay to avoid busy-waiting std::thread::sleep(std::time::Duration::from_millis(10)); continue; } @@ -351,7 +328,6 @@ fn run_camera_feed( } let _ = camera.stop_stream(); - println!("Closed {} stream", camera.info().human_name()); } struct FrameConverter { @@ -369,8 +345,8 @@ pub enum HwConverter { impl FrameConverter { fn build(camera_format: CameraFormat) -> Self { let format = match camera_format.format() { - FrameFormat::MJPEG => RawVideoFormat::Rgba, - FrameFormat::YUYV => RawVideoFormat::Uyvy, + FrameFormat::MJPEG => RawVideoFormat::Mjpeg, + FrameFormat::YUYV => RawVideoFormat::Rgba, FrameFormat::NV12 => RawVideoFormat::Nv12, FrameFormat::GRAY => RawVideoFormat::Gray, FrameFormat::RAWRGB => RawVideoFormat::RawRgb, @@ -384,66 +360,108 @@ impl FrameConverter { camera_format.frame_rate(), ); + // Create FFmpeg converter let context = ffmpeg::software::converter( (video_info.width, video_info.height), - video_info.pixel_format, + if camera_format.format() == FrameFormat::YUYV { + ffmpeg::format::Pixel::YUYV422 + } else { + video_info.pixel_format + }, ffmpeg::format::Pixel::RGBA, - ) - .unwrap(); - - let hw_converter = match camera_format.format() { - FrameFormat::NV12 => Some(HwConverter::NV12(futures::executor::block_on( - NV12ToRGBA::new(), - ))), - FrameFormat::YUYV => Some(HwConverter::UYVY(futures::executor::block_on( - UYVYToRGBA::new(), - ))), - _ => None, - }; + ).unwrap(); Self { video_info, context, format: camera_format.format(), - hw_converter, + hw_converter: None, // Don't use hardware converters } } fn rgba(&mut self, buffer: &nokhwa::Buffer) -> Vec { let resolution = buffer.resolution(); - let data = match &self.hw_converter { - Some(HwConverter::NV12(converter)) => converter.convert( - NV12Input::from_buffer(buffer.buffer(), resolution.width(), resolution.height()), - resolution.width(), - resolution.height(), - ), - Some(HwConverter::UYVY(converter)) => { - converter.convert(buffer.buffer(), resolution.width(), resolution.height()) + match self.format { + FrameFormat::YUYV => { + self.convert_with_ffmpeg(buffer, resolution) } - None => { - let input_frame = self.video_info.wrap_frame( - buffer.buffer(), - 0, - buffer.buffer().len() / buffer.resolution().height() as usize, - ); - let mut rgba_frame = FFVideo::empty(); - - self.context.run(&input_frame, &mut rgba_frame).unwrap(); + _ => { + match &self.hw_converter { + Some(HwConverter::NV12(converter)) => { + converter.convert( + NV12Input::from_buffer(buffer.buffer(), resolution.width(), resolution.height()), + resolution.width(), + resolution.height(), + ) + } + _ => { + self.convert_with_ffmpeg(buffer, resolution) + } + } + } + } + } + fn convert_with_ffmpeg(&mut self, buffer: &nokhwa::Buffer, resolution: Resolution) -> Vec { + if self.format == FrameFormat::YUYV { + // For YUYV, we need to handle the conversion differently + let stride = resolution.width() as usize * 2; // YUYV uses 2 bytes per pixel + + // Create input frame with YUYV format + let mut input_frame = FFVideo::new(ffmpeg::format::Pixel::YUYV422, resolution.width(), resolution.height()); + input_frame.data_mut(0).copy_from_slice(buffer.buffer()); + + // Convert directly to RGBA + let mut rgba_frame = FFVideo::new(ffmpeg::format::Pixel::RGBA, resolution.width(), resolution.height()); + + if self.context.run(&input_frame, &mut rgba_frame).is_ok() { rgba_frame.data(0).to_vec() + } else { + vec![0; (resolution.width() * resolution.height() * 4) as usize] } - }; - - data + } else { + // For other formats, use the normal conversion path + let stride = resolution.width() as usize * 4; // RGBA uses 4 bytes per pixel + let input_frame = self.video_info.wrap_frame(buffer.buffer(), 0, stride); + + let mut rgba_frame = FFVideo::empty(); + if self.context.run(&input_frame, &mut rgba_frame).is_ok() { + rgba_frame.data(0).to_vec() + } else { + vec![0; (resolution.width() * resolution.height() * 4) as usize] + } + } } fn raw(&mut self, buffer: &nokhwa::Buffer) -> FFVideo { - self.video_info.wrap_frame( - buffer.buffer(), - 0, - buffer.buffer_bytes().len() / buffer.resolution().height() as usize, - ) + let resolution = buffer.resolution(); + + if self.format == FrameFormat::YUYV { + // For YUYV, we need to handle the conversion differently + let stride = resolution.width() as usize * 2; // YUYV uses 2 bytes per pixel + + // Create input frame with YUYV format + let mut input_frame = FFVideo::new(ffmpeg::format::Pixel::YUYV422, resolution.width(), resolution.height()); + input_frame.data_mut(0).copy_from_slice(buffer.buffer()); + input_frame + } else { + // For other formats, use the normal conversion path + let stride = match self.format { + FrameFormat::NV12 => resolution.width() as usize, // 1 byte per pixel for Y plane + FrameFormat::BGRA => resolution.width() as usize * 4, // 4 bytes per pixel + FrameFormat::MJPEG => resolution.width() as usize * 4, // 4 bytes per pixel + FrameFormat::GRAY => resolution.width() as usize, // 1 byte per pixel + FrameFormat::RAWRGB => resolution.width() as usize * 3, // 3 bytes per pixel + _ => buffer.buffer_bytes().len() / resolution.height() as usize, + }; + + self.video_info.wrap_frame( + buffer.buffer(), + 0, + stride, + ) + } } } diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index f13cca56..7a1da07c 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -82,6 +82,18 @@ pub enum Content { }, } +impl Content { + pub fn camera_path(&self) -> Option { + match self { + Content::SingleSegment { segment } => segment.camera.as_ref().map(|c| c.path.clone()), + Content::MultipleSegments { inner } => inner + .segments + .first() + .and_then(|s| s.camera.as_ref().map(|c| c.path.clone())), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct SingleSegment { pub display: Display, diff --git a/crates/rendering/src/decoder.rs b/crates/rendering/src/decoder.rs index 30f0f93b..0e869f7e 100644 --- a/crates/rendering/src/decoder.rs +++ b/crates/rendering/src/decoder.rs @@ -1,7 +1,7 @@ use std::{ collections::BTreeMap, path::PathBuf, - sync::{mpsc, Arc}, + sync::{mpsc, Arc, Mutex}, }; use ffmpeg::{ @@ -179,6 +179,7 @@ impl AsyncVideoDecoder { let mut last_decoded_frame = None::; let mut last_sent_frame = None::<(u32, DecodedFrame)>; + let mut reached_end = false; let mut peekable_requests = PeekableReceiver { rx, peeked: None }; @@ -187,6 +188,14 @@ impl AsyncVideoDecoder { while let Ok(r) = peekable_requests.recv() { match r { VideoDecoderMessage::GetFrame(requested_frame, sender) => { + // If we've already reached the end and have a last frame, return it + if reached_end { + if let Some((_, last_frame)) = &last_sent_frame { + sender.send(Some(last_frame.clone())).ok(); + continue; + } + } + let mut sender = if let Some(cached) = cache.get_mut(&requested_frame) { let data = cached.process(&mut scaler_input_format, &mut scaler, &decoder); @@ -201,30 +210,95 @@ impl AsyncVideoDecoder { let cache_min = requested_frame.saturating_sub(FRAME_CACHE_SIZE as u32 / 2); let cache_max = requested_frame + FRAME_CACHE_SIZE as u32 / 2; - if requested_frame <= 0 - || last_sent_frame - .as_ref() - .map(|last| { - requested_frame < last.0 || - // seek forward for big jumps. this threshold is arbitrary but should be derived from i-frames in future - requested_frame - last.0 > FRAME_CACHE_SIZE as u32 + if cache.len() >= FRAME_CACHE_SIZE { + // When cache is full, remove old frames that are far from the requested frame + let frames_to_remove: Vec<_> = cache + .keys() + .filter(|&&k| { + // Keep frames within a window of the requested frame + let distance = if k <= requested_frame { + requested_frame - k + } else { + k - requested_frame + }; + // Remove frames that are more than half the cache size away + distance > FRAME_CACHE_SIZE as u32 / 2 }) - .unwrap_or(true) + .copied() + .collect(); + + for frame in frames_to_remove { + println!( + "Removing old frame {} from cache (requested_frame: {})", + frame, requested_frame + ); + cache.remove(&frame); + } + + // If we still need to remove frames, remove the ones furthest from the requested frame + if cache.len() >= FRAME_CACHE_SIZE { + let frame_to_remove = cache + .keys() + .max_by_key(|&&k| { + if k <= requested_frame { + requested_frame - k + } else { + k - requested_frame + } + }) + .copied() + .unwrap(); + println!( + "Removing distant frame {} from cache (requested_frame: {})", + frame_to_remove, requested_frame + ); + cache.remove(&frame_to_remove); + } + } + + // Only seek if we're going backwards or if we're jumping more than half the cache size + // AND we don't have the frame in cache already + // AND we haven't reached the end of the video + if !reached_end + && !cache.contains_key(&requested_frame) + && (requested_frame <= 0 + || last_sent_frame + .as_ref() + .map(|last| { + let backwards = requested_frame < last.0; + let big_jump = requested_frame > last.0 + && requested_frame.saturating_sub(last.0) + > FRAME_CACHE_SIZE as u32 / 2; + backwards || big_jump + }) + .unwrap_or(true)) { let timestamp_us = ((requested_frame as f32 / frame_rate.numerator() as f32) * 1_000_000.0) as i64; let position = timestamp_us.rescale((1, 1_000_000), rescale::TIME_BASE); - println!("seeking to {position} for frame {requested_frame}"); - decoder.flush(); - input.seek(position, ..position).unwrap(); - cache.clear(); - last_decoded_frame = None; - last_sent_frame = None; - + // Drop the old packets iterator to release the mutable borrow + drop(packets); + let seek_result = input.seek(position, ..position); + // Create new packets iterator regardless of seek result packets = input.packets(); + + match seek_result { + Ok(_) => { + cache.clear(); + last_decoded_frame = None; + } + Err(_) => { + // If seek fails, we've likely reached the end + reached_end = true; + if let Some((_, last_frame)) = &last_sent_frame { + sender.take().map(|s| s.send(Some(last_frame.clone()))); + } + continue; + } + } } // handle when requested_frame == last_decoded_frame or last_decoded_frame > requested_frame. @@ -310,35 +384,65 @@ impl AsyncVideoDecoder { last_sent_frame = Some((current_frame, data.clone())); sender.send(Some(data)).ok(); - break; } + } else if current_frame + > last_sent_frame.as_ref().map(|f| f.0).unwrap_or(0) + { + // Keep last_sent_frame up to date even for frames we're not sending + let data = cache_frame.process( + &mut scaler_input_format, + &mut scaler, + &decoder, + ); + last_sent_frame = Some((current_frame, data)); } if cache.len() >= FRAME_CACHE_SIZE { - if let Some(last_active_frame) = &last_active_frame { - let frame = if requested_frame > *last_active_frame - { - *cache.keys().next().unwrap() - } else if requested_frame < *last_active_frame { - *cache.keys().next_back().unwrap() - } else { - let min = *cache.keys().min().unwrap(); - let max = *cache.keys().max().unwrap(); - - if current_frame > max { - min + // When cache is full, remove old frames that are far from the requested frame + let frames_to_remove: Vec<_> = cache + .keys() + .filter(|&&k| { + // Keep frames within a window of the requested frame + let distance = if k <= requested_frame { + requested_frame - k } else { - max - } - }; - + k - requested_frame + }; + // Remove frames that are more than half the cache size away + distance > FRAME_CACHE_SIZE as u32 / 2 + }) + .copied() + .collect(); + + for frame in frames_to_remove { + println!("Removing old frame {} from cache (requested_frame: {})", frame, requested_frame); cache.remove(&frame); - } else { - cache.clear() + } + + // If we still need to remove frames, remove the ones furthest from the requested frame + if cache.len() >= FRAME_CACHE_SIZE { + let frame_to_remove = cache + .keys() + .max_by_key(|&&k| { + if k <= requested_frame { + requested_frame - k + } else { + k - requested_frame + } + }) + .copied() + .unwrap(); + println!("Removing distant frame {} from cache (requested_frame: {})", frame_to_remove, requested_frame); + cache.remove(&frame_to_remove); } } + println!( + "Inserting frame {} into cache (size: {})", + current_frame, + cache.len() + ); cache.insert(current_frame, cache_frame); } @@ -359,23 +463,51 @@ impl AsyncVideoDecoder { } }); - AsyncVideoDecoderHandle { sender: tx } + AsyncVideoDecoderHandle { + sender: tx, + last_valid_frame: Arc::new(Mutex::new(None)), + reached_end: Arc::new(Mutex::new(false)), + } } } #[derive(Clone)] pub struct AsyncVideoDecoderHandle { sender: mpsc::Sender, + last_valid_frame: Arc>>, + reached_end: Arc>, } impl AsyncVideoDecoderHandle { - pub async fn get_frame(&self, time: u32) -> Option>> { + pub async fn get_frame(&self, frame_number: u32) -> Option { + // If we've already reached the end of the video, just return the last valid frame + if *self.reached_end.lock().unwrap() { + return self.last_valid_frame.lock().unwrap().clone(); + } + let (tx, rx) = tokio::sync::oneshot::channel(); self.sender - .send(VideoDecoderMessage::GetFrame(time, tx)) - .unwrap(); - let res = rx.await.ok().flatten(); - res + .send(VideoDecoderMessage::GetFrame(frame_number, tx)) + .ok()?; + + // Wait for response with a timeout + match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { + Ok(Ok(frame)) => { + if let Some(frame) = &frame { + // Store this as the last valid frame + *self.last_valid_frame.lock().unwrap() = Some(frame.clone()); + } else { + // If we got no frame, we've reached the end + *self.reached_end.lock().unwrap() = true; + } + // If we got no frame but have a last valid frame, return that instead + frame.or_else(|| self.last_valid_frame.lock().unwrap().clone()) + } + _ => { + // On timeout, return last valid frame if we have one + self.last_valid_frame.lock().unwrap().clone() + } + } } } diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 511c6887..06bd5575 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1,6 +1,13 @@ use anyhow::Result; use bytemuck::{Pod, Zeroable}; use cap_flags::FLAGS; +use cap_project::{ + AspectRatio, BackgroundSource, CameraXPosition, CameraYPosition, Content, Crop, + CursorAnimationStyle, CursorClickEvent, CursorData, CursorEvents, CursorMoveEvent, + ProjectConfiguration, RecordingMeta, ZoomSegment, FAST_SMOOTHING_SAMPLES, + FAST_VELOCITY_THRESHOLD, REGULAR_SMOOTHING_SAMPLES, REGULAR_VELOCITY_THRESHOLD, + SLOW_SMOOTHING_SAMPLES, SLOW_VELOCITY_THRESHOLD, XY, +}; use core::f64; use decoder::{AsyncVideoDecoder, AsyncVideoDecoderHandle}; use futures::future::OptionFuture; @@ -13,14 +20,6 @@ use tokio::sync::mpsc; use wgpu::util::DeviceExt; use wgpu::{CommandEncoder, COPY_BYTES_PER_ROW_ALIGNMENT}; -use cap_project::{ - AspectRatio, BackgroundSource, CameraXPosition, CameraYPosition, Content, Crop, - CursorAnimationStyle, CursorClickEvent, CursorData, CursorEvents, CursorMoveEvent, - ProjectConfiguration, RecordingMeta, ZoomSegment, FAST_SMOOTHING_SAMPLES, - FAST_VELOCITY_THRESHOLD, REGULAR_SMOOTHING_SAMPLES, REGULAR_VELOCITY_THRESHOLD, - SLOW_SMOOTHING_SAMPLES, SLOW_VELOCITY_THRESHOLD, XY, -}; - use image::GenericImageView; use std::path::Path; use std::time::Instant; @@ -118,7 +117,19 @@ impl RecordingSegmentDecoders { OptionFuture::from(self.camera.as_ref().map(|d| d.get_frame(frame_number))) ); - screen_frame.map(|f| (f, camera_frame.flatten())) + // Create black frames with the correct dimensions + let black_screen = vec![0; (1920 * 804 * 4) as usize]; + let black_camera = vec![0; (1920 * 1080 * 4) as usize]; + + // Return frames or black frames as needed + Some(( + screen_frame.unwrap_or_else(|| Arc::new(black_screen)), + self.camera.as_ref().map(|_| { + camera_frame + .flatten() + .unwrap_or_else(|| Arc::new(black_camera)) + }), + )) } } @@ -143,7 +154,7 @@ pub struct RenderSegment { pub async fn render_video_to_channel( options: RenderOptions, - project: ProjectConfiguration, + mut project: ProjectConfiguration, sender: mpsc::Sender, meta: &RecordingMeta, segments: Vec, @@ -155,76 +166,197 @@ pub async fn render_video_to_channel( let start_time = Instant::now(); - let duration = project - .timeline() - .map(|t| t.duration()) - .unwrap_or(recordings.duration()); + // Get the duration from the timeline if it exists, otherwise use the longest source duration + let duration = { + let mut max_duration = recordings.duration(); + println!("Initial screen recording duration: {}", max_duration); + + // Check camera duration if it exists + if let Some(camera_path) = meta.content.camera_path() { + if let Ok(camera_duration) = recordings.get_source_duration(&camera_path) { + println!("Camera recording duration: {}", camera_duration); + max_duration = max_duration.max(camera_duration); + println!("New max duration after camera check: {}", max_duration); + } + } - println!("export duration: {duration}"); - println!("export duration: {duration}"); + // If there's a timeline, ensure all segments extend to the max duration + if let Some(timeline) = &mut project.timeline { + println!("Found timeline with {} segments", timeline.segments.len()); + for (i, segment) in timeline.segments.iter_mut().enumerate() { + println!( + "Segment {} - current end: {}, max_duration: {}", + i, segment.end, max_duration + ); + if segment.end < max_duration { + segment.end = max_duration; + println!("Extended segment {} to new end: {}", i, segment.end); + } + } + let final_duration = timeline.duration(); + println!( + "Final timeline duration after adjustments: {}", + final_duration + ); + final_duration + } else { + println!("No timeline found, using max_duration: {}", max_duration); + max_duration + } + }; - let mut frame_number = 0; + let total_frames = (30_f64 * duration).ceil() as u32; + println!( + "Final export duration: {} seconds ({} frames at 30fps)", + duration, total_frames + ); + // Send initial frame to communicate total frames + let initial_frame = RenderedFrame { + data: vec![], + width: 0, + height: 0, + padded_bytes_per_row: 0, + total_frames: Some(total_frames), + }; + sender.send(initial_frame).await?; + + let mut frame_number = 0; let background = Background::from(project.background.source.clone()); loop { - if frame_number as f64 > 30_f64 * duration { + if frame_number >= total_frames { + println!("Reached total frames: {frame_number}/{total_frames}"); break; - }; + } - let (time, segment_i) = if let Some(timeline) = project.timeline() { + println!("Processing frame {frame_number}/{total_frames}"); + + let (time, segment_i) = if let Some(timeline) = &project.timeline { + println!("Getting time from timeline for frame {}", frame_number); match timeline.get_recording_time(frame_number as f64 / 30_f64) { - Some(value) => value, + Some(value) => { + println!( + "Timeline returned time: {}, segment: {:?}", + value.0, value.1 + ); + value + } None => { - println!("no time"); + println!( + "Timeline returned None for frame {} (time: {})", + frame_number, + frame_number as f64 / 30_f64 + ); + println!( + "Timeline segments: {:?}", + timeline + .segments + .iter() + .map(|s| (s.start, s.end)) + .collect::>() + ); break; } } } else { - (frame_number as f64 / 30_f64, None) + let time = frame_number as f64 / 30_f64; + println!("No timeline, using direct time calculation: {}", time); + (time, None) }; - let segment = &segments[segment_i.unwrap_or(0) as usize]; - + let segment_index = segment_i.unwrap_or(0) as usize; + println!("Using segment {} for frame {}", segment_index, frame_number); + let segment = &segments[segment_index]; let uniforms = ProjectUniforms::new(&constants, &project, time as f32); - if let Some((screen_frame, camera_frame)) = - segment.decoders.get_frames((time * 30.0) as u32).await - { - let frame = produce_frame( - &constants, - &screen_frame, - &camera_frame, - background, - &uniforms, - time as f32, - ) - .await?; + println!("Getting frames for time: {} (frame {})", time, frame_number); + // Get frames or use last valid frames if past duration + let (screen_frame, camera_frame) = + match segment.decoders.get_frames((time * 30.0) as u32).await { + Some((screen, camera)) => { + println!( + "Successfully got frames for time {} (frame {})", + time, frame_number + ); + (screen, camera) + } + None => { + println!( + "No frames from decoder at time {} (frame {}), using last valid frames", + time, frame_number + ); + // Get the last valid frame from each decoder + let screen = segment + .decoders + .screen + .get_frame((time * 30.0) as u32) + .await + .unwrap_or_else(|| { + println!("Using empty frame for screen"); + Arc::new(vec![ + 0; + (constants.options.screen_size.x + * constants.options.screen_size.y + * 4) as usize + ]) + }); + + let camera = match &segment.decoders.camera { + Some(camera_decoder) => { + println!("Getting camera frame at time {}", time); + Some( + camera_decoder + .get_frame((time * 30.0) as u32) + .await + .unwrap_or_else(|| { + println!("Using empty frame for camera"); + Arc::new(match constants.options.camera_size { + Some(size) => vec![0; (size.x * size.y * 4) as usize], + None => vec![0; 0], + }) + }), + ) + } + None => None, + }; - sender.send(frame).await?; - } else { - println!("no decoder frames: {:?}", (time, segment_i)); - }; + (screen, camera) + } + }; + + println!("Producing frame {frame_number}"); + let frame = produce_frame( + &constants, + &screen_frame, + &camera_frame, + background, + &uniforms, + time as f32, + total_frames, + ) + .await?; + + println!("Sending frame {frame_number}"); + if let Err(e) = sender.send(frame).await { + println!("Failed to send frame: {e}"); + break; + } frame_number += 1; if frame_number % 60 == 0 { let elapsed = start_time.elapsed(); println!( - "Rendered {} frames in {:?} seconds", - frame_number, + "Rendered {frame_number}/{total_frames} frames in {:?} seconds", elapsed.as_secs_f32() ); } } - println!("Render loop exited"); - - let total_frames = frame_number; - + println!("Render loop exited at frame {frame_number}/{total_frames}"); let total_time = start_time.elapsed(); println!( - "Render complete. Processed {} frames in {:?} seconds", - total_frames, + "Render complete. Processed {frame_number} frames in {:?} seconds", total_time.as_secs_f32() ); @@ -712,6 +844,7 @@ pub struct RenderedFrame { pub width: u32, pub height: u32, pub padded_bytes_per_row: u32, + pub total_frames: Option, } pub async fn produce_frame( @@ -721,6 +854,7 @@ pub async fn produce_frame( background: Background, uniforms: &ProjectUniforms, time: f32, + total_frames: u32, ) -> Result { let mut encoder = constants.device.create_command_encoder( &(wgpu::CommandEncoderDescriptor { @@ -997,6 +1131,7 @@ pub async fn produce_frame( padded_bytes_per_row, width: uniforms.output_size.0, height: uniforms.output_size.1, + total_frames: Some(total_frames), }) } diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index 16c45a0a..b6205744 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -12,21 +12,25 @@ pub struct Video { } impl Video { - pub fn new(path: &PathBuf) -> Self { - let input = ffmpeg::format::input(path).unwrap(); - let stream = input.streams().best(ffmpeg::media::Type::Video).unwrap(); + pub fn new(path: &PathBuf) -> Result { + let input = + ffmpeg::format::input(path).map_err(|e| format!("Failed to open video: {}", e))?; + let stream = input + .streams() + .best(ffmpeg::media::Type::Video) + .ok_or_else(|| "No video stream found".to_string())?; let video_decoder = ffmpeg::codec::Context::from_parameters(stream.parameters()) - .unwrap() + .map_err(|e| format!("Failed to create decoder: {}", e))? .decoder() .video() - .unwrap(); + .map_err(|e| format!("Failed to get video decoder: {}", e))?; - Video { + Ok(Video { width: video_decoder.width(), height: video_decoder.height(), duration: input.duration() as f64 / 1_000_000.0, - } + }) } } @@ -65,11 +69,12 @@ impl ProjectRecordings { pub fn new(meta: &RecordingMeta) -> Self { let segments = match &meta.content { crate::Content::SingleSegment { segment } => { - let display = Video::new(&meta.project_path.join(&segment.display.path)); - let camera = segment - .camera - .as_ref() - .map(|camera| Video::new(&meta.project_path.join(&camera.path))); + let display = Video::new(&meta.project_path.join(&segment.display.path)) + .expect("Failed to read display video"); + let camera = segment.camera.as_ref().map(|camera| { + Video::new(&meta.project_path.join(&camera.path)) + .expect("Failed to read camera video") + }); let audio = segment .audio .as_ref() @@ -85,11 +90,12 @@ impl ProjectRecordings { .segments .iter() .map(|s| { - let display = Video::new(&meta.project_path.join(&s.display.path)); - let camera = s - .camera - .as_ref() - .map(|camera| Video::new(&meta.project_path.join(&camera.path))); + let display = Video::new(&meta.project_path.join(&s.display.path)) + .expect("Failed to read display video"); + let camera = s.camera.as_ref().map(|camera| { + Video::new(&meta.project_path.join(&camera.path)) + .expect("Failed to read camera video") + }); let audio = s .audio .as_ref() @@ -110,6 +116,10 @@ impl ProjectRecordings { pub fn duration(&self) -> f64 { self.segments.iter().map(|s| s.duration()).sum() } + + pub fn get_source_duration(&self, path: &PathBuf) -> Result { + Video::new(path).map(|v| v.duration) + } } #[derive(Debug, Clone, Serialize, Type)] diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 668d7971..520963df 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -17,8 +17,12 @@ windows = { version = "0.58.0", features = [ "Win32_System_Pipes", "Win32_System_Diagnostics_Debug", ] } +windows-sys = "0.52.0" [dependencies] futures = "0.3.31" -tokio = { workspace = true, features = ["net"] } +tokio = { workspace = true, features = ["net", "io-util"] } uuid = "1.11.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +flume = "0.11.0" diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 533ed800..188445e9 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -1,6 +1,6 @@ -use futures::FutureExt; -use std::{ffi::OsString, fs::OpenOptions, io::Write, path::PathBuf}; -// use tokio::{fs::OpenOptions, io::AsyncWriteExt}; +use std::{ffi::OsString, path::PathBuf}; +use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc::Receiver; #[cfg(windows)] pub fn get_last_win32_error_formatted() -> String { @@ -42,75 +42,78 @@ fn create_named_pipe(path: &std::path::Path) -> Result<(), Box( - mut rx: tokio::sync::mpsc::Receiver, - unix_path: PathBuf, - get_bytes: impl FnMut(&T) -> Option<&[u8]> + Clone + Send + 'static, -) -> OsString { - #[cfg(unix)] +pub fn create_channel_named_pipe( + mut rx: Receiver, + pipe_path: PathBuf, + mut chunk_fn: F, +) -> OsString +where + T: Send + 'static, + F: FnMut(&T) -> Option<&[u8]> + Send + 'static, +{ + #[cfg(windows)] { - create_named_pipe(&unix_path).unwrap(); - - let path = unix_path.clone(); - tokio::spawn( - async move { - let mut file = OpenOptions::new() - .write(true) - .create(false) - .truncate(true) - .open(&path) - // .await - .unwrap(); - println!("video pipe opened"); - - while let Some(bytes) = rx.recv().await { - let mut get_bytes = get_bytes.clone(); - - while let Some(bytes) = get_bytes(&bytes) { - file.write_all(&bytes).unwrap(); + // Build proper Windows named pipe path, e.g. \\.\pipe\my_pipe_name + // Use the final filename from `pipe_path` to avoid conflicts + let filename = pipe_path + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("cap-default-pipe")); + let pipe_name = format!(r"\\.\pipe\{}", filename.to_string_lossy()); + let os_pipe_name = OsString::from(&pipe_name); + + tokio::spawn(async move { + let mut server = tokio::net::windows::named_pipe::ServerOptions::new() + .first_pipe_instance(true) + .create(&pipe_name) + .expect("Failed to create named pipe"); + + // For each message from rx, repeatedly call chunk_fn until None is returned + while let Some(msg) = rx.recv().await { + loop { + if let Some(bytes) = chunk_fn(&msg) { + if let Err(e) = server.write_all(bytes).await { + eprintln!("Error writing to named pipe: {e}"); + break; + } + } else { + break; } } - - println!("done writing to video pipe"); - Ok::<(), std::io::Error>(()) } - .then(|result| async { - if let Err(e) = result { - eprintln!("error writing to video pipe: {}", e); - } - }), - ); + }); - unix_path.into_os_string() + return os_pipe_name; } - #[cfg(windows)] + #[cfg(unix)] { - use tokio::io::AsyncWriteExt; - use tokio::net::windows::named_pipe::ServerOptions; - - let uuid = uuid::Uuid::new_v4(); - let pipe_name = format!(r#"\\.\pipe\{uuid}"#); - - let mut server = ServerOptions::new() - .first_pipe_instance(true) - .create(&pipe_name) - .unwrap(); - - tokio::spawn({ - async move { - println!("video pipe opened"); - - server.connect().await.unwrap(); - - while let Some(bytes) = rx.recv().await { - server.write_all(&bytes).await.unwrap(); + use nix::sys::stat; + let os_pipe_name = pipe_path.clone().into_os_string(); + + let _ = std::fs::remove_file(&pipe_path); + // Make FIFO if not existing + nix::unistd::mkfifo(&pipe_path, stat::Mode::S_IRWXU) + .expect("Failed to create a Unix FIFO with mkfifo()"); + + tokio::spawn(async move { + let mut file = tokio::fs::File::create(&pipe_path) + .await + .expect("Failed to open FIFO for writing"); + + while let Some(msg) = rx.recv().await { + loop { + if let Some(bytes) = chunk_fn(&msg) { + if let Err(e) = file.write_all(bytes).await { + eprintln!("Error writing to FIFO: {e}"); + break; + } + } else { + break; + } } - - println!("done writing to video pipe"); } }); - pipe_name.into() + return os_pipe_name; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cd8935c..393d98cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,7 +18,7 @@ importers: version: 1.13.4(@types/node@20.16.9)(typescript@5.7.2) dotenv-cli: specifier: latest - version: 7.4.4 + version: 8.0.0 eslint: specifier: ^7.32.0 version: 7.32.0 @@ -191,6 +191,9 @@ importers: '@types/dom-webcodecs': specifier: ^0.1.11 version: 0.1.11 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 typescript: specifier: ^5.7.2 version: 5.7.2 @@ -415,7 +418,7 @@ importers: version: 8.57.1 eslint-config-airbnb-typescript: specifier: ^18.0.0 - version: 18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint@8.57.1)(typescript@5.7.2))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.1) + version: 18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint@8.57.1)(typescript@5.7.2))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1))(eslint@8.57.1) eslint-import-resolver-typescript: specifier: ^3.6.1 version: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.1) @@ -754,10 +757,10 @@ importers: version: 18.3.9 '@types/react-dom': specifier: latest - version: 19.0.0 + version: 19.0.2(@types/react@18.3.9) dotenv-cli: specifier: latest - version: 7.4.4 + version: 8.0.0 drizzle-kit: specifier: ^0.20.12 version: 0.20.18 @@ -792,25 +795,25 @@ importers: version: 0.9.0(tailwindcss@3.4.13(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.7.2))) '@radix-ui/react-dialog': specifier: ^1.0.5 - version: 1.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.0.6 - version: 2.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': specifier: ^2.0.2 - version: 2.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-navigation-menu': specifier: ^1.1.4 - version: 1.2.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.2.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.0.7 - version: 1.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.1.0 - version: 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tailwindcss/typography': specifier: ^0.5.9 version: 0.5.15(tailwindcss@3.4.13(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.7.2))) @@ -841,7 +844,7 @@ importers: version: 18.3.9 '@types/react-dom': specifier: latest - version: 19.0.0 + version: 19.0.2(@types/react@18.3.9) '@vitejs/plugin-react': specifier: ^4.0.3 version: 4.3.1(vite@4.5.5(@types/node@20.16.9)(terser@5.34.0)) @@ -4294,10 +4297,10 @@ packages: react-dom: optional: true - '@storybook/builder-vite@8.5.0-alpha.18': - resolution: {integrity: sha512-jAfMwEt/mm469T3Vsx8E0xwCen8M+dd5zb87WUqgGbKZfle8xycZLNu2CvY6wlpIfxrLb+hHNRtFN/9W1i4Exg==} + '@storybook/builder-vite@8.5.0-beta.4': + resolution: {integrity: sha512-a4NFy6y2pfBMQWe4ZPCMh5/Fr3YMDeWWsPoc6KGXc5IbV0YMF8bPlQt3hLKRBGWQOR7C/VuuKnD4FE5CSHIbCA==} peerDependencies: - storybook: ^8.5.0-alpha.18 + storybook: ^8.5.0-beta.4 vite: ^4.0.0 || ^5.0.0 || ^6.0.0 '@storybook/core@8.3.3': @@ -4308,10 +4311,10 @@ packages: peerDependencies: storybook: ^8.3.3 - '@storybook/csf-plugin@8.5.0-alpha.18': - resolution: {integrity: sha512-RYwwErCZBUEjpryOHjFR8ZlQNxlo5LRRMiOroNFnyiG2Mpc76ztJ3v8i+frhTgEMKofWcrhnc+rFjmTHh46pTQ==} + '@storybook/csf-plugin@8.5.0-beta.4': + resolution: {integrity: sha512-b+V6hWSjo1g1jj+CQBm0br0/XAZ92NTEL7yiu3UuZsPZr27bNFysDEEpfxWjnlJP28z9R7VaHvMnEsQ5hRjdOA==} peerDependencies: - storybook: ^8.5.0-alpha.18 + storybook: ^8.5.0-beta.4 '@storybook/csf@0.1.11': resolution: {integrity: sha512-dHYFQH3mA+EtnCkHXzicbLgsvzYjcDJ1JWsogbItZogkPHgSJM/Wr71uMkcvw8v9mmCyP4NpXJuu6bPoVsOnzg==} @@ -4806,8 +4809,10 @@ packages: '@types/react-dom@18.3.0': resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} - '@types/react-dom@19.0.0': - resolution: {integrity: sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w==} + '@types/react-dom@19.0.2': + resolution: {integrity: sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==} + peerDependencies: + '@types/react': ^19.0.0 '@types/react-tooltip@4.2.4': resolution: {integrity: sha512-UzjzmgY/VH3Str6DcAGTLMA1mVVhGOyARNTANExrirtp+JgxhaIOVDxq4TIRmpSi4voLv+w4HA9CC5GvhhCA0A==} @@ -5861,6 +5866,11 @@ packages: resolution: {integrity: sha512-1VdUuRnQP4drdFkS8NKvDR1NBgevm8TOuflcaZEKsxw42CxonjW/2vkj1AKlinJb4ZLwBcuWF9GiPr7FQc6AQA==} engines: {node: '>=18.0'} + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-fetch@3.1.8: resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} @@ -6154,8 +6164,8 @@ packages: resolution: {integrity: sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ==} engines: {node: '>=16'} - dotenv-cli@7.4.4: - resolution: {integrity: sha512-XkBYCG0tPIes+YZr4SpfFv76SQrV/LeCE8CI7JSEMi3VR9MvTihCGTOtbIexD6i2mXF+6px7trb1imVCXSNMDw==} + dotenv-cli@8.0.0: + resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==} hasBin: true dotenv-expand@10.0.0: @@ -13728,26 +13738,26 @@ snapshots: '@radix-ui/primitive@1.1.0': {} - '@radix-ui/react-arrow@1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-arrow@1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) - '@radix-ui/react-collection@1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-collection@1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.1.0(@types/react@18.3.9)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-compose-refs@1.0.0(react@18.3.1)': dependencies: @@ -13800,18 +13810,18 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@radix-ui/react-dialog@1.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dialog@1.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-portal': 1.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.9)(react@18.3.1) aria-hidden: 1.2.4 @@ -13820,7 +13830,7 @@ snapshots: react-remove-scroll: 2.5.7(@types/react@18.3.9)(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-direction@1.1.0(@types/react@18.3.9)(react@18.3.1)': dependencies: @@ -13839,33 +13849,33 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-dismissable-layer@1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.9)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) - '@radix-ui/react-dropdown-menu@2.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dropdown-menu@2.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-id': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-menu': 2.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menu': 2.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.9)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-focus-guards@1.0.0(react@18.3.1)': dependencies: @@ -13887,16 +13897,16 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.9)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-id@1.0.0(react@18.3.1)': dependencies: @@ -13911,31 +13921,31 @@ snapshots: optionalDependencies: '@types/react': 18.3.9 - '@radix-ui/react-label@2.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-label@2.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) - '@radix-ui/react-menu@2.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-menu@2.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-collection': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-direction': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-popper': 1.2.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.9)(react@18.3.1) aria-hidden: 1.2.4 @@ -13944,43 +13954,43 @@ snapshots: react-remove-scroll: 2.5.7(@types/react@18.3.9)(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) - '@radix-ui/react-navigation-menu@1.2.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-navigation-menu@1.2.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-collection': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-direction': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-presence': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) - '@radix-ui/react-popover@1.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popover@1.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-popper': 1.2.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.9)(react@18.3.1) aria-hidden: 1.2.4 @@ -13989,15 +13999,15 @@ snapshots: react-remove-scroll: 2.5.7(@types/react@18.3.9)(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) - '@radix-ui/react-popper@1.2.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popper@1.2.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-rect': 1.1.0(@types/react@18.3.9)(react@18.3.1) @@ -14007,7 +14017,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-portal@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -14016,15 +14026,15 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-portal@1.1.1(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.1.1(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.9)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-presence@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -14034,7 +14044,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-presence@1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-presence@1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.9)(react@18.3.1) @@ -14042,7 +14052,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-primitive@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -14051,31 +14061,31 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@radix-ui/react-primitive@2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-primitive@2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.1.0(@types/react@18.3.9)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) - '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 - '@radix-ui/react-collection': 1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-direction': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-id': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.9)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-slot@1.0.0(react@18.3.1)': dependencies: @@ -14098,12 +14108,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.9 - '@radix-ui/react-switch@1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-switch@1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.9)(react@18.3.1) '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.9)(react@18.3.1) @@ -14111,7 +14121,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/react-use-callback-ref@1.0.0(react@18.3.1)': dependencies: @@ -14181,14 +14191,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.9 - '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@19.0.2(@types/react@18.3.9))(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 - '@types/react-dom': 19.0.0 + '@types/react-dom': 19.0.2(@types/react@18.3.9) '@radix-ui/rect@1.1.0': {} @@ -15036,9 +15046,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.5.0-alpha.18(storybook@8.3.3)(vite@5.4.8(@types/node@20.16.9)(terser@5.34.0))': + '@storybook/builder-vite@8.5.0-beta.4(storybook@8.3.3)(vite@5.4.8(@types/node@20.16.9)(terser@5.34.0))': dependencies: - '@storybook/csf-plugin': 8.5.0-alpha.18(storybook@8.3.3) + '@storybook/csf-plugin': 8.5.0-beta.4(storybook@8.3.3) browser-assert: 1.2.1 storybook: 8.3.3 ts-dedent: 2.2.0 @@ -15069,7 +15079,7 @@ snapshots: storybook: 8.3.3 unplugin: 1.16.0 - '@storybook/csf-plugin@8.5.0-alpha.18(storybook@8.3.3)': + '@storybook/csf-plugin@8.5.0-beta.4(storybook@8.3.3)': dependencies: storybook: 8.3.3 unplugin: 1.16.0 @@ -15607,7 +15617,7 @@ snapshots: dependencies: '@types/react': 18.3.9 - '@types/react-dom@19.0.0': + '@types/react-dom@19.0.2(@types/react@18.3.9)': dependencies: '@types/react': 18.3.9 @@ -16917,6 +16927,10 @@ snapshots: croner@8.1.1: {} + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-fetch@3.1.8: dependencies: node-fetch: 2.7.0 @@ -17180,7 +17194,7 @@ snapshots: dependencies: type-fest: 3.13.1 - dotenv-cli@7.4.4: + dotenv-cli@8.0.0: dependencies: cross-spawn: 7.0.6 dotenv: 16.4.5 @@ -17622,7 +17636,7 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.30.0)(eslint@8.57.1): + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1))(eslint@8.57.1): dependencies: confusing-browser-globals: 1.0.11 eslint: 8.57.1 @@ -17631,12 +17645,12 @@ snapshots: object.entries: 1.1.8 semver: 6.3.1 - eslint-config-airbnb-typescript@18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint@8.57.1)(typescript@5.7.2))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.1): + eslint-config-airbnb-typescript@18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint@8.57.1)(typescript@5.7.2))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1))(eslint@8.57.1): dependencies: '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint@8.57.1)(typescript@5.7.2) '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.7.2) eslint: 8.57.1 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.30.0)(eslint@8.57.1) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - eslint-plugin-import @@ -17666,7 +17680,7 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.7.2) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.30.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.1) eslint-plugin-react: 7.37.0(eslint@8.57.1) @@ -17695,13 +17709,32 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.3.7(supports-color@5.5.0) + enhanced-resolve: 5.17.1 + eslint: 8.57.1 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + fast-glob: 3.3.2 + get-tsconfig: 4.8.1 + is-bun-module: 1.2.1 + is-glob: 4.0.3 + optionalDependencies: + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - supports-color + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.3.7(supports-color@5.5.0) enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -17720,7 +17753,7 @@ snapshots: debug: 4.3.7(supports-color@5.5.0) enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -17733,7 +17766,18 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.7.2) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(eslint@8.57.1))(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -17744,7 +17788,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -17766,7 +17810,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -17794,7 +17838,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -18103,7 +18147,7 @@ snapshots: execa@5.1.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 6.0.1 human-signals: 2.1.0 is-stream: 2.0.1 @@ -18115,7 +18159,7 @@ snapshots: execa@8.0.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 8.0.1 human-signals: 5.0.0 is-stream: 3.0.0 @@ -18306,7 +18350,7 @@ snapshots: foreground-child@3.3.0: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 signal-exit: 4.1.0 form-data-encoder@1.7.2: {} @@ -22573,7 +22617,7 @@ snapshots: storybook-solidjs-vite@1.0.0-beta.2(storybook@8.3.3)(vite@5.4.8(@types/node@20.16.9)(terser@5.34.0)): dependencies: - '@storybook/builder-vite': 8.5.0-alpha.18(storybook@8.3.3)(vite@5.4.8(@types/node@20.16.9)(terser@5.34.0)) + '@storybook/builder-vite': 8.5.0-beta.4(storybook@8.3.3)(vite@5.4.8(@types/node@20.16.9)(terser@5.34.0)) transitivePeerDependencies: - storybook - vite