diff --git a/.gitignore b/.gitignore index e62030a..248b11a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,37 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Rust shit -/target/ -**/*.rs.bk - -# Vite shit -/node_modules/ - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# macOS -**/.DS_Store +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Rust shit +/target/ +**/*.rs.bk + +# Vite shit +/node_modules/ + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# macOS +**/.DS_Store diff --git a/README.md b/README.md index 55636f1..781e0c6 100644 --- a/README.md +++ b/README.md @@ -1,204 +1,224 @@ -# RoverGUI - -RoverGUI provides a live camera view to anyone operating the rover via a web browser! Non-Autonomous members will likely be using the GUI, so it's easy to use. - -## Usage - -First, open two `ssh` windows to the Rover. Run both the backend and frontend, starting **with the backend first**: - -In window 1: - -```console -~ $ cd RoverGUI/backend -~/RoverGUI/backend $ cargo run -``` - -In window 2: - -```console -~ $ cd RoverGUI/react-app -~/RoverGUI/backend $ npm run start --host -``` - -From your laptop, **open the GUI in a web browser with its URL**: (i.e., open this link in Firefox/Chrome: [http://192.168.1.68:3000](http://192.168.1.68:3000), where `192.168.1.68` is the Rover's IP address, and `3000` is the port from the frontend window above; the port might be something different, so please look carefully). You should then be presented with a page with a dropdown list of available cameras. Select a camera -- then, a live stream should be visible! - -## Dependencies - -You **must** download and install the following dependencies: - -- `Node.js`: [Download Node.js](https://nodejs.org/en/download) -- `npm`: [Downloading and installing Node.js and `npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) - - note: `npm` is usually included with Node.js. -- Rust (`cargo`): [An installer for the systems programming language Rust](https://rustup.rs/) - -If you're running Linux, and you want to use the real backend (i.e., you'd like to stream real video from real cameras), you should also install `Video4Linux` on your Linux distro. See: [pkgs.org - v4l-utils](https://pkgs.org/search/?q=v4l-utils). - -## Detailed Instructions - -### Frontend - -#### Installing Frontend's Package Dependencies - -Before running the frontend, you will need to install the package's dependencies using your favorite terminal! - -```bash -# First make sure that you are in the frontend's folder (/react-app) -cd react-app/ - -# Now install the dependencies! -npm i - -# Your output should look something like this: -added 1472 packages, and audited 1473 packages in 14s - -262 packages are looking for funding - run `npm fund` for details - -28 vulnerabilities (6 low, 8 moderate, 13 high, 1 critical) - -To address issues that do not require attention, run: - npm audit fix - -To address all issues (including breaking changes), run: - npm audit fix --force - -Run `npm audit` for details. -npm notice -npm notice New major version of npm available! 10.9.4 -> 11.6.2 -npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.6.2 -npm notice To update run: npm install -g npm@11.6.2 -npm notice - -# Done! -``` - -#### Running the frontend - -```bash -# To start the frontend, simply run the following command in the frontend's folder. -npm run start --host - -# Your output should look like this: -VITE v7.3.1 ready in 96 ms - -➜ Local: http://localhost:5173/ -➜ Network: http://192.168.1.68:5173/ -➜ press h + enter to show help -``` - -The URLs that are outputted by this command (i.e. http://localhost:3000) are the same URLs that you use to see the GUI from your web browser. You may hold ctrl and then click on the links to view them or simply copy & paste them into your browser. - -> [!TIP] -> If the `npm run start` command fails, it may be because something else (like another instance of the frontend) is running on the frontend's same port (`3000` in this case). See the code block below for an example of this! - -```bash -? Something is already running on port 3000. Probably: - /home/mizael/.nvm/versions/node/v22.21.0/bin/node /home/mizael/RoverGUI/react-app/node_modules/react-scripts/scripts/start.js (pid 67442) - in /home/mizael/RoverGUI/react-app - -Would you like to run the app on another port instead? › (Y/n) -``` - -#### Stopping the frontend - -In the same terminal in which you are running the frontend, press `Ctrl+C` to send a SIGINT signal to interrupt the frontend (this stops it)! - -### Backend (`/backend`) - -#### Building the backend crate and its Cargo dependencies - -Before running the backend, you will need to build the frontend along with all of its dependencies. - -```bash -# First make sure that you are in the backend's folder (/backend) -cd backend/ - -# Cargo makes the process of building the crate really easy! Simply run the following command which will fetch and compile all of the dependencies along with building the backend package! -cargo build - -# The output should look something like this: - Compiling fastrand v2.3.0 - Compiling spin v0.9.8 - Compiling mime v0.3.17 - Compiling async-stream v0.3.6 - Compiling tempfile v3.23.0 - Compiling webrtc-data v0.9.0 - Compiling webrtc-ice v0.11.0 - Compiling rocket_codegen v0.5.1 - Compiling webrtc-dtls v0.10.0 - Compiling sdp v0.6.2 - Compiling rayon v1.11.0 - Compiling interceptor v0.12.0 - Compiling wide v0.7.33 - Compiling webrtc-media v0.8.0 - Compiling tokio-stream v0.1.17 - Compiling smol_str v0.2.2 - Compiling ubyte v0.10.4 - Compiling num_cpus v1.17.0 - Compiling atomic v0.5.3 - Compiling binascii v0.1.4 - Compiling hex v0.4.3 - Compiling jpeg-decoder v0.3.2 - Compiling v4l v0.14.0 - Compiling webrtc v0.11.0 - Compiling openh264 v0.6.6 - Compiling backend-rs v0.1.0 (/home/mizael/RoverGUI/backend) - Finished `dev` profile [unoptimized + debuginfo] target(s) in 49.80s - -# Done! -``` - -## Running the backend - -```bash -# Simply run: -cargo run - -# Your output should look like this: -arget(s) in 0.24s - Running `target/debug/backend-rs` -🔧 Configured for debug. - >> address: 127.0.0.1 - >> port: 3600 - >> workers: 12 - >> max blocking threads: 512 - >> ident: Rocket - >> IP header: X-Real-IP - >> limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB - >> temp dir: /tmp - >> http/2: true - >> keep-alive: 5s - >> tls: disabled - >> shutdown: ctrlc = true, force = true, signals = [SIGTERM], grace = 2s, mercy = 3s - >> log level: normal - >> cli colors: true -📬 Routes: - >> (get_available_cameras) GET /stream/cameras - >> (get_camera_feed) POST /stream/cameras//start - >> (get_camera_modes) GET /stream/cameras//modes - >> (get_camera_mode) GET /stream/cameras//modes/current - >> (get_camera_mode_set) GET /stream/cameras//modes/set/ -📡 Fairings: - >> Shield (liftoff, response, singleton) -🛡️ Shield: - >> X-Content-Type-Options: nosniff - >> X-Frame-Options: SAMEORIGIN - >> Permissions-Policy: interest-cohort=() -🚀 Rocket has launched from http://127.0.0.1:3600 - -# Done! -``` - -The output of this command tells important information about the backend like its address, port, and its available routes. In this case, the frontend would need to access the backend from the URL `http://127.0.0.1:3600`. - -### Stopping the backend - -In the same terminal in which you are running the backend, simply do `Ctrl+C` to send a SIGINT signal to interrupt the backend (this stops it)! - -### The "fake" backend - -There's a `fake_backend` binary you can use to test the frontend on a non-Linux computer (i.e., macOS or Windows). To use it, just type: `NUM_CAMERAS=1 cargo run --bin fake_backend $NUM_CAMERAS`. You can set `NUM_CAMERAS` to any value you'd like. - -Then, open up the frontend as described above. It'll connect successfully! - -You can press `Ctrl+C` to stop the fake backend from running. +# RoverGUI + +RoverGUI provides a live camera view to anyone operating the rover via a web browser! Non-Autonomous members will likely be using the GUI, so it's easy to use. + +## Usage + +First, open two `ssh` windows to the Rover. Run both the backend and frontend, starting **with the backend first**: + +In window 1: + +```console +~ $ cd RoverGUI/backend +~/RoverGUI/backend $ cargo run +``` + +In window 2: + +```console +~ $ cd RoverGUI/react-app +~/RoverGUI/react-app $ pnpm start --host +``` + +From your laptop, **open the GUI in a web browser with its URL**: (i.e., open this link in Firefox/Chrome: [http://192.168.1.68:3000](http://192.168.1.68:3000), where `192.168.1.68` is the Rover's IP address, and `3000` is the port from the frontend window above; the port might be something different, so please look carefully). You should then be presented with a page with a dropdown list of available cameras. Select a camera -- then, a live stream should be visible! + +## Dependencies + +You **must** download and install the following dependencies: + +- `Node.js`: [Download Node.js](https://nodejs.org/en/download) +- `pnpm`: [Downloading and installing `pnpm`](https://pnpm.io/installation) +- Rust (`cargo`): [An installer for the systems programming language Rust](https://rustup.rs/) + +If you're running Linux, and you want to use the real backend (i.e., you'd like to stream real video from real cameras), you should also install `Video4Linux` on your Linux distro. See: [pkgs.org - v4l-utils](https://pkgs.org/search/?q=v4l-utils). + +## Detailed Instructions + +### Frontend + +#### Installing Frontend's Package Dependencies + +Before running the frontend, you will need to install the package's dependencies using your favorite terminal! + +```bash +# First make sure that you are in the frontend's folder (/react-app) +cd react-app/ + +# Now install the dependencies! +pnpm install + +# Your output should look something like this, if installing for the first time: +Packages: +345 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +Progress: resolved 345, reused 0, downloaded 345, added 345, done + +dependencies: ++ @testing-library/jest-dom 5.17.0 ++ @testing-library/react 16.3.2 ++ @testing-library/user-event 13.5.0 ++ @types/react 19.2.14 ++ @types/react-dom 19.2.3 ++ react 19.2.4 ++ react-dom 19.2.4 ++ react-player 2.16.1 ++ web-vitals 2.1.4 + +devDependencies: ++ @biomejs/biome 2.3.8 ++ @vitejs/plugin-react 5.2.0 ++ jsdom 28.1.0 ++ source-map-loader 5.0.0 ++ ts-loader 9.5.4 ++ typescript 5.9.3 ++ vite 7.3.1 ++ vitest 4.1.0 + +Done in 12.3s using pnpm v10.32.1 + + +# Your output should look something like this, if already installed: +Lockfile is up to date, resolution step is skipped +Already up to date + + ╭─────────────────────────────────────────╮ + │ │ + │ Update available! 10.32.1 → 11.0.8. │ + │ Changelog: https://pnpm.io/v/11.0.8 │ + │ To update, run: pnpm self-update │ + │ │ + ╰─────────────────────────────────────────╯ + +Done in 862ms using pnpm v10.32.1 +# Done! +``` + +#### Running the frontend + +```bash +# To start the frontend, simply run the following command in the frontend's folder. +pnpm start --host + +# Your output should look like this: +VITE v7.3.1 ready in 96 ms + +➜ Local: http://localhost:5173/ +➜ Network: http://192.168.1.68:5173/ +➜ press h + enter to show help +``` + +The URLs that are outputted by this command (i.e. http://localhost:3000) are the same URLs that you use to see the GUI from your web browser. You may hold ctrl and then click on the links to view them or simply copy & paste them into your browser. + +> [!TIP] +> If the `pnpm start` command fails, it may be because something else (like another instance of the frontend) is running on the frontend's same port (`3000` in this case). See the code block below for an example of this! + +```bash +? Something is already running on port 3000. Probably: + /home/mizael/.nvm/versions/node/v22.21.0/bin/node /home/mizael/RoverGUI/react-app/node_modules/react-scripts/scripts/start.js (pid 67442) + in /home/mizael/RoverGUI/react-app + +Would you like to run the app on another port instead? › (Y/n) +``` + +#### Stopping the frontend + +In the same terminal in which you are running the frontend, press `Ctrl+C` to send a SIGINT signal to interrupt the frontend (this stops it)! + +### Backend (`/backend`) + +#### Building the backend crate and its Cargo dependencies + +Before running the backend, you will need to build the frontend along with all of its dependencies. + +```bash +# First make sure that you are in the backend's folder (/backend) +cd backend/ + +# Cargo makes the process of building the crate really easy! Simply run the following command which will fetch and compile all of the dependencies along with building the backend package! +cargo build + +# The output should look something like this: + Compiling fastrand v2.3.0 + Compiling spin v0.9.8 + Compiling mime v0.3.17 + Compiling async-stream v0.3.6 + Compiling tempfile v3.23.0 + Compiling webrtc-data v0.9.0 + Compiling webrtc-ice v0.11.0 + Compiling rocket_codegen v0.5.1 + Compiling webrtc-dtls v0.10.0 + Compiling sdp v0.6.2 + Compiling rayon v1.11.0 + Compiling interceptor v0.12.0 + Compiling wide v0.7.33 + Compiling webrtc-media v0.8.0 + Compiling tokio-stream v0.1.17 + Compiling smol_str v0.2.2 + Compiling ubyte v0.10.4 + Compiling num_cpus v1.17.0 + Compiling atomic v0.5.3 + Compiling binascii v0.1.4 + Compiling hex v0.4.3 + Compiling jpeg-decoder v0.3.2 + Compiling v4l v0.14.0 + Compiling webrtc v0.11.0 + Compiling openh264 v0.6.6 + Compiling backend-rs v0.1.0 (/home/mizael/RoverGUI/backend) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 49.80s + +# Done! +``` + +## Running the backend + +```bash +# Simply run: +cargo run + +# Your output should look like this: +arget(s) in 0.24s + Running `target/debug/backend-rs` +🔧 Configured for debug. + >> address: 127.0.0.1 + >> port: 3600 + >> workers: 12 + >> max blocking threads: 512 + >> ident: Rocket + >> IP header: X-Real-IP + >> limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB + >> temp dir: /tmp + >> http/2: true + >> keep-alive: 5s + >> tls: disabled + >> shutdown: ctrlc = true, force = true, signals = [SIGTERM], grace = 2s, mercy = 3s + >> log level: normal + >> cli colors: true +📬 Routes: + >> (get_available_cameras) GET /stream/cameras + >> (get_camera_feed) POST /stream/cameras//start + >> (get_camera_modes) GET /stream/cameras//modes + >> (get_camera_mode) GET /stream/cameras//modes/current + >> (get_camera_mode_set) GET /stream/cameras//modes/set/ +📡 Fairings: + >> Shield (liftoff, response, singleton) +🛡️ Shield: + >> X-Content-Type-Options: nosniff + >> X-Frame-Options: SAMEORIGIN + >> Permissions-Policy: interest-cohort=() +🚀 Rocket has launched from http://127.0.0.1:3600 + +# Done! +``` + +The output of this command tells important information about the backend like its address, port, and its available routes. In this case, the frontend would need to access the backend from the URL `http://127.0.0.1:3600`. + +### Stopping the backend + +In the same terminal in which you are running the backend, simply do `Ctrl+C` to send a SIGINT signal to interrupt the backend (this stops it)! + +### The "fake" backend + +There's a `fake_backend` binary you can use to test the frontend on a non-Linux computer (i.e., macOS or Windows). To use it, just type: `NUM_CAMERAS=1 cargo run --bin fake_backend $NUM_CAMERAS`. You can set `NUM_CAMERAS` to any value you'd like. + +Then, open up the frontend as described above. It'll connect successfully! + +You can press `Ctrl+C` to stop the fake backend from running. diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3e0e2ff..9805f7a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,24 +1,24 @@ -[package] -name = "backend-rs" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "fake_backend" -path = "src/fake_backend.rs" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -async-stream = "0.3.5" -rocket = { version = "0.5.1", features = ["serde_json", "json"] } -serde = { version = "1.0.210", features = ["derive"] } -serde_json = "1.0.128" -tokio-util = { version = "0.7.12", features = ["io"] } -webrtc = "0.11.0" -openh264 = "0.6.2" -jpeg-decoder = "0.3.1" -mp4 = "0.14.0" - -[target.'cfg(target_os = "linux")'.dependencies] -v4l = "0.14.0" +[package] +name = "backend-rs" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "fake_backend" +path = "src/fake_backend.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-stream = "0.3.5" +rocket = { version = "0.5.1", features = ["serde_json", "json"] } +serde = { version = "1.0.210", features = ["derive"] } +serde_json = "1.0.128" +tokio-util = { version = "0.7.12", features = ["io"] } +webrtc = "0.11.0" +openh264 = "0.6.2" +jpeg-decoder = "0.3.1" +mp4 = "0.14.0" + +[target.'cfg(target_os = "linux")'.dependencies] +v4l = "0.14.0" diff --git a/backend/src/fake_backend.rs b/backend/src/fake_backend.rs index f8dc297..0f2dd8d 100644 --- a/backend/src/fake_backend.rs +++ b/backend/src/fake_backend.rs @@ -1,487 +1,487 @@ -use std::{ - env::Args, - error::Error, - path::PathBuf, - sync::{Arc, Mutex}, -}; - -use rocket::{routes, Config}; - -use crate::utils::{CameraMode, FakeCamera, Fraction}; - -struct AppState { - cameras: Arc>>, -} - -mod utils { - use std::path::PathBuf; - - #[derive(Debug, Clone, Copy)] - pub struct CameraMode { - pub width: u32, - pub height: u32, - pub frame_interval: Fraction, - } - - impl core::fmt::Display for CameraMode { - fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { - write!( - f, - "{}x{} @{}fps", - self.width, self.height, self.frame_interval.denominator - ) - } - } - - #[derive(Debug, Clone, Copy)] - pub struct Fraction { - #[expect(unused, reason = "buggy FPS impl in `Display`")] - pub numerator: u32, - pub denominator: u32, - } - - /// A fake camera. Imitates a V4L2 camera. - pub struct FakeCamera { - /// The camera's capture settings. - pub camera_mode: CameraMode, - - /// A list of all supported camera modes for this device. - pub supported_camera_modes: Vec, - - /// File representation on disk. - /// - /// Could become stale if unplugged. - pub path: PathBuf, - } -} - -#[rocket::main] -async fn main() -> Result<(), Box> { - let num_cameras: u8 = { - // ensure args given correctly - let mut args: Args = std::env::args(); - if args.len() != 2 { - panic!( - "You ran this script with {} argument(s)! \ - Please run like so: \ - `cargo run --bin fake_backend -- {{NUM_CAMERAS}}`", - args.len() - ); - } - - // skip binary name - _ = args.next(); - - // grab number of cameras - str::parse( - args.next() - .expect("one argument found but couldn't access it") - .as_str(), - ) - .expect("number of cameras should be a value in [0, 255].") - }; - - // initialize the fake cameras - let fake_cameras: Vec = Vec::from_iter((0..num_cameras).map(|n| { - let camera_mode: CameraMode = CameraMode { - width: 1_000 * (n + 1) as u32, - height: 2_000 * (n + 1) as u32, - frame_interval: Fraction { - numerator: 1, - denominator: (n + 1) as u32, - }, - }; - - FakeCamera { - camera_mode, - supported_camera_modes: vec![camera_mode], - path: PathBuf::from(format!("/fake/camera{n}")), - } - })); - - // Create a Rocket instance with the default configuration. - rocket::build() - // This is arbitrary and can be changed at any time through a config file or it can be left hardcoded. - .configure(Config::figment().merge(("port", 3600))) - .mount( - "/stream", - routes![ - api::get_available_cameras, - api::get_camera_feed, - api::get_camera_mode, - api::get_camera_modes, - api::put_camera_mode_set - ], - ) - .manage(AppState { - cameras: Arc::new(Mutex::new(fake_cameras)), - }) - .launch() - .await?; - - Ok(()) -} - -mod api { - use std::{collections::HashMap, io::Cursor, path::PathBuf, sync::Arc, time::Duration}; - - use rocket::{get, http::Status, post, put, serde::json::Json, tokio::time, State}; - use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; - use webrtc::{ - api::{ - interceptor_registry, - media_engine::{MediaEngine, MIME_TYPE_H264}, - APIBuilder, - }, - interceptor::registry::Registry, - media::Sample, - peer_connection::configuration::RTCConfiguration, - rtp_transceiver::rtp_codec::RTCRtpCodecCapability, - track::track_local::{track_local_static_sample::TrackLocalStaticSample, TrackLocal}, - }; - - use crate::AppState; - - struct FakeVideoSample { - bytes: Vec, - duration: Duration, - } - - struct Mp4BitstreamConverter { - length_size: u8, - sps: Vec>, - pps: Vec>, - } - - impl Mp4BitstreamConverter { - fn for_mp4_track(track: &mp4::Mp4Track) -> Result { - let avcc = &track - .trak - .mdia - .minf - .stbl - .stsd - .avc1 - .as_ref() - .ok_or(Status::InternalServerError)? - .avcc; - - Ok(Self { - length_size: avcc.length_size_minus_one + 1, - sps: avcc - .sequence_parameter_sets - .iter() - .map(|v| v.bytes.clone()) - .collect(), - pps: avcc - .picture_parameter_sets - .iter() - .map(|v| v.bytes.clone()) - .collect(), - }) - } - - fn convert_packet(&self, packet: &[u8], out: &mut Vec) { - out.clear(); - let mut stream = packet; - let mut should_prefix_sps_pps = false; - - while !stream.is_empty() { - let mut nal_size: usize = 0; - for _ in 0..self.length_size { - if stream.is_empty() { - return; - } - nal_size = (nal_size << 8) | usize::from(stream[0]); - stream = &stream[1..]; - } - - if nal_size == 0 || stream.len() < nal_size { - return; - } - - let nal = &stream[..nal_size]; - stream = &stream[nal_size..]; - - if !nal.is_empty() && (nal[0] & 0x1F) == 5 { - should_prefix_sps_pps = true; - } - - out.extend([0, 0, 0, 1]); - out.extend(nal); - } - - if should_prefix_sps_pps { - let mut with_params = Vec::with_capacity(out.len() + 128); - for sps in &self.sps { - with_params.extend([0, 0, 0, 1]); - with_params.extend(sps); - } - for pps in &self.pps { - with_params.extend([0, 0, 0, 1]); - with_params.extend(pps); - } - with_params.extend(out.iter().copied()); - *out = with_params; - } - } - } - - fn load_fake_video_samples(path: PathBuf) -> Result, Status> { - let mp4_bytes = std::fs::read(path).map_err(|_| Status::InternalServerError)?; - let mut mp4_reader = - mp4::Mp4Reader::read_header(Cursor::new(&mp4_bytes), mp4_bytes.len() as u64) - .map_err(|_| Status::InternalServerError)?; - - let (_, track) = mp4_reader - .tracks() - .iter() - .find(|(_, t)| t.media_type().ok() == Some(mp4::MediaType::H264)) - .ok_or(Status::InternalServerError)?; - - let track_id = track.track_id(); - let timescale = track.timescale(); - let sample_count = track.sample_count(); - let converter = Mp4BitstreamConverter::for_mp4_track(track)?; - let mut converted = Vec::new(); - let mut out = Vec::new(); - - for i in 1..=sample_count { - let Some(sample) = mp4_reader - .read_sample(track_id, i) - .map_err(|_| Status::InternalServerError)? - else { - continue; - }; - - converter.convert_packet(&sample.bytes, &mut out); - if out.is_empty() { - continue; - } - - let duration_ms = ((u64::from(sample.duration) * 1_000) / u64::from(timescale)).max(1); - converted.push(FakeVideoSample { - bytes: out.clone(), - duration: Duration::from_millis(duration_ms), - }); - } - - if converted.is_empty() { - return Err(Status::InternalServerError); - } - - Ok(converted) - } - - // Fetch all the available v4l cameras in the system - #[get("/cameras")] - pub async fn get_available_cameras( - state: &State, - ) -> Result>, (Status, &'static str)> { - Ok(Json( - state - .cameras - .lock() - .expect("not poisoned") - .iter() - .map(|c| c.path.clone()) - .collect(), - )) - } - - // Start a WebRTC stream by creating and returning an offer - #[post("/cameras//start", data = "")] - pub async fn get_camera_feed( - camera_path: &str, - offer: Json, - state: &State, - ) -> Result, Status> { - // warning: slop code below - - if state - .cameras - .lock() - .expect("not poisoned") - .iter() - .all(|c| c.path.to_string_lossy() != camera_path) - { - return Err(Status::NotFound); - } - - let mut media_engine = MediaEngine::default(); - media_engine - .register_default_codecs() - .map_err(|_| Status::InternalServerError)?; - let mut registry = Registry::new(); - registry = interceptor_registry::register_default_interceptors(registry, &mut media_engine) - .map_err(|_| Status::InternalServerError)?; - let rtc_api = APIBuilder::new() - .with_media_engine(media_engine) - .with_interceptor_registry(registry) - .build(); - - let peer_connection = Arc::new( - rtc_api - .new_peer_connection(RTCConfiguration::default()) - .await - .map_err(|_| Status::InternalServerError)?, - ); - let video_track = Arc::new(TrackLocalStaticSample::new( - RTCRtpCodecCapability { - mime_type: MIME_TYPE_H264.to_owned(), - ..Default::default() - }, - "video".to_owned(), - "webrtc".to_owned(), - )); - let rtp_sender = peer_connection - .add_track(Arc::clone(&video_track) as Arc) - .await - .map_err(|_| Status::InternalServerError)?; - - rocket::tokio::spawn(async move { - let mut rtcp_buf = vec![0u8; 1500]; - while rtp_sender.read(&mut rtcp_buf).await.is_ok() {} - }); - - let samples = load_fake_video_samples( - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fake_stream.mp4"), - )?; - let notify_tx = Arc::new(rocket::tokio::sync::Notify::new()); - let notify_rx = notify_tx.clone(); - - rocket::tokio::spawn(async move { - notify_rx.notified().await; - loop { - for sample in &samples { - if video_track - .write_sample(&Sample { - data: sample.bytes.clone().into(), - duration: sample.duration, - ..Default::default() - }) - .await - .is_err() - { - return; - } - time::sleep(sample.duration).await; - } - } - }); - - peer_connection.on_ice_connection_state_change(Box::new( - move |connection_state: webrtc::ice_transport::ice_connection_state::RTCIceConnectionState| { - if connection_state - == webrtc::ice_transport::ice_connection_state::RTCIceConnectionState::Connected - { - notify_tx.notify_waiters(); - } - - Box::pin(async {}) - }, - )); - - peer_connection - .set_remote_description(offer.into_inner()) - .await - .map_err(|_| Status::InternalServerError)?; - let answer = peer_connection - .create_answer(None) - .await - .map_err(|_| Status::InternalServerError)?; - let mut ice_gather_rx = peer_connection.gathering_complete_promise().await; - peer_connection - .set_local_description(answer) - .await - .map_err(|_| Status::InternalServerError)?; - ice_gather_rx.recv().await; - - Ok(Json( - peer_connection - .local_description() - .await - .ok_or(Status::InternalServerError)?, - )) - } - - // Get the current camera mode - #[get("/cameras//modes/current")] - pub async fn get_camera_mode( - camera_path: &str, - state: &State, - ) -> Result { - // grab the mutex - let locked_cameras = state.cameras.lock().expect("not poisoned"); - - // find the camera; error if we don't find it - let Some(camera) = locked_cameras - .iter() - .find(|c| c.path.to_string_lossy() == camera_path) - else { - return Err((Status::NotFound, "Camera with given value not found")); - }; - - Ok(camera.camera_mode.to_string()) - } - - // Get all the available camera modes - #[get("/cameras//modes")] - pub async fn get_camera_modes( - camera_path: &str, - state: &State, - ) -> Result>, (Status, &'static str)> { - // grab the mutex - let locked_cameras = state.cameras.lock().expect("not poisoned"); - - // find the camera; error if we don't find it - let Some(camera) = locked_cameras - .iter() - .find(|c| c.path.to_string_lossy() == camera_path) - else { - return Err((Status::NotFound, "Camera with given value not found")); - }; - - // list all the supported camera modes in a hashmap - Ok(Json( - camera - .supported_camera_modes - .iter() - .enumerate() - .map(|(i, camera_mode)| (i, camera_mode.to_string())) - .collect(), - )) - } - - // Set the current camera mode for the camera path - #[put("/cameras//modes/set/")] - pub async fn put_camera_mode_set( - camera_path: &str, - mode_id: usize, - state: &State, - ) -> Result<(), (Status, &'static str)> { - // grab the mutex - let mut locked_cameras = state.cameras.lock().expect("not poisoned"); - - // find the requested camera - let Some(camera) = locked_cameras - .iter_mut() - .find(|c| c.path.to_string_lossy() == camera_path) - else { - return Err((Status::NotFound, "Camera with given value not found")); - }; - - // find the requested camera mode - let Some(new_camera_mode) = camera.supported_camera_modes.get(mode_id) else { - return Err(( - Status::NotFound, - "Camera mode at given index was not found.", - )); - }; - - // swap to the new mode - camera.camera_mode = *new_camera_mode; - - Ok(()) - } -} +use std::{ + env::Args, + error::Error, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use rocket::{routes, Config}; + +use crate::utils::{CameraMode, FakeCamera, Fraction}; + +struct AppState { + cameras: Arc>>, +} + +mod utils { + use std::path::PathBuf; + + #[derive(Debug, Clone, Copy)] + pub struct CameraMode { + pub width: u32, + pub height: u32, + pub frame_interval: Fraction, + } + + impl core::fmt::Display for CameraMode { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + write!( + f, + "{}x{} @{}fps", + self.width, self.height, self.frame_interval.denominator + ) + } + } + + #[derive(Debug, Clone, Copy)] + pub struct Fraction { + #[expect(unused, reason = "buggy FPS impl in `Display`")] + pub numerator: u32, + pub denominator: u32, + } + + /// A fake camera. Imitates a V4L2 camera. + pub struct FakeCamera { + /// The camera's capture settings. + pub camera_mode: CameraMode, + + /// A list of all supported camera modes for this device. + pub supported_camera_modes: Vec, + + /// File representation on disk. + /// + /// Could become stale if unplugged. + pub path: PathBuf, + } +} + +#[rocket::main] +async fn main() -> Result<(), Box> { + let num_cameras: u8 = { + // ensure args given correctly + let mut args: Args = std::env::args(); + if args.len() != 2 { + panic!( + "You ran this script with {} argument(s)! \ + Please run like so: \ + `cargo run --bin fake_backend -- {{NUM_CAMERAS}}`", + args.len() + ); + } + + // skip binary name + _ = args.next(); + + // grab number of cameras + str::parse( + args.next() + .expect("one argument found but couldn't access it") + .as_str(), + ) + .expect("number of cameras should be a value in [0, 255].") + }; + + // initialize the fake cameras + let fake_cameras: Vec = Vec::from_iter((0..num_cameras).map(|n| { + let camera_mode: CameraMode = CameraMode { + width: 1_000 * (n + 1) as u32, + height: 2_000 * (n + 1) as u32, + frame_interval: Fraction { + numerator: 1, + denominator: (n + 1) as u32, + }, + }; + + FakeCamera { + camera_mode, + supported_camera_modes: vec![camera_mode], + path: PathBuf::from(format!("/fake/camera{n}")), + } + })); + + // Create a Rocket instance with the default configuration. + rocket::build() + // This is arbitrary and can be changed at any time through a config file or it can be left hardcoded. + .configure(Config::figment().merge(("port", 3600))) + .mount( + "/stream", + routes![ + api::get_available_cameras, + api::get_camera_feed, + api::get_camera_mode, + api::get_camera_modes, + api::put_camera_mode_set + ], + ) + .manage(AppState { + cameras: Arc::new(Mutex::new(fake_cameras)), + }) + .launch() + .await?; + + Ok(()) +} + +mod api { + use std::{collections::HashMap, io::Cursor, path::PathBuf, sync::Arc, time::Duration}; + + use rocket::{get, http::Status, post, put, serde::json::Json, tokio::time, State}; + use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; + use webrtc::{ + api::{ + interceptor_registry, + media_engine::{MediaEngine, MIME_TYPE_H264}, + APIBuilder, + }, + interceptor::registry::Registry, + media::Sample, + peer_connection::configuration::RTCConfiguration, + rtp_transceiver::rtp_codec::RTCRtpCodecCapability, + track::track_local::{track_local_static_sample::TrackLocalStaticSample, TrackLocal}, + }; + + use crate::AppState; + + struct FakeVideoSample { + bytes: Vec, + duration: Duration, + } + + struct Mp4BitstreamConverter { + length_size: u8, + sps: Vec>, + pps: Vec>, + } + + impl Mp4BitstreamConverter { + fn for_mp4_track(track: &mp4::Mp4Track) -> Result { + let avcc = &track + .trak + .mdia + .minf + .stbl + .stsd + .avc1 + .as_ref() + .ok_or(Status::InternalServerError)? + .avcc; + + Ok(Self { + length_size: avcc.length_size_minus_one + 1, + sps: avcc + .sequence_parameter_sets + .iter() + .map(|v| v.bytes.clone()) + .collect(), + pps: avcc + .picture_parameter_sets + .iter() + .map(|v| v.bytes.clone()) + .collect(), + }) + } + + fn convert_packet(&self, packet: &[u8], out: &mut Vec) { + out.clear(); + let mut stream = packet; + let mut should_prefix_sps_pps = false; + + while !stream.is_empty() { + let mut nal_size: usize = 0; + for _ in 0..self.length_size { + if stream.is_empty() { + return; + } + nal_size = (nal_size << 8) | usize::from(stream[0]); + stream = &stream[1..]; + } + + if nal_size == 0 || stream.len() < nal_size { + return; + } + + let nal = &stream[..nal_size]; + stream = &stream[nal_size..]; + + if !nal.is_empty() && (nal[0] & 0x1F) == 5 { + should_prefix_sps_pps = true; + } + + out.extend([0, 0, 0, 1]); + out.extend(nal); + } + + if should_prefix_sps_pps { + let mut with_params = Vec::with_capacity(out.len() + 128); + for sps in &self.sps { + with_params.extend([0, 0, 0, 1]); + with_params.extend(sps); + } + for pps in &self.pps { + with_params.extend([0, 0, 0, 1]); + with_params.extend(pps); + } + with_params.extend(out.iter().copied()); + *out = with_params; + } + } + } + + fn load_fake_video_samples(path: PathBuf) -> Result, Status> { + let mp4_bytes = std::fs::read(path).map_err(|_| Status::InternalServerError)?; + let mut mp4_reader = + mp4::Mp4Reader::read_header(Cursor::new(&mp4_bytes), mp4_bytes.len() as u64) + .map_err(|_| Status::InternalServerError)?; + + let (_, track) = mp4_reader + .tracks() + .iter() + .find(|(_, t)| t.media_type().ok() == Some(mp4::MediaType::H264)) + .ok_or(Status::InternalServerError)?; + + let track_id = track.track_id(); + let timescale = track.timescale(); + let sample_count = track.sample_count(); + let converter = Mp4BitstreamConverter::for_mp4_track(track)?; + let mut converted = Vec::new(); + let mut out = Vec::new(); + + for i in 1..=sample_count { + let Some(sample) = mp4_reader + .read_sample(track_id, i) + .map_err(|_| Status::InternalServerError)? + else { + continue; + }; + + converter.convert_packet(&sample.bytes, &mut out); + if out.is_empty() { + continue; + } + + let duration_ms = ((u64::from(sample.duration) * 1_000) / u64::from(timescale)).max(1); + converted.push(FakeVideoSample { + bytes: out.clone(), + duration: Duration::from_millis(duration_ms), + }); + } + + if converted.is_empty() { + return Err(Status::InternalServerError); + } + + Ok(converted) + } + + // Fetch all the available v4l cameras in the system + #[get("/cameras")] + pub async fn get_available_cameras( + state: &State, + ) -> Result>, (Status, &'static str)> { + Ok(Json( + state + .cameras + .lock() + .expect("not poisoned") + .iter() + .map(|c| c.path.clone()) + .collect(), + )) + } + + // Start a WebRTC stream by creating and returning an offer + #[post("/cameras//start", data = "")] + pub async fn get_camera_feed( + camera_path: &str, + offer: Json, + state: &State, + ) -> Result, Status> { + // warning: slop code below + + if state + .cameras + .lock() + .expect("not poisoned") + .iter() + .all(|c| c.path.to_string_lossy() != camera_path) + { + return Err(Status::NotFound); + } + + let mut media_engine = MediaEngine::default(); + media_engine + .register_default_codecs() + .map_err(|_| Status::InternalServerError)?; + let mut registry = Registry::new(); + registry = interceptor_registry::register_default_interceptors(registry, &mut media_engine) + .map_err(|_| Status::InternalServerError)?; + let rtc_api = APIBuilder::new() + .with_media_engine(media_engine) + .with_interceptor_registry(registry) + .build(); + + let peer_connection = Arc::new( + rtc_api + .new_peer_connection(RTCConfiguration::default()) + .await + .map_err(|_| Status::InternalServerError)?, + ); + let video_track = Arc::new(TrackLocalStaticSample::new( + RTCRtpCodecCapability { + mime_type: MIME_TYPE_H264.to_owned(), + ..Default::default() + }, + "video".to_owned(), + "webrtc".to_owned(), + )); + let rtp_sender = peer_connection + .add_track(Arc::clone(&video_track) as Arc) + .await + .map_err(|_| Status::InternalServerError)?; + + rocket::tokio::spawn(async move { + let mut rtcp_buf = vec![0u8; 1500]; + while rtp_sender.read(&mut rtcp_buf).await.is_ok() {} + }); + + let samples = load_fake_video_samples( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fake_stream.mp4"), + )?; + let notify_tx = Arc::new(rocket::tokio::sync::Notify::new()); + let notify_rx = notify_tx.clone(); + + rocket::tokio::spawn(async move { + notify_rx.notified().await; + loop { + for sample in &samples { + if video_track + .write_sample(&Sample { + data: sample.bytes.clone().into(), + duration: sample.duration, + ..Default::default() + }) + .await + .is_err() + { + return; + } + time::sleep(sample.duration).await; + } + } + }); + + peer_connection.on_ice_connection_state_change(Box::new( + move |connection_state: webrtc::ice_transport::ice_connection_state::RTCIceConnectionState| { + if connection_state + == webrtc::ice_transport::ice_connection_state::RTCIceConnectionState::Connected + { + notify_tx.notify_waiters(); + } + + Box::pin(async {}) + }, + )); + + peer_connection + .set_remote_description(offer.into_inner()) + .await + .map_err(|_| Status::InternalServerError)?; + let answer = peer_connection + .create_answer(None) + .await + .map_err(|_| Status::InternalServerError)?; + let mut ice_gather_rx = peer_connection.gathering_complete_promise().await; + peer_connection + .set_local_description(answer) + .await + .map_err(|_| Status::InternalServerError)?; + ice_gather_rx.recv().await; + + Ok(Json( + peer_connection + .local_description() + .await + .ok_or(Status::InternalServerError)?, + )) + } + + // Get the current camera mode + #[get("/cameras//modes/current")] + pub async fn get_camera_mode( + camera_path: &str, + state: &State, + ) -> Result { + // grab the mutex + let locked_cameras = state.cameras.lock().expect("not poisoned"); + + // find the camera; error if we don't find it + let Some(camera) = locked_cameras + .iter() + .find(|c| c.path.to_string_lossy() == camera_path) + else { + return Err((Status::NotFound, "Camera with given value not found")); + }; + + Ok(camera.camera_mode.to_string()) + } + + // Get all the available camera modes + #[get("/cameras//modes")] + pub async fn get_camera_modes( + camera_path: &str, + state: &State, + ) -> Result>, (Status, &'static str)> { + // grab the mutex + let locked_cameras = state.cameras.lock().expect("not poisoned"); + + // find the camera; error if we don't find it + let Some(camera) = locked_cameras + .iter() + .find(|c| c.path.to_string_lossy() == camera_path) + else { + return Err((Status::NotFound, "Camera with given value not found")); + }; + + // list all the supported camera modes in a hashmap + Ok(Json( + camera + .supported_camera_modes + .iter() + .enumerate() + .map(|(i, camera_mode)| (i, camera_mode.to_string())) + .collect(), + )) + } + + // Set the current camera mode for the camera path + #[put("/cameras//modes/set/")] + pub async fn put_camera_mode_set( + camera_path: &str, + mode_id: usize, + state: &State, + ) -> Result<(), (Status, &'static str)> { + // grab the mutex + let mut locked_cameras = state.cameras.lock().expect("not poisoned"); + + // find the requested camera + let Some(camera) = locked_cameras + .iter_mut() + .find(|c| c.path.to_string_lossy() == camera_path) + else { + return Err((Status::NotFound, "Camera with given value not found")); + }; + + // find the requested camera mode + let Some(new_camera_mode) = camera.supported_camera_modes.get(mode_id) else { + return Err(( + Status::NotFound, + "Camera mode at given index was not found.", + )); + }; + + // swap to the new mode + camera.camera_mode = *new_camera_mode; + + Ok(()) + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 06f827e..8c47e52 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,170 +1,170 @@ -#[cfg(target_os = "linux")] -mod utils; - -#[cfg(target_os = "linux")] -#[rocket::main] -async fn main() -> Result<(), Box> { - use std::path::PathBuf; - - use rocket::{routes, Config}; - use utils::{CameraMode, H264CameraReader, WebcamManager}; - use v4l::Device; - - use crate::api::AppState; - - let mut available_camera_paths: Vec = Vec::new(); - - // Only add cameras that are streamable with h264. - // available_camera_paths is only updated at initial start, however, this could be better changed to run when a camera is plugged in or removed. - for node in v4l::context::enum_devices() { - let mut device = Device::new(node.index())?; - let Ok(modes) = CameraMode::fetch_all(&device) else { - continue; - }; - let initial_mode: CameraMode = *modes.first().ok_or( - "Error creating a `CameraThreadHandle`: \ - Failed to initialize camera, as no valid camera operating modes \ - were provided by Video4Linux. \ - (Check the camera, as this was an OS-level issue!)", - )?; - - // If can't query or create a stream, then it can't be displayed. - if H264CameraReader::new(&mut device, initial_mode).is_ok() { - available_camera_paths.push(node.path().to_path_buf()); - } - } - - // Create a Rocket instance with the default configuration. - rocket::build() - // This is arbitrary and can be changed at any time through a config file or it can be left hardcoded. - .configure(Config::figment().merge(("port", 3600))) - .mount( - "/stream", - routes![ - api::get_available_cameras, - api::get_camera_feed, - api::get_camera_mode, - api::get_camera_modes, - api::put_camera_mode_set - ], - ) - .manage(AppState { - webcam_manager: WebcamManager::new().unwrap(), - available_camera_paths, - }) - .launch() - .await?; - - Ok(()) -} - -#[cfg(not(target_os = "linux"))] -fn main() -> Result<(), u8> { - eprintln!( - "The camera backend is unsupported on non-Linux machines. \ - For testing, please use the `fake_backend` instead. \ - Otherwise, please run the real backend on the Rover -- it isn't \ - supposed to run on your personal device!" - ); - Err(1) -} - -#[cfg(target_os = "linux")] -mod api { - use std::{collections::HashMap, path::PathBuf}; - - use super::utils::WebcamManager; - use rocket::{get, http::Status, post, put, serde::json::Json, State}; - use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; - - pub struct AppState { - pub webcam_manager: WebcamManager, - pub available_camera_paths: Vec, - } - - // Fetch all the available v4l cameras in the system - #[get("/cameras")] - pub async fn get_available_cameras( - state: &State, - ) -> Result>, (Status, &'static str)> { - Ok(Json(state.available_camera_paths.clone())) - } - - // Start a WebRTC stream by creating and returning an offer - #[post("/cameras//start", data = "")] - pub async fn get_camera_feed( - camera_path: &str, - offer: Json, - state: &State, - ) -> Result, Status> { - let webcam_manager = &state.webcam_manager; - let local_description = webcam_manager - .add_client(camera_path.to_owned(), offer.into_inner()) - .await - .map_err(|_| Status::InternalServerError)?; - - Ok(Json(local_description)) - } - - // Get the current camera mode - #[get("/cameras//modes/current")] - pub async fn get_camera_mode( - camera_path: &str, - state: &State, - ) -> Result { - let camera_handles_mutex = state.webcam_manager.camera_handles(); - let camera_handles = &mut *camera_handles_mutex.lock().await; - let handle = camera_handles - .iter() - .find(|handle| handle.camera_path() == camera_path) - .ok_or((Status::BadRequest, "Invalid / Inactive Camera Path"))?; - - Ok(handle.current_mode().to_string()) - } - - // Get all the available camera modes - #[get("/cameras//modes")] - pub async fn get_camera_modes( - camera_path: &str, - state: &State, - ) -> Result>, (Status, &'static str)> { - let camera_handles_mutex = state.webcam_manager.camera_handles(); - let camera_handles = &mut *camera_handles_mutex.lock().await; - let handle = camera_handles - .iter() - .find(|handle| handle.camera_path() == camera_path) - .ok_or((Status::BadRequest, "Invalid / Inactive Camera Path"))?; - - // Using a HashMap to make sure that all modes stay in their exact order represented in the Vec of the handle. - let mut mapped_modes: HashMap = HashMap::new(); - for i in 0..handle.camera_modes().len() { - let mode = handle.camera_modes()[i]; - mapped_modes.insert(i, mode.to_string()); - } - - Ok(Json(mapped_modes)) - } - - // Set the current camera mode for the camera path - #[put("/cameras//modes/set/")] - pub async fn put_camera_mode_set( - camera_path: &str, - mode_id: usize, - state: &State, - ) -> Result<(), (Status, &'static str)> { - let camera_handles_mutex = state.webcam_manager.camera_handles(); - let camera_handles = &mut *camera_handles_mutex.lock().await; - let handle = camera_handles - .iter_mut() - .find(|handle| handle.camera_path() == camera_path) - .ok_or((Status::BadRequest, "Invalid / Inactive Camera Path"))?; - handle.update_camera_mode(mode_id).await.map_err(|_| { - ( - Status::BadRequest, - "Failed to Update Camera Mode; index may be invalid", - ) - })?; - - Ok(()) - } -} +#[cfg(target_os = "linux")] +mod utils; + +#[cfg(target_os = "linux")] +#[rocket::main] +async fn main() -> Result<(), Box> { + use std::path::PathBuf; + + use rocket::{routes, Config}; + use utils::{CameraMode, H264CameraReader, WebcamManager}; + use v4l::Device; + + use crate::api::AppState; + + let mut available_camera_paths: Vec = Vec::new(); + + // Only add cameras that are streamable with h264. + // available_camera_paths is only updated at initial start, however, this could be better changed to run when a camera is plugged in or removed. + for node in v4l::context::enum_devices() { + let mut device = Device::new(node.index())?; + let Ok(modes) = CameraMode::fetch_all(&device) else { + continue; + }; + let initial_mode: CameraMode = *modes.first().ok_or( + "Error creating a `CameraThreadHandle`: \ + Failed to initialize camera, as no valid camera operating modes \ + were provided by Video4Linux. \ + (Check the camera, as this was an OS-level issue!)", + )?; + + // If can't query or create a stream, then it can't be displayed. + if H264CameraReader::new(&mut device, initial_mode).is_ok() { + available_camera_paths.push(node.path().to_path_buf()); + } + } + + // Create a Rocket instance with the default configuration. + rocket::build() + // This is arbitrary and can be changed at any time through a config file or it can be left hardcoded. + .configure(Config::figment().merge(("port", 3600))) + .mount( + "/stream", + routes![ + api::get_available_cameras, + api::get_camera_feed, + api::get_camera_mode, + api::get_camera_modes, + api::put_camera_mode_set + ], + ) + .manage(AppState { + webcam_manager: WebcamManager::new().unwrap(), + available_camera_paths, + }) + .launch() + .await?; + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn main() -> Result<(), u8> { + eprintln!( + "The camera backend is unsupported on non-Linux machines. \ + For testing, please use the `fake_backend` instead. \ + Otherwise, please run the real backend on the Rover -- it isn't \ + supposed to run on your personal device!" + ); + Err(1) +} + +#[cfg(target_os = "linux")] +mod api { + use std::{collections::HashMap, path::PathBuf}; + + use super::utils::WebcamManager; + use rocket::{get, http::Status, post, put, serde::json::Json, State}; + use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; + + pub struct AppState { + pub webcam_manager: WebcamManager, + pub available_camera_paths: Vec, + } + + // Fetch all the available v4l cameras in the system + #[get("/cameras")] + pub async fn get_available_cameras( + state: &State, + ) -> Result>, (Status, &'static str)> { + Ok(Json(state.available_camera_paths.clone())) + } + + // Start a WebRTC stream by creating and returning an offer + #[post("/cameras//start", data = "")] + pub async fn get_camera_feed( + camera_path: &str, + offer: Json, + state: &State, + ) -> Result, Status> { + let webcam_manager = &state.webcam_manager; + let local_description = webcam_manager + .add_client(camera_path.to_owned(), offer.into_inner()) + .await + .map_err(|_| Status::InternalServerError)?; + + Ok(Json(local_description)) + } + + // Get the current camera mode + #[get("/cameras//modes/current")] + pub async fn get_camera_mode( + camera_path: &str, + state: &State, + ) -> Result { + let camera_handles_mutex = state.webcam_manager.camera_handles(); + let camera_handles = &mut *camera_handles_mutex.lock().await; + let handle = camera_handles + .iter() + .find(|handle| handle.camera_path() == camera_path) + .ok_or((Status::BadRequest, "Invalid / Inactive Camera Path"))?; + + Ok(handle.current_mode().to_string()) + } + + // Get all the available camera modes + #[get("/cameras//modes")] + pub async fn get_camera_modes( + camera_path: &str, + state: &State, + ) -> Result>, (Status, &'static str)> { + let camera_handles_mutex = state.webcam_manager.camera_handles(); + let camera_handles = &mut *camera_handles_mutex.lock().await; + let handle = camera_handles + .iter() + .find(|handle| handle.camera_path() == camera_path) + .ok_or((Status::BadRequest, "Invalid / Inactive Camera Path"))?; + + // Using a HashMap to make sure that all modes stay in their exact order represented in the Vec of the handle. + let mut mapped_modes: HashMap = HashMap::new(); + for i in 0..handle.camera_modes().len() { + let mode = handle.camera_modes()[i]; + mapped_modes.insert(i, mode.to_string()); + } + + Ok(Json(mapped_modes)) + } + + // Set the current camera mode for the camera path + #[put("/cameras//modes/set/")] + pub async fn put_camera_mode_set( + camera_path: &str, + mode_id: usize, + state: &State, + ) -> Result<(), (Status, &'static str)> { + let camera_handles_mutex = state.webcam_manager.camera_handles(); + let camera_handles = &mut *camera_handles_mutex.lock().await; + let handle = camera_handles + .iter_mut() + .find(|handle| handle.camera_path() == camera_path) + .ok_or((Status::BadRequest, "Invalid / Inactive Camera Path"))?; + handle.update_camera_mode(mode_id).await.map_err(|_| { + ( + Status::BadRequest, + "Failed to Update Camera Mode; index may be invalid", + ) + })?; + + Ok(()) + } +} diff --git a/backend/src/utils.rs b/backend/src/utils.rs index cf1ebb7..eb76e44 100644 --- a/backend/src/utils.rs +++ b/backend/src/utils.rs @@ -1,437 +1,437 @@ -use std::{ - error::Error, - path::Path, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - thread::{self}, - time::Duration, -}; - -use jpeg_decoder::Decoder; -use openh264::{ - encoder::Encoder, - formats::{RgbSliceU8, YUVBuffer}, -}; -use rocket::tokio::{ - sync::{ - mpsc::{self, Receiver, Sender}, - Mutex, Notify, - }, - task, time, -}; -use v4l::{ - buffer::Type, - frameinterval::{FrameIntervalEnum, Stepwise}, - io::traits::CaptureStream, - prelude::MmapStream, - video::{capture::Parameters, Capture}, - Device, Format, FourCC, Fraction, -}; -use webrtc::{ - api::{ - interceptor_registry, - media_engine::{MediaEngine, MIME_TYPE_H264}, - APIBuilder, API, - }, - ice_transport::ice_connection_state::RTCIceConnectionState, - interceptor::registry::Registry, - media::Sample, - peer_connection::{ - configuration::RTCConfiguration, peer_connection_state::RTCPeerConnectionState, - sdp::session_description::RTCSessionDescription, - }, - rtp_transceiver::rtp_codec::RTCRtpCodecCapability, - track::track_local::{track_local_static_sample::TrackLocalStaticSample, TrackLocal}, -}; - -// Struct used for reading a camera's MJPG frames to H264 buffers. -pub struct H264CameraReader<'a> { - stream: MmapStream<'a>, - camera_mode: CameraMode, - h264_encoder: Encoder, -} - -// Heavily inspired by https://github.com/D1plo1d/h264_webcam_stream -impl<'a> H264CameraReader<'a> { - pub fn new(device: &mut Device, mode: CameraMode) -> Result> { - let format = Format::new(mode.width, mode.height, FourCC::new(b"MJPG")); - Capture::set_format(device, &format)?; - - let parameters = Parameters::new(mode.frame_interval); - Capture::set_params(device, ¶meters)?; - - let stream = MmapStream::with_buffers(device, Type::VideoCapture, 4)?; - let encoder = Encoder::new()?; - - Ok(Self { - stream, - camera_mode: mode, - h264_encoder: encoder, - }) - } - - pub fn read(&mut self) -> Result, Box> { - let (buffer, _) = self.stream.next()?; - let mut jpg = Decoder::new(buffer); - let pixels = jpg.decode()?; - - let yuv_buffer = YUVBuffer::from_rgb_source(RgbSliceU8::new( - &pixels[..], - ( - self.camera_mode.width as usize, - self.camera_mode.height as usize, - ), - )); - let bitstream = self.h264_encoder.encode(&yuv_buffer)?; - - Ok(bitstream.to_vec()) - } -} - -// CameraMode(s) are used for controlling what resolution and frame rate the H264CameraReader operates at. -#[derive(Debug, Clone, Copy)] -pub struct CameraMode { - width: u32, - height: u32, - frame_interval: Fraction, // 1/30 is 30 fps, 1/15 is 15fps, etc. -} - -impl core::fmt::Display for CameraMode { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "{}x{} @{}fps", - self.width, self.height, self.frame_interval.denominator - ) - } -} - -impl CameraMode { - // Fetch all possible CameraMode(s) of the Device camera. - pub fn fetch_all(device: &Device) -> Result, Box> { - let mut configs: Vec = Vec::new(); - - let discrete_iter = Capture::enum_framesizes(device, FourCC::new(b"MJPG"))? - .into_iter() - .flat_map(|framesize| framesize.size.to_discrete()); - - for discrete_frame_size in discrete_iter { - for interval in Capture::enum_frameintervals( - device, - FourCC::new(b"MJPG"), - discrete_frame_size.width, - discrete_frame_size.height, - )? { - let fraction = match interval.interval { - FrameIntervalEnum::Discrete(fraction) => fraction, - FrameIntervalEnum::Stepwise(Stepwise { min, .. }) => min, - }; - - configs.push(CameraMode { - width: discrete_frame_size.width, - height: discrete_frame_size.height, - frame_interval: fraction, - }); - } - } - - Ok(configs) - } -} - -/// Handles communication between CameraThreadHandle(s) and WebRTC clients. -pub struct WebcamManager { - camera_handles: Arc>>, - rtc_api: API, -} - -impl WebcamManager { - pub fn new() -> Result> { - let mut media_engine = MediaEngine::default(); - media_engine.register_default_codecs()?; - - let mut registry = Registry::new(); - registry = - interceptor_registry::register_default_interceptors(registry, &mut media_engine)?; - - let rtc_api = APIBuilder::new() - .with_media_engine(media_engine) - .with_interceptor_registry(registry) - .build(); - - Ok(Self { - camera_handles: Arc::new(Mutex::new(Vec::new())), - rtc_api, - }) - } - - pub fn camera_handles(&self) -> Arc>> { - self.camera_handles.clone() - } - - /// Add a WebRTC "Client". A client in this sense is simply a WebRTC Connection created from an RTC Offer or [`RTCSessionDescription`]. - /// This connection serves an H264 encoded stream from the corresponding camera to the `camera_path` argument. "Client" is just used here - /// to describe the connection itself and all its respective mpsc channels and tokio tasks needed to serve the required stream. - /// **IMPORTANT: Everything in here (within the client) runs within the tokio runtime!** - pub async fn add_client( - &self, - camera_path: String, - rtc_offer: RTCSessionDescription, - ) -> Result> { - let peer_connection = self - .rtc_api - .new_peer_connection(RTCConfiguration::default()) - .await?; - let peer_connection = Arc::new(peer_connection); - let peer_disconnected = Arc::new(AtomicBool::new(false)); - let notify_tx = Arc::new(Notify::new()); - let notify_rx = notify_tx.clone(); - - let video_track = Arc::new(TrackLocalStaticSample::new( - RTCRtpCodecCapability { - mime_type: MIME_TYPE_H264.to_owned(), - ..Default::default() - }, - // These are arbitrary values. I'm sure that in WebRTC scenarios that require more defined control, the id and stream_id would be important. - // However, this is not the case as we are simply serving a video stream! - "video".to_owned(), - "webrtc".to_owned(), - )); - - let rtp_sender = peer_connection - .add_track(Arc::clone(&video_track) as Arc) - .await?; - - // Get a Receiver from the CameraThreadHandle thread. Additionally, create a CameraThreadHandle if one isn't active for the current path. - let mut rx = { - let camera_handles = &mut self.camera_handles.lock().await; - match camera_handles - .iter() - .find(|handle| handle.camera_path == camera_path) - { - Some(handle) => handle.enroll_rx().await, - None => { - let handle = CameraThreadHandle::start_camera_thread( - &camera_path, - self.camera_handles.clone(), - ) - .unwrap(); - let rx = handle.enroll_rx().await; - - camera_handles.push(handle); - rx - } - } - }; - - // Should drop if connection is canceled - task::spawn(async move { - let mut rtcp_buf = vec![0u8; 1500]; - while let Ok((_, _)) = rtp_sender.read(&mut rtcp_buf).await {} - }); - - let c_peer_disconnected = peer_disconnected.clone(); - let c_peer_connection = peer_connection.clone(); - task::spawn(async move { - // We must wait for the ice connection status state to be Connected before sending bytes to the WebRTC client. - if time::timeout(Duration::from_millis(5000), notify_rx.notified()) - .await - .is_err() - { - return; - } - - // Read h264 bytes from the CameraThreadHandle using mpsc. - while let Some(bytes) = rx.recv().await { - if c_peer_disconnected.load(Ordering::SeqCst) { - let _ = c_peer_connection.close().await; - return; - } - - video_track - .write_sample(&Sample { - data: bytes.into(), - duration: Duration::from_millis(20), - ..Default::default() - }) - .await - .unwrap(); - } - }); - - peer_connection.on_ice_connection_state_change(Box::new( - move |connection_state: RTCIceConnectionState| { - if connection_state == RTCIceConnectionState::Connected { - notify_tx.notify_waiters(); - } - - Box::pin(async {}) - }, - )); - - peer_connection.on_peer_connection_state_change(Box::new( - move |state: RTCPeerConnectionState| { - if state == RTCPeerConnectionState::Disconnected { - peer_disconnected.store(true, Ordering::SeqCst); - } - - Box::pin(async {}) - }, - )); - - // WebRTC connection processes - peer_connection.set_remote_description(rtc_offer).await?; - let answer = peer_connection.create_answer(None).await?; - let mut ice_gather_rx = peer_connection.gathering_complete_promise().await; - peer_connection.set_local_description(answer).await?; - ice_gather_rx.recv().await; - - Ok(peer_connection - .local_description() - .await - .ok_or("Failed to Generate Description")?) - } -} - -/* - How CameraThreadHandle Works: - - This struct manages a "reader" thread that owns an H264CameraReader. Importantly, this struct sends WebRTC client Receivers - to the reader thread. The reader thread then constantly loops and reads H264 bytes from H264CameraReader and then iterates - through all the WebRTC client Receivers, sending each receiver a copy of the bytes read. If there aren't any more Receivers, - then the reader thread is dropped and CameraThreadHandle is removed from WebcamManager. - - This could be thought of as an "event system" in which all the WebRTC clients on the tokio runtime receiver bytes from the reader thread. -*/ - -pub struct CameraThreadHandle { - camera_path: String, - cam_mode_tx: Sender, - current_mode: CameraMode, - camera_modes: Vec, - manual_shutdown_needed: Arc, - sink_flush_needed: Arc, - tx_sink: Arc>>>>, -} - -impl CameraThreadHandle { - fn start_camera_thread( - camera_path: &str, - camera_handles: Arc>>, - ) -> Result> { - let camera_path = camera_path.to_owned(); - let (cam_mode_tx, mut cam_mode_rx) = mpsc::channel::(1); - let manual_shutdown_needed: Arc = Arc::new(AtomicBool::new(false)); - let sink_flush_needed: Arc = Arc::new(AtomicBool::new(false)); - let tx_sink: Arc>>>> = Arc::new(Mutex::new(Vec::new())); - - let c_camera_path = camera_path.clone(); - let c_manual_shutdown_needed = manual_shutdown_needed.clone(); - let c_sink_flush_needed = sink_flush_needed.clone(); - let c_tx_sink = tx_sink.clone(); - - let device_path = Path::new(&c_camera_path); - let node = v4l::context::enum_devices() - .into_iter() - .find(|node| node.path() == device_path) - .ok_or("V4l Node Not Found")?; - let mut device = Device::new(node.index())?; - let modes = CameraMode::fetch_all(&device)?; - let initial_mode = *modes.last().ok_or( - "Error creating a CameraThreadHandle: \ - Failed to initialize camera, as no valid camera operating \ - modes were provided by Video4Linux. \ - (Check the camera, as this was an OS-level issue!)", - )?; // The last camera mode tends to be the one with the best resolution and fps. - thread::spawn(move || { - let mut reader = H264CameraReader::new(&mut device, initial_mode).unwrap(); - let mut rtc_txs: Vec>> = Vec::new(); - - // Run until the CameraThreadHandle has been dropped from memory. - while !c_manual_shutdown_needed.load(Ordering::SeqCst) { - // Used to prevent having to constantly lock the c_tx_sink mutex. - if c_sink_flush_needed.load(Ordering::SeqCst) { - let tx_sink = &mut *c_tx_sink.blocking_lock(); - - while let Some(tx) = tx_sink.pop() { - rtc_txs.push(tx); - } - - c_sink_flush_needed.store(false, Ordering::SeqCst); - } - - // Restart reader / stream with the new mode - if let Ok(mode) = cam_mode_rx.try_recv() { - drop(reader); - reader = H264CameraReader::new(&mut device, mode).unwrap(); - } - - let bytes = reader.read().unwrap(); - rtc_txs.retain(|tx| tx.blocking_send(bytes.clone()).is_ok()); - - if rtc_txs.is_empty() { - let camera_handles = &mut *camera_handles.blocking_lock(); - camera_handles.retain(|handle| handle.camera_path != c_camera_path); - - return; - } - } - }); - - Ok(CameraThreadHandle { - camera_path, - manual_shutdown_needed, - cam_mode_tx, - current_mode: *modes.last().ok_or( - "Error creating a CameraThreadHandle: \ - Failed to initialize camera, as no valid camera operating \ - modes were provided by Video4Linux. \ - (Check the camera, as this was an OS-level issue!)", - )?, - camera_modes: modes, - sink_flush_needed, - tx_sink, - }) - } - - pub async fn update_camera_mode(&mut self, mode_index: usize) -> Result<(), Box> { - let mode = self - .camera_modes - .get(mode_index) - .ok_or("Invalid Mode Index")?; - self.cam_mode_tx.send(*mode).await?; - - Ok(()) - } - - pub fn camera_path(&self) -> &str { - &self.camera_path - } - - pub fn current_mode(&self) -> &CameraMode { - &self.current_mode - } - - pub fn camera_modes(&self) -> &[CameraMode] { - &self.camera_modes - } - - async fn enroll_rx(&self) -> Receiver> { - let (tx, rx) = mpsc::channel::>(1); - let tx_sink = &mut *self.tx_sink.lock().await; - - tx_sink.push(tx); - self.sink_flush_needed.store(true, Ordering::SeqCst); - - rx - } -} - -// Signal the reader thread to stop running after drop. -impl Drop for CameraThreadHandle { - fn drop(&mut self) { - self.manual_shutdown_needed.store(true, Ordering::SeqCst); - } -} +use std::{ + error::Error, + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::{self}, + time::Duration, +}; + +use jpeg_decoder::Decoder; +use openh264::{ + encoder::Encoder, + formats::{RgbSliceU8, YUVBuffer}, +}; +use rocket::tokio::{ + sync::{ + mpsc::{self, Receiver, Sender}, + Mutex, Notify, + }, + task, time, +}; +use v4l::{ + buffer::Type, + frameinterval::{FrameIntervalEnum, Stepwise}, + io::traits::CaptureStream, + prelude::MmapStream, + video::{capture::Parameters, Capture}, + Device, Format, FourCC, Fraction, +}; +use webrtc::{ + api::{ + interceptor_registry, + media_engine::{MediaEngine, MIME_TYPE_H264}, + APIBuilder, API, + }, + ice_transport::ice_connection_state::RTCIceConnectionState, + interceptor::registry::Registry, + media::Sample, + peer_connection::{ + configuration::RTCConfiguration, peer_connection_state::RTCPeerConnectionState, + sdp::session_description::RTCSessionDescription, + }, + rtp_transceiver::rtp_codec::RTCRtpCodecCapability, + track::track_local::{track_local_static_sample::TrackLocalStaticSample, TrackLocal}, +}; + +// Struct used for reading a camera's MJPG frames to H264 buffers. +pub struct H264CameraReader<'a> { + stream: MmapStream<'a>, + camera_mode: CameraMode, + h264_encoder: Encoder, +} + +// Heavily inspired by https://github.com/D1plo1d/h264_webcam_stream +impl<'a> H264CameraReader<'a> { + pub fn new(device: &mut Device, mode: CameraMode) -> Result> { + let format = Format::new(mode.width, mode.height, FourCC::new(b"MJPG")); + Capture::set_format(device, &format)?; + + let parameters = Parameters::new(mode.frame_interval); + Capture::set_params(device, ¶meters)?; + + let stream = MmapStream::with_buffers(device, Type::VideoCapture, 4)?; + let encoder = Encoder::new()?; + + Ok(Self { + stream, + camera_mode: mode, + h264_encoder: encoder, + }) + } + + pub fn read(&mut self) -> Result, Box> { + let (buffer, _) = self.stream.next()?; + let mut jpg = Decoder::new(buffer); + let pixels = jpg.decode()?; + + let yuv_buffer = YUVBuffer::from_rgb_source(RgbSliceU8::new( + &pixels[..], + ( + self.camera_mode.width as usize, + self.camera_mode.height as usize, + ), + )); + let bitstream = self.h264_encoder.encode(&yuv_buffer)?; + + Ok(bitstream.to_vec()) + } +} + +// CameraMode(s) are used for controlling what resolution and frame rate the H264CameraReader operates at. +#[derive(Debug, Clone, Copy)] +pub struct CameraMode { + width: u32, + height: u32, + frame_interval: Fraction, // 1/30 is 30 fps, 1/15 is 15fps, etc. +} + +impl core::fmt::Display for CameraMode { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "{}x{} @{}fps", + self.width, self.height, self.frame_interval.denominator + ) + } +} + +impl CameraMode { + // Fetch all possible CameraMode(s) of the Device camera. + pub fn fetch_all(device: &Device) -> Result, Box> { + let mut configs: Vec = Vec::new(); + + let discrete_iter = Capture::enum_framesizes(device, FourCC::new(b"MJPG"))? + .into_iter() + .flat_map(|framesize| framesize.size.to_discrete()); + + for discrete_frame_size in discrete_iter { + for interval in Capture::enum_frameintervals( + device, + FourCC::new(b"MJPG"), + discrete_frame_size.width, + discrete_frame_size.height, + )? { + let fraction = match interval.interval { + FrameIntervalEnum::Discrete(fraction) => fraction, + FrameIntervalEnum::Stepwise(Stepwise { min, .. }) => min, + }; + + configs.push(CameraMode { + width: discrete_frame_size.width, + height: discrete_frame_size.height, + frame_interval: fraction, + }); + } + } + + Ok(configs) + } +} + +/// Handles communication between CameraThreadHandle(s) and WebRTC clients. +pub struct WebcamManager { + camera_handles: Arc>>, + rtc_api: API, +} + +impl WebcamManager { + pub fn new() -> Result> { + let mut media_engine = MediaEngine::default(); + media_engine.register_default_codecs()?; + + let mut registry = Registry::new(); + registry = + interceptor_registry::register_default_interceptors(registry, &mut media_engine)?; + + let rtc_api = APIBuilder::new() + .with_media_engine(media_engine) + .with_interceptor_registry(registry) + .build(); + + Ok(Self { + camera_handles: Arc::new(Mutex::new(Vec::new())), + rtc_api, + }) + } + + pub fn camera_handles(&self) -> Arc>> { + self.camera_handles.clone() + } + + /// Add a WebRTC "Client". A client in this sense is simply a WebRTC Connection created from an RTC Offer or [`RTCSessionDescription`]. + /// This connection serves an H264 encoded stream from the corresponding camera to the `camera_path` argument. "Client" is just used here + /// to describe the connection itself and all its respective mpsc channels and tokio tasks needed to serve the required stream. + /// **IMPORTANT: Everything in here (within the client) runs within the tokio runtime!** + pub async fn add_client( + &self, + camera_path: String, + rtc_offer: RTCSessionDescription, + ) -> Result> { + let peer_connection = self + .rtc_api + .new_peer_connection(RTCConfiguration::default()) + .await?; + let peer_connection = Arc::new(peer_connection); + let peer_disconnected = Arc::new(AtomicBool::new(false)); + let notify_tx = Arc::new(Notify::new()); + let notify_rx = notify_tx.clone(); + + let video_track = Arc::new(TrackLocalStaticSample::new( + RTCRtpCodecCapability { + mime_type: MIME_TYPE_H264.to_owned(), + ..Default::default() + }, + // These are arbitrary values. I'm sure that in WebRTC scenarios that require more defined control, the id and stream_id would be important. + // However, this is not the case as we are simply serving a video stream! + "video".to_owned(), + "webrtc".to_owned(), + )); + + let rtp_sender = peer_connection + .add_track(Arc::clone(&video_track) as Arc) + .await?; + + // Get a Receiver from the CameraThreadHandle thread. Additionally, create a CameraThreadHandle if one isn't active for the current path. + let mut rx = { + let camera_handles = &mut self.camera_handles.lock().await; + match camera_handles + .iter() + .find(|handle| handle.camera_path == camera_path) + { + Some(handle) => handle.enroll_rx().await, + None => { + let handle = CameraThreadHandle::start_camera_thread( + &camera_path, + self.camera_handles.clone(), + ) + .unwrap(); + let rx = handle.enroll_rx().await; + + camera_handles.push(handle); + rx + } + } + }; + + // Should drop if connection is canceled + task::spawn(async move { + let mut rtcp_buf = vec![0u8; 1500]; + while let Ok((_, _)) = rtp_sender.read(&mut rtcp_buf).await {} + }); + + let c_peer_disconnected = peer_disconnected.clone(); + let c_peer_connection = peer_connection.clone(); + task::spawn(async move { + // We must wait for the ice connection status state to be Connected before sending bytes to the WebRTC client. + if time::timeout(Duration::from_millis(5000), notify_rx.notified()) + .await + .is_err() + { + return; + } + + // Read h264 bytes from the CameraThreadHandle using mpsc. + while let Some(bytes) = rx.recv().await { + if c_peer_disconnected.load(Ordering::SeqCst) { + let _ = c_peer_connection.close().await; + return; + } + + video_track + .write_sample(&Sample { + data: bytes.into(), + duration: Duration::from_millis(20), + ..Default::default() + }) + .await + .unwrap(); + } + }); + + peer_connection.on_ice_connection_state_change(Box::new( + move |connection_state: RTCIceConnectionState| { + if connection_state == RTCIceConnectionState::Connected { + notify_tx.notify_waiters(); + } + + Box::pin(async {}) + }, + )); + + peer_connection.on_peer_connection_state_change(Box::new( + move |state: RTCPeerConnectionState| { + if state == RTCPeerConnectionState::Disconnected { + peer_disconnected.store(true, Ordering::SeqCst); + } + + Box::pin(async {}) + }, + )); + + // WebRTC connection processes + peer_connection.set_remote_description(rtc_offer).await?; + let answer = peer_connection.create_answer(None).await?; + let mut ice_gather_rx = peer_connection.gathering_complete_promise().await; + peer_connection.set_local_description(answer).await?; + ice_gather_rx.recv().await; + + Ok(peer_connection + .local_description() + .await + .ok_or("Failed to Generate Description")?) + } +} + +/* + How CameraThreadHandle Works: + + This struct manages a "reader" thread that owns an H264CameraReader. Importantly, this struct sends WebRTC client Receivers + to the reader thread. The reader thread then constantly loops and reads H264 bytes from H264CameraReader and then iterates + through all the WebRTC client Receivers, sending each receiver a copy of the bytes read. If there aren't any more Receivers, + then the reader thread is dropped and CameraThreadHandle is removed from WebcamManager. + + This could be thought of as an "event system" in which all the WebRTC clients on the tokio runtime receiver bytes from the reader thread. +*/ + +pub struct CameraThreadHandle { + camera_path: String, + cam_mode_tx: Sender, + current_mode: CameraMode, + camera_modes: Vec, + manual_shutdown_needed: Arc, + sink_flush_needed: Arc, + tx_sink: Arc>>>>, +} + +impl CameraThreadHandle { + fn start_camera_thread( + camera_path: &str, + camera_handles: Arc>>, + ) -> Result> { + let camera_path = camera_path.to_owned(); + let (cam_mode_tx, mut cam_mode_rx) = mpsc::channel::(1); + let manual_shutdown_needed: Arc = Arc::new(AtomicBool::new(false)); + let sink_flush_needed: Arc = Arc::new(AtomicBool::new(false)); + let tx_sink: Arc>>>> = Arc::new(Mutex::new(Vec::new())); + + let c_camera_path = camera_path.clone(); + let c_manual_shutdown_needed = manual_shutdown_needed.clone(); + let c_sink_flush_needed = sink_flush_needed.clone(); + let c_tx_sink = tx_sink.clone(); + + let device_path = Path::new(&c_camera_path); + let node = v4l::context::enum_devices() + .into_iter() + .find(|node| node.path() == device_path) + .ok_or("V4l Node Not Found")?; + let mut device = Device::new(node.index())?; + let modes = CameraMode::fetch_all(&device)?; + let initial_mode = *modes.last().ok_or( + "Error creating a CameraThreadHandle: \ + Failed to initialize camera, as no valid camera operating \ + modes were provided by Video4Linux. \ + (Check the camera, as this was an OS-level issue!)", + )?; // The last camera mode tends to be the one with the best resolution and fps. + thread::spawn(move || { + let mut reader = H264CameraReader::new(&mut device, initial_mode).unwrap(); + let mut rtc_txs: Vec>> = Vec::new(); + + // Run until the CameraThreadHandle has been dropped from memory. + while !c_manual_shutdown_needed.load(Ordering::SeqCst) { + // Used to prevent having to constantly lock the c_tx_sink mutex. + if c_sink_flush_needed.load(Ordering::SeqCst) { + let tx_sink = &mut *c_tx_sink.blocking_lock(); + + while let Some(tx) = tx_sink.pop() { + rtc_txs.push(tx); + } + + c_sink_flush_needed.store(false, Ordering::SeqCst); + } + + // Restart reader / stream with the new mode + if let Ok(mode) = cam_mode_rx.try_recv() { + drop(reader); + reader = H264CameraReader::new(&mut device, mode).unwrap(); + } + + let bytes = reader.read().unwrap(); + rtc_txs.retain(|tx| tx.blocking_send(bytes.clone()).is_ok()); + + if rtc_txs.is_empty() { + let camera_handles = &mut *camera_handles.blocking_lock(); + camera_handles.retain(|handle| handle.camera_path != c_camera_path); + + return; + } + } + }); + + Ok(CameraThreadHandle { + camera_path, + manual_shutdown_needed, + cam_mode_tx, + current_mode: *modes.last().ok_or( + "Error creating a CameraThreadHandle: \ + Failed to initialize camera, as no valid camera operating \ + modes were provided by Video4Linux. \ + (Check the camera, as this was an OS-level issue!)", + )?, + camera_modes: modes, + sink_flush_needed, + tx_sink, + }) + } + + pub async fn update_camera_mode(&mut self, mode_index: usize) -> Result<(), Box> { + let mode = self + .camera_modes + .get(mode_index) + .ok_or("Invalid Mode Index")?; + self.cam_mode_tx.send(*mode).await?; + + Ok(()) + } + + pub fn camera_path(&self) -> &str { + &self.camera_path + } + + pub fn current_mode(&self) -> &CameraMode { + &self.current_mode + } + + pub fn camera_modes(&self) -> &[CameraMode] { + &self.camera_modes + } + + async fn enroll_rx(&self) -> Receiver> { + let (tx, rx) = mpsc::channel::>(1); + let tx_sink = &mut *self.tx_sink.lock().await; + + tx_sink.push(tx); + self.sink_flush_needed.store(true, Ordering::SeqCst); + + rx + } +} + +// Signal the reader thread to stop running after drop. +impl Drop for CameraThreadHandle { + fn drop(&mut self) { + self.manual_shutdown_needed.store(true, Ordering::SeqCst); + } +} diff --git a/react-app/README.md b/react-app/README.md index 429885c..b8df852 100644 --- a/react-app/README.md +++ b/react-app/README.md @@ -6,7 +6,7 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo In the react-app directory, you can run: -### `npm start` +### `pnpm start` Runs the app in the development mode.\ Open [http://localhost:3000](http://localhost:3000) to view it in your browser. @@ -14,12 +14,12 @@ Open [http://localhost:3000](http://localhost:3000) to view it in your browser. The page will reload when you make changes.\ You may also see any lint errors in the console. -### `npm test` +### `pnpm test` Launches the test runner in the interactive watch mode.\ See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. -### `npm run build` +### `pnpm build` Builds the app for production to the `build` folder.\ It correctly bundles React in production mode and optimizes the build for the best performance. @@ -29,7 +29,11 @@ Your app is ready to be deployed! See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. -### `npm run eject` +### `pnpm preview` + +Allows you to locally preview the production build. + +### `pnpm eject` **Note: this is a one-way operation. Once you `eject`, you can't go back!** diff --git a/react-app/package-lock.json b/react-app/package-lock.json deleted file mode 100644 index f3084eb..0000000 --- a/react-app/package-lock.json +++ /dev/null @@ -1,2706 +0,0 @@ -{ - "name": "react-app", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "react-app", - "version": "0.1.0", - "dependencies": { - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^13.5.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-player": "^2.15.0", - "web-vitals": "^2.1.4" - }, - "devDependencies": { - "@biomejs/biome": "2.3.8", - "@vitejs/plugin-react": "^5.1.4", - "source-map-loader": "^5.0.0", - "ts-loader": "^9.5.4", - "typescript": "^5.9.3", - "vite": "^7.3.1" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", - "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@biomejs/biome": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.8.tgz", - "integrity": "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==", - "dev": true, - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.8", - "@biomejs/cli-darwin-x64": "2.3.8", - "@biomejs/cli-linux-arm64": "2.3.8", - "@biomejs/cli-linux-arm64-musl": "2.3.8", - "@biomejs/cli-linux-x64": "2.3.8", - "@biomejs/cli-linux-x64-musl": "2.3.8", - "@biomejs/cli-win32-arm64": "2.3.8", - "@biomejs/cli-win32-x64": "2.3.8" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.8.tgz", - "integrity": "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.8.tgz", - "integrity": "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.8.tgz", - "integrity": "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.8.tgz", - "integrity": "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.8.tgz", - "integrity": "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.8.tgz", - "integrity": "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.8.tgz", - "integrity": "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.8.tgz", - "integrity": "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" - }, - "node_modules/@testing-library/jest-dom": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", - "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", - "dependencies": { - "@adobe/css-tools": "^4.0.1", - "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=8", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", - "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "29.5.12", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", - "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" - }, - "node_modules/@types/node": { - "version": "20.12.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", - "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" - }, - "node_modules/@types/testing-library__jest-dom": { - "version": "5.14.9", - "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", - "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", - "dependencies": { - "@types/jest": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", - "dev": true, - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitejs/plugin-react/node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", - "dev": true, - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "dev": true - }, - "node_modules/enhanced-resolve": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.1.tgz", - "integrity": "sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/load-script": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz", - "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==" - }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/memoize-one": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" - }, - "node_modules/react-player": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/react-player/-/react-player-2.15.0.tgz", - "integrity": "sha512-wo7LM3CwxkjW9WdvGrQ3I0PhIl5xY1h+9EdpSnCRaQhE4dRMGmfH60RITPaauUhd2uJkGpzAj27kWYHT1j/dBw==", - "dependencies": { - "deepmerge": "^4.0.0", - "load-script": "^1.0.0", - "memoize-one": "^5.1.1", - "prop-types": "^15.7.2", - "react-fast-compare": "^3.0.1" - }, - "peerDependencies": { - "react": ">=16.6.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", - "dev": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.72.1" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-loader": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", - "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", - "source-map": "^0.7.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "typescript": "*", - "webpack": "^5.0.0" - } - }, - "node_modules/ts-loader/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vite/node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/web-vitals": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", - "integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - } - } -} diff --git a/react-app/package.json b/react-app/package.json index b87dd2f..08260ae 100644 --- a/react-app/package.json +++ b/react-app/package.json @@ -41,9 +41,11 @@ "devDependencies": { "@biomejs/biome": "2.3.8", "@vitejs/plugin-react": "^5.1.4", + "jsdom": "^28.1.0", "source-map-loader": "^5.0.0", "ts-loader": "^9.5.4", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.0" } } \ No newline at end of file diff --git a/react-app/pnpm-lock.yaml b/react-app/pnpm-lock.yaml index 83e3451..c60eebe 100644 --- a/react-app/pnpm-lock.yaml +++ b/react-app/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.4 version: 5.2.0(vite@7.3.2(@types/node@24.10.2)(jiti@1.21.7)(terser@5.44.1)) + jsdom: + specifier: ^28.1.0 + version: 28.1.0 source-map-loader: specifier: ^5.0.0 version: 5.0.0(webpack@5.103.0) @@ -54,12 +57,32 @@ importers: vite: specifier: ^7.3.1 version: 7.3.2(@types/node@24.10.2)(jiti@1.21.7)(terser@5.44.1) + vitest: + specifier: ^4.1.0 + version: 4.1.5(@types/node@24.10.2)(jsdom@28.1.0)(vite@7.3.2(@types/node@24.10.2)(jiti@1.21.7)(terser@5.44.1)) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -217,6 +240,46 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -373,6 +436,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@jest/diff-sequences@30.0.1': resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -560,6 +632,9 @@ packages: '@sinclair/typebox@0.34.41': resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@8.20.1': resolution: {integrity: sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==} engines: {node: '>=12'} @@ -604,6 +679,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -657,6 +738,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -719,6 +829,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -758,6 +872,10 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -766,6 +884,9 @@ packages: resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -793,6 +914,10 @@ packages: caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} engines: {node: '>=8'} @@ -822,12 +947,24 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -837,6 +974,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-equal@2.2.3: resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} engines: {node: '>= 0.4'} @@ -867,6 +1007,10 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -881,6 +1025,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -914,10 +1061,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + expect@30.2.0: resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1001,6 +1155,18 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1049,6 +1215,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1115,6 +1284,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1145,6 +1323,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1152,10 +1334,16 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -1212,6 +1400,15 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1242,6 +1439,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -1302,6 +1503,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1345,6 +1550,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1374,6 +1582,12 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1390,6 +1604,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -1415,14 +1632,40 @@ packages: engines: {node: '>=10'} hasBin: true + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-loader@9.5.7: resolution: {integrity: sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==} engines: {node: '>=12.0.0'} @@ -1438,6 +1681,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + update-browserslist-db@1.2.2: resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true @@ -1484,6 +1731,51 @@ packages: yaml: optional: true + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watchpack@2.4.4: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} @@ -1491,6 +1783,10 @@ packages: web-vitals@2.1.4: resolution: {integrity: sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -1505,6 +1801,14 @@ packages: webpack-cli: optional: true + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1517,13 +1821,47 @@ packages: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} snapshots: + '@acemir/cssom@0.9.31': {} + '@adobe/css-tools@4.4.4': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1688,6 +2026,34 @@ snapshots: '@biomejs/cli-win32-x64@2.3.8': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -1766,6 +2132,8 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@exodus/bytes@1.15.0': {} + '@jest/diff-sequences@30.0.1': {} '@jest/expect-utils@30.2.0': @@ -1896,9 +2264,11 @@ snapshots: '@sinclair/typebox@0.34.41': {} + '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@8.20.1': dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 '@babel/runtime': 7.28.4 '@types/aria-query': 5.0.4 aria-query: 5.1.3 @@ -1957,6 +2327,13 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -2022,6 +2399,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@7.3.2(@types/node@24.10.2)(jiti@1.21.7)(terser@5.44.1))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@24.10.2)(jiti@1.21.7)(terser@5.44.1) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -2108,6 +2526,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -2143,12 +2563,18 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 baseline-browser-mapping@2.9.5: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -2182,6 +2608,8 @@ snapshots: caniuse-lite@1.0.30001760: {} + chai@6.2.2: {} + chalk@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -2206,14 +2634,35 @@ snapshots: convert-source-map@2.0.0: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.3.6 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-equal@2.2.3: dependencies: array-buffer-byte-length: 1.0.2 @@ -2264,6 +2713,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@8.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2282,6 +2733,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2332,8 +2785,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + events@3.3.0: {} + expect-type@1.3.0: {} + expect@30.2.0: dependencies: '@jest/expect-utils': 30.2.0 @@ -2410,6 +2869,26 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -2458,6 +2937,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -2545,6 +3026,33 @@ snapshots: js-tokens@4.0.0: {} + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -2563,14 +3071,22 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 lz-string@1.5.0: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + memoize-one@5.2.1: {} merge-stream@2.0.0: {} @@ -2616,6 +3132,14 @@ snapshots: has-symbols: 1.1.0 object-keys: 1.1.1 + obug@2.1.1: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -2648,6 +3172,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + punycode@2.3.1: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -2735,6 +3261,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} schema-utils@4.3.3: @@ -2796,6 +3326,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + slash@3.0.0: {} source-map-js@1.2.1: {} @@ -2819,6 +3351,10 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -2836,6 +3372,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tapable@2.3.0: {} terser-webpack-plugin@5.3.15(webpack@5.103.0): @@ -2854,15 +3392,35 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-loader@9.5.7(typescript@5.9.3)(webpack@5.103.0): dependencies: chalk: 4.1.2 @@ -2877,6 +3435,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.25.0: {} + update-browserslist-db@1.2.2(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -2897,6 +3457,38 @@ snapshots: jiti: 1.21.7 terser: 5.44.1 + vitest@4.1.5(@types/node@24.10.2)(jsdom@28.1.0)(vite@7.3.2(@types/node@24.10.2)(jiti@1.21.7)(terser@5.44.1)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@7.3.2(@types/node@24.10.2)(jiti@1.21.7)(terser@5.44.1)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@24.10.2)(jiti@1.21.7)(terser@5.44.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.2 + jsdom: 28.1.0 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 @@ -2904,6 +3496,8 @@ snapshots: web-vitals@2.1.4: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: {} webpack@5.103.0: @@ -2938,6 +3532,16 @@ snapshots: - esbuild - uglify-js + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -2963,4 +3567,13 @@ snapshots: gopd: 1.2.0 has-tostringtag: 1.0.2 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} diff --git a/react-app/pnpm-workspace.yaml b/react-app/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/react-app/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/react-app/public/index.html b/react-app/public/index.html deleted file mode 100644 index aa069f2..0000000 --- a/react-app/public/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - React App - - - -
- - - diff --git a/react-app/public/loading_indicator.svg b/react-app/public/loading_indicator.svg index 126ef56..0b2643d 100644 --- a/react-app/public/loading_indicator.svg +++ b/react-app/public/loading_indicator.svg @@ -1,15 +1,15 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/react-app/src/App.css b/react-app/src/App.css index fd2d25f..3d1808b 100644 --- a/react-app/src/App.css +++ b/react-app/src/App.css @@ -1,59 +1,59 @@ -.App { - text-align: center; - background-color: #FFF0F0; - min-height: 100vh; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.camera-select { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; - background-color: #841617; - padding: 15px; - gap: 10px; -} - -.camera-grid { - width: 100%; -} - -.slider-container input { - margin-bottom: 10px; - margin-right: 15px; - margin-left: 10px; -} - -.slider-label { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } +.App { + text-align: center; + background-color: #FFF0F0; + min-height: 100vh; +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.camera-select { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; + background-color: #841617; + padding: 15px; + gap: 10px; +} + +.camera-grid { + width: 100%; +} + +.slider-container input { + margin-bottom: 10px; + margin-right: 15px; + margin-left: 10px; +} + +.slider-label { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/react-app/src/App.test.tsx b/react-app/src/App.test.tsx index 108d1c0..b1903c0 100644 --- a/react-app/src/App.test.tsx +++ b/react-app/src/App.test.tsx @@ -1,6 +1,32 @@ +/* +import { test } from 'vitest'; import { render } from '@testing-library/react'; import App from './App'; test('renders without crashing', () => { render(); +}); +*/ + +import { test, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import App from './App'; + +test('renders camera select label', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(['Camera 1', 'Camera 2']), + } as Response) + ) + ); + + render(); + + expect(screen.getByLabelText(/select camera/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Camera 1' })).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/react-app/src/App.tsx b/react-app/src/App.tsx index e3ccb05..3900972 100644 --- a/react-app/src/App.tsx +++ b/react-app/src/App.tsx @@ -1,288 +1,288 @@ -import { useState, useEffect } from "react"; -import "./App.css"; -import CameraGrid, { type CameraContainer } from "./CameraGrid"; - -//filepath for testing (DELETE LATER): ../../../GitHub/Automomous/examples/ARTrackerTest/videos -function App() { - const [fpsSlider, setFpsSlider] = useState(50); // Initial fps slider value - const [resolutionSlider, setResolutionSlider] = useState(50); // Initial resolution slider value - - //Need to create a selection of camera names to choose from, and then pass that camera name - //to the image source to get the video feed from the server - - const [cameras, setCameras] = useState([]); //getting available devices from server - - const [cameraConnections, setCameraConnections] = useState>(new Map()); - - // Fetch Camera(s) Information from Server - useEffect(() => { - (async () => { - const response = await fetch("/stream/cameras"); - const cameras = await response.json(); - - console.log(`Found cameras: ${cameras}`); - - setCameras(["", ...cameras]); - })(); - }, []); - - //On change of the camera selection, add components for camera feed and sliders - //to control the camera feed - const [selectedCamera, setSelectedCamera] = useState(""); - - const [cameraContainers, setCameraContainers] = useState([ - { id: '1', name: 'Camera 1', size: 'large', connection: null, stream: null }, - { id: '2', name: 'Camera 2', size: 'large', connection: null, stream: null }, - { id: '3', name: 'Camera 3', size: 'small', connection: null, stream: null }, - { id: '4', name: 'Camera 4', size: 'small', connection: null, stream: null }, - { id: '5', name: 'Camera 5', size: 'small', connection: null, stream: null }, - ]) - - const throwCameraError = (connection: RTCPeerConnection, errorMessage: string) => { - const cameraId = Array.from(cameraConnections.entries()).find(([_, conn]) => conn === connection)?.[0]; - - alert(`stream: camera connection error\nCamera ID: ${cameraId}\nError: ${errorMessage}`); - - connection.close(); - - setCameraContainers((prev) => - prev.map((container) => - container.name === cameraId - ? { ...container, connStream: null, error: errorMessage } - : container - ) - ); - } - - const handleCameraChange = async ( - event: React.ChangeEvent, - ) => { - const selectedCameraPath = event.target.value; - console.info( - `stream: camera selection changed to: \`${selectedCameraPath}\``, - ); - - setSelectedCamera(selectedCameraPath); - }; - - const launchCameraStream = async (cameraConnection: CameraContainer) => { - - if (selectedCamera === "") { - alert("stream: no camera selected; cannot add camera."); - return; - } - - if (cameraConnections.has(selectedCamera)) { - alert("stream: camera already has an active connection; cannot add camera."); - return; - } - - const cameraId = selectedCamera; - - setCameraContainers((prev) => - prev.map((container) => - container.id === cameraConnection.id - ? { - ...container, - name: cameraId, - connection: peerConnection, - } - : container - ) - ); - - const peerConnection = new RTCPeerConnection(); - - peerConnection.onconnectionstatechange = () => { - console.info("stream: peer connection change", { - cameraId, - state: peerConnection.connectionState, - }); - - if(peerConnection.connectionState === "failed") { - throwCameraError(peerConnection, `Peer connection state is ${peerConnection.connectionState}`); - } - }; - - peerConnection.oniceconnectionstatechange = () => { - console.info("stream: ice connection state changed", { - cameraId, - state: peerConnection.iceConnectionState, - }); - if(peerConnection.connectionState === "failed") { - throwCameraError(peerConnection, `Peer connection state is ${peerConnection.connectionState}`); - } - }; - - peerConnection.ontrack = (e) => { - console.debug("stream: received track event on peer connection", { - cameraId, - trackKind: e.track.kind, - streamIds: e.streams.map((s) => s.id), - } - ); - - if(e.track.kind === "video" && e.streams.length > 0) { - const stream = e.streams[0]; - - setCameraContainers((prev) => { - return prev.map((container) => - container.id === cameraConnection.id - ? { - ...container, - name: cameraId, - stream: stream || null, - connection: peerConnection, - } - : container - ); - }); - - console.debug("stream: created video element for track event", { - cameraId, - trackKind: e.track.kind, - streamIds: e.streams.map((s) => s.id), - }); - }; - }; - - peerConnection.onicecandidate = async (e) => { - console.debug("stream: ice candidate event", { - cameraId, - candidate: e.candidate, - }); - - // IMPORTANT: Calls to the API should only run after this point. - - /* API Requests Example - // Get the current mode (ex. 1920x1080 @ 30fps) - let modeResponse = await fetch(`/stream/cameras/${encodeURIComponent(selectedCameraPath)}/modes/current`); - console.log(await modeResponse.text()); - - // Get the possible modes for the camera (ex. { 0: "1920x1080 @ 30fps", .. }) - let modesResponse = await fetch(`/stream/cameras/${encodeURIComponent(selectedCameraPath)}/modes`); - console.log(await modesResponse.json()); - - // Set the current mode for the camera by the index found in the top api request - let setResponse = await fetch(`/stream/cameras/${encodeURIComponent(selectedCameraPath)}/modes/set/${1}`, { method: "PUT" }); - console.log(setResponse.status); - */ - }; - - peerConnection.addTransceiver("video", { direction: "sendrecv" }); - peerConnection.addTransceiver("audio", { direction: "sendrecv" }); - - try { - const offer = await peerConnection.createOffer(); - await peerConnection.setLocalDescription(offer); - - const response = await fetch( - `/stream/cameras/${encodeURIComponent(cameraId)}/start`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(peerConnection.localDescription), - }, - ); - if (!response.ok) { - throw new Error( - `failed to start stream! http err: ${response.status}`, - ); - } - const remoteOffer = await response.json(); - await peerConnection.setRemoteDescription( - new RTCSessionDescription(remoteOffer), - ); - setCameraConnections((prev) => new Map(prev).set(cameraId, peerConnection)); - } catch (error) { - throwCameraError(peerConnection, error instanceof Error ? error.message : String(error)); - } - }; - - const handleAddCamera = async () => { - // Find the first available container - const availableContainer = cameraContainers.find( - (container) => container.stream === null && container.connection === null - ); - - if (!availableContainer) { - console.warn("stream: no available containers for camera"); - return; - } - - await launchCameraStream(availableContainer); - } - - const handleRemoveCamera = () => { - const connection = cameraConnections.get(selectedCamera); - if (connection) { - connection.close(); - const updatedConnections = new Map(cameraConnections); - updatedConnections.delete(selectedCamera); - setCameraConnections(updatedConnections); - - setCameraContainers((prev) => - prev.map((container) => - container.name === selectedCamera - ? { ...container, stream: null, connection: null, name: '' } - : container - ) - ); - - setSelectedCamera(""); - } - }; - - return ( -
-
- - - {selectedCamera && } -
-
-
- -
- - {selectedCamera && ( -
- - setFpsSlider(Number(e.target.value))} - /> - - - setResolutionSlider(Number(e.target.value))} - /> -
- )} -
-
- ); -} - -export default App; +import { useState, useEffect } from "react"; +import "./App.css"; +import CameraGrid, { type CameraContainer } from "./CameraGrid"; + +//filepath for testing (DELETE LATER): ../../../GitHub/Automomous/examples/ARTrackerTest/videos +function App() { + const [fpsSlider, setFpsSlider] = useState(50); // Initial fps slider value + const [resolutionSlider, setResolutionSlider] = useState(50); // Initial resolution slider value + + //Need to create a selection of camera names to choose from, and then pass that camera name + //to the image source to get the video feed from the server + + const [cameras, setCameras] = useState([]); //getting available devices from server + + const [cameraConnections, setCameraConnections] = useState>(new Map()); + + // Fetch Camera(s) Information from Server + useEffect(() => { + (async () => { + const response = await fetch("/stream/cameras"); + const cameras = await response.json(); + + console.log(`Found cameras: ${cameras}`); + + setCameras(["", ...cameras]); + })(); + }, []); + + //On change of the camera selection, add components for camera feed and sliders + //to control the camera feed + const [selectedCamera, setSelectedCamera] = useState(""); + + const [cameraContainers, setCameraContainers] = useState([ + { id: '1', name: 'Camera 1', size: 'large', connection: null, stream: null }, + { id: '2', name: 'Camera 2', size: 'large', connection: null, stream: null }, + { id: '3', name: 'Camera 3', size: 'small', connection: null, stream: null }, + { id: '4', name: 'Camera 4', size: 'small', connection: null, stream: null }, + { id: '5', name: 'Camera 5', size: 'small', connection: null, stream: null }, + ]) + + const throwCameraError = (connection: RTCPeerConnection, errorMessage: string) => { + const cameraId = Array.from(cameraConnections.entries()).find(([_, conn]) => conn === connection)?.[0]; + + alert(`stream: camera connection error\nCamera ID: ${cameraId}\nError: ${errorMessage}`); + + connection.close(); + + setCameraContainers((prev) => + prev.map((container) => + container.name === cameraId + ? { ...container, connStream: null, error: errorMessage } + : container + ) + ); + } + + const handleCameraChange = async ( + event: React.ChangeEvent, + ) => { + const selectedCameraPath = event.target.value; + console.info( + `stream: camera selection changed to: \`${selectedCameraPath}\``, + ); + + setSelectedCamera(selectedCameraPath); + }; + + const launchCameraStream = async (cameraConnection: CameraContainer) => { + + if (selectedCamera === "") { + alert("stream: no camera selected; cannot add camera."); + return; + } + + if (cameraConnections.has(selectedCamera)) { + alert("stream: camera already has an active connection; cannot add camera."); + return; + } + + const cameraId = selectedCamera; + + setCameraContainers((prev) => + prev.map((container) => + container.id === cameraConnection.id + ? { + ...container, + name: cameraId, + connection: peerConnection, + } + : container + ) + ); + + const peerConnection = new RTCPeerConnection(); + + peerConnection.onconnectionstatechange = () => { + console.info("stream: peer connection change", { + cameraId, + state: peerConnection.connectionState, + }); + + if(peerConnection.connectionState === "failed") { + throwCameraError(peerConnection, `Peer connection state is ${peerConnection.connectionState}`); + } + }; + + peerConnection.oniceconnectionstatechange = () => { + console.info("stream: ice connection state changed", { + cameraId, + state: peerConnection.iceConnectionState, + }); + if(peerConnection.connectionState === "failed") { + throwCameraError(peerConnection, `Peer connection state is ${peerConnection.connectionState}`); + } + }; + + peerConnection.ontrack = (e) => { + console.debug("stream: received track event on peer connection", { + cameraId, + trackKind: e.track.kind, + streamIds: e.streams.map((s) => s.id), + } + ); + + if(e.track.kind === "video" && e.streams.length > 0) { + const stream = e.streams[0]; + + setCameraContainers((prev) => { + return prev.map((container) => + container.id === cameraConnection.id + ? { + ...container, + name: cameraId, + stream: stream || null, + connection: peerConnection, + } + : container + ); + }); + + console.debug("stream: created video element for track event", { + cameraId, + trackKind: e.track.kind, + streamIds: e.streams.map((s) => s.id), + }); + }; + }; + + peerConnection.onicecandidate = async (e) => { + console.debug("stream: ice candidate event", { + cameraId, + candidate: e.candidate, + }); + + // IMPORTANT: Calls to the API should only run after this point. + + /* API Requests Example + // Get the current mode (ex. 1920x1080 @ 30fps) + let modeResponse = await fetch(`/stream/cameras/${encodeURIComponent(selectedCameraPath)}/modes/current`); + console.log(await modeResponse.text()); + + // Get the possible modes for the camera (ex. { 0: "1920x1080 @ 30fps", .. }) + let modesResponse = await fetch(`/stream/cameras/${encodeURIComponent(selectedCameraPath)}/modes`); + console.log(await modesResponse.json()); + + // Set the current mode for the camera by the index found in the top api request + let setResponse = await fetch(`/stream/cameras/${encodeURIComponent(selectedCameraPath)}/modes/set/${1}`, { method: "PUT" }); + console.log(setResponse.status); + */ + }; + + peerConnection.addTransceiver("video", { direction: "sendrecv" }); + peerConnection.addTransceiver("audio", { direction: "sendrecv" }); + + try { + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + + const response = await fetch( + `/stream/cameras/${encodeURIComponent(cameraId)}/start`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(peerConnection.localDescription), + }, + ); + if (!response.ok) { + throw new Error( + `failed to start stream! http err: ${response.status}`, + ); + } + const remoteOffer = await response.json(); + await peerConnection.setRemoteDescription( + new RTCSessionDescription(remoteOffer), + ); + setCameraConnections((prev) => new Map(prev).set(cameraId, peerConnection)); + } catch (error) { + throwCameraError(peerConnection, error instanceof Error ? error.message : String(error)); + } + }; + + const handleAddCamera = async () => { + // Find the first available container + const availableContainer = cameraContainers.find( + (container) => container.stream === null && container.connection === null + ); + + if (!availableContainer) { + console.warn("stream: no available containers for camera"); + return; + } + + await launchCameraStream(availableContainer); + } + + const handleRemoveCamera = () => { + const connection = cameraConnections.get(selectedCamera); + if (connection) { + connection.close(); + const updatedConnections = new Map(cameraConnections); + updatedConnections.delete(selectedCamera); + setCameraConnections(updatedConnections); + + setCameraContainers((prev) => + prev.map((container) => + container.name === selectedCamera + ? { ...container, stream: null, connection: null, name: '' } + : container + ) + ); + + setSelectedCamera(""); + } + }; + + return ( +
+
+ + + {selectedCamera && } +
+
+
+ +
+ + {selectedCamera && ( +
+ + setFpsSlider(Number(e.target.value))} + /> + + + setResolutionSlider(Number(e.target.value))} + /> +
+ )} +
+
+ ); +} + +export default App; diff --git a/react-app/src/CameraGrid.css b/react-app/src/CameraGrid.css index 19ec8fa..82858a8 100644 --- a/react-app/src/CameraGrid.css +++ b/react-app/src/CameraGrid.css @@ -1,138 +1,138 @@ -.grid-container { - width: 95%; - margin: 0 auto; - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 10px; - padding: 16px; -} - -.camera-tile { - border-radius: 8px; - color: white; - display: flex; - flex-direction: column; - font-weight: bold; - background: linear-gradient(to bottom, black, #262626); - border: 6px solid transparent; - padding: 10px; -} - -.camera-tile.selected { - border: 6px solid #841617; -} - -.camera-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.camera-error { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background: rgba(255, 0, 0, 0.2); - color: red; - font-size: 16px; - font-weight: bold; - text-align: center; - border-radius: 6px; - } - -.remove-button { - background-color: #841617; - color: white; - border: none; - border-radius: 4px; - padding: 8px 12px; - cursor: pointer; - font-size: 14px; - transition: background-color 0.3s ease; -} - -.remove-button:hover { - background-color: #a91a1a; -} - -.camera-title { - text-align: left; - font-size: 16px; - margin-bottom: 8px; - padding: 4px 8px; - background: rgba(0, 0, 0, 0.4); - border-radius: 4px; -} - -.camera-body { - flex: 1; - border-radius: 6px; - background: rgba(255, 255, 255, 0.05); - overflow: hidden; - position: relative; -} - -/* Size variants */ -.camera-tile.large { - height: 400px; - grid-column: span 3; /* 1/2 width */ -} - -.camera-tile.small { - height: 250px; - grid-column: span 2; /* 1/3 width */ -} - -.camera-feed { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 0; - position: absolute; - top: 0; - left: 0; -} - -.camera-loading { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.6); - color: white; - font-size: 16px; - z-index: 2; -} - -.camera-placeholder { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #b6b6b6; - position: relative; -} - -h1 { - font-family: "Inter", sans-serif; - margin: 0; - padding: 0; - font-size: 30px; - color: white; -} - -h3 { - font-family: "Inter", sans-serif; - font-weight: lighter; - margin: 0; - padding: 0; - font-size: 16px; - color: #b6b6b6; -} +.grid-container { + width: 95%; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; + padding: 16px; +} + +.camera-tile { + border-radius: 8px; + color: white; + display: flex; + flex-direction: column; + font-weight: bold; + background: linear-gradient(to bottom, black, #262626); + border: 6px solid transparent; + padding: 10px; +} + +.camera-tile.selected { + border: 6px solid #841617; +} + +.camera-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.camera-error { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(255, 0, 0, 0.2); + color: red; + font-size: 16px; + font-weight: bold; + text-align: center; + border-radius: 6px; + } + +.remove-button { + background-color: #841617; + color: white; + border: none; + border-radius: 4px; + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s ease; +} + +.remove-button:hover { + background-color: #a91a1a; +} + +.camera-title { + text-align: left; + font-size: 16px; + margin-bottom: 8px; + padding: 4px 8px; + background: rgba(0, 0, 0, 0.4); + border-radius: 4px; +} + +.camera-body { + flex: 1; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + overflow: hidden; + position: relative; +} + +/* Size variants */ +.camera-tile.large { + height: 400px; + grid-column: span 3; /* 1/2 width */ +} + +.camera-tile.small { + height: 250px; + grid-column: span 2; /* 1/3 width */ +} + +.camera-feed { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 0; + position: absolute; + top: 0; + left: 0; +} + +.camera-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + color: white; + font-size: 16px; + z-index: 2; +} + +.camera-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #b6b6b6; + position: relative; +} + +h1 { + font-family: "Inter", sans-serif; + margin: 0; + padding: 0; + font-size: 30px; + color: white; +} + +h3 { + font-family: "Inter", sans-serif; + font-weight: lighter; + margin: 0; + padding: 0; + font-size: 16px; + color: #b6b6b6; +} diff --git a/react-app/src/CameraGrid.tsx b/react-app/src/CameraGrid.tsx index c4b4e4b..9312d2c 100644 --- a/react-app/src/CameraGrid.tsx +++ b/react-app/src/CameraGrid.tsx @@ -1,85 +1,85 @@ -import type React from "react"; -import "./CameraGrid.css"; - -export interface CameraContainer { - id: string; - name: string; - size: "large" | "small"; - connection: RTCPeerConnection | null; - stream: MediaStream | null; - error?: string | null; -} - -interface CameraGridProps { - cameras: CameraContainer[]; - onRemoveCamera: (cameraId: string) => void; - selectedCamera: string; - setSelectedCamera: (cameraId: string) => void; -} - -const CameraGrid: React.FC = ({ - cameras, - selectedCamera, - setSelectedCamera, - onRemoveCamera, -}) => { - return ( -
- {cameras.map((cam) => ( - -
-
- {cam.error ? ( -
-

CAMERA ERROR

-

{cam.error}

-
- ) : cam.connection && cam.stream ? ( -
- - ))} - - ); -}; - -export default CameraGrid; +import type React from "react"; +import "./CameraGrid.css"; + +export interface CameraContainer { + id: string; + name: string; + size: "large" | "small"; + connection: RTCPeerConnection | null; + stream: MediaStream | null; + error?: string | null; +} + +interface CameraGridProps { + cameras: CameraContainer[]; + onRemoveCamera: (cameraId: string) => void; + selectedCamera: string; + setSelectedCamera: (cameraId: string) => void; +} + +const CameraGrid: React.FC = ({ + cameras, + selectedCamera, + setSelectedCamera, + onRemoveCamera, +}) => { + return ( +
+ {cameras.map((cam) => ( + +
+
+ {cam.error ? ( +
+

CAMERA ERROR

+

{cam.error}

+
+ ) : cam.connection && cam.stream ? ( +
+ + ))} + + ); +}; + +export default CameraGrid; diff --git a/react-app/tests/cameras.test.ts b/react-app/tests/cameras.test.ts deleted file mode 100644 index 0561e78..0000000 --- a/react-app/tests/cameras.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, test } from '@jest/globals'; -import { cameras } from '../src/main'; - -// test if cameras array is populated -describe('camera module', () => { - test('check that camera array is non-empty', () => { - expect(cameras).not.toHaveLength(0); - }); -}); \ No newline at end of file diff --git a/react-app/tests/cameras.test.tsx b/react-app/tests/cameras.test.tsx new file mode 100644 index 0000000..50c29ae --- /dev/null +++ b/react-app/tests/cameras.test.tsx @@ -0,0 +1,39 @@ +/* +import { describe, expect, test } from 'vitest'; +import { cameras } from '../src/main'; + +// test if cameras array is populated +describe('camera module', () => { + test('check that camera array is non-empty', () => { + expect(cameras).not.toHaveLength(0); + }); +}); +*/ + +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import App from '../src/App'; + +// Tests that the App fetches the camera list from /stream/cameras on mount. +describe('camera module', () => { + beforeEach(() => { + // Replace global fetch with a mock that returns a fake list of cameras. + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify(['/dev/video0', '/dev/video1']), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + )); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('fetches the camera list from /stream/cameras', async () => { + render(); + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/stream/cameras'); + }); + }); +}); \ No newline at end of file diff --git a/react-app/tsconfig.json b/react-app/tsconfig.json index 69d2db1..0a77e2f 100644 --- a/react-app/tsconfig.json +++ b/react-app/tsconfig.json @@ -8,7 +8,7 @@ // See also https://aka.ms/tsconfig/module "module": "nodenext", "target": "esnext", - "types": [], + "types": ["vitest/globals"], // For nodejs: // "lib": ["esnext"], "allowJs": true, diff --git a/react-app/vite.config.ts b/react-app/vite.config.ts index 074086f..c48d5e0 100644 --- a/react-app/vite.config.ts +++ b/react-app/vite.config.ts @@ -1,12 +1,17 @@ -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [react()], - server: { - proxy: { - "/api": "http://localhost:3600", - "/stream": "http://localhost:3600", - }, - }, -}); +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:3600", + "/stream": "http://localhost:3600", + }, + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/setupTests.ts'], + } +});