diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7f820b11da..2e45d6b647 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -30,7 +30,18 @@ "Bash(pnpm exec tsc:*)", "Bash(pnpm biome check:*)", "Bash(pnpm --dir apps/desktop exec tsc:*)", - "Bash(xxd:*)" + "Bash(xxd:*)", + "Bash(git checkout:*)", + "WebFetch(domain:www.npmjs.com)", + "Bash(pnpm install:*)", + "Bash(pnpm --dir apps/desktop exec biome check:*)", + "Bash(pnpm --dir apps/desktop exec biome format:*)", + "Bash(echo:*)", + "Bash(pnpm exec biome:*)", + "Bash(rustfmt:*)", + "Bash(cargo tree:*)", + "WebFetch(domain:github.com)", + "WebFetch(domain:docs.rs)" ], "deny": [], "ask": [] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3e5bec0bd..006c4ee79b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,9 +141,7 @@ jobs: run: node scripts/setup.js - name: Run Clippy - uses: actions-rs-plus/clippy-check@v2 - with: - args: --workspace --all-features --locked + run: cargo clippy --workspace --all-features --locked -- -D warnings lint-biome: name: Lint (Biome) @@ -279,6 +277,4 @@ jobs: - name: Run Clippy if: ${{ matrix.settings.target == 'aarch64-apple-darwin' || matrix.settings.target == 'x86_64-pc-windows-msvc' }} - uses: actions-rs-plus/clippy-check@v2 - with: - args: --workspace --all-features --locked + run: cargo clippy --workspace --all-features --locked -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 8deccdc263..4fc058cb19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -1212,6 +1218,7 @@ dependencies = [ "keyed_priority_queue", "lazy_static", "log", + "lz4_flex", "md5", "nix 0.29.0", "objc", @@ -1290,6 +1297,7 @@ dependencies = [ "ffmpeg-next", "flume", "futures", + "lru", "ringbuf", "sentry", "serde", @@ -1575,11 +1583,14 @@ dependencies = [ "clap", "ffmpeg-hw-device", "ffmpeg-next", + "foreign-types 0.5.0", "futures", "futures-intrusive", "glyphon", "image 0.25.8", "log", + "metal 0.31.0", + "objc2 0.6.2", "pretty_assertions", "reactive_graph", "resvg", @@ -1591,6 +1602,8 @@ dependencies = [ "tokio", "tracing", "wgpu", + "wgpu-core", + "wgpu-hal", "workspace-hack", ] @@ -3893,6 +3906,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -4949,6 +4964,9 @@ name = "lru" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] [[package]] name = "lru-slab" @@ -4956,6 +4974,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4_flex" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +dependencies = [ + "twox-hash", +] + [[package]] name = "mac" version = "0.1.1" @@ -10234,6 +10261,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index e0350317cc..61e8ab20f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,9 @@ nokhwa = { git = "https://github.com/CapSoftware/nokhwa", rev = "b9c8079e82e2", "serialize", ] } nokhwa-bindings-macos = { git = "https://github.com/CapSoftware/nokhwa", rev = "b9c8079e82e2" } -wgpu = "25.0.0" +wgpu = { version = "25.0.0", features = ["wgpu-core"] } +wgpu-hal = "25.0.0" +wgpu-core = "25.0.0" flume = "0.11.0" thiserror = "1.0" sentry = { version = "0.42.0", features = [ @@ -53,12 +55,15 @@ cidre = { git = "https://github.com/CapSoftware/cidre", rev = "bf84b67079a8", fe "macos_12_7", "cv", "cf", + "cg", "core_audio", "sc", "av", "blocks", "async", "dispatch", + "io_surface", + "mtl", ], default-features = false } windows = "0.60.0" diff --git a/apps/desktop/app.config.ts b/apps/desktop/app.config.ts index 4a96df5224..c7569b9349 100644 --- a/apps/desktop/app.config.ts +++ b/apps/desktop/app.config.ts @@ -1,5 +1,7 @@ import capUIPlugin from "@cap/ui-solid/vite"; import { defineConfig } from "@solidjs/start/config"; +import topLevelAwait from "vite-plugin-top-level-await"; +import wasm from "vite-plugin-wasm"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ @@ -13,18 +15,22 @@ export default defineConfig({ port: 3001, strictPort: true, watch: { - // 2. tell vite to ignore watching `src-tauri` ignored: ["**/src-tauri/**"], }, + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, }, // 3. to make use of `TAURI_DEBUG` and other env variables // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand envPrefix: ["VITE_", "TAURI_"], assetsInclude: ["**/*.riv"], plugins: [ + wasm(), + topLevelAwait(), capUIPlugin, tsconfigPaths({ - // If this isn't set Vinxi hangs on startup root: ".", }), ], diff --git a/apps/desktop/core b/apps/desktop/core deleted file mode 100644 index 5a29217500..0000000000 Binary files a/apps/desktop/core and /dev/null differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b5f4e3d96d..aa83e5edee 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -56,6 +56,7 @@ "@types/react-tooltip": "^4.2.4", "cva": "npm:class-variance-authority@^0.7.0", "effect": "^3.18.4", + "lz4-wasm": "^0.9.2", "mp4box": "^0.5.2", "posthog-js": "^1.215.3", "solid-js": "^1.9.3", @@ -72,6 +73,7 @@ }, "devDependencies": { "@fontsource/geist-sans": "^5.0.3", + "@webgpu/types": "^0.1.44", "@iconify/json": "^2.2.239", "@tauri-apps/cli": ">=2.1.0", "@total-typescript/ts-reset": "^0.6.1", @@ -80,6 +82,8 @@ "cross-env": "^7.0.3", "typescript": "^5.8.3", "vite": "^6.3.5", + "vite-plugin-top-level-await": "^1.4.4", + "vite-plugin-wasm": "^3.4.1", "vite-tsconfig-paths": "^5.0.1", "vitest": "~2.1.9" } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index df574378e5..184bb073d1 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -107,6 +107,7 @@ tauri-plugin-sentry = "0.5.0" thiserror.workspace = true bytes = "1.10.1" async-stream = "0.3.6" +lz4_flex = "0.11" sanitize-filename = "0.6.0" tracing-futures = { version = "0.2.5", features = ["futures-03"] } tracing-opentelemetry = "0.32.0" diff --git a/apps/desktop/src-tauri/icons/tray-default-icon-instant.png b/apps/desktop/src-tauri/icons/tray-default-icon-instant.png new file mode 100644 index 0000000000..ecea018f63 Binary files /dev/null and b/apps/desktop/src-tauri/icons/tray-default-icon-instant.png differ diff --git a/apps/desktop/src-tauri/icons/tray-default-icon-screenshot.png b/apps/desktop/src-tauri/icons/tray-default-icon-screenshot.png new file mode 100644 index 0000000000..2c6703766b Binary files /dev/null and b/apps/desktop/src-tauri/icons/tray-default-icon-screenshot.png differ diff --git a/apps/desktop/src-tauri/icons/tray-default-icon-studio.png b/apps/desktop/src-tauri/icons/tray-default-icon-studio.png new file mode 100644 index 0000000000..64d2abe6be Binary files /dev/null and b/apps/desktop/src-tauri/icons/tray-default-icon-studio.png differ diff --git a/apps/desktop/src-tauri/icons/tray-default-icon.png b/apps/desktop/src-tauri/icons/tray-default-icon.png index 7e6242b86a..859e881caa 100644 Binary files a/apps/desktop/src-tauri/icons/tray-default-icon.png and b/apps/desktop/src-tauri/icons/tray-default-icon.png differ diff --git a/apps/desktop/src-tauri/icons/tray-stop-icon.png b/apps/desktop/src-tauri/icons/tray-stop-icon.png index 21d34e3819..65bc3105f8 100644 Binary files a/apps/desktop/src-tauri/icons/tray-stop-icon.png and b/apps/desktop/src-tauri/icons/tray-stop-icon.png differ diff --git a/apps/desktop/src-tauri/src/camera_legacy.rs b/apps/desktop/src-tauri/src/camera_legacy.rs index 444d0f24d4..ee99b50fb1 100644 --- a/apps/desktop/src-tauri/src/camera_legacy.rs +++ b/apps/desktop/src-tauri/src/camera_legacy.rs @@ -1,3 +1,5 @@ +use std::time::Instant; + use cap_recording::FFmpegVideoFrame; use flume::Sender; use tokio_util::sync::CancellationToken; @@ -62,6 +64,7 @@ pub async fn create_camera_preview_ws() -> (Sender, u16, Cance width: frame.width(), height: frame.height(), stride: frame.stride(0) as u32, + created_at: Instant::now(), }) .ok(); } diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index 0dde354181..f4fb8ac49b 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -61,7 +61,7 @@ pub async fn save_model_file(path: String, data: Vec) -> Result<(), String> async fn extract_audio_from_video(video_path: &str, output_path: &PathBuf) -> Result<(), String> { log::info!("=== EXTRACT AUDIO START ==="); log::info!("Attempting to extract audio from: {video_path}"); - log::info!("Output path: {:?}", output_path); + log::info!("Output path: {output_path:?}"); if video_path.ends_with(".cap") { log::info!("Detected .cap project directory"); @@ -160,12 +160,11 @@ async fn extract_audio_from_video(video_path: &str, output_path: &PathBuf) -> Re log::info!("Final mixed audio: {} samples", mixed_samples.len()); let mix_rms = (mixed_samples.iter().map(|&s| s * s).sum::() / mixed_samples.len() as f32).sqrt(); - log::info!("Mixed audio RMS: {:.4}", mix_rms); + log::info!("Mixed audio RMS: {mix_rms:.4}"); if mix_rms < 0.001 { log::warn!( - "WARNING: Mixed audio RMS is very low ({:.6}) - audio may be nearly silent!", - mix_rms + "WARNING: Mixed audio RMS is very low ({mix_rms:.6}) - audio may be nearly silent!" ); } @@ -509,7 +508,7 @@ fn is_special_token(token_text: &str) -> bool { || trimmed.contains("<|"); if is_special { - log::debug!("Filtering special token: {:?}", token_text); + log::debug!("Filtering special token: {token_text:?}"); } is_special @@ -522,7 +521,7 @@ fn process_with_whisper( ) -> Result { log::info!("=== WHISPER TRANSCRIPTION START ==="); log::info!("Processing audio file: {audio_path:?}"); - log::info!("Language setting: {}", language); + log::info!("Language setting: {language}"); let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); @@ -569,18 +568,11 @@ fn process_with_whisper( / audio_data_f32.len() as f32) .sqrt(); log::info!( - "Audio samples - min: {:.4}, max: {:.4}, avg: {:.6}, RMS: {:.4}", - min_sample, - max_sample, - avg_sample, - rms + "Audio samples - min: {min_sample:.4}, max: {max_sample:.4}, avg: {avg_sample:.6}, RMS: {rms:.4}" ); if rms < 0.001 { - log::warn!( - "WARNING: Audio RMS is very low ({:.6}) - audio may be nearly silent!", - rms - ); + log::warn!("WARNING: Audio RMS is very low ({rms:.6}) - audio may be nearly silent!"); } log::info!("First 20 audio samples:"); @@ -646,10 +638,7 @@ fn process_with_whisper( if is_special_token(&token_text) { log::debug!( - " Token[{}]: id={}, text={:?} -> SKIPPED (special)", - t, - token_id, - token_text + " Token[{t}]: id={token_id}, text={token_text:?} -> SKIPPED (special)" ); continue; } @@ -661,13 +650,7 @@ fn process_with_whisper( let token_end = (data.t1 as f32) / 100.0; log::info!( - " Token[{}]: id={}, text={:?}, t0={:.2}s, t1={:.2}s, prob={:.4}", - t, - token_id, - token_text, - token_start, - token_end, - token_prob + " Token[{t}]: id={token_id}, text={token_text:?}, t0={token_start:.2}s, t1={token_end:.2}s, prob={token_prob:.4}" ); if token_text.starts_with(' ') || token_text.starts_with('\n') { @@ -688,27 +671,18 @@ fn process_with_whisper( } current_word = token_text.trim().to_string(); word_start = Some(token_start); - log::debug!( - " -> Starting new word: '{}' at {:.2}s", - current_word, - token_start - ); + log::debug!(" -> Starting new word: '{current_word}' at {token_start:.2}s"); } else { if word_start.is_none() { word_start = Some(token_start); - log::debug!(" -> Word start set to {:.2}s", token_start); + log::debug!(" -> Word start set to {token_start:.2}s"); } current_word.push_str(&token_text); - log::debug!(" -> Appending to word: '{}'", current_word); + log::debug!(" -> Appending to word: '{current_word}'"); } word_end = token_end; } else { - log::warn!( - " Token[{}]: id={}, text={:?} -> NO TIMING DATA", - t, - token_id, - token_text - ); + log::warn!(" Token[{t}]: id={token_id}, text={token_text:?} -> NO TIMING DATA"); } } @@ -740,7 +714,7 @@ fn process_with_whisper( } if words.is_empty() { - log::warn!(" Segment {} has no words, skipping", i); + log::warn!(" Segment {i} has no words, skipping"); continue; } @@ -778,7 +752,7 @@ fn process_with_whisper( log::info!("Total segments: {}", segments.len()); let total_words: usize = segments.iter().map(|s| s.words.len()).sum(); - log::info!("Total words: {}", total_words); + log::info!("Total words: {total_words}"); log::info!("=== FINAL TRANSCRIPTION SUMMARY ==="); for segment in &segments { @@ -813,7 +787,7 @@ fn find_python() -> Option { if version.contains("Python 3") || String::from_utf8_lossy(&output.stderr).contains("Python 3") { - log::info!("Found Python 3 at: {}", cmd); + log::info!("Found Python 3 at: {cmd}"); return Some(cmd.to_string()); } } @@ -961,8 +935,8 @@ fn ensure_server_script_exists() -> Result { if !script_path.exists() { std::fs::write(&script_path, get_whisperx_server_script()) - .map_err(|e| format!("Failed to write server script: {}", e))?; - log::info!("Created WhisperX server script at {:?}", script_path); + .map_err(|e| format!("Failed to write server script: {e}"))?; + log::info!("Created WhisperX server script at {script_path:?}"); } Ok(script_path) @@ -978,7 +952,7 @@ fn start_whisperx_server( let script_path = ensure_server_script_exists()?; - log::info!("Starting WhisperX server with model size: {}", model_size); + log::info!("Starting WhisperX server with model size: {model_size}"); let mut child = Command::new(venv_python) .arg(&script_path) @@ -989,7 +963,7 @@ fn start_whisperx_server( .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() - .map_err(|e| format!("Failed to start WhisperX server: {}", e))?; + .map_err(|e| format!("Failed to start WhisperX server: {e}"))?; let stdin = child .stdin @@ -1012,9 +986,9 @@ fn start_whisperx_server( let reader = std::io::BufReader::new(stderr); for line in reader.lines().map_while(Result::ok) { if let Some(stripped) = line.strip_prefix("STDERR:") { - log::info!("[WhisperX] {}", stripped); + log::info!("[WhisperX] {stripped}"); } else { - log::info!("[WhisperX stderr] {}", line); + log::info!("[WhisperX stderr] {line}"); } } }); @@ -1047,7 +1021,7 @@ fn start_whisperx_server( } Err(e) => { let _ = child.kill(); - return Err(format!("Error reading from WhisperX server: {}", e)); + return Err(format!("Error reading from WhisperX server: {e}")); } } } @@ -1092,24 +1066,24 @@ fn transcribe_with_server( server .stdin .flush() - .map_err(|e| format!("Failed to flush request: {}", e))?; + .map_err(|e| format!("Failed to flush request: {e}"))?; let mut response_line = String::new(); server .stdout .read_line(&mut response_line) - .map_err(|e| format!("Failed to read response from server: {}", e))?; + .map_err(|e| format!("Failed to read response from server: {e}"))?; let response: serde_json::Value = serde_json::from_str(&response_line) - .map_err(|e| format!("Failed to parse server response: {}", e))?; + .map_err(|e| format!("Failed to parse server response: {e}"))?; if !response["success"].as_bool().unwrap_or(false) { let error = response["error"].as_str().unwrap_or("Unknown error"); - return Err(format!("WhisperX server error: {}", error)); + return Err(format!("WhisperX server error: {error}")); } let whisperx_result: WhisperXOutput = serde_json::from_value(response["result"].clone()) - .map_err(|e| format!("Failed to parse WhisperX output: {}", e))?; + .map_err(|e| format!("Failed to parse WhisperX output: {e}"))?; log::info!( "WhisperX server produced {} segments", @@ -1188,7 +1162,7 @@ fn transcribe_with_server( .unwrap_or(whisperx_seg.end as f32); segments.push(CaptionSegment { - id: format!("segment-{}-{}", seg_idx, chunk_idx), + id: format!("segment-{seg_idx}-{chunk_idx}"), start: segment_start, end: segment_end, text: segment_text, @@ -1209,7 +1183,7 @@ fn get_whisperx_cache_dir() -> Result { .ok_or_else(|| "Could not determine cache directory".to_string())?; let whisperx_dir = cache_dir.join("cap").join("whisperx"); std::fs::create_dir_all(&whisperx_dir) - .map_err(|e| format!("Failed to create whisperx cache directory: {}", e))?; + .map_err(|e| format!("Failed to create whisperx cache directory: {e}"))?; Ok(whisperx_dir) } @@ -1217,7 +1191,7 @@ fn get_whisperx_models_cache_dir() -> Result { let cache_dir = get_whisperx_cache_dir()?; let models_dir = cache_dir.join("models"); std::fs::create_dir_all(&models_dir) - .map_err(|e| format!("Failed to create whisperx models cache directory: {}", e))?; + .map_err(|e| format!("Failed to create whisperx models cache directory: {e}"))?; Ok(models_dir) } @@ -1225,7 +1199,7 @@ fn get_huggingface_cache_dir() -> Result { let cache_dir = get_whisperx_cache_dir()?; let hf_dir = cache_dir.join("huggingface"); std::fs::create_dir_all(&hf_dir) - .map_err(|e| format!("Failed to create huggingface cache directory: {}", e))?; + .map_err(|e| format!("Failed to create huggingface cache directory: {e}"))?; Ok(hf_dir) } @@ -1233,7 +1207,7 @@ fn get_torch_cache_dir() -> Result { let cache_dir = get_whisperx_cache_dir()?; let torch_dir = cache_dir.join("torch"); std::fs::create_dir_all(&torch_dir) - .map_err(|e| format!("Failed to create torch cache directory: {}", e))?; + .map_err(|e| format!("Failed to create torch cache directory: {e}"))?; Ok(torch_dir) } @@ -1254,24 +1228,20 @@ fn create_venv_if_needed(system_python: &str) -> Result { let venv_python = get_venv_python()?; if venv_python.exists() { - log::info!("Virtual environment already exists at: {:?}", venv_dir); + log::info!("Virtual environment already exists at: {venv_dir:?}"); return Ok(venv_python); } - log::info!( - "Creating virtual environment at: {:?} using {}", - venv_dir, - system_python - ); + log::info!("Creating virtual environment at: {venv_dir:?} using {system_python}"); let output = Command::new(system_python) .args(["-m", "venv", venv_dir.to_str().unwrap()]) .output() - .map_err(|e| format!("Failed to create venv: {}", e))?; + .map_err(|e| format!("Failed to create venv: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Failed to create virtual environment: {}", stderr)); + return Err(format!("Failed to create virtual environment: {stderr}")); } if !venv_python.exists() { @@ -1286,7 +1256,7 @@ fn create_venv_if_needed(system_python: &str) -> Result { .output(); if let Err(e) = pip_upgrade { - log::warn!("Failed to upgrade pip in venv: {}", e); + log::warn!("Failed to upgrade pip in venv: {e}"); } Ok(venv_python) @@ -1297,11 +1267,11 @@ fn download_whisperx_whl() -> Result { let whl_path = cache_dir.join(WHISPERX_WHL_NAME); if whl_path.exists() { - log::info!("WhisperX wheel already cached at: {:?}", whl_path); + log::info!("WhisperX wheel already cached at: {whl_path:?}"); return Ok(whl_path); } - log::info!("Downloading WhisperX wheel from: {}", WHISPERX_WHL_URL); + log::info!("Downloading WhisperX wheel from: {WHISPERX_WHL_URL}"); let output = Command::new("curl") .args([ @@ -1312,18 +1282,18 @@ fn download_whisperx_whl() -> Result { WHISPERX_WHL_URL, ]) .output() - .map_err(|e| format!("Failed to run curl: {}", e))?; + .map_err(|e| format!("Failed to run curl: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Failed to download WhisperX wheel: {}", stderr)); + return Err(format!("Failed to download WhisperX wheel: {stderr}")); } if !whl_path.exists() { return Err("WhisperX wheel was not downloaded".to_string()); } - log::info!("Successfully downloaded WhisperX wheel to: {:?}", whl_path); + log::info!("Successfully downloaded WhisperX wheel to: {whl_path:?}"); Ok(whl_path) } @@ -1331,16 +1301,16 @@ fn install_whisperx_in_venv(venv_python: &PathBuf) -> Result<(), String> { log::info!("Installing whisperx in virtual environment..."); let whl_path = download_whisperx_whl()?; - log::info!("Installing WhisperX from: {:?}", whl_path); + log::info!("Installing WhisperX from: {whl_path:?}"); let install_result = Command::new(venv_python) .args(["-m", "pip", "install", whl_path.to_str().unwrap()]) .output() - .map_err(|e| format!("Failed to run pip install: {}", e))?; + .map_err(|e| format!("Failed to run pip install: {e}"))?; if !install_result.status.success() { let stderr = String::from_utf8_lossy(&install_result.stderr); - return Err(format!("Failed to install whisperx: {}", stderr)); + return Err(format!("Failed to install whisperx: {stderr}")); } log::info!("Successfully installed whisperx in virtual environment"); @@ -1422,7 +1392,7 @@ pub async fn prewarm_whisperx(model_path: String) -> Result { let venv_python = match setup_whisperx_environment(&system_python) { Ok(p) => p, Err(e) => { - log::info!("WhisperX environment not ready: {}, skipping pre-warm", e); + log::info!("WhisperX environment not ready: {e}, skipping pre-warm"); return Ok(false); } }; @@ -1450,7 +1420,7 @@ pub async fn prewarm_whisperx(model_path: String) -> Result { *server_guard = Some(server); } Err(e) => { - log::warn!("Failed to pre-warm WhisperX server: {}", e); + log::warn!("Failed to pre-warm WhisperX server: {e}"); } } }); @@ -1472,12 +1442,12 @@ pub async fn transcribe_audio( log::info!("Language: {}", language); if !std::path::Path::new(&video_path).exists() { - log::error!("Video file not found at path: {}", video_path); + log::error!("Video file not found at path: {video_path}"); return Err(format!("Video file not found at path: {video_path}")); } if !std::path::Path::new(&model_path).exists() { - log::error!("Model file not found at path: {}", model_path); + log::error!("Model file not found at path: {model_path}"); return Err(format!("Model file not found at path: {model_path}")); } @@ -1494,7 +1464,7 @@ pub async fn transcribe_audio( } if !audio_path.exists() { - log::error!("Audio file was not created at {:?}", audio_path); + log::error!("Audio file was not created at {audio_path:?}"); return Err("Failed to create audio file for transcription".to_string()); } @@ -1511,7 +1481,7 @@ pub async fn transcribe_audio( log::info!("Detected model size: {}", model_size); if let Some(system_python) = find_python() { - log::info!("Found system Python at: {}", system_python); + log::info!("Found system Python at: {system_python}"); match setup_whisperx_environment(&system_python) { Ok(venv_python) => { @@ -1549,7 +1519,7 @@ pub async fn transcribe_audio( *server_guard = Some(server); } Err(e) => { - log::warn!("Failed to start WhisperX server: {}", e); + log::warn!("Failed to start WhisperX server: {e}"); return Err(e); } } @@ -1572,8 +1542,7 @@ pub async fn transcribe_audio( && is_server_communication_error(e) { log::warn!( - "Server communication error detected, clearing dead server: {}", - e + "Server communication error detected, clearing dead server: {e}" ); *server_guard = None; } @@ -1608,20 +1577,16 @@ pub async fn transcribe_audio( } } Ok(Err(e)) => { - log::warn!("WhisperX failed: {}. Falling back to built-in Whisper.", e); + log::warn!("WhisperX failed: {e}. Falling back to built-in Whisper."); } Err(e) => { - log::warn!( - "WhisperX task error: {}. Falling back to built-in Whisper.", - e - ); + log::warn!("WhisperX task error: {e}. Falling back to built-in Whisper."); } } } Err(e) => { log::warn!( - "Failed to setup WhisperX environment: {}. Falling back to built-in Whisper.", - e + "Failed to setup WhisperX environment: {e}. Falling back to built-in Whisper." ); } } diff --git a/apps/desktop/src-tauri/src/editor_window.rs b/apps/desktop/src-tauri/src/editor_window.rs index 2d637a8a7b..46a3bbd1ca 100644 --- a/apps/desktop/src-tauri/src/editor_window.rs +++ b/apps/desktop/src-tauri/src/editor_window.rs @@ -1,13 +1,39 @@ -use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc, time::Instant}; use tauri::{AppHandle, Manager, Runtime, Window, ipc::CommandArg}; -use tokio::sync::{RwLock, broadcast}; +use tokio::sync::{RwLock, watch}; use tokio_util::sync::CancellationToken; +use tracing::debug; + +use cap_rendering::RenderedFrame; use crate::{ create_editor_instance_impl, - frame_ws::{WSFrame, create_frame_ws}, + frame_ws::{WSFrame, create_watch_frame_ws}, }; +fn strip_frame_padding(frame: RenderedFrame) -> Result<(Vec, u32), &'static str> { + let expected_stride = frame + .width + .checked_mul(4) + .ok_or("overflow computing expected_stride")?; + if frame.padded_bytes_per_row == expected_stride { + Ok((frame.data, expected_stride)) + } else { + let capacity = expected_stride + .checked_mul(frame.height) + .ok_or("overflow computing buffer capacity")?; + let mut stripped = Vec::with_capacity(capacity as usize); + for row in 0..frame.height { + let start = row + .checked_mul(frame.padded_bytes_per_row) + .ok_or("overflow computing row start")? as usize; + let end = start + expected_stride as usize; + stripped.extend_from_slice(&frame.data[start..end]); + } + Ok((stripped, expected_stride)) + } +} + pub struct EditorInstance { inner: Arc, pub ws_port: u16, @@ -21,19 +47,26 @@ type PendingReceiver = tokio::sync::watch::Receiver>; pub struct PendingEditorInstances(Arc>>); async fn do_prewarm(app: AppHandle, path: PathBuf) -> PendingResult { - let (frame_tx, _) = broadcast::channel(4); + let (frame_tx, frame_rx) = watch::channel(None); - let (ws_port, ws_shutdown_token) = create_frame_ws(frame_tx.clone()).await; + let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx).await; let inner = create_editor_instance_impl( &app, path, Box::new(move |frame| { - let _ = frame_tx.send(WSFrame { - data: frame.data, - width: frame.width, - height: frame.height, - stride: frame.padded_bytes_per_row, - }); + let width = frame.width; + let height = frame.height; + if let Ok((data, stride)) = strip_frame_padding(frame) + && let Err(e) = frame_tx.send(Some(WSFrame { + data, + width, + height, + stride, + created_at: Instant::now(), + })) + { + debug!("Frame receiver dropped during prewarm: {e}"); + } }), ) .await?; @@ -176,19 +209,26 @@ impl EditorInstances { } } - let (frame_tx, _) = broadcast::channel(4); + let (frame_tx, frame_rx) = watch::channel(None); - let (ws_port, ws_shutdown_token) = create_frame_ws(frame_tx.clone()).await; + let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx).await; let instance = create_editor_instance_impl( window.app_handle(), path, Box::new(move |frame| { - let _ = frame_tx.send(WSFrame { - data: frame.data, - width: frame.width, - height: frame.height, - stride: frame.padded_bytes_per_row, - }); + let width = frame.width; + let height = frame.height; + if let Ok((data, stride)) = strip_frame_padding(frame) + && let Err(e) = frame_tx.send(Some(WSFrame { + data, + width, + height, + stride, + created_at: Instant::now(), + })) + { + debug!("Frame receiver dropped in get_or_create: {e}"); + } }), ) .await?; diff --git a/apps/desktop/src-tauri/src/frame_ws.rs b/apps/desktop/src-tauri/src/frame_ws.rs index 16fa506e39..93146003f1 100644 --- a/apps/desktop/src-tauri/src/frame_ws.rs +++ b/apps/desktop/src-tauri/src/frame_ws.rs @@ -1,12 +1,22 @@ +use std::time::Instant; use tokio::sync::{broadcast, watch}; use tokio_util::sync::CancellationToken; +fn pack_frame_data(mut data: Vec, stride: u32, height: u32, width: u32) -> Vec { + data.extend_from_slice(&stride.to_le_bytes()); + data.extend_from_slice(&height.to_le_bytes()); + data.extend_from_slice(&width.to_le_bytes()); + data +} + #[derive(Clone)] pub struct WSFrame { pub data: Vec, pub width: u32, pub height: u32, pub stride: u32, + #[allow(dead_code)] + pub created_at: Instant, } pub async fn create_watch_frame_ws( @@ -36,16 +46,12 @@ pub async fn create_watch_frame_ws( tracing::info!("Socket connection established"); let now = std::time::Instant::now(); - // Send the current frame immediately upon connection (if one exists) - // This ensures the client doesn't wait for the next config change to see the image { let frame_opt = camera_rx.borrow().clone(); - if let Some(mut frame) = frame_opt { - frame.data.extend_from_slice(&frame.stride.to_le_bytes()); - frame.data.extend_from_slice(&frame.height.to_le_bytes()); - frame.data.extend_from_slice(&frame.width.to_le_bytes()); + if let Some(frame) = frame_opt { + let packed = pack_frame_data(frame.data, frame.stride, frame.height, frame.width); - if let Err(e) = socket.send(Message::Binary(frame.data)).await { + if let Err(e) = socket.send(Message::Binary(packed)).await { tracing::error!("Failed to send initial frame to socket: {:?}", e); return; } @@ -75,12 +81,10 @@ pub async fn create_watch_frame_ws( break; } let frame_opt = camera_rx.borrow().clone(); - if let Some(mut frame) = frame_opt { - frame.data.extend_from_slice(&frame.stride.to_le_bytes()); - frame.data.extend_from_slice(&frame.height.to_le_bytes()); - frame.data.extend_from_slice(&frame.width.to_le_bytes()); + if let Some(frame) = frame_opt { + let packed = pack_frame_data(frame.data, frame.stride, frame.height, frame.width); - if let Err(e) = socket.send(Message::Binary(frame.data)).await { + if let Err(e) = socket.send(Message::Binary(packed)).await { tracing::error!("Failed to send frame to socket: {:?}", e); break; } @@ -162,12 +166,10 @@ pub async fn create_frame_ws(frame_tx: broadcast::Sender) -> (u16, Canc }, incoming_frame = camera_rx.recv() => { match incoming_frame { - Ok(mut frame) => { - frame.data.extend_from_slice(&frame.stride.to_le_bytes()); - frame.data.extend_from_slice(&frame.height.to_le_bytes()); - frame.data.extend_from_slice(&frame.width.to_le_bytes()); + Ok(frame) => { + let packed = pack_frame_data(frame.data, frame.stride, frame.height, frame.width); - if let Err(e) = socket.send(Message::Binary(frame.data)).await { + if let Err(e) = socket.send(Message::Binary(packed)).await { tracing::error!("Failed to send frame to socket: {:?}", e); break; } @@ -178,8 +180,7 @@ pub async fn create_frame_ws(frame_tx: broadcast::Sender) -> (u16, Canc ); break; } - Err(broadcast::error::RecvError::Lagged(skipped)) => { - tracing::warn!("Missed {skipped} frames on websocket receiver"); + Err(broadcast::error::RecvError::Lagged(_skipped)) => { continue; } } diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index 14bf3d38cd..52df0540af 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -1,6 +1,8 @@ use crate::{ RequestOpenRecordingPicker, RequestStartRecording, recording, - recording_settings::RecordingTargetMode, windows::ShowCapWindow, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + tray, + windows::ShowCapWindow, }; use global_hotkey::HotKeyState; use serde::{Deserialize, Serialize}; @@ -52,12 +54,11 @@ pub enum HotkeyAction { StartInstantRecording, StopRecording, RestartRecording, - // TakeScreenshot, + CycleRecordingMode, OpenRecordingPicker, OpenRecordingPickerDisplay, OpenRecordingPickerWindow, OpenRecordingPickerArea, - // Needed for deserialization of deprecated actions #[serde(other)] Other, } @@ -150,6 +151,26 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin HotkeyAction::RestartRecording => recording::restart_recording(app.clone(), app.state()) .await .map(|_| ()), + HotkeyAction::CycleRecordingMode => { + let current = RecordingSettingsStore::get(&app) + .ok() + .flatten() + .and_then(|s| s.mode) + .unwrap_or_default(); + + let next = match current { + cap_recording::RecordingMode::Studio => cap_recording::RecordingMode::Instant, + cap_recording::RecordingMode::Instant => cap_recording::RecordingMode::Screenshot, + cap_recording::RecordingMode::Screenshot => cap_recording::RecordingMode::Studio, + }; + + RecordingSettingsStore::set_mode(&app, next) + .map_err(|e| format!("Failed to cycle mode: {e}"))?; + + tray::update_tray_icon_for_mode(&app, next); + + Ok(()) + } HotkeyAction::OpenRecordingPicker => { let _ = RequestOpenRecordingPicker { target_mode: None }.emit(&app); Ok(()) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 3bab3baefa..98eccdfef2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -484,8 +484,7 @@ async fn set_camera_input( Err(e) => { if attempts >= 3 { break Err(format!( - "Failed to initialize camera after {} attempts: {}", - attempts, e + "Failed to initialize camera after {attempts} attempts: {e}" )); } warn!( @@ -2314,6 +2313,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .commands(tauri_specta::collect_commands![ set_mic_input, set_camera_input, + recording_settings::set_recording_mode, upload_logs, recording::start_recording, recording::stop_recording, @@ -3126,11 +3126,6 @@ async fn create_editor_instance_impl( RenderFrameEvent::listen_any(&app, { let preview_tx = instance.preview_tx.clone(); move |e| { - tracing::debug!( - frame = e.payload.frame_number, - fps = e.payload.fps, - "RenderFrameEvent received" - ); preview_tx.send_modify(|v| { *v = Some(( e.payload.frame_number, diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index a964cf3e4d..a796749932 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -31,6 +31,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use specta::Type; use std::borrow::Cow; +#[cfg(target_os = "macos")] use std::error::Error as StdError; use std::{ any::Any, @@ -47,6 +48,8 @@ use tauri_specta::Event; use tracing::*; use crate::camera::{CameraPreviewManager, CameraPreviewShape}; +#[cfg(target_os = "macos")] +use crate::general_settings; use crate::web_api::AuthedApiError; use crate::{ App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, @@ -55,9 +58,7 @@ use crate::{ audio::AppSounds, auth::AuthStore, create_screenshot, - general_settings::{ - self, GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour, - }, + general_settings::{GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour}, open_external_link, presets::PresetsStore, thumbnails::*, diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-tauri/src/recording_settings.rs index 9b62c4f57d..aaaff11780 100644 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ b/apps/desktop/src-tauri/src/recording_settings.rs @@ -4,6 +4,8 @@ use cap_recording::{ use tauri::{AppHandle, Wry}; use tauri_plugin_store::StoreExt; +use crate::tray; + #[derive(serde::Serialize, serde::Deserialize, specta::Type, Debug, Clone, Copy)] #[serde(rename_all = "camelCase")] pub enum RecordingTargetMode { @@ -28,35 +30,29 @@ impl RecordingSettingsStore { pub fn get(app: &AppHandle) -> Result, String> { match app.store("store").map(|s| s.get(Self::KEY)) { - Ok(Some(store)) => { - // Handle potential deserialization errors gracefully - match serde_json::from_value(store) { - Ok(settings) => Ok(Some(settings)), - Err(e) => Err(format!("Failed to deserialize general settings store: {e}")), - } - } + Ok(Some(store)) => match serde_json::from_value(store) { + Ok(settings) => Ok(Some(settings)), + Err(e) => Err(format!("Failed to deserialize general settings store: {e}")), + }, _ => Ok(None), } } - // i don't trust anyone to not overwrite the whole store lols - // pub fn update(app: &AppHandle, update: impl FnOnce(&mut Self)) -> Result<(), String> { - // let Ok(store) = app.store("store") else { - // return Err("Store not found".to_string()); - // }; - - // let mut settings = Self::get(app)?.unwrap_or_default(); - // update(&mut settings); - // store.set(Self::KEY, json!(settings)); - // store.save().map_err(|e| e.to_string()) - // } - - // fn save(&self, app: &AppHandle) -> Result<(), String> { - // let Ok(store) = app.store("store") else { - // return Err("Store not found".to_string()); - // }; - - // store.set(Self::KEY, json!(self)); - // store.save().map_err(|e| e.to_string()) - // } + pub fn set_mode(app: &AppHandle, mode: RecordingMode) -> Result<(), String> { + let store = app.store("store").map_err(|e| e.to_string())?; + + let mut settings = Self::get(app)?.unwrap_or_default(); + settings.mode = Some(mode); + + store.set(Self::KEY, serde_json::json!(settings)); + store.save().map_err(|e| e.to_string()) + } +} + +#[tauri::command] +#[specta::specta] +pub fn set_recording_mode(app: AppHandle, mode: RecordingMode) -> Result<(), String> { + RecordingSettingsStore::set_mode(&app, mode)?; + tray::update_tray_icon_for_mode(&app, mode); + Ok(()) } diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index e52510b7dd..4b0e41d914 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -15,6 +15,7 @@ use relative_path::RelativePathBuf; use serde::Serialize; use specta::Type; use std::str::FromStr; +use std::time::Instant; use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc}; use tauri::{ Manager, Runtime, Window, @@ -157,7 +158,7 @@ impl ScreenshotEditorInstances { .map(|e| e.path()) }) .ok_or_else(|| { - format!("No PNG file found in directory: {:?}", path) + format!("No PNG file found in directory: {path:?}") })? } } else { @@ -353,6 +354,7 @@ impl ScreenshotEditorInstances { width: frame.width, height: frame.height, stride: frame.padded_bytes_per_row, + created_at: Instant::now(), })); } Err(e) => { @@ -463,15 +465,12 @@ pub async fn update_screenshot_config( if parent.extension().and_then(|s| s.to_str()) == Some("cap") { let path = parent.to_path_buf(); if let Err(e) = config.write(&path) { - eprintln!("Failed to save screenshot config: {}", e); + eprintln!("Failed to save screenshot config: {e}"); } else { - println!("Saved screenshot config to {:?}", path); + println!("Saved screenshot config to {path:?}"); } } else { - println!( - "Not saving config: parent {:?} is not a .cap directory", - parent - ); + println!("Not saving config: parent {parent:?} is not a .cap directory"); } Ok(()) } diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index f72949c096..e41efe5e91 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -138,7 +138,7 @@ fn should_skip_window(window: &Window, exclusions: &[WindowExclusion]) -> bool { let bundle_identifier = window.raw_handle().bundle_identifier(); #[cfg(not(target_os = "macos"))] - let bundle_identifier = None::<&str>; + let bundle_identifier = None::; exclusions.iter().any(|entry| { entry.matches( diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 8cf7e57cfa..8b2e1efa9c 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -1,8 +1,10 @@ use crate::{ NewScreenshotAdded, NewStudioRecordingAdded, RecordingStarted, RecordingStopped, RequestOpenRecordingPicker, RequestOpenSettings, recording, - recording_settings::RecordingTargetMode, windows::ShowCapWindow, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + windows::ShowCapWindow, }; +use cap_recording::RecordingMode; use cap_project::{RecordingMeta, RecordingMetaInner}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -39,6 +41,9 @@ pub enum TrayItem { UploadLogs, Quit, PreviousItem(String), + ModeStudio, + ModeInstant, + ModeScreenshot, } impl From for MenuId { @@ -56,6 +61,9 @@ impl From for MenuId { TrayItem::PreviousItem(id) => { return format!("{PREVIOUS_ITEM_PREFIX}{id}").into(); } + TrayItem::ModeStudio => "mode_studio", + TrayItem::ModeInstant => "mode_instant", + TrayItem::ModeScreenshot => "mode_screenshot", } .into() } @@ -81,6 +89,9 @@ impl TryFrom for TrayItem { "open_settings" => Ok(TrayItem::OpenSettings), "upload_logs" => Ok(TrayItem::UploadLogs), "quit" => Ok(TrayItem::Quit), + "mode_studio" => Ok(TrayItem::ModeStudio), + "mode_instant" => Ok(TrayItem::ModeInstant), + "mode_screenshot" => Ok(TrayItem::ModeScreenshot), value => Err(format!("Invalid tray item id {value}")), } } @@ -302,8 +313,47 @@ fn create_previous_submenu( Ok(submenu) } +fn get_current_mode(app: &AppHandle) -> RecordingMode { + RecordingSettingsStore::get(app) + .ok() + .flatten() + .and_then(|s| s.mode) + .unwrap_or_default() +} + +fn create_mode_submenu(app: &AppHandle) -> tauri::Result> { + let current_mode = get_current_mode(app); + + let submenu = Submenu::with_id(app, "select_mode", "Select Mode", true)?; + + let modes = [ + (TrayItem::ModeStudio, RecordingMode::Studio, "Studio"), + (TrayItem::ModeInstant, RecordingMode::Instant, "Instant"), + ( + TrayItem::ModeScreenshot, + RecordingMode::Screenshot, + "Screenshot", + ), + ]; + + for (tray_item, mode, label) in modes { + let is_selected = current_mode == mode; + let display_label = if is_selected { + format!("✓ {label}") + } else { + format!(" {label}") + }; + + let menu_item = MenuItem::with_id(app, tray_item, display_label, true, None::<&str>)?; + submenu.append(&menu_item)?; + } + + Ok(submenu) +} + fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result> { let previous_submenu = create_previous_submenu(app, cache)?; + let mode_submenu = create_mode_submenu(app)?; Menu::with_items( app, @@ -331,6 +381,7 @@ fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result )?, &MenuItem::with_id(app, TrayItem::RecordArea, "Record Area", true, None::<&str>)?, &PredefinedMenuItem::separator(app)?, + &mode_submenu, &previous_submenu, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id( @@ -445,6 +496,38 @@ fn handle_previous_item_click(app: &AppHandle, path_str: &str) { } } +pub fn get_mode_icon(mode: RecordingMode) -> &'static [u8] { + match mode { + RecordingMode::Studio => include_bytes!("../icons/tray-default-icon-studio.png"), + RecordingMode::Instant => include_bytes!("../icons/tray-default-icon-instant.png"), + RecordingMode::Screenshot => include_bytes!("../icons/tray-default-icon-screenshot.png"), + } +} + +pub fn update_tray_icon_for_mode(app: &AppHandle, mode: RecordingMode) { + let Some(tray) = app.tray_by_id("tray") else { + return; + }; + + if let Ok(icon) = Image::from_bytes(get_mode_icon(mode)) { + let _ = tray.set_icon(Some(icon)); + } +} + +fn handle_mode_selection( + app: &AppHandle, + mode: RecordingMode, + cache: &Arc>, +) { + if let Err(e) = RecordingSettingsStore::set_mode(app, mode) { + tracing::error!("Failed to set recording mode: {e}"); + return; + } + + update_tray_icon_for_mode(app, mode); + refresh_tray_menu(app, cache); +} + pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { let items = load_all_previous_items(app, false); let cache = Arc::new(Mutex::new(PreviousItemsCache { items })); @@ -456,14 +539,16 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { let app = app.clone(); let is_recording = Arc::new(AtomicBool::new(false)); + let current_mode = get_current_mode(&app); + let initial_icon = Image::from_bytes(get_mode_icon(current_mode))?; + let _ = TrayIconBuilder::with_id("tray") - .icon(Image::from_bytes(include_bytes!( - "../icons/tray-default-icon.png" - ))?) + .icon(initial_icon) .menu(&menu) .show_menu_on_left_click(true) .on_menu_event({ let app_handle = app.clone(); + let cache = cache.clone(); move |app: &AppHandle, event| match TrayItem::try_from(event.id) { Ok(TrayItem::OpenCap) => { let app = app.clone(); @@ -534,6 +619,15 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { Ok(TrayItem::PreviousItem(path)) => { handle_previous_item_click(app, &path); } + Ok(TrayItem::ModeStudio) => { + handle_mode_selection(app, RecordingMode::Studio, &cache); + } + Ok(TrayItem::ModeInstant) => { + handle_mode_selection(app, RecordingMode::Instant, &cache); + } + Ok(TrayItem::ModeScreenshot) => { + handle_mode_selection(app, RecordingMode::Screenshot, &cache); + } _ => {} } }) @@ -622,7 +716,8 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { return; }; - if let Ok(icon) = Image::from_bytes(include_bytes!("../icons/tray-default-icon.png")) { + let current_mode = get_current_mode(&app_handle); + if let Ok(icon) = Image::from_bytes(get_mode_icon(current_mode)) { let _ = tray.set_icon(Some(icon)); } } diff --git a/apps/desktop/src-tauri/src/update_project_names.rs b/apps/desktop/src-tauri/src/update_project_names.rs index 723ad80b87..38e9f4542b 100644 --- a/apps/desktop/src-tauri/src/update_project_names.rs +++ b/apps/desktop/src-tauri/src/update_project_names.rs @@ -17,7 +17,7 @@ const STORE_KEY: &str = "uuid_projects_migrated"; pub fn migrate_if_needed(app: &AppHandle) -> Result<(), String> { let store = app .store("store") - .map_err(|e| format!("Failed to access store: {}", e))?; + .map_err(|e| format!("Failed to access store: {e}"))?; if store .get(STORE_KEY) @@ -51,7 +51,7 @@ pub async fn migrate(app: &AppHandle) -> Result<(), String> { let recordings_dir = recordings_path(app); if !fs::try_exists(&recordings_dir) .await - .map_err(|e| format!("Failed to check recordings directory: {}", e))? + .map_err(|e| format!("Failed to check recordings directory: {e}"))? { return Ok(()); } @@ -154,12 +154,12 @@ async fn collect_uuid_projects(recordings_dir: &Path) -> Result, St let mut uuid_projects = Vec::new(); let mut entries = fs::read_dir(recordings_dir) .await - .map_err(|e| format!("Failed to read recordings directory: {}", e))?; + .map_err(|e| format!("Failed to read recordings directory: {e}"))?; while let Some(entry) = entries .next_entry() .await - .map_err(|e| format!("Failed to read directory entry: {}", e))? + .map_err(|e| format!("Failed to read directory entry: {e}"))? { let path = entry.path(); if !path.is_dir() { @@ -197,7 +197,7 @@ async fn migrate_single_project( Ok(meta) => meta, Err(e) => { tracing::warn!("Failed to load metadata for {}: {}", filename, e); - return Err(format!("Failed to load metadata: {}", e)); + return Err(format!("Failed to load metadata: {e}")); } }; @@ -260,7 +260,7 @@ async fn migrate_project_filename_async( let filename = if sanitized.ends_with(".cap") { sanitized } else { - format!("{}.cap", sanitized) + format!("{sanitized}.cap") }; let parent_dir = project_path @@ -268,13 +268,13 @@ async fn migrate_project_filename_async( .ok_or("Project path has no parent directory")?; let unique_filename = cap_utils::ensure_unique_filename(&filename, parent_dir) - .map_err(|e| format!("Failed to ensure unique filename: {}", e))?; + .map_err(|e| format!("Failed to ensure unique filename: {e}"))?; let final_path = parent_dir.join(&unique_filename); fs::rename(project_path, &final_path) .await - .map_err(|e| format!("Failed to rename project directory: {}", e))?; + .map_err(|e| format!("Failed to rename project directory: {e}"))?; Ok(final_path) } diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index f426ea2d9a..5ba1b0bd7e 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -230,7 +230,7 @@ pub async fn create_or_get_video( } if let Some(org_id) = organization_id { - s3_config_url.push_str(&format!("&orgId={}", org_id)); + s3_config_url.push_str(&format!("&orgId={org_id}")); } let response = app diff --git a/apps/desktop/src-tauri/src/window_exclusion.rs b/apps/desktop/src-tauri/src/window_exclusion.rs index 67fc1c1e1e..1a6f9e597d 100644 --- a/apps/desktop/src-tauri/src/window_exclusion.rs +++ b/apps/desktop/src-tauri/src/window_exclusion.rs @@ -1,3 +1,4 @@ +#[cfg(target_os = "macos")] use scap_targets::{Window, WindowId}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -61,6 +62,7 @@ impl WindowExclusion { } } +#[cfg(target_os = "macos")] pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { if exclusions.is_empty() { return Vec::new(); @@ -71,13 +73,8 @@ pub fn resolve_window_ids(exclusions: &[WindowExclusion]) -> Vec { .filter_map(|window| { let owner_name = window.owner_name(); let window_title = window.name(); - - #[cfg(target_os = "macos")] let bundle_identifier = window.raw_handle().bundle_identifier(); - #[cfg(not(target_os = "macos"))] - let bundle_identifier = None::<&str>; - exclusions .iter() .find(|entry| { diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 485bc54283..b922843054 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -188,7 +188,7 @@ impl CapWindowId { Self::Settings => (600.0, 450.0), Self::Camera => (200.0, 200.0), Self::Upgrade => (950.0, 850.0), - Self::ModeSelect => (900.0, 500.0), + Self::ModeSelect => (580.0, 340.0), _ => return None, }) } @@ -486,16 +486,15 @@ impl ShowCapWindow { builder.build()? } Self::ModeSelect => { - // Hide main window when mode select window opens if let Some(main) = CapWindowId::Main.get(app) { let _ = main.hide(); } let mut builder = self .window_builder(app, "/mode-select") - .inner_size(900.0, 500.0) - .min_inner_size(900.0, 500.0) - .resizable(true) + .inner_size(580.0, 340.0) + .min_inner_size(580.0, 340.0) + .resizable(false) .maximized(false) .maximizable(false) .center() diff --git a/apps/desktop/src/components/ModeSelect.tsx b/apps/desktop/src/components/ModeSelect.tsx index 61f1d17a6f..14fb82d995 100644 --- a/apps/desktop/src/components/ModeSelect.tsx +++ b/apps/desktop/src/components/ModeSelect.tsx @@ -1,47 +1,89 @@ import { cx } from "cva"; import { type JSX, Show } from "solid-js"; +import instantModeDark from "~/assets/illustrations/instant-mode-dark.png"; +import instantModeLight from "~/assets/illustrations/instant-mode-light.png"; +import studioModeDark from "~/assets/illustrations/studio-mode-dark.png"; +import studioModeLight from "~/assets/illustrations/studio-mode-light.png"; import { createOptionsQuery } from "~/utils/queries"; -import type { RecordingMode } from "~/utils/tauri"; +import { commands, type RecordingMode } from "~/utils/tauri"; interface ModeOptionProps { mode: RecordingMode; title: string; description: string; - standalone?: boolean; - icon: (props: { class: string; style?: JSX.CSSProperties }) => JSX.Element; + imageDark?: string; + imageLight?: string; + icon?: (props: { class: string; style?: JSX.CSSProperties }) => JSX.Element; isSelected: boolean; onSelect: (mode: RecordingMode) => void; } const ModeOption = (props: ModeOptionProps) => { + const hasImage = () => props.imageDark && props.imageLight; + return (
props.onSelect(props.mode)} class={cx( - "p-5 space-y-3 rounded-lg border transition-all duration-200 cursor-pointer h-fit", + "relative flex flex-col items-center rounded-xl border-2 transition-all duration-200 cursor-pointer overflow-hidden group", props.isSelected - ? "border-blue-7 bg-blue-3/60 dark:border-blue-6 dark:bg-blue-4/40" - : "border-gray-4 dark:border-gray-3 dark:bg-gray-2 hover:border-gray-6 dark:hover:border-gray-4 hover:bg-gray-2 dark:hover:bg-gray-3", + ? "border-blue-9 bg-blue-3 dark:bg-blue-3/30 shadow-lg shadow-blue-9/10" + : "border-gray-4 dark:border-gray-5 bg-gray-2 dark:bg-gray-3 hover:border-gray-6 dark:hover:border-gray-6 hover:bg-gray-3 dark:hover:bg-gray-4", )} role="button" aria-pressed={props.isSelected} > -
- {props.icon({ - class: "size-12 mb-5 invert dark:invert-0", - })} -

{props.title}

+ +
+ +
+
+ +
+ + {props.icon?.({ + class: "size-10 invert dark:invert-0", + })} +
+ } + > + + {props.title} +
-

- {props.description} -

+
+

+ {props.title} +

+

+ {props.description} +

+
); }; @@ -51,28 +93,28 @@ const ModeSelect = (props: { onClose?: () => void; standalone?: boolean }) => { const handleModeChange = (mode: RecordingMode) => { setOptions({ mode }); + commands.setRecordingMode(mode); }; const modeOptions = [ { mode: "instant" as const, - title: "Instant Mode", - description: - "Share your screen instantly with a shareable link. No waiting for rendering, just capture and share. Uploads in the background as you record.", - icon: IconCapInstant, + title: "Instant", + description: "Share instantly with a link. Uploads as you record.", + imageDark: instantModeDark, + imageLight: instantModeLight, }, { mode: "studio" as const, - title: "Studio Mode", - description: - "Records at the highest quality and framerate, completely locally. Captures both your screen and camera separately for editing and exporting later.", - icon: IconCapFilmCut, + title: "Studio", + description: "Highest quality local recording for editing later.", + imageDark: studioModeDark, + imageLight: studioModeLight, }, { mode: "screenshot" as const, - title: "Screenshot Mode", - description: - "Capture high-quality screenshots of your screen or specific windows. Annotate and share instantly.", + title: "Screenshot", + description: "Capture and annotate screenshots instantly.", icon: IconCapScreenshot, }, ]; @@ -81,9 +123,9 @@ const ModeSelect = (props: { onClose?: () => void; standalone?: boolean }) => {
e.stopPropagation()} @@ -91,26 +133,26 @@ const ModeSelect = (props: { onClose?: () => void; standalone?: boolean }) => {
props.onClose?.()} - class="absolute -top-2.5 -right-2.5 p-2 rounded-full border duration-200 bg-gray-2 border-gray-3 hover:bg-gray-3 transition-duration" + class="absolute -top-2.5 -right-2.5 p-2 rounded-full border duration-200 bg-gray-2 border-gray-3 hover:bg-gray-3 transition-colors cursor-pointer" >
- {props.standalone && ( -

- Recording Modes -

- )} - {modeOptions.map((option) => ( - - ))} + +
+ {modeOptions.map((option) => ( + + ))} +
); }; diff --git a/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx b/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx index f489d43f3b..2712ab7801 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx @@ -25,6 +25,7 @@ const ACTION_TEXT = { startInstantRecording: "Start instant recording", restartRecording: "Restart recording", stopRecording: "Stop recording", + cycleRecordingMode: "Cycle recording mode", openRecordingPicker: "Open recording picker", openRecordingPickerDisplay: "Record display", openRecordingPickerWindow: "Record window", @@ -83,6 +84,7 @@ function Inner(props: { initialStore: HotkeysStore | null }) { : (["startStudioRecording", "startInstantRecording"] as const)), "stopRecording", "restartRecording", + "cycleRecordingMode", ...(generalSettings.data?.enableNewRecordingFlow ? ([ "openRecordingPickerDisplay", diff --git a/apps/desktop/src/routes/editor/Player.tsx b/apps/desktop/src/routes/editor/Player.tsx index d596f06791..315db42cc9 100644 --- a/apps/desktop/src/routes/editor/Player.tsx +++ b/apps/desktop/src/routes/editor/Player.tsx @@ -1,6 +1,7 @@ import { Select as KSelect } from "@kobalte/core/select"; import { ToggleButton as KToggleButton } from "@kobalte/core/toggle-button"; import { createElementBounds } from "@solid-primitives/bounds"; +import { debounce } from "@solid-primitives/scheduled"; import { cx } from "cva"; import { createEffect, createSignal, onMount, Show } from "solid-js"; @@ -184,7 +185,6 @@ export function PlayerContent() { await commands.stopPlayback(); setEditorState("playing", false); } else { - // Ensure we seek to the current playback time before starting playback await commands.seekTo(Math.floor(editorState.playbackTime * FPS)); await commands.startPlayback(FPS, previewResolutionBase()); setEditorState("playing", true); @@ -455,37 +455,66 @@ const gridStyle = { }; function PreviewCanvas() { - const { latestFrame } = useEditorContext(); + const { latestFrame, canvasControls } = useEditorContext(); - let canvasRef: HTMLCanvasElement | undefined; + const hasRenderedFrame = () => canvasControls()?.hasRenderedFrame() ?? false; + + const canvasTransferredRef = { current: false }; const [canvasContainerRef, setCanvasContainerRef] = createSignal(); const containerBounds = createElementBounds(canvasContainerRef); + const [debouncedBounds, setDebouncedBounds] = createSignal({ + width: 0, + height: 0, + }); + + const updateDebouncedBounds = debounce( + (width: number, height: number) => setDebouncedBounds({ width, height }), + 100, + ); + createEffect(() => { - const frame = latestFrame(); - if (!frame) return; - if (!canvasRef) return; - const ctx = canvasRef.getContext("2d"); - ctx?.putImageData(frame.data, 0, 0); + const width = containerBounds.width ?? 0; + const height = containerBounds.height ?? 0; + if (debouncedBounds().width === 0 && debouncedBounds().height === 0) { + setDebouncedBounds({ width, height }); + } else { + updateDebouncedBounds(width, height); + } }); + const initCanvas = (canvas: HTMLCanvasElement) => { + if (canvasTransferredRef.current) return; + const controls = canvasControls(); + if (!controls) return; + + try { + const offscreen = canvas.transferControlToOffscreen(); + controls.initCanvas(offscreen); + canvasTransferredRef.current = true; + } catch (e) { + console.error("[PreviewCanvas] Failed to transfer canvas:", e); + } + }; + return (
{(currentFrame) => { const padding = 4; const frameWidth = () => currentFrame().width; - const frameHeight = () => currentFrame().data.height; + const frameHeight = () => currentFrame().height; const availableWidth = () => - Math.max((containerBounds.width ?? 0) - padding * 2, 0); + Math.max(debouncedBounds().width - padding * 2, 0); const availableHeight = () => - Math.max((containerBounds.height ?? 0) - padding * 2, 0); + Math.max(debouncedBounds().height - padding * 2, 0); const containerAspect = () => { const width = availableWidth(); @@ -522,15 +551,18 @@ function PreviewCanvas() { style={{ width: `${size().width}px`, height: `${size().height}px`, + contain: "strict", }} > ; export const [EditorInstanceContextProvider, useEditorInstanceContext] = createContextProvider(() => { - const [latestFrame, setLatestFrame] = createLazySignal<{ - width: number; - data: ImageData; - }>(); + const [latestFrame, setLatestFrame] = createLazySignal(); - const [isConnected, setIsConnected] = createSignal(false); + const [_isConnected, setIsConnected] = createSignal(false); + const [isWorkerReady, setIsWorkerReady] = createSignal(false); + const [canvasControls, setCanvasControls] = + createSignal(null); const [editorInstance] = createResource(async () => { console.log("[Editor] Creating editor instance..."); const instance = await commands.createEditorInstance(); console.log("[Editor] Editor instance created, setting up WebSocket"); - const [ws, wsConnected] = createImageDataWS( + const requestFrame = () => { + events.renderFrameEvent.emit({ + frame_number: 0, + fps: FPS, + resolution_base: getPreviewResolution(DEFAULT_PREVIEW_QUALITY), + }); + }; + + const [ws, _wsConnected, workerReady, controls] = createImageDataWS( instance.framesSocketUrl, setLatestFrame, + requestFrame, ); + setCanvasControls(controls); + + createEffect(() => { + setIsWorkerReady(workerReady()); + }); + ws.addEventListener("open", () => { - console.log("[Editor] WebSocket open event - emitting initial frame"); setIsConnected(true); - events.renderFrameEvent.emit({ - frame_number: 0, - fps: FPS, - resolution_base: getPreviewResolution(DEFAULT_PREVIEW_QUALITY), - }); + requestFrame(); }); ws.addEventListener("close", () => { @@ -783,6 +798,8 @@ export const [EditorInstanceContextProvider, useEditorInstanceContext] = latestFrame, presets: createPresets(), metaQuery, + isWorkerReady, + canvasControls, }; }, null!); diff --git a/apps/desktop/src/routes/mode-select.tsx b/apps/desktop/src/routes/mode-select.tsx index 2726a9240e..9ba65b0ff2 100644 --- a/apps/desktop/src/routes/mode-select.tsx +++ b/apps/desktop/src/routes/mode-select.tsx @@ -24,8 +24,8 @@ const ModeSelectWindow = () => { try { const currentSize = await window.innerSize(); - if (currentSize.width !== 900 || currentSize.height !== 500) { - await window.setSize(new LogicalSize(900, 500)); + if (currentSize.width !== 580 || currentSize.height !== 340) { + await window.setSize(new LogicalSize(580, 340)); } } catch (error) { console.error("Failed to set window size:", error); @@ -39,21 +39,27 @@ const ModeSelectWindow = () => { return (
{isWindows && (
)} -
-

- Recording Modes -

- + +
+
+

+ Choose Recording Mode +

+

+ Select how you want to capture your screen +

+
+ +
+ +
); diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index 56d0ad594e..d28ce0a407 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -66,16 +66,16 @@ export function Header() { const cropDialogHandler = () => { const frame = latestFrame(); - if (!frame?.data) return; + if (!frame?.bitmap) return; setDialog({ open: true, type: "crop", - originalSize: { x: frame.data.width, y: frame.data.height }, + originalSize: { x: frame.width, y: frame.height }, currentCrop: project.background.crop, }); }; - const isCropDisabled = () => !latestFrame()?.data; + const isCropDisabled = () => !latestFrame()?.bitmap; return (
void }) { const [pan, setPan] = createSignal({ x: 0, y: 0 }); + const [previousBitmap, setPreviousBitmap] = createSignal( + null, + ); + + createEffect(() => { + const frame = latestFrame(); + const currentBitmap = frame?.bitmap ?? null; + const prevBitmap = previousBitmap(); + + if (prevBitmap && prevBitmap !== currentBitmap) { + prevBitmap.close(); + } + + setPreviousBitmap(currentBitmap); + }); + + onCleanup(() => { + const bitmap = previousBitmap(); + if (bitmap) { + bitmap.close(); + setPreviousBitmap(null); + } + }); + const zoomIn = () => { props.setZoom(Math.min(3, props.zoom + 0.1)); setPan({ x: 0, y: 0 }); @@ -92,10 +116,10 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { createEffect(() => { const frame = latestFrame(); - if (frame && canvasRef) { + if (frame?.bitmap && canvasRef) { const ctx = canvasRef.getContext("2d"); if (ctx) { - ctx.putImageData(frame.data, 0, 0); + ctx.drawImage(frame.bitmap, 0, 0); const crop = project.background.crop; if (crop) { const width = canvasRef.width; @@ -172,13 +196,14 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { if (!f) return { width: 0, - data: { width: 0, height: 0 } as ImageData, + height: 0, + bitmap: null, }; return f; }; const frameWidth = () => frame().width; - const frameHeight = () => frame().data.height; + const frameHeight = () => frame().height; const imageRect = createMemo(() => { const crop = project.background.crop; @@ -403,10 +428,10 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { if ( maskCanvasRef.width !== frameData.width || - maskCanvasRef.height !== frameData.data.height + maskCanvasRef.height !== frameData.height ) { maskCanvasRef.width = frameData.width; - maskCanvasRef.height = frameData.data.height; + maskCanvasRef.height = frameData.height; } ctx.clearRect(0, 0, maskCanvasRef.width, maskCanvasRef.height); diff --git a/apps/desktop/src/routes/screenshot-editor/context.tsx b/apps/desktop/src/routes/screenshot-editor/context.tsx index 6d5f4ba83f..45dddfb1f0 100644 --- a/apps/desktop/src/routes/screenshot-editor/context.tsx +++ b/apps/desktop/src/routes/screenshot-editor/context.tsx @@ -3,9 +3,19 @@ import { trackStore } from "@solid-primitives/deep"; import { debounce } from "@solid-primitives/scheduled"; import { makePersisted } from "@solid-primitives/storage"; import { convertFileSrc } from "@tauri-apps/api/core"; -import { createEffect, createResource, createSignal, on } from "solid-js"; +import { + createEffect, + createResource, + createSignal, + on, + onCleanup, +} from "solid-js"; import { createStore, reconcile, unwrap } from "solid-js/store"; -import { createImageDataWS, createLazySignal } from "~/utils/socket"; +import { + createImageDataWS, + createLazySignal, + type FrameData, +} from "~/utils/socket"; import { type Annotation, type AnnotationType, @@ -126,10 +136,7 @@ function createScreenshotEditorContext() { open: false, }); - const [latestFrame, setLatestFrame] = createLazySignal<{ - width: number; - data: ImageData; - }>(); + const [latestFrame, setLatestFrame] = createLazySignal(); const [editorInstance] = createResource(async () => { const instance = await commands.createScreenshotEditorInstance(); @@ -141,30 +148,36 @@ function createScreenshotEditorContext() { } } - // Load initial frame from disk in case WS fails or is slow if (instance.path) { const img = new Image(); img.crossOrigin = "anonymous"; img.src = convertFileSrc(instance.path); - img.onload = () => { - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.drawImage(img, 0, 0); - const data = ctx.getImageData( - 0, - 0, - img.naturalWidth, - img.naturalHeight, - ); - setLatestFrame({ width: img.naturalWidth, data }); + img.onload = async () => { + try { + const bitmap = await createImageBitmap(img); + const existing = latestFrame(); + if (existing?.bitmap) { + existing.bitmap.close(); + } + setLatestFrame({ + width: img.naturalWidth, + height: img.naturalHeight, + bitmap, + }); + } catch (e: unknown) { + console.error("Failed to create ImageBitmap from fallback image:", e); } }; + img.onerror = (event) => { + console.error("Failed to load screenshot image:", { + path: instance.path, + src: img.src, + event, + }); + }; } - const [_ws, _isConnected] = createImageDataWS( + const [_ws, _isConnected, _isWorkerReady] = createImageDataWS( instance.framesSocketUrl, setLatestFrame, ); @@ -172,6 +185,21 @@ function createScreenshotEditorContext() { return instance; }); + createEffect( + on(latestFrame, (current, previous) => { + if (previous?.bitmap && previous.bitmap !== current?.bitmap) { + previous.bitmap.close(); + } + }), + ); + + onCleanup(() => { + const frame = latestFrame(); + if (frame?.bitmap) { + frame.bitmap.close(); + } + }); + const saveConfig = debounce((config: ProjectConfiguration) => { commands.updateScreenshotConfig(config, true); }, 1000); diff --git a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts index 10e8d754dd..7d53365a26 100644 --- a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts +++ b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts @@ -188,10 +188,10 @@ export function useScreenshotExport() { if (!ctx) throw new Error("Could not get canvas context"); const frame = latestFrame(); - if (frame) { + if (frame?.bitmap) { canvas.width = frame.width; - canvas.height = frame.data.height; - ctx.putImageData(frame.data, 0, 0); + canvas.height = frame.height; + ctx.drawImage(frame.bitmap, 0, 0); } else { const img = new Image(); img.src = convertFileSrc(editorCtx.path); diff --git a/apps/desktop/src/utils/frame-worker.ts b/apps/desktop/src/utils/frame-worker.ts new file mode 100644 index 0000000000..18a9c75736 --- /dev/null +++ b/apps/desktop/src/utils/frame-worker.ts @@ -0,0 +1,513 @@ +import { type Consumer, createConsumer } from "./shared-frame-buffer"; +import { + disposeWebGPU, + initWebGPU, + isWebGPUSupported, + renderFrameWebGPU, + type WebGPURenderer, +} from "./webgpu-renderer"; + +interface FrameMessage { + type: "frame"; + buffer: ArrayBuffer; +} + +interface InitCanvasMessage { + type: "init-canvas"; + canvas: OffscreenCanvas; +} + +interface ResizeMessage { + type: "resize"; + width: number; + height: number; +} + +interface InitSharedBufferMessage { + type: "init-shared-buffer"; + buffer: SharedArrayBuffer; +} + +interface CleanupMessage { + type: "cleanup"; +} + +interface ReadyMessage { + type: "ready"; +} + +interface FrameRenderedMessage { + type: "frame-rendered"; + width: number; + height: number; +} + +interface FrameQueuedMessage { + type: "frame-queued"; + width: number; + height: number; +} + +interface RendererModeMessage { + type: "renderer-mode"; + mode: "webgpu" | "canvas2d"; +} + +interface DecodedFrame { + type: "decoded"; + bitmap: ImageBitmap; + width: number; + height: number; +} + +interface ErrorMessage { + type: "error"; + message: string; +} + +interface RequestFrameMessage { + type: "request-frame"; +} + +export type { + FrameRenderedMessage, + FrameQueuedMessage, + RendererModeMessage, + DecodedFrame, + ErrorMessage, + ReadyMessage, + RequestFrameMessage, +}; + +type IncomingMessage = + | FrameMessage + | InitCanvasMessage + | ResizeMessage + | InitSharedBufferMessage + | CleanupMessage; + +interface PendingFrameCanvas2D { + mode: "canvas2d"; + imageData: ImageData; + width: number; + height: number; +} + +interface PendingFrameWebGPU { + mode: "webgpu"; + data: Uint8ClampedArray; + width: number; + height: number; +} + +type PendingFrame = PendingFrameCanvas2D | PendingFrameWebGPU; + +let workerReady = false; +let isInitializing = false; +let initializationPromise: Promise | null = null; + +type RenderMode = "webgpu" | "canvas2d"; +let renderMode: RenderMode = "canvas2d"; +let webgpuRenderer: WebGPURenderer | null = null; + +let offscreenCanvas: OffscreenCanvas | null = null; +let offscreenCtx: OffscreenCanvasRenderingContext2D | null = null; +let lastImageData: ImageData | null = null; +let pendingCanvasInit: OffscreenCanvas | null = null; + +let strideBuffer: Uint8ClampedArray | null = null; +let strideBufferSize = 0; +let cachedImageData: ImageData | null = null; +let cachedWidth = 0; +let cachedHeight = 0; + +let lastRawFrameData: Uint8ClampedArray | null = null; +let lastRawFrameWidth = 0; +let lastRawFrameHeight = 0; + +let consumer: Consumer | null = null; +let useSharedBuffer = false; + +let pendingRenderFrame: PendingFrame | null = null; +let _rafId: number | null = null; +let rafRunning = false; + +function renderLoop() { + _rafId = null; + + const hasRenderer = + renderMode === "webgpu" + ? webgpuRenderer !== null + : offscreenCanvas !== null && offscreenCtx !== null; + + if (!hasRenderer) { + rafRunning = false; + return; + } + + const frame = pendingRenderFrame; + if (frame) { + pendingRenderFrame = null; + + if (frame.mode === "webgpu" && webgpuRenderer) { + renderFrameWebGPU(webgpuRenderer, frame.data, frame.width, frame.height); + } else if (frame.mode === "canvas2d" && offscreenCanvas && offscreenCtx) { + if ( + offscreenCanvas.width !== frame.width || + offscreenCanvas.height !== frame.height + ) { + offscreenCanvas.width = frame.width; + offscreenCanvas.height = frame.height; + } + offscreenCtx.putImageData(frame.imageData, 0, 0); + } + + self.postMessage({ + type: "frame-rendered", + width: frame.width, + height: frame.height, + } satisfies FrameRenderedMessage); + } + + _rafId = requestAnimationFrame(renderLoop); +} + +function startRenderLoop() { + if (rafRunning) return; + + const hasRenderer = + renderMode === "webgpu" + ? webgpuRenderer !== null + : offscreenCanvas !== null && offscreenCtx !== null; + + if (!hasRenderer) return; + + rafRunning = true; + _rafId = requestAnimationFrame(renderLoop); +} + +function stopRenderLoop() { + if (_rafId !== null) { + cancelAnimationFrame(_rafId); + _rafId = null; + } + rafRunning = false; +} + +function cleanup() { + stopRenderLoop(); + if (webgpuRenderer) { + disposeWebGPU(webgpuRenderer); + webgpuRenderer = null; + } + offscreenCanvas = null; + offscreenCtx = null; + consumer = null; + useSharedBuffer = false; + pendingRenderFrame = null; + lastImageData = null; + cachedImageData = null; + cachedWidth = 0; + cachedHeight = 0; + strideBuffer = null; + strideBufferSize = 0; + lastRawFrameData = null; + lastRawFrameWidth = 0; + lastRawFrameHeight = 0; +} + +function initWorker() { + workerReady = true; + self.postMessage({ type: "ready" } satisfies ReadyMessage); + + if (pendingCanvasInit) { + initCanvas(pendingCanvasInit); + pendingCanvasInit = null; + } + + if (useSharedBuffer && consumer) { + pollSharedBuffer(); + } +} + +initWorker(); + +async function initCanvas(canvas: OffscreenCanvas): Promise { + if (isInitializing) { + return initializationPromise ?? Promise.resolve(); + } + isInitializing = true; + + const doInit = async () => { + offscreenCanvas = canvas; + + if (await isWebGPUSupported()) { + try { + webgpuRenderer = await initWebGPU(canvas); + renderMode = "webgpu"; + self.postMessage({ + type: "renderer-mode", + mode: "webgpu", + } satisfies RendererModeMessage); + } catch { + renderMode = "canvas2d"; + offscreenCtx = canvas.getContext("2d", { + alpha: false, + desynchronized: true, + }); + self.postMessage({ + type: "renderer-mode", + mode: "canvas2d", + } satisfies RendererModeMessage); + } + } else { + renderMode = "canvas2d"; + offscreenCtx = canvas.getContext("2d", { + alpha: false, + desynchronized: true, + }); + self.postMessage({ + type: "renderer-mode", + mode: "canvas2d", + } satisfies RendererModeMessage); + } + + let frameRendered = false; + if ( + renderMode === "webgpu" && + webgpuRenderer && + lastRawFrameData && + lastRawFrameWidth > 0 && + lastRawFrameHeight > 0 + ) { + renderFrameWebGPU( + webgpuRenderer, + lastRawFrameData, + lastRawFrameWidth, + lastRawFrameHeight, + ); + self.postMessage({ + type: "frame-rendered", + width: lastRawFrameWidth, + height: lastRawFrameHeight, + } satisfies FrameRenderedMessage); + frameRendered = true; + } else if (renderMode === "canvas2d" && lastImageData && offscreenCtx) { + offscreenCanvas.width = lastImageData.width; + offscreenCanvas.height = lastImageData.height; + offscreenCtx.putImageData(lastImageData, 0, 0); + self.postMessage({ + type: "frame-rendered", + width: lastImageData.width, + height: lastImageData.height, + } satisfies FrameRenderedMessage); + frameRendered = true; + } else if (renderMode === "canvas2d" && offscreenCtx) { + offscreenCtx.fillStyle = "#000000"; + offscreenCtx.fillRect(0, 0, canvas.width, canvas.height); + } + + startRenderLoop(); + + if (!frameRendered) { + self.postMessage({ type: "request-frame" }); + } + }; + + initializationPromise = doInit().finally(() => { + isInitializing = false; + initializationPromise = null; + }); + + return initializationPromise; +} + +type DecodeResult = FrameQueuedMessage | DecodedFrame | ErrorMessage; + +async function processFrame(buffer: ArrayBuffer): Promise { + const data = new Uint8Array(buffer); + if (data.length < 12) { + return { + type: "error", + message: "Received frame too small to contain metadata", + }; + } + + const metadataOffset = data.length - 12; + const meta = new DataView(buffer, metadataOffset, 12); + const strideBytes = meta.getUint32(0, true); + const height = meta.getUint32(4, true); + const width = meta.getUint32(8, true); + const frameData = new Uint8ClampedArray(buffer, 0, metadataOffset); + + if (!width || !height) { + return { + type: "error", + message: `Received invalid frame dimensions: ${width}x${height}`, + }; + } + + const expectedRowBytes = width * 4; + const expectedLength = expectedRowBytes * height; + const availableLength = strideBytes * height; + + if ( + strideBytes === 0 || + strideBytes < expectedRowBytes || + frameData.length < availableLength + ) { + return { + type: "error", + message: `Received invalid frame stride: ${strideBytes}, expected: ${expectedRowBytes}`, + }; + } + + let processedFrameData: Uint8ClampedArray; + if (strideBytes === expectedRowBytes) { + processedFrameData = frameData.subarray(0, expectedLength); + } else { + if (!strideBuffer || strideBufferSize < expectedLength) { + strideBuffer = new Uint8ClampedArray(expectedLength); + strideBufferSize = expectedLength; + } + for (let row = 0; row < height; row += 1) { + const srcStart = row * strideBytes; + const destStart = row * expectedRowBytes; + strideBuffer.set( + frameData.subarray(srcStart, srcStart + expectedRowBytes), + destStart, + ); + } + processedFrameData = strideBuffer.subarray(0, expectedLength); + } + + if (renderMode === "webgpu" && webgpuRenderer) { + const frameDataCopy = new Uint8ClampedArray(processedFrameData); + pendingRenderFrame = { + mode: "webgpu", + data: frameDataCopy, + width, + height, + }; + return { type: "frame-queued", width, height }; + } + + lastRawFrameData = new Uint8ClampedArray(processedFrameData); + lastRawFrameWidth = width; + lastRawFrameHeight = height; + + if (!cachedImageData || cachedWidth !== width || cachedHeight !== height) { + cachedImageData = new ImageData(width, height); + cachedWidth = width; + cachedHeight = height; + } + cachedImageData.data.set(processedFrameData); + lastImageData = cachedImageData; + + if (offscreenCanvas && offscreenCtx) { + pendingRenderFrame = { + mode: "canvas2d", + imageData: cachedImageData, + width, + height, + }; + + return { type: "frame-queued", width, height }; + } + + try { + const bitmap = await createImageBitmap(cachedImageData); + return { + type: "decoded", + bitmap, + width, + height, + }; + } catch (e) { + return { + type: "error", + message: `Failed to create ImageBitmap: ${e}`, + }; + } +} + +async function pollSharedBuffer(): Promise { + if (!consumer || !useSharedBuffer) return; + + const buffer = consumer.read(50); + if (buffer) { + const result = await processFrame(buffer); + if (result.type === "decoded") { + self.postMessage(result, { transfer: [result.bitmap] }); + } else if (result.type === "frame-queued") { + self.postMessage(result); + } else if (result.type === "error") { + self.postMessage(result); + } + } + + if (!consumer.isShutdown()) { + setTimeout(pollSharedBuffer, 0); + } +} + +self.onmessage = async (e: MessageEvent) => { + if (e.data.type === "cleanup") { + cleanup(); + return; + } + + if (e.data.type === "init-shared-buffer") { + consumer = createConsumer(e.data.buffer); + useSharedBuffer = true; + + if (workerReady) { + pollSharedBuffer(); + } + return; + } + + if (e.data.type === "init-canvas") { + if (!workerReady) { + pendingCanvasInit = e.data.canvas; + return; + } + await initCanvas(e.data.canvas); + return; + } + + if (e.data.type === "resize") { + if (offscreenCanvas) { + offscreenCanvas.width = e.data.width; + offscreenCanvas.height = e.data.height; + if (offscreenCtx) { + if ( + lastImageData && + lastImageData.width === e.data.width && + lastImageData.height === e.data.height + ) { + offscreenCtx.putImageData(lastImageData, 0, 0); + } else { + lastImageData = null; + cachedImageData = null; + cachedWidth = 0; + cachedHeight = 0; + offscreenCtx.fillStyle = "#000000"; + offscreenCtx.fillRect(0, 0, e.data.width, e.data.height); + } + } + } + return; + } + + if (e.data.type === "frame") { + const result = await processFrame(e.data.buffer); + if (result.type === "decoded") { + self.postMessage(result, { transfer: [result.bitmap] }); + } else if (result.type === "frame-queued") { + self.postMessage(result); + } else if (result.type === "error") { + self.postMessage(result); + } + } +}; diff --git a/apps/desktop/src/utils/queries.ts b/apps/desktop/src/utils/queries.ts index fafc9cb35c..8ee5c89a8d 100644 --- a/apps/desktop/src/utils/queries.ts +++ b/apps/desktop/src/utils/queries.ts @@ -7,7 +7,7 @@ import { useQuery, } from "@tanstack/solid-query"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { createEffect, createMemo } from "solid-js"; +import { createEffect, createMemo, onCleanup } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { useRecordingOptions } from "~/routes/(window-chrome)/OptionsContext"; import { @@ -161,6 +161,13 @@ export function createOptionsQuery() { }); }); + const storeListenerCleanup = recordingSettingsStore.listen((data) => { + if (data?.mode && data.mode !== _state.mode) { + _setState("mode", data.mode); + } + }); + onCleanup(() => storeListenerCleanup.then((c) => c())); + const [state, setState] = makePersisted([_state, _setState], { name: PERSIST_KEY, }); diff --git a/apps/desktop/src/utils/shared-frame-buffer.ts b/apps/desktop/src/utils/shared-frame-buffer.ts new file mode 100644 index 0000000000..7b0f82737f --- /dev/null +++ b/apps/desktop/src/utils/shared-frame-buffer.ts @@ -0,0 +1,380 @@ +export const SLOT_STATE = { + EMPTY: 0, + WRITING: 1, + READY: 2, + READING: 3, +} as const; + +export const PROTOCOL_VERSION = 1; + +const CONTROL_BLOCK_SIZE = 64; +const METADATA_ENTRY_SIZE = 12; + +const CONTROL_WRITE_INDEX = 0; +const CONTROL_READ_INDEX = 1; +const CONTROL_SHUTDOWN = 2; +const CONTROL_SLOT_COUNT = 3; +const CONTROL_SLOT_SIZE = 4; +const CONTROL_METADATA_OFFSET = 5; +const CONTROL_DATA_OFFSET = 6; +const CONTROL_VERSION = 7; +const CONTROL_READER_ACTIVE = 8; + +const META_FRAME_SIZE = 0; +const META_FRAME_NUMBER = 1; +const META_SLOT_STATE = 2; + +export interface SharedFrameBufferConfig { + slotCount: number; + slotSize: number; +} + +export interface SharedFrameBufferInit { + buffer: SharedArrayBuffer; + config: SharedFrameBufferConfig; +} + +export function isSharedArrayBufferSupported(): boolean { + try { + return ( + typeof SharedArrayBuffer !== "undefined" && + typeof Atomics !== "undefined" && + (typeof crossOriginIsolated !== "undefined" + ? crossOriginIsolated === true + : false) + ); + } catch { + return false; + } +} + +export function createSharedFrameBuffer( + config: SharedFrameBufferConfig, +): SharedFrameBufferInit { + if ( + typeof config.slotCount !== "number" || + !Number.isFinite(config.slotCount) || + !Number.isInteger(config.slotCount) || + config.slotCount <= 0 + ) { + throw new Error( + `Invalid slotCount: expected a positive integer, got ${config.slotCount}`, + ); + } + if ( + typeof config.slotSize !== "number" || + !Number.isFinite(config.slotSize) || + !Number.isInteger(config.slotSize) || + config.slotSize <= 0 + ) { + throw new Error( + `Invalid slotSize: expected a positive integer, got ${config.slotSize}`, + ); + } + + const metadataSize = METADATA_ENTRY_SIZE * config.slotCount; + const totalSize = + CONTROL_BLOCK_SIZE + metadataSize + config.slotSize * config.slotCount; + + const buffer = new SharedArrayBuffer(totalSize); + const controlView = new Uint32Array(buffer, 0, 16); + + controlView[CONTROL_WRITE_INDEX] = 0; + controlView[CONTROL_READ_INDEX] = 0; + controlView[CONTROL_SHUTDOWN] = 0; + controlView[CONTROL_SLOT_COUNT] = config.slotCount; + controlView[CONTROL_SLOT_SIZE] = config.slotSize; + controlView[CONTROL_METADATA_OFFSET] = CONTROL_BLOCK_SIZE; + controlView[CONTROL_DATA_OFFSET] = CONTROL_BLOCK_SIZE + metadataSize; + controlView[CONTROL_VERSION] = PROTOCOL_VERSION; + + const metadataView = new Int32Array(buffer); + for (let i = 0; i < config.slotCount; i++) { + const slotMetaIdx = (CONTROL_BLOCK_SIZE + i * METADATA_ENTRY_SIZE) / 4; + Atomics.store( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.EMPTY, + ); + } + + return { buffer, config }; +} + +export interface Producer { + write(frameData: ArrayBuffer): boolean; + signalShutdown(): void; +} + +export function createProducer(init: SharedFrameBufferInit): Producer { + const { buffer, config } = init; + const controlView = new Uint32Array(buffer, 0, 8); + const metadataView = new Int32Array(buffer); + const metadataOffset = controlView[CONTROL_METADATA_OFFSET]; + const dataOffset = controlView[CONTROL_DATA_OFFSET]; + let frameCounter = 0; + + return { + write(frameData: ArrayBuffer): boolean { + if ( + frameData == null || + !(frameData instanceof ArrayBuffer) || + typeof frameData.byteLength !== "number" + ) { + throw new TypeError( + `Invalid frameData: expected ArrayBuffer, got ${frameData == null ? String(frameData) : typeof frameData}`, + ); + } + + if (frameData.byteLength > config.slotSize) { + return false; + } + + const writeIdx = Atomics.load(controlView, CONTROL_WRITE_INDEX); + const slotMetaIdx = (metadataOffset + writeIdx * METADATA_ENTRY_SIZE) / 4; + + const currentState = Atomics.load( + metadataView, + slotMetaIdx + META_SLOT_STATE, + ); + if (currentState !== SLOT_STATE.EMPTY) { + return false; + } + + const exchanged = Atomics.compareExchange( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.EMPTY, + SLOT_STATE.WRITING, + ); + if (exchanged !== SLOT_STATE.EMPTY) { + return false; + } + + if (writeIdx < 0 || writeIdx >= config.slotCount) { + Atomics.store( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.EMPTY, + ); + return false; + } + + const slotDataOffset = dataOffset + writeIdx * config.slotSize; + + if ( + slotDataOffset < 0 || + slotDataOffset + frameData.byteLength > buffer.byteLength + ) { + Atomics.store( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.EMPTY, + ); + return false; + } + + const dest = new Uint8Array(buffer, slotDataOffset, frameData.byteLength); + dest.set(new Uint8Array(frameData)); + + Atomics.store( + metadataView, + slotMetaIdx + META_FRAME_SIZE, + frameData.byteLength, + ); + const currentFrame = frameCounter; + frameCounter = (frameCounter + 1) | 0; + Atomics.store( + metadataView, + slotMetaIdx + META_FRAME_NUMBER, + currentFrame, + ); + + const MAX_CAS_RETRIES = 10; + let observed = writeIdx; + + for (let casAttempt = 0; casAttempt < MAX_CAS_RETRIES; casAttempt++) { + const nextIdx = (observed + 1) % config.slotCount; + const oldValue = Atomics.compareExchange( + controlView, + CONTROL_WRITE_INDEX, + observed, + nextIdx, + ); + + if (oldValue === observed) { + Atomics.store( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.READY, + ); + Atomics.notify(metadataView, slotMetaIdx + META_SLOT_STATE, 1); + return true; + } + + observed = oldValue; + } + + Atomics.store( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.EMPTY, + ); + return false; + }, + + signalShutdown(): void { + Atomics.store(controlView, CONTROL_SHUTDOWN, 1); + for (let i = 0; i < config.slotCount; i++) { + const slotMetaIdx = (metadataOffset + i * METADATA_ENTRY_SIZE) / 4; + Atomics.notify(metadataView, slotMetaIdx + META_SLOT_STATE, 1); + } + }, + }; +} + +export interface Consumer { + read(timeoutMs?: number): ArrayBuffer | null; + isShutdown(): boolean; +} + +function advanceReadIndexCAS( + controlView: Uint32Array, + readIdx: number, + nextIdx: number, +): void { + let expectedIdx = readIdx; + while (true) { + const exchanged = Atomics.compareExchange( + controlView, + CONTROL_READ_INDEX, + expectedIdx, + nextIdx, + ); + if (exchanged === expectedIdx) { + return; + } + const currentIdx = Atomics.load(controlView, CONTROL_READ_INDEX); + const hasProgressed = + currentIdx !== readIdx && + ((nextIdx > readIdx && (currentIdx >= nextIdx || currentIdx < readIdx)) || + (nextIdx < readIdx && currentIdx >= nextIdx && currentIdx < readIdx)); + if (hasProgressed) { + return; + } + expectedIdx = exchanged; + } +} + +export function createConsumer(buffer: SharedArrayBuffer): Consumer { + const controlView = new Uint32Array(buffer, 0, 8); + + const storedVersion = controlView[CONTROL_VERSION]; + if (storedVersion !== PROTOCOL_VERSION) { + throw new Error( + `SharedFrameBuffer protocol version mismatch: expected ${PROTOCOL_VERSION}, got ${storedVersion}`, + ); + } + + const slotCount = controlView[CONTROL_SLOT_COUNT]; + const slotSize = controlView[CONTROL_SLOT_SIZE]; + const metadataOffset = controlView[CONTROL_METADATA_OFFSET]; + const dataOffset = controlView[CONTROL_DATA_OFFSET]; + const metadataView = new Int32Array(buffer); + + return { + read(timeoutMs: number = 100): ArrayBuffer | null { + const MAX_CAS_RETRIES = 3; + + for (let attempt = 0; attempt < MAX_CAS_RETRIES; attempt++) { + const shutdownFlag = Atomics.load(controlView, CONTROL_SHUTDOWN); + if (shutdownFlag) { + return null; + } + + const readIdx = Atomics.load(controlView, CONTROL_READ_INDEX); + const slotMetaIdx = + (metadataOffset + readIdx * METADATA_ENTRY_SIZE) / 4; + + let state = Atomics.load(metadataView, slotMetaIdx + META_SLOT_STATE); + + if (state !== SLOT_STATE.READY) { + const waitResult = Atomics.wait( + metadataView, + slotMetaIdx + META_SLOT_STATE, + state, + timeoutMs, + ); + if (waitResult === "timed-out") { + return null; + } + + const shutdownCheck = Atomics.load(controlView, CONTROL_SHUTDOWN); + if (shutdownCheck) { + return null; + } + + state = Atomics.load(metadataView, slotMetaIdx + META_SLOT_STATE); + if (state !== SLOT_STATE.READY) { + continue; + } + } + + const exchangedState = Atomics.compareExchange( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.READY, + SLOT_STATE.READING, + ); + if (exchangedState !== SLOT_STATE.READY) { + continue; + } + + const frameSize = Atomics.load( + metadataView, + slotMetaIdx + META_FRAME_SIZE, + ); + const slotDataOffset = dataOffset + readIdx * slotSize; + + if ( + !Number.isInteger(frameSize) || + frameSize < 0 || + frameSize > slotSize || + slotDataOffset < 0 || + slotDataOffset + frameSize > buffer.byteLength + ) { + Atomics.store( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.EMPTY, + ); + const nextIdx = (readIdx + 1) % slotCount; + advanceReadIndexCAS(controlView, readIdx, nextIdx); + return null; + } + + const frameBuffer = new ArrayBuffer(frameSize); + new Uint8Array(frameBuffer).set( + new Uint8Array(buffer, slotDataOffset, frameSize), + ); + + Atomics.store( + metadataView, + slotMetaIdx + META_SLOT_STATE, + SLOT_STATE.EMPTY, + ); + + const nextIdx = (readIdx + 1) % slotCount; + advanceReadIndexCAS(controlView, readIdx, nextIdx); + + return frameBuffer; + } + + return null; + }, + + isShutdown(): boolean { + return Atomics.load(controlView, CONTROL_SHUTDOWN) === 1; + }, + }; +} diff --git a/apps/desktop/src/utils/socket.ts b/apps/desktop/src/utils/socket.ts index cd5cbc06c4..b34511f856 100644 --- a/apps/desktop/src/utils/socket.ts +++ b/apps/desktop/src/utils/socket.ts @@ -1,88 +1,231 @@ import { createWS } from "@solid-primitives/websocket"; import { createResource, createSignal } from "solid-js"; +import FrameWorker from "./frame-worker?worker"; +import { + createProducer, + createSharedFrameBuffer, + isSharedArrayBufferSupported, + type Producer, + type SharedFrameBufferConfig, +} from "./shared-frame-buffer"; + +const SAB_SUPPORTED = isSharedArrayBufferSupported(); +const FRAME_BUFFER_CONFIG: SharedFrameBufferConfig = { + slotCount: 4, + slotSize: 8 * 1024 * 1024, +}; + +export type FrameData = { + width: number; + height: number; + bitmap?: ImageBitmap | null; +}; + +export type CanvasControls = { + initCanvas: (canvas: OffscreenCanvas) => void; + resizeCanvas: (width: number, height: number) => void; + hasRenderedFrame: () => boolean; +}; + +interface ReadyMessage { + type: "ready"; +} + +interface FrameRenderedMessage { + type: "frame-rendered"; + width: number; + height: number; +} + +interface FrameQueuedMessage { + type: "frame-queued"; + width: number; + height: number; +} + +interface DecodedFrame { + type: "decoded"; + bitmap: ImageBitmap; + width: number; + height: number; +} + +interface ErrorMessage { + type: "error"; + message: string; +} + +interface RequestFrameMessage { + type: "request-frame"; +} + +type WorkerMessage = + | ReadyMessage + | FrameRenderedMessage + | FrameQueuedMessage + | DecodedFrame + | ErrorMessage + | RequestFrameMessage; export function createImageDataWS( url: string, - onmessage: (data: { width: number; data: ImageData }) => void, -): [Omit, () => boolean] { + onmessage: (data: FrameData) => void, + onRequestFrame?: () => void, +): [ + Omit, + () => boolean, + () => boolean, + CanvasControls, +] { const [isConnected, setIsConnected] = createSignal(false); + const [isWorkerReady, setIsWorkerReady] = createSignal(false); const ws = createWS(url); - ws.addEventListener("open", () => { - console.log("WebSocket connected"); - setIsConnected(true); - }); + const worker = new FrameWorker(); + let pendingFrame: ArrayBuffer | null = null; + let isProcessing = false; + let nextFrame: ArrayBuffer | null = null; + + let producer: Producer | null = null; + if (SAB_SUPPORTED) { + try { + const init = createSharedFrameBuffer(FRAME_BUFFER_CONFIG); + producer = createProducer(init); + worker.postMessage({ + type: "init-shared-buffer", + buffer: init.buffer, + }); + } catch (e) { + console.error( + "[socket] SharedArrayBuffer allocation failed, falling back to non-SAB mode:", + e instanceof Error ? e.message : e, + ); + producer = null; + } + } - ws.addEventListener("close", () => { - console.log("WebSocket disconnected"); - setIsConnected(false); - }); + const [hasRenderedFrame, setHasRenderedFrame] = createSignal(false); + let isCleanedUp = false; + + function cleanup() { + if (isCleanedUp) return; + isCleanedUp = true; + + if (producer) { + producer.signalShutdown(); + producer = null; + } + + worker.onmessage = null; + worker.terminate(); + + pendingFrame = null; + nextFrame = null; + isProcessing = false; - ws.addEventListener("error", (error) => { - console.error("WebSocket error:", error); setIsConnected(false); - }); + } - ws.binaryType = "arraybuffer"; - ws.onmessage = (event) => { - const buffer = event.data as ArrayBuffer; - const clamped = new Uint8ClampedArray(buffer); - if (clamped.length < 12) { - console.error("Received frame too small to contain metadata"); + const canvasControls: CanvasControls = { + initCanvas: (canvas: OffscreenCanvas) => { + worker.postMessage({ type: "init-canvas", canvas }, [canvas]); + }, + resizeCanvas: (width: number, height: number) => { + worker.postMessage({ type: "resize", width, height }); + }, + hasRenderedFrame, + }; + + worker.onmessage = (e: MessageEvent) => { + if (e.data.type === "ready") { + setIsWorkerReady(true); return; } - const metadataOffset = clamped.length - 12; - const meta = new DataView(buffer, metadataOffset, 12); - const strideBytes = meta.getUint32(0, true); - const height = meta.getUint32(4, true); - const width = meta.getUint32(8, true); + if (e.data.type === "error") { + console.error("[FrameWorker]", e.data.message); + isProcessing = false; + processNextFrame(); + return; + } - if (!width || !height) { - console.error("Received invalid frame dimensions", { width, height }); + if (e.data.type === "frame-queued") { + const { width, height } = e.data; + onmessage({ width, height }); + isProcessing = false; + processNextFrame(); return; } - const source = clamped.subarray(0, metadataOffset); - const expectedRowBytes = width * 4; - const expectedLength = expectedRowBytes * height; - const availableLength = strideBytes * height; - - if ( - strideBytes === 0 || - strideBytes < expectedRowBytes || - source.length < availableLength - ) { - console.error("Received invalid frame stride", { - strideBytes, - expectedRowBytes, - height, - sourceLength: source.length, - }); + if (e.data.type === "frame-rendered") { + if (!hasRenderedFrame()) { + setHasRenderedFrame(true); + } + return; + } + + if (e.data.type === "request-frame") { + onRequestFrame?.(); return; } - let pixels: Uint8ClampedArray; + if (e.data.type === "decoded") { + const { bitmap, width, height } = e.data; + onmessage({ width, height, bitmap }); + isProcessing = false; + processNextFrame(); + } + }; + + function processNextFrame() { + if (isProcessing) return; + + const buffer = nextFrame || pendingFrame; + if (!buffer) return; - if (strideBytes === expectedRowBytes) { - pixels = source.subarray(0, expectedLength); + if (nextFrame) { + nextFrame = null; } else { - pixels = new Uint8ClampedArray(expectedLength); - for (let row = 0; row < height; row += 1) { - const srcStart = row * strideBytes; - const destStart = row * expectedRowBytes; - pixels.set( - source.subarray(srcStart, srcStart + expectedRowBytes), - destStart, - ); + pendingFrame = null; + } + + isProcessing = true; + + if (producer) { + const written = producer.write(buffer); + if (!written) { + worker.postMessage({ type: "frame", buffer }, [buffer]); } + } else { + worker.postMessage({ type: "frame", buffer }, [buffer]); } + } - const imageData = new ImageData(pixels, width, height); - onmessage({ width, data: imageData }); + ws.addEventListener("open", () => { + setIsConnected(true); + }); + + ws.addEventListener("close", () => { + cleanup(); + }); + + ws.addEventListener("error", () => { + cleanup(); + }); + + ws.binaryType = "arraybuffer"; + ws.onmessage = (event) => { + const buffer = event.data as ArrayBuffer; + + if (isProcessing) { + nextFrame = buffer; + } else { + pendingFrame = buffer; + processNextFrame(); + } }; - return [ws, isConnected]; + return [ws, isConnected, isWorkerReady, canvasControls]; } export function createLazySignal() { diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index d3f34c9c9b..bdabd64664 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -11,6 +11,9 @@ async setMicInput(label: string | null) : Promise { async setCameraInput(id: DeviceOrModelID | null) : Promise { return await TAURI_INVOKE("set_camera_input", { id }); }, +async setRecordingMode(mode: RecordingMode) : Promise { + return await TAURI_INVOKE("set_recording_mode", { mode }); +}, async uploadLogs() : Promise { return await TAURI_INVOKE("upload_logs"); }, @@ -420,7 +423,7 @@ fast: boolean | null } export type HapticPattern = "alignment" | "levelChange" | "generic" export type HapticPerformanceTime = "default" | "now" | "drawCompleted" export type Hotkey = { code: string; meta: boolean; ctrl: boolean; alt: boolean; shift: boolean } -export type HotkeyAction = "startStudioRecording" | "startInstantRecording" | "stopRecording" | "restartRecording" | "openRecordingPicker" | "openRecordingPickerDisplay" | "openRecordingPickerWindow" | "openRecordingPickerArea" | "other" +export type HotkeyAction = "startStudioRecording" | "startInstantRecording" | "stopRecording" | "restartRecording" | "cycleRecordingMode" | "openRecordingPicker" | "openRecordingPickerDisplay" | "openRecordingPickerWindow" | "openRecordingPickerArea" | "other" export type HotkeysConfiguration = { show: boolean } export type HotkeysStore = { hotkeys: { [key in HotkeyAction]: Hotkey } } export type IncompleteRecordingInfo = { projectPath: string; prettyName: string; segmentCount: number; estimatedDurationSecs: number } diff --git a/apps/desktop/src/utils/webgpu-renderer.ts b/apps/desktop/src/utils/webgpu-renderer.ts new file mode 100644 index 0000000000..bf98ac5e20 --- /dev/null +++ b/apps/desktop/src/utils/webgpu-renderer.ts @@ -0,0 +1,230 @@ +const VERTEX_SHADER = ` +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) texCoord: vec2f, +} + +@vertex +fn vs(@builtin(vertex_index) vi: u32) -> VertexOutput { + var positions = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0) + ); + var texCoords = array( + vec2f(0.0, 1.0), + vec2f(2.0, 1.0), + vec2f(0.0, -1.0) + ); + var output: VertexOutput; + output.position = vec4f(positions[vi], 0.0, 1.0); + output.texCoord = texCoords[vi]; + return output; +} +`; + +const FRAGMENT_SHADER = ` +@group(0) @binding(0) var frameSampler: sampler; +@group(0) @binding(1) var frameTexture: texture_2d; + +@fragment +fn fs(@location(0) texCoord: vec2f) -> @location(0) vec4f { + return textureSample(frameTexture, frameSampler, texCoord); +} +`; + +export interface WebGPURenderer { + device: GPUDevice; + context: GPUCanvasContext; + pipeline: GPURenderPipeline; + sampler: GPUSampler; + frameTexture: GPUTexture | null; + bindGroup: GPUBindGroup | null; + bindGroupLayout: GPUBindGroupLayout; + cachedWidth: number; + cachedHeight: number; + canvas: OffscreenCanvas; +} + +export async function isWebGPUSupported(): Promise { + if (typeof navigator === "undefined" || !navigator.gpu) { + return false; + } + try { + const adapter = await navigator.gpu.requestAdapter(); + return adapter !== null; + } catch { + return false; + } +} + +export async function initWebGPU( + canvas: OffscreenCanvas, +): Promise { + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + throw new Error("No WebGPU adapter available"); + } + + const device = await adapter.requestDevice(); + + device.lost.then((info) => { + if (info.reason !== "destroyed") { + self.postMessage({ + type: "error", + message: `WebGPU device lost: ${info.reason} - ${info.message}`, + }); + } + }); + + const context = canvas.getContext("webgpu"); + if (!context) { + throw new Error("Failed to get WebGPU context from OffscreenCanvas"); + } + + const format = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ + device, + format, + alphaMode: "opaque", + }); + + const bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + sampler: { type: "filtering" }, + }, + { + binding: 1, + visibility: GPUShaderStage.FRAGMENT, + texture: { sampleType: "float" }, + }, + ], + }); + + const pipelineLayout = device.createPipelineLayout({ + bindGroupLayouts: [bindGroupLayout], + }); + + const vertexModule = device.createShaderModule({ code: VERTEX_SHADER }); + const fragmentModule = device.createShaderModule({ code: FRAGMENT_SHADER }); + + const pipeline = device.createRenderPipeline({ + layout: pipelineLayout, + vertex: { + module: vertexModule, + entryPoint: "vs", + }, + fragment: { + module: fragmentModule, + entryPoint: "fs", + targets: [{ format }], + }, + primitive: { + topology: "triangle-list", + }, + }); + + const sampler = device.createSampler({ + magFilter: "linear", + minFilter: "linear", + addressModeU: "clamp-to-edge", + addressModeV: "clamp-to-edge", + }); + + return { + device, + context, + pipeline, + sampler, + frameTexture: null, + bindGroup: null, + bindGroupLayout, + cachedWidth: 0, + cachedHeight: 0, + canvas, + }; +} + +export function renderFrameWebGPU( + renderer: WebGPURenderer, + data: Uint8ClampedArray, + width: number, + height: number, +): void { + const { device, context, pipeline, sampler, bindGroupLayout, canvas } = + renderer; + + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + + if (renderer.cachedWidth !== width || renderer.cachedHeight !== height) { + renderer.frameTexture?.destroy(); + renderer.frameTexture = device.createTexture({ + size: { width, height }, + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + renderer.bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: sampler }, + { binding: 1, resource: renderer.frameTexture.createView() }, + ], + }); + renderer.cachedWidth = width; + renderer.cachedHeight = height; + } + + if (!renderer.frameTexture || !renderer.bindGroup) { + return; + } + + const requiredBytes = width * height * 4; + if (data.byteLength < requiredBytes) { + console.error( + `WebGPU renderFrame: buffer too small. Expected at least ${requiredBytes} bytes, got ${data.byteLength}`, + ); + return; + } + + const textureData = + data.byteLength > requiredBytes ? data.subarray(0, requiredBytes) : data; + + device.queue.writeTexture( + { texture: renderer.frameTexture }, + textureData, + { bytesPerRow: width * 4, rowsPerImage: height }, + { width, height }, + ); + + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: context.getCurrentTexture().createView(), + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + loadOp: "clear", + storeOp: "store", + }, + ], + }); + + pass.setPipeline(pipeline); + pass.setBindGroup(0, renderer.bindGroup); + pass.draw(3); + pass.end(); + + device.queue.submit([encoder.finish()]); +} + +export function disposeWebGPU(renderer: WebGPURenderer): void { + renderer.frameTexture?.destroy(); + renderer.frameTexture = null; + renderer.bindGroup = null; + renderer.device.destroy(); +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index d2590b683b..a1c4b56f2f 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -18,7 +18,8 @@ "vite/client", "vinxi/client", "@cap/ui-solid/types", - "vitest/importMeta" + "vitest/importMeta", + "@webgpu/types" ], /* Linting */ diff --git a/core b/core deleted file mode 100644 index 9e3a488172..0000000000 Binary files a/core and /dev/null differ diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 89201baddf..4410cd6fac 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -25,4 +25,5 @@ tracing.workspace = true flume.workspace = true tokio-util = "0.7.15" ringbuf = "0.4.8" +lru = "0.12" workspace-hack = { version = "0.1", path = "../workspace-hack" } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 28171fa991..82d443f24e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Instant; use cap_project::{CursorEvents, RecordingMeta, StudioRecordingMeta}; use cap_rendering::{ @@ -14,7 +15,6 @@ pub enum RendererMessage { uniforms: ProjectUniforms, finished: oneshot::Sender<()>, cursor: Arc, - frame_number: u32, }, Stop { finished: oneshot::Sender<()>, @@ -56,7 +56,7 @@ impl Renderer { let total_frames = (30_f64 * max_duration).ceil() as u32; - let (tx, rx) = mpsc::channel(4); + let (tx, rx) = mpsc::channel(8); let this = Self { rx, @@ -81,8 +81,6 @@ impl Renderer { uniforms: ProjectUniforms, finished: oneshot::Sender<()>, cursor: Arc, - #[allow(dead_code)] - frame_number: u32, } let mut pending_frame: Option = None; @@ -97,13 +95,11 @@ impl Renderer { uniforms, finished, cursor, - frame_number, }) => Some(PendingFrame { segment_frames, uniforms, finished, cursor, - frame_number, }), Some(RendererMessage::Stop { finished }) => { let _ = finished.send(()); @@ -117,6 +113,7 @@ impl Renderer { continue; }; + let queue_drain_start = Instant::now(); while let Ok(msg) = self.rx.try_recv() { match msg { RendererMessage::RenderFrame { @@ -124,7 +121,6 @@ impl Renderer { uniforms, finished, cursor, - frame_number, } => { let _ = current.finished.send(()); current = PendingFrame { @@ -132,7 +128,6 @@ impl Renderer { uniforms, finished, cursor, - frame_number, }; } RendererMessage::Stop { finished } => { @@ -141,6 +136,9 @@ impl Renderer { return; } } + if queue_drain_start.elapsed().as_millis() > 5 { + break; + } } let frame = frame_renderer @@ -170,20 +168,16 @@ impl RendererHandle { segment_frames: DecodedSegmentFrames, uniforms: ProjectUniforms, cursor: Arc, - frame_number: u32, ) { - let (finished_tx, finished_rx) = oneshot::channel(); + let (finished_tx, _finished_rx) = oneshot::channel(); self.send(RendererMessage::RenderFrame { segment_frames, uniforms, finished: finished_tx, cursor, - frame_number, }) .await; - - finished_rx.await.ok(); } pub async fn stop(&self) { diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index ac627b2430..fd0c33d9e8 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -12,14 +12,16 @@ use cap_rendering::{ }; use std::{path::PathBuf, sync::Arc}; use tokio::sync::{Mutex, watch}; -use tracing::{trace, warn}; +use tokio_util::sync::CancellationToken; +use tracing::warn; pub struct EditorInstance { pub project_path: PathBuf, - // pub ws_port: u16, pub recordings: Arc, pub renderer: Arc, pub render_constants: Arc, + playback_active: watch::Sender, + playback_active_rx: watch::Receiver, pub state: Arc>, on_state_change: Box, pub preview_tx: watch::Sender>, @@ -38,13 +40,10 @@ impl EditorInstance { on_state_change: impl Fn(&EditorState) + Send + Sync + 'static, frame_cb: Box, ) -> Result, String> { - trace!("EditorInstance::new starting for {:?}", project_path); - if !project_path.exists() { return Err(format!("Video path {} not found!", project_path.display())); } - trace!("Loading recording meta"); let recording_meta = cap_project::RecordingMeta::load_for_project(&project_path) .map_err(|e| format!("Failed to load recording meta: {e}"))?; @@ -57,8 +56,6 @@ impl EditorInstance { StudioRecordingMeta::MultipleSegments { inner } => inner.segments.len(), }; - trace!("Recording has {} segments", segment_count); - if segment_count == 0 { return Err( "Recording has no segments. It may need to be recovered first.".to_string(), @@ -131,30 +128,23 @@ impl EditorInstance { if let Err(e) = project.write(&recording_meta.project_path) { warn!("Failed to save auto-generated timeline: {}", e); - } else { - trace!("Auto-generated timeline saved to project config"); } } } - trace!("Creating ProjectRecordingsMeta"); let recordings = Arc::new(ProjectRecordingsMeta::new( &recording_meta.project_path, meta, )?); - trace!("Creating segments with decoders"); let segments = create_segments(&recording_meta, meta).await?; - trace!("Segments created successfully"); - trace!("Creating render constants"); let render_constants = Arc::new( RenderVideoConstants::new(&recordings.segments, recording_meta.clone(), meta.clone()) .await .map_err(|e| format!("Failed to create render constants: {e}"))?, ); - trace!("Spawning renderer"); let renderer = Arc::new(editor::Renderer::spawn( render_constants.clone(), frame_cb, @@ -163,6 +153,7 @@ impl EditorInstance { )?); let (preview_tx, preview_rx) = watch::channel(None); + let (playback_active_tx, playback_active_rx) = watch::channel(false); let this = Arc::new(Self { project_path, @@ -179,12 +170,13 @@ impl EditorInstance { project_config: watch::channel(project), segment_medias: Arc::new(segments), meta: recording_meta, + playback_active: playback_active_tx, + playback_active_rx, }); this.state.lock().await.preview_task = Some(this.clone().spawn_preview_renderer(preview_rx)); - trace!("EditorInstance::new completed successfully"); Ok(this) } @@ -193,25 +185,19 @@ impl EditorInstance { } pub async fn dispose(&self) { - trace!("Disposing EditorInstance"); - let mut state = self.state.lock().await; - // Stop playback if let Some(handle) = state.playback_task.take() { - trace!("Stopping playback"); handle.stop(); } - // Stop preview if let Some(task) = state.preview_task.take() { - trace!("Stopping preview"); task.abort(); - task.await.ok(); // Await the task to ensure it's fully stopped + if let Err(e) = task.await { + tracing::warn!("preview task abort await failed: {e}"); + } } - // Stop renderer - trace!("Stopping renderer"); self.renderer.stop().await; // // Clear audio data @@ -224,8 +210,6 @@ impl EditorInstance { tokio::task::yield_now().await; drop(state); - - println!("EditorInstance disposed"); } pub async fn modify_and_emit_state(&self, modify: impl Fn(&mut EditorState)) { @@ -236,9 +220,7 @@ impl EditorInstance { pub async fn start_playback(self: &Arc, fps: u32, resolution_base: XY) { let (mut handle, prev) = { - let Ok(mut state) = self.state.try_lock() else { - return; - }; + let mut state = self.state.lock().await; let start_frame_number = state.playhead_position; @@ -259,6 +241,10 @@ impl EditorInstance { } }; + if let Err(e) = self.playback_active.send(true) { + tracing::warn!(%e, "failed to send playback_active=true"); + } + let prev = state.playback_task.replace(playback_handle.clone()); (playback_handle, prev) @@ -278,7 +264,9 @@ impl EditorInstance { .await; } playback::PlaybackEvent::Stop => { - // ! This editor instance (self) gets dropped here + if let Err(e) = this.playback_active.send(false) { + tracing::warn!(%e, "failed to send playback_active=false"); + } return; } } @@ -294,13 +282,11 @@ impl EditorInstance { self: Arc, mut preview_rx: watch::Receiver)>>, ) -> tokio::task::JoinHandle<()> { - trace!("Starting preview renderer task"); tokio::spawn(async move { - trace!("Preview renderer task running"); + let mut prefetch_cancel_token: Option = None; + loop { - trace!("Preview renderer: waiting for frame request"); preview_rx.changed().await.unwrap(); - trace!("Preview renderer: received change notification"); loop { let Some((frame_number, fps, resolution_base)) = @@ -309,7 +295,13 @@ impl EditorInstance { break; }; - trace!("Preview renderer: processing frame {}", frame_number); + if let Some(token) = prefetch_cancel_token.take() { + token.cancel(); + } + + if *self.playback_active_rx.borrow() { + break; + } let project = self.project_config.1.borrow().clone(); @@ -330,6 +322,55 @@ impl EditorInstance { .find(|v| v.index == segment.recording_clip); let clip_offsets = clip_config.map(|v| v.offsets).unwrap_or_default(); + let new_cancel_token = CancellationToken::new(); + prefetch_cancel_token = Some(new_cancel_token.clone()); + + let playback_is_active = *self.playback_active_rx.borrow(); + if !playback_is_active { + let prefetch_frames_count = 5u32; + let hide_camera = project.camera.hide; + let playback_rx = self.playback_active_rx.clone(); + for offset in 1..=prefetch_frames_count { + let prefetch_frame = frame_number + offset; + if let Some((prefetch_segment_time, prefetch_segment)) = + project.get_segment_time(prefetch_frame as f64 / fps as f64) + && let Some(prefetch_segment_media) = self + .segment_medias + .get(prefetch_segment.recording_clip as usize) + { + let prefetch_clip_offsets = project + .clips + .iter() + .find(|v| v.index == prefetch_segment.recording_clip) + .map(|v| v.offsets) + .unwrap_or_default(); + let decoders = prefetch_segment_media.decoders.clone(); + let cancel_token = new_cancel_token.clone(); + let playback_rx = playback_rx.clone(); + tokio::spawn(async move { + if cancel_token.is_cancelled() || *playback_rx.borrow() { + return; + } + if decoders + .get_frames( + prefetch_segment_time as f32, + !hide_camera, + prefetch_clip_offsets, + ) + .await + .is_none() + { + tracing::warn!( + prefetch_segment_time, + hide_camera, + "prefetch get_frames returned None" + ); + } + }); + } + } + } + let get_frames_future = segment_medias.decoders.get_frames( segment_time as f32, !project.camera.hide, @@ -349,7 +390,6 @@ impl EditorInstance { } if let Some(segment_frames) = segment_frames_opt { - trace!("Preview renderer: rendering frame {}", frame_number); let uniforms = ProjectUniforms::new( &self.render_constants, &project, @@ -360,7 +400,7 @@ impl EditorInstance { &segment_frames, ); self.renderer - .render_frame(segment_frames, uniforms, segment_medias.cursor.clone(), frame_number) + .render_frame(segment_frames, uniforms, segment_medias.cursor.clone()) .await; } else { warn!("Preview renderer: no frames returned for frame {}", frame_number); @@ -395,14 +435,7 @@ impl EditorInstance { } impl Drop for EditorInstance { - fn drop(&mut self) { - // TODO: Ensure that *all* resources have been released by this point? - // For now the `dispose` method is adequate. - println!( - "*** Editor instance has been released: {:?} ***", - self.project_path - ); - } + fn drop(&mut self) {} } type PreviewFrameInstruction = (u32, u32, XY); diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs index ab66f429a6..f02469dcca 100644 --- a/crates/editor/src/playback.rs +++ b/crates/editor/src/playback.rs @@ -10,16 +10,18 @@ use cpal::{ traits::{DeviceTrait, HostTrait, StreamTrait}, }; use futures::stream::{FuturesUnordered, StreamExt}; +use lru::LruCache; use std::{ collections::{HashSet, VecDeque}, - sync::Arc, + num::NonZeroUsize, + sync::{Arc, RwLock}, time::Duration, }; use tokio::{ sync::{mpsc as tokio_mpsc, watch}, time::Instant, }; -use tracing::{error, info, trace, warn}; +use tracing::{error, info, warn}; use crate::{ audio::{AudioPlaybackBuffer, AudioSegment}, @@ -28,8 +30,11 @@ use crate::{ segments::get_audio_segments, }; -const PREFETCH_BUFFER_SIZE: usize = 16; -const PARALLEL_DECODE_TASKS: usize = 4; +const PREFETCH_BUFFER_SIZE: usize = 180; +const PARALLEL_DECODE_TASKS: usize = 20; +const MAX_PREFETCH_AHEAD: u32 = 240; +const PREFETCH_BEHIND: u32 = 60; +const FRAME_CACHE_SIZE: usize = 150; #[derive(Debug)] pub enum PlaybackStartError { @@ -63,9 +68,37 @@ struct PrefetchedFrame { segment_index: u32, } +struct FrameCache { + cache: LruCache, u32)>, +} + +impl FrameCache { + fn new(capacity: usize) -> Self { + Self { + cache: LruCache::new(NonZeroUsize::new(capacity).unwrap()), + } + } + + fn get(&mut self, frame_number: u32) -> Option<(Arc, u32)> { + self.cache + .get(&frame_number) + .map(|(frames, idx)| (Arc::clone(frames), *idx)) + } + + fn insert( + &mut self, + frame_number: u32, + segment_frames: Arc, + segment_index: u32, + ) { + self.cache + .put(frame_number, (segment_frames, segment_index)); + } +} + impl Playback { pub async fn start( - self, + mut self, fps: u32, resolution_base: XY, ) -> Result { @@ -90,9 +123,14 @@ impl Playback { let (prefetch_tx, mut prefetch_rx) = tokio_mpsc::channel::(PREFETCH_BUFFER_SIZE * 2); let (frame_request_tx, mut frame_request_rx) = watch::channel(self.start_frame_number); + let (playback_position_tx, playback_position_rx) = watch::channel(self.start_frame_number); + + let in_flight_frames: Arc>> = Arc::new(RwLock::new(HashSet::new())); + let prefetch_in_flight = in_flight_frames.clone(); + let main_in_flight = in_flight_frames; let prefetch_stop_rx = stop_rx.clone(); - let prefetch_project = self.project.clone(); + let mut prefetch_project = self.project.clone(); let prefetch_segment_medias = self.segment_medias.clone(); let prefetch_duration = if let Some(timeline) = &self.project.borrow().timeline { timeline.duration() @@ -101,43 +139,92 @@ impl Playback { }; tokio::spawn(async move { + type PrefetchFuture = std::pin::Pin< + Box< + dyn std::future::Future)> + + Send, + >, + >; let mut next_prefetch_frame = *frame_request_rx.borrow(); - let mut in_flight: FuturesUnordered<_> = FuturesUnordered::new(); - let mut in_flight_frames: HashSet = HashSet::new(); + let mut in_flight: FuturesUnordered = FuturesUnordered::new(); + let mut frames_decoded: u32 = 0; + let mut prefetched_behind: HashSet = HashSet::new(); + const INITIAL_PARALLEL_TASKS: usize = 8; + const RAMP_UP_AFTER_FRAMES: u32 = 5; + + let mut cached_project = prefetch_project.borrow().clone(); loop { if *prefetch_stop_rx.borrow() { break; } + if prefetch_project.has_changed().unwrap_or(false) { + cached_project = prefetch_project.borrow_and_update().clone(); + } + if let Ok(true) = frame_request_rx.has_changed() { let requested = *frame_request_rx.borrow_and_update(); - if requested > next_prefetch_frame { + if requested != next_prefetch_frame { + let old_frame = next_prefetch_frame; + let is_backward_seek = requested < old_frame; + let seek_distance = if is_backward_seek { + old_frame - requested + } else { + requested - old_frame + }; + next_prefetch_frame = requested; - in_flight_frames.retain(|&f| f >= requested); + frames_decoded = 0; + prefetched_behind.clear(); + + if let Ok(mut in_flight_guard) = prefetch_in_flight.write() { + in_flight_guard.clear(); + } + + if is_backward_seek || seek_distance > MAX_PREFETCH_AHEAD / 2 { + in_flight = FuturesUnordered::new(); + } } } - while in_flight.len() < PARALLEL_DECODE_TASKS { + let current_playback_frame = *playback_position_rx.borrow(); + let max_prefetch_frame = current_playback_frame + MAX_PREFETCH_AHEAD; + + let effective_parallel = if frames_decoded < RAMP_UP_AFTER_FRAMES { + INITIAL_PARALLEL_TASKS + } else { + PARALLEL_DECODE_TASKS + }; + + while in_flight.len() < effective_parallel { let frame_num = next_prefetch_frame; + + if frame_num > max_prefetch_frame { + break; + } + let prefetch_time = frame_num as f64 / fps_f64; if prefetch_time >= prefetch_duration { break; } - if in_flight_frames.contains(&frame_num) { + let already_in_flight = prefetch_in_flight + .read() + .map(|guard| guard.contains(&frame_num)) + .unwrap_or(false); + if already_in_flight { next_prefetch_frame += 1; continue; } - let project = prefetch_project.borrow().clone(); - - if let Some((segment_time, segment)) = project.get_segment_time(prefetch_time) + if let Some((segment_time, segment)) = + cached_project.get_segment_time(prefetch_time) && let Some(segment_media) = prefetch_segment_medias.get(segment.recording_clip as usize) { - let clip_offsets = project + let clip_offsets = cached_project .clips .iter() .find(|v| v.index == segment.recording_clip) @@ -145,27 +232,86 @@ impl Playback { .unwrap_or_default(); let decoders = segment_media.decoders.clone(); - let hide_camera = project.camera.hide; + let hide_camera = cached_project.camera.hide; let segment_index = segment.recording_clip; - in_flight_frames.insert(frame_num); + if let Ok(mut in_flight_guard) = prefetch_in_flight.write() { + in_flight_guard.insert(frame_num); + } - in_flight.push(async move { + in_flight.push(Box::pin(async move { let result = decoders .get_frames(segment_time as f32, !hide_camera, clip_offsets) .await; (frame_num, segment_index, result) - }); + })); } next_prefetch_frame += 1; } + if in_flight.len() < effective_parallel { + for behind_offset in 1..=PREFETCH_BEHIND { + if in_flight.len() >= effective_parallel { + break; + } + let behind_frame = current_playback_frame.saturating_sub(behind_offset); + if behind_frame == 0 || prefetched_behind.contains(&behind_frame) { + continue; + } + + let prefetch_time = behind_frame as f64 / fps_f64; + if prefetch_time >= prefetch_duration || prefetch_time < 0.0 { + continue; + } + + let already_in_flight = prefetch_in_flight + .read() + .map(|guard| guard.contains(&behind_frame)) + .unwrap_or(false); + if already_in_flight { + continue; + } + + if let Some((segment_time, segment)) = + cached_project.get_segment_time(prefetch_time) + && let Some(segment_media) = + prefetch_segment_medias.get(segment.recording_clip as usize) + { + let clip_offsets = cached_project + .clips + .iter() + .find(|v| v.index == segment.recording_clip) + .map(|v| v.offsets) + .unwrap_or_default(); + + let decoders = segment_media.decoders.clone(); + let hide_camera = cached_project.camera.hide; + let segment_index = segment.recording_clip; + + if let Ok(mut in_flight_guard) = prefetch_in_flight.write() { + in_flight_guard.insert(behind_frame); + } + + prefetched_behind.insert(behind_frame); + in_flight.push(Box::pin(async move { + let result = decoders + .get_frames(segment_time as f32, !hide_camera, clip_offsets) + .await; + (behind_frame, segment_index, result) + })); + } + } + } + tokio::select! { biased; Some((frame_num, segment_index, result)) = in_flight.next() => { - in_flight_frames.remove(&frame_num); + if let Ok(mut in_flight_guard) = prefetch_in_flight.write() { + in_flight_guard.remove(&frame_num); + } + frames_decoded = frames_decoded.saturating_add(1); if let Some(segment_frames) = result { let _ = prefetch_tx.send(PrefetchedFrame { frame_number: frame_num, @@ -181,8 +327,6 @@ impl Playback { }); tokio::spawn(async move { - let start = Instant::now(); - let duration = if let Some(timeline) = &self.project.borrow().timeline { timeline.duration() } else { @@ -202,14 +346,73 @@ impl Playback { let mut frame_number = self.start_frame_number; let mut prefetch_buffer: VecDeque = VecDeque::with_capacity(PREFETCH_BUFFER_SIZE); - let max_frame_skip = 3u32; + let mut frame_cache = FrameCache::new(FRAME_CACHE_SIZE); + let aggressive_skip_threshold = 5u32; + + let mut total_frames_rendered = 0u64; + let mut _total_frames_skipped = 0u64; + + let warmup_target_frames = 2usize; + let warmup_after_first_timeout = Duration::from_millis(50); + let mut first_frame_time: Option = None; + + while !*stop_rx.borrow() { + let should_start = if let Some(first_time) = first_frame_time { + prefetch_buffer.len() >= warmup_target_frames + || first_time.elapsed() > warmup_after_first_timeout + } else { + false + }; + + if should_start { + break; + } + + tokio::select! { + Some(prefetched) = prefetch_rx.recv() => { + if prefetched.frame_number >= frame_number { + prefetch_buffer.push_back(prefetched); + if first_frame_time.is_none() { + first_frame_time = Some(Instant::now()); + } + } + } + _ = stop_rx.changed() => { + if *stop_rx.borrow() { + break; + } + } + } + } + + prefetch_buffer + .make_contiguous() + .sort_by_key(|p| p.frame_number); + + let start = Instant::now(); + let mut cached_project = self.project.borrow().clone(); 'playback: loop { + if self.project.has_changed().unwrap_or(false) { + cached_project = self.project.borrow_and_update().clone(); + } while let Ok(prefetched) = prefetch_rx.try_recv() { if prefetched.frame_number >= frame_number { prefetch_buffer.push_back(prefetched); - if prefetch_buffer.len() > PREFETCH_BUFFER_SIZE { - prefetch_buffer.pop_front(); + while prefetch_buffer.len() > PREFETCH_BUFFER_SIZE { + if let Some(idx) = prefetch_buffer + .iter() + .enumerate() + .filter(|(_, p)| { + p.frame_number > frame_number + PREFETCH_BUFFER_SIZE as u32 + }) + .max_by_key(|(_, p)| p.frame_number) + .map(|(i, _)| i) + { + prefetch_buffer.remove(idx); + } else { + prefetch_buffer.pop_front(); + } } } } @@ -231,43 +434,157 @@ impl Playback { break; } - let project = self.project.borrow().clone(); + let mut was_cached = false; - let prefetched_idx = prefetch_buffer - .iter() - .position(|p| p.frame_number == frame_number); - - let segment_frames_opt = if let Some(idx) = prefetched_idx { - let prefetched = prefetch_buffer.remove(idx).unwrap(); - Some((prefetched.segment_frames, prefetched.segment_index)) + let segment_frames_opt = if let Some(cached) = frame_cache.get(frame_number) { + was_cached = true; + Some(cached) } else { - let Some((segment_time, segment)) = project.get_segment_time(playback_time) - else { - break; - }; - - let Some(segment_media) = - self.segment_medias.get(segment.recording_clip as usize) - else { - frame_number = frame_number.saturating_add(1); - continue; - }; - - let clip_offsets = project - .clips + let prefetched_idx = prefetch_buffer .iter() - .find(|v| v.index == segment.recording_clip) - .map(|v| v.offsets) - .unwrap_or_default(); - - let data = tokio::select! { - _ = stop_rx.changed() => break 'playback, - data = segment_media - .decoders - .get_frames(segment_time as f32, !project.camera.hide, clip_offsets) => data, - }; - - data.map(|frames| (frames, segment.recording_clip)) + .position(|p| p.frame_number == frame_number); + + if let Some(idx) = prefetched_idx { + let prefetched = prefetch_buffer.remove(idx).unwrap(); + Some(( + Arc::new(prefetched.segment_frames), + prefetched.segment_index, + )) + } else { + let is_in_flight = main_in_flight + .read() + .map(|guard| guard.contains(&frame_number)) + .unwrap_or(false); + + if is_in_flight { + let wait_start = Instant::now(); + let max_wait = Duration::from_millis(150); + let mut found_frame = None; + + while wait_start.elapsed() < max_wait { + tokio::select! { + _ = stop_rx.changed() => break 'playback, + Some(prefetched) = prefetch_rx.recv() => { + if prefetched.frame_number == frame_number { + found_frame = Some(prefetched); + break; + } else if prefetched.frame_number >= self.start_frame_number { + prefetch_buffer.push_back(prefetched); + } + } + _ = tokio::time::sleep(Duration::from_millis(5)) => { + let still_in_flight = main_in_flight + .read() + .map(|guard| guard.contains(&frame_number)) + .unwrap_or(false); + if !still_in_flight { + break; + } + } + } + } + + if let Some(prefetched) = found_frame { + Some(( + Arc::new(prefetched.segment_frames), + prefetched.segment_index, + )) + } else { + let prefetched_idx = prefetch_buffer + .iter() + .position(|p| p.frame_number == frame_number); + if let Some(idx) = prefetched_idx { + let prefetched = prefetch_buffer.remove(idx).unwrap(); + Some(( + Arc::new(prefetched.segment_frames), + prefetched.segment_index, + )) + } else { + frame_number = frame_number.saturating_add(1); + _total_frames_skipped += 1; + continue; + } + } + } else if prefetch_buffer.is_empty() && total_frames_rendered < 15 { + let _ = frame_request_tx.send(frame_number); + + let wait_result = tokio::time::timeout( + Duration::from_millis(100), + prefetch_rx.recv(), + ) + .await; + + if let Ok(Some(prefetched)) = wait_result { + if prefetched.frame_number == frame_number { + Some(( + Arc::new(prefetched.segment_frames), + prefetched.segment_index, + )) + } else { + prefetch_buffer.push_back(prefetched); + frame_number = frame_number.saturating_add(1); + _total_frames_skipped += 1; + continue; + } + } else { + frame_number = frame_number.saturating_add(1); + _total_frames_skipped += 1; + continue; + } + } else { + let Some((segment_time, segment)) = + cached_project.get_segment_time(playback_time) + else { + break; + }; + + let Some(segment_media) = + self.segment_medias.get(segment.recording_clip as usize) + else { + frame_number = frame_number.saturating_add(1); + continue; + }; + + let clip_offsets = cached_project + .clips + .iter() + .find(|v| v.index == segment.recording_clip) + .map(|v| v.offsets) + .unwrap_or_default(); + + if let Ok(mut guard) = main_in_flight.write() { + guard.insert(frame_number); + } + + let max_wait = Duration::from_millis(150); + let data = tokio::select! { + _ = stop_rx.changed() => { + if let Ok(mut guard) = main_in_flight.write() { + guard.remove(&frame_number); + } + break 'playback + }, + _ = tokio::time::sleep(max_wait) => { + if let Ok(mut guard) = main_in_flight.write() { + guard.remove(&frame_number); + } + frame_number = frame_number.saturating_add(1); + _total_frames_skipped += 1; + continue; + }, + data = segment_media + .decoders + .get_frames(segment_time as f32, !cached_project.camera.hide, clip_offsets) => { + if let Ok(mut guard) = main_in_flight.write() { + guard.remove(&frame_number); + } + data + }, + }; + + data.map(|frames| (Arc::new(frames), segment.recording_clip)) + } + } }; if let Some((segment_frames, segment_index)) = segment_frames_opt { @@ -277,9 +594,17 @@ impl Playback { continue; }; + if !was_cached { + frame_cache.insert( + frame_number, + Arc::clone(&segment_frames), + segment_index, + ); + } + let uniforms = ProjectUniforms::new( &self.render_constants, - &project, + &cached_project, frame_number, fps, resolution_base, @@ -289,36 +614,39 @@ impl Playback { self.renderer .render_frame( - segment_frames, + Arc::unwrap_or_clone(segment_frames), uniforms, segment_media.cursor.clone(), - frame_number, ) .await; + + total_frames_rendered += 1; } event_tx.send(PlaybackEvent::Frame(frame_number)).ok(); frame_number = frame_number.saturating_add(1); + let _ = playback_position_tx.send(frame_number); let expected_frame = self.start_frame_number + (start.elapsed().as_secs_f64() * fps_f64).floor() as u32; if frame_number < expected_frame { let frames_behind = expected_frame - frame_number; - if frames_behind <= max_frame_skip { - frame_number = expected_frame; - trace!("Skipping {} frames to catch up", frames_behind); - } else { - frame_number += max_frame_skip; - trace!( - "Limiting frame skip to {} (was {} behind)", - max_frame_skip, frames_behind - ); + + if frames_behind <= aggressive_skip_threshold { + continue; } - prefetch_buffer.retain(|p| p.frame_number >= frame_number); - let _ = frame_request_tx.send(frame_number); + let skipped = (frames_behind / 2).min(fps / 4); + if skipped > 0 { + frame_number += skipped; + _total_frames_skipped += skipped as u64; + + prefetch_buffer.retain(|p| p.frame_number >= frame_number); + let _ = frame_request_tx.send(frame_number); + let _ = playback_position_tx.send(frame_number); + } } } diff --git a/crates/enc-mediafoundation/src/video/h264.rs b/crates/enc-mediafoundation/src/video/h264.rs index 08c52fb4ee..4fae2c1890 100644 --- a/crates/enc-mediafoundation/src/video/h264.rs +++ b/crates/enc-mediafoundation/src/video/h264.rs @@ -213,7 +213,7 @@ impl H264Encoder { transform .ProcessMessage( MFT_MESSAGE_SET_D3D_MANAGER, - std::mem::transmute::<_, usize>(temp), + std::mem::transmute::(temp), ) .map_err(NewVideoEncoderError::EncoderTransform)?; }; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 2e80be962e..4b187f7c90 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -5,7 +5,6 @@ use cap_editor::SegmentMedia; use cap_project::{ProjectConfiguration, RecordingMeta, StudioRecordingMeta}; use cap_rendering::{ProjectRecordingsMeta, RenderVideoConstants}; use std::{path::PathBuf, sync::Arc}; -use tracing::error; #[derive(thiserror::Error, Debug)] pub enum ExportError { diff --git a/crates/recording/examples/encoding-benchmark.rs b/crates/recording/examples/encoding-benchmark.rs index 456a45d3ca..8169e30ff9 100644 --- a/crates/recording/examples/encoding-benchmark.rs +++ b/crates/recording/examples/encoding-benchmark.rs @@ -139,8 +139,6 @@ fn run_synthetic_benchmark( let pipeline_latency = encode_start.elapsed() + conversion_duration; metrics.record_frame_encoded(encode_duration, pipeline_latency); } - - frame_sequence += 1; } let drain_deadline = Instant::now() + Duration::from_secs(5); diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index b652c09af1..73ed99a91f 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -575,9 +575,11 @@ async fn setup_camera( if buffer_ready { let _ = unsafe { buffer.SetCurrentLength(data_len as u32) }; + #[allow(clippy::arc_with_non_send_sync)] + let buffer = std::sync::Arc::new(std::sync::Mutex::new(buffer)); let _ = native_recipient .tell(NewNativeFrame(NativeCameraFrame { - buffer: std::sync::Arc::new(std::sync::Mutex::new(buffer)), + buffer, pixel_format: frame.native().pixel_format, width: frame.native().width as u32, height: frame.native().height as u32, diff --git a/crates/recording/src/output_pipeline/mod.rs b/crates/recording/src/output_pipeline/mod.rs index f141fefe51..458b20d909 100644 --- a/crates/recording/src/output_pipeline/mod.rs +++ b/crates/recording/src/output_pipeline/mod.rs @@ -1,11 +1,13 @@ mod async_camera; mod core; pub mod ffmpeg; +#[cfg(target_os = "macos")] mod fragmented; pub use async_camera::*; pub use core::*; pub use ffmpeg::*; +#[cfg(target_os = "macos")] pub use fragmented::*; #[cfg(target_os = "macos")] diff --git a/crates/recording/src/output_pipeline/win.rs b/crates/recording/src/output_pipeline/win.rs index c06076bfe8..419cc7159c 100644 --- a/crates/recording/src/output_pipeline/win.rs +++ b/crates/recording/src/output_pipeline/win.rs @@ -282,7 +282,7 @@ impl Muxer for WindowsMuxer { let mut output = output.lock().unwrap(); let _ = muxer - .write_sample(&output_sample, &mut *output) + .write_sample(&output_sample, &mut output) .map_err(|e| format!("WriteSample: {e}")); Ok(()) diff --git a/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index dac11d84a3..da5560e4c5 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -82,21 +82,30 @@ impl RecoveryManager { }; if let Some(studio_meta) = meta.studio_meta() - && matches!( - studio_meta.status(), - StudioRecordingStatus::InProgress - | StudioRecordingStatus::NeedsRemux - | StudioRecordingStatus::Failed { .. } - ) - && let Some(incomplete_recording) = Self::analyze_incomplete(&path, &meta) + && Self::should_check_for_recovery(&studio_meta.status()) { - incomplete.push(incomplete_recording); + match Self::analyze_incomplete(&path, &meta) { + Some(incomplete_recording) => { + incomplete.push(incomplete_recording); + } + None => { + Self::mark_unrecoverable(&path, &meta); + } + } } } incomplete } + fn should_check_for_recovery(status: &StudioRecordingStatus) -> bool { + match status { + StudioRecordingStatus::InProgress | StudioRecordingStatus::NeedsRemux => true, + StudioRecordingStatus::Failed { error } => error != "No recoverable segments found", + StudioRecordingStatus::Complete => false, + } + } + fn analyze_incomplete( project_path: &Path, meta: &RecordingMeta, @@ -455,14 +464,12 @@ impl RecoveryManager { } Ok(false) => { return Err(RecoveryError::UnplayableVideo(format!( - "Display video has no decodable frames: {:?}", - display_output + "Display video has no decodable frames: {display_output:?}" ))); } Err(e) => { return Err(RecoveryError::UnplayableVideo(format!( - "Display video validation failed for {:?}: {}", - display_output, e + "Display video validation failed for {display_output:?}: {e}" ))); } } @@ -521,7 +528,8 @@ impl RecoveryManager { .recoverable_segments .iter() .map(|seg| { - let segment_base = format!("content/segments/segment-{}", seg.index); + let segment_index = seg.index; + let segment_base = format!("content/segments/segment-{segment_index}"); let segment_dir = recording.project_path.join(&segment_base); let display_path = segment_dir.join("display.mp4"); @@ -534,13 +542,13 @@ impl RecoveryManager { MultipleSegment { display: VideoMeta { - path: RelativePathBuf::from(format!("{}/display.mp4", segment_base)), + path: RelativePathBuf::from(format!("{segment_base}/display.mp4")), fps, start_time: None, }, camera: if camera_path.exists() { Some(VideoMeta { - path: RelativePathBuf::from(format!("{}/camera.mp4", segment_base)), + path: RelativePathBuf::from(format!("{segment_base}/camera.mp4")), fps: 30, start_time: None, }) @@ -549,10 +557,7 @@ impl RecoveryManager { }, mic: if mic_path.exists() { Some(AudioMeta { - path: RelativePathBuf::from(format!( - "{}/audio-input.ogg", - segment_base - )), + path: RelativePathBuf::from(format!("{segment_base}/audio-input.ogg")), start_time: None, }) } else { @@ -560,20 +565,14 @@ impl RecoveryManager { }, system_audio: if system_audio_path.exists() { Some(AudioMeta { - path: RelativePathBuf::from(format!( - "{}/system_audio.ogg", - segment_base - )), + path: RelativePathBuf::from(format!("{segment_base}/system_audio.ogg")), start_time: None, }) } else { None }, cursor: if cursor_path.exists() { - Some(RelativePathBuf::from(format!( - "{}/cursor.json", - segment_base - ))) + Some(RelativePathBuf::from(format!("{segment_base}/cursor.json"))) } else { None }, @@ -605,7 +604,7 @@ impl RecoveryManager { .iter() .enumerate() .filter_map(|(i, segment)| { - let segment_base = format!("content/segments/segment-{}", i); + let segment_base = format!("content/segments/segment-{i}"); let display_path = recording .project_path .join(&segment_base) @@ -738,4 +737,32 @@ impl RecoveryManager { Ok(()) } + + fn mark_unrecoverable(project_path: &Path, meta: &RecordingMeta) { + let mut updated_meta = meta.clone(); + + let status_updated = match &mut updated_meta.inner { + RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments { inner, .. }) => { + inner.status = Some(StudioRecordingStatus::Failed { + error: "No recoverable segments found".to_string(), + }); + true + } + _ => false, + }; + + if status_updated { + if let Err(e) = updated_meta.save_for_project() { + warn!( + "Failed to mark recording as unrecoverable at {:?}: {}", + project_path, e + ); + } else { + info!( + "Marked recording as unrecoverable (no recoverable segments): {:?}", + project_path + ); + } + } + } } diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index f711f5eaaa..68a11b3bfc 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -350,7 +350,7 @@ fn capture_bitmap_with( return Err(unsupported_error()); } - let mut info = BITMAPINFO { + let info = BITMAPINFO { bmiHeader: BITMAPINFOHEADER { biSize: std::mem::size_of::() as u32, biWidth: width, diff --git a/crates/recording/src/sources/audio_mixer.rs b/crates/recording/src/sources/audio_mixer.rs index ee312c971e..5157aa1374 100644 --- a/crates/recording/src/sources/audio_mixer.rs +++ b/crates/recording/src/sources/audio_mixer.rs @@ -251,7 +251,8 @@ impl AudioMixer { let buffer_last_elapsed = buffer_last_timestamp.duration_since(self.timestamps); if timestamp_elapsed > buffer_last_elapsed { - let elapsed_since_last_frame = timestamp_elapsed - buffer_last_elapsed; + let elapsed_since_last_frame = + timestamp_elapsed.saturating_sub(buffer_last_elapsed); if let Some(diff) = elapsed_since_last_frame.checked_sub(buffer_last_duration) @@ -342,7 +343,8 @@ impl AudioMixer { frame.set_rate(source.info.rate() as u32); - let timestamp = start_timestamp + (elapsed_since_start - remaining); + let timestamp = + start_timestamp + elapsed_since_start.saturating_sub(remaining); source.buffer_last = Some((timestamp, frame_duration)); source.buffer.push_front(AudioFrame::new(frame, timestamp)); diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index 536507b7e4..da0a4b90b3 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -305,25 +305,26 @@ impl ScreenCaptureConfig { let target_refresh = validated_refresh_rate(display.refresh_rate()); let fps = std::cmp::max(1, std::cmp::min(max_fps, target_refresh)); - let output_size: PhysicalSize = crop_bounds - .and_then(|b| -> Option { - #[cfg(target_os = "macos")] - { + let output_size: PhysicalSize = { + #[cfg(target_os = "macos")] + { + crop_bounds.and_then(|b| { let logical_size = b.size(); let scale = display.raw_handle().scale()?; Some(PhysicalSize::new( logical_size.width() * scale, logical_size.height() * scale, )) - } + }) + } - #[cfg(target_os = "windows")] - { - Some(b.size().map(|v| (v / 2.0).floor() * 2.0)) - } - }) - .or_else(|| display.physical_size()) - .ok_or(ScreenCaptureInitError::NoBounds)?; + #[cfg(target_os = "windows")] + { + crop_bounds.map(|b| b.size().map(|v| (v / 2.0).floor() * 2.0)) + } + } + .or_else(|| display.physical_size()) + .ok_or(ScreenCaptureInitError::NoBounds)?; Ok(Self { config: Config { diff --git a/crates/rendering/Cargo.toml b/crates/rendering/Cargo.toml index 4ee460611d..95ea663cd3 100644 --- a/crates/rendering/Cargo.toml +++ b/crates/rendering/Cargo.toml @@ -36,6 +36,11 @@ workspace-hack = { version = "0.1", path = "../workspace-hack" } [target.'cfg(target_os = "macos")'.dependencies] cidre.workspace = true +metal = "0.31" +objc2 = "0.6" +wgpu-hal = { workspace = true, features = ["metal"] } +wgpu-core.workspace = true +foreign-types = "0.5" [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index 8d2eaae2fd..e4742b1159 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -6,6 +6,7 @@ use crate::create_shader_render_pipeline; pub struct CompositeVideoFramePipeline { pub bind_group_layout: wgpu::BindGroupLayout, pub render_pipeline: wgpu::RenderPipeline, + sampler: wgpu::Sampler, } #[derive(Debug, Clone, Copy, Pod, Zeroable)] @@ -98,9 +99,20 @@ impl CompositeVideoFramePipeline { include_wgsl!("shaders/composite-video-frame.wgsl"), ); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + Self { bind_group_layout, render_pipeline, + sampler, } } @@ -144,38 +156,24 @@ impl CompositeVideoFramePipeline { uniforms: &wgpu::Buffer, frame: &wgpu::TextureView, ) -> wgpu::BindGroup { - let sampler = device.create_sampler( - &(wgpu::SamplerDescriptor { - address_mode_u: wgpu::AddressMode::ClampToEdge, - address_mode_v: wgpu::AddressMode::ClampToEdge, - address_mode_w: wgpu::AddressMode::ClampToEdge, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - mipmap_filter: wgpu::FilterMode::Linear, - ..Default::default() - }), - ); - - device.create_bind_group( - &(wgpu::BindGroupDescriptor { - layout: &self.bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: uniforms.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(frame), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&sampler), - }, - ], - label: Some("bind_group"), - }), - ) + device.create_bind_group(&wgpu::BindGroupDescriptor { + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniforms.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(frame), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + label: Some("bind_group"), + }) } pub fn create_frame_texture(device: &wgpu::Device, width: u32, height: u32) -> wgpu::Texture { diff --git a/crates/rendering/src/decoder/avassetreader.rs b/crates/rendering/src/decoder/avassetreader.rs index a36dd4e086..0503c474be 100644 --- a/crates/rendering/src/decoder/avassetreader.rs +++ b/crates/rendering/src/decoder/avassetreader.rs @@ -6,62 +6,81 @@ use std::{ sync::{Arc, mpsc}, }; +use tracing::debug; + use cidre::{ arc::R, cv::{self, pixel_buffer::LockFlags}, }; -use ffmpeg::{Rational, format, frame}; +use ffmpeg::{Rational, format}; use tokio::{runtime::Handle as TokioHandle, sync::oneshot}; -use crate::DecodedFrame; +use crate::{DecodedFrame, PixelFormat}; -use super::frame_converter::{FrameConverter, copy_rgba_plane}; +use super::frame_converter::copy_rgba_plane; use super::{FRAME_CACHE_SIZE, VideoDecoderMessage, pts_to_frame}; #[derive(Clone)] struct ProcessedFrame { - number: u32, + _number: u32, data: Arc>, width: u32, height: u32, + format: PixelFormat, + y_stride: u32, + uv_stride: u32, + image_buf: Option>, } -#[derive(Clone)] -enum CachedFrame { - Raw { - image_buf: R, - number: u32, - }, - Processed(ProcessedFrame), +impl ProcessedFrame { + fn to_decoded_frame(&self) -> DecodedFrame { + match self.format { + PixelFormat::Rgba => DecodedFrame::new((*self.data).clone(), self.width, self.height), + PixelFormat::Nv12 => { + if let Some(image_buf) = &self.image_buf { + DecodedFrame::new_nv12_with_iosurface( + (*self.data).clone(), + self.width, + self.height, + self.y_stride, + self.uv_stride, + image_buf.retained(), + ) + } else { + DecodedFrame::new_nv12( + (*self.data).clone(), + self.width, + self.height, + self.y_stride, + self.uv_stride, + ) + } + } + PixelFormat::Yuv420p => DecodedFrame::new_yuv420p( + (*self.data).clone(), + self.width, + self.height, + self.y_stride, + self.uv_stride, + ), + } + } } -struct ImageBufProcessor { - converter: FrameConverter, - scratch_frame: frame::Video, - scratch_spec: Option<(format::Pixel, u32, u32)>, -} +#[derive(Clone)] +struct CachedFrame(ProcessedFrame); + +struct ImageBufProcessor; impl ImageBufProcessor { fn new() -> Self { - Self { - converter: FrameConverter::new(), - scratch_frame: frame::Video::empty(), - scratch_spec: None, - } + Self } - fn convert(&mut self, image_buf: &mut R) -> Vec { - let format = + fn extract_raw(&self, image_buf: &mut R) -> (Vec, PixelFormat, u32, u32) { + let pixel_format = cap_video_decode::avassetreader::pixel_format_to_pixel(image_buf.pixel_format()); - if matches!(format, format::Pixel::RGBA) { - return self.copy_rgba(image_buf); - } - - let width = image_buf.width() as u32; - let height = image_buf.height() as u32; - self.ensure_scratch(format, width, height); - unsafe { image_buf .lock_base_addr(LockFlags::READ_ONLY) @@ -69,102 +88,131 @@ impl ImageBufProcessor { .unwrap(); } - self.copy_planes(image_buf); + let result = match pixel_format { + format::Pixel::RGBA => { + let bytes_per_row = image_buf.plane_bytes_per_row(0); + let width = image_buf.width(); + let height = image_buf.height(); + + let slice = unsafe { + std::slice::from_raw_parts::<'static, _>( + image_buf.plane_base_address(0), + bytes_per_row * height, + ) + }; - unsafe { image_buf.unlock_lock_base_addr(LockFlags::READ_ONLY) }; + let bytes = copy_rgba_plane(slice, bytes_per_row, width, height); + (bytes, PixelFormat::Rgba, width as u32 * 4, 0) + } + format::Pixel::NV12 => { + let y_stride = image_buf.plane_bytes_per_row(0); + let uv_stride = image_buf.plane_bytes_per_row(1); + let y_height = image_buf.plane_height(0); + let uv_height = image_buf.plane_height(1); + + let y_size = y_stride * y_height; + let uv_size = uv_stride * uv_height; + + let y_slice = unsafe { + std::slice::from_raw_parts::<'static, _>( + image_buf.plane_base_address(0), + y_size, + ) + }; - self.converter.convert(&mut self.scratch_frame) - } + let uv_slice = unsafe { + std::slice::from_raw_parts::<'static, _>( + image_buf.plane_base_address(1), + uv_size, + ) + }; - fn ensure_scratch(&mut self, format: format::Pixel, width: u32, height: u32) { - let needs_new = - self.scratch_spec - .is_none_or(|(current_format, current_width, current_height)| { - current_format != format || current_width != width || current_height != height - }); + let mut data = Vec::with_capacity(y_size + uv_size); + data.extend_from_slice(y_slice); + data.extend_from_slice(uv_slice); - if needs_new { - self.scratch_frame = frame::Video::new(format, width, height); - self.scratch_spec = Some((format, width, height)); - } - } + (data, PixelFormat::Nv12, y_stride as u32, uv_stride as u32) + } + format::Pixel::YUV420P => { + let y_stride = image_buf.plane_bytes_per_row(0); + let u_stride = image_buf.plane_bytes_per_row(1); + let v_stride = image_buf.plane_bytes_per_row(2); + let y_height = image_buf.plane_height(0); + let uv_height = image_buf.plane_height(1); + + let y_size = y_stride * y_height; + let u_size = u_stride * uv_height; + let v_size = v_stride * uv_height; + + let y_slice = unsafe { + std::slice::from_raw_parts::<'static, _>( + image_buf.plane_base_address(0), + y_size, + ) + }; - fn copy_rgba(&mut self, image_buf: &mut R) -> Vec { - unsafe { - image_buf - .lock_base_addr(LockFlags::READ_ONLY) - .result() - .unwrap(); - } + let u_slice = unsafe { + std::slice::from_raw_parts::<'static, _>( + image_buf.plane_base_address(1), + u_size, + ) + }; + + let v_slice = unsafe { + std::slice::from_raw_parts::<'static, _>( + image_buf.plane_base_address(2), + v_size, + ) + }; - let bytes_per_row = image_buf.plane_bytes_per_row(0); - let width = image_buf.width(); - let height = image_buf.height(); + let mut data = Vec::with_capacity(y_size + u_size + v_size); + data.extend_from_slice(y_slice); + data.extend_from_slice(u_slice); + data.extend_from_slice(v_slice); - let slice = unsafe { - std::slice::from_raw_parts::<'static, _>( - image_buf.plane_base_address(0), - bytes_per_row * height, - ) + (data, PixelFormat::Yuv420p, y_stride as u32, u_stride as u32) + } + _ => { + let width = image_buf.width(); + let height = image_buf.height(); + let black_frame = vec![0u8; width * height * 4]; + (black_frame, PixelFormat::Rgba, width as u32 * 4, 0) + } }; - let bytes = copy_rgba_plane(slice, bytes_per_row, width, height); - unsafe { image_buf.unlock_lock_base_addr(LockFlags::READ_ONLY) }; - bytes - } - - fn copy_planes(&mut self, image_buf: &mut R) { - match self.scratch_frame.format() { - format::Pixel::NV12 | format::Pixel::YUV420P => { - let scratch = &mut self.scratch_frame; - for plane_i in 0..image_buf.plane_count() { - let bytes_per_row = image_buf.plane_bytes_per_row(plane_i); - let height = image_buf.plane_height(plane_i); - - let ffmpeg_stride = scratch.stride(plane_i); - let row_length = bytes_per_row.min(ffmpeg_stride); - - let slice = unsafe { - std::slice::from_raw_parts::<'static, _>( - image_buf.plane_base_address(plane_i), - bytes_per_row * height, - ) - }; - - for i in 0..height { - scratch.data_mut(plane_i) - [i * ffmpeg_stride..(i * ffmpeg_stride + row_length)] - .copy_from_slice( - &slice[i * bytes_per_row..(i * bytes_per_row + row_length)], - ); - } - } - } - format => todo!("implement {:?}", format), - } + result } } impl CachedFrame { - fn process(&mut self, processor: &mut ImageBufProcessor) -> ProcessedFrame { - match self { - CachedFrame::Raw { image_buf, number } => { - let frame_buffer = processor.convert(image_buf); - let data = ProcessedFrame { - number: *number, - data: Arc::new(frame_buffer), - width: image_buf.width() as u32, - height: image_buf.height() as u32, - }; - - *self = Self::Processed(data.clone()); + fn new(processor: &ImageBufProcessor, mut image_buf: R, number: u32) -> Self { + let width = image_buf.width() as u32; + let height = image_buf.height() as u32; + let (data, format, y_stride, uv_stride) = processor.extract_raw(&mut image_buf); + + let retain_iosurface = format == PixelFormat::Nv12 && image_buf.io_surf().is_some(); + + let frame = ProcessedFrame { + _number: number, + data: Arc::new(data), + width, + height, + format, + y_stride, + uv_stride, + image_buf: if retain_iosurface { + Some(image_buf) + } else { + None + }, + }; + Self(frame) + } - data - } - CachedFrame::Processed(data) => data.clone(), - } + fn data(&self) -> &ProcessedFrame { + &self.0 } } @@ -226,52 +274,77 @@ impl AVAssetReaderDecoder { let last_sent_frame = Rc::new(RefCell::new(None::)); let mut frames = this.inner.frames(); - let mut processor = ImageBufProcessor::new(); + let processor = ImageBufProcessor::new(); while let Ok(r) = rx.recv() { match r { VideoDecoderMessage::GetFrame(requested_time, sender) => { + if sender.is_closed() { + continue; + } + let requested_frame = (requested_time * fps as f32).floor() as u32; - let mut sender = if let Some(cached) = cache.get_mut(&requested_frame) { - let data = cached.process(&mut processor); + const BACKWARD_SEEK_TOLERANCE: u32 = 120; + let cache_frame_min_early = cache.keys().next().copied(); + let cache_frame_max_early = cache.keys().next_back().copied(); - let _ = sender.send(DecodedFrame { - data: data.data.clone(), - width: data.width, - height: data.height, - }); + if let (Some(c_min), Some(_c_max)) = + (cache_frame_min_early, cache_frame_max_early) + { + let is_backward_within_tolerance = requested_frame < c_min + && requested_frame + BACKWARD_SEEK_TOLERANCE >= c_min; + if is_backward_within_tolerance + && let Some(closest_frame) = cache.get(&c_min) + { + let data = closest_frame.data().clone(); + if sender.send(data.to_decoded_frame()).is_err() { + debug!("frame receiver dropped before send"); + } + *last_sent_frame.borrow_mut() = Some(data); + continue; + } + } + + let mut sender = if let Some(cached) = cache.get(&requested_frame) { + let data = cached.data().clone(); + if sender.send(data.to_decoded_frame()).is_err() { + debug!("frame receiver dropped before send"); + } *last_sent_frame.borrow_mut() = Some(data); continue; } else { let last_sent_frame = last_sent_frame.clone(); Some(move |data: ProcessedFrame| { *last_sent_frame.borrow_mut() = Some(data.clone()); - let _ = sender.send(DecodedFrame { - data: data.data.clone(), - width: data.width, - height: data.height, - }); + if sender.send(data.to_decoded_frame()).is_err() { + debug!("frame receiver dropped before send"); + } }) }; let cache_min = requested_frame.saturating_sub(FRAME_CACHE_SIZE as u32 / 2); let cache_max = requested_frame + FRAME_CACHE_SIZE as u32 / 2; - if requested_frame == 0 - || last_sent_frame - .borrow() - .as_ref() - .map(|last| { - requested_frame < last.number - || requested_frame - last.number > FRAME_CACHE_SIZE as u32 - }) - .unwrap_or(true) - { + let cache_frame_min = cache.keys().next().copied(); + let cache_frame_max = cache.keys().next_back().copied(); + + let needs_reset = + if let (Some(c_min), Some(c_max)) = (cache_frame_min, cache_frame_max) { + let is_backward_seek_beyond_tolerance = + requested_frame + BACKWARD_SEEK_TOLERANCE < c_min; + let is_forward_seek_beyond_cache = + requested_frame > c_max + FRAME_CACHE_SIZE as u32 / 4; + is_backward_seek_beyond_tolerance || is_forward_seek_beyond_cache + } else { + true + }; + + if needs_reset { this.reset(requested_time); frames = this.inner.frames(); *last_sent_frame.borrow_mut() = None; - cache.clear(); + cache.retain(|&f, _| f >= cache_min && f <= cache_max); } last_active_frame = Some(requested_frame); @@ -293,37 +366,22 @@ impl AVAssetReaderDecoder { continue; }; - let mut cache_frame = CachedFrame::Raw { - image_buf: frame.retained(), - number: current_frame, - }; + let cache_frame = + CachedFrame::new(&processor, frame.retained(), current_frame); this.is_done = false; - // Handles frame skips. - // We use the cache instead of last_sent_frame as newer non-matching frames could have been decoded. if let Some(most_recent_prev_frame) = - cache.iter_mut().rev().find(|v| *v.0 < requested_frame) + cache.iter().rev().find(|v| *v.0 < requested_frame) && let Some(sender) = sender.take() { - (sender)(most_recent_prev_frame.1.process(&mut processor)); + (sender)(most_recent_prev_frame.1.data().clone()); } let exceeds_cache_bounds = current_frame > cache_max; let too_small_for_cache_bounds = current_frame < cache_min; if !too_small_for_cache_bounds { - if current_frame == requested_frame - && let Some(sender) = sender.take() - { - let data = cache_frame.process(&mut processor); - // info!("sending frame {requested_frame}"); - - (sender)(data); - - break; - } - if cache.len() >= FRAME_CACHE_SIZE { if let Some(last_active_frame) = &last_active_frame { let frame = if requested_frame > *last_active_frame { @@ -344,6 +402,13 @@ impl AVAssetReaderDecoder { } cache.insert(current_frame, cache_frame.clone()); + + if current_frame == requested_frame + && let Some(sender) = sender.take() + { + (sender)(cache_frame.data().clone()); + break; + } } if current_frame > requested_frame && sender.is_some() { @@ -360,11 +425,7 @@ impl AVAssetReaderDecoder { (sender)(last_sent_frame); } else if let Some(sender) = sender.take() { - // info!( - // "sending forward frame {current_frame} for {requested_frame}", - // ); - - (sender)(cache_frame.process(&mut processor)); + (sender)(cache_frame.data().clone()); } } @@ -382,16 +443,17 @@ impl AVAssetReaderDecoder { if let Some(last_sent_frame) = last_sent_frame { (sender)(last_sent_frame); } else { - tracing::debug!( - "No frames available for request {requested_frame}, sending black frame" - ); let black_frame_data = vec![0u8; (video_width * video_height * 4) as usize]; let black_frame = ProcessedFrame { - number: requested_frame, + _number: requested_frame, data: Arc::new(black_frame_data), width: video_width, height: video_height, + format: PixelFormat::Rgba, + y_stride: video_width * 4, + uv_stride: 0, + image_buf: None, }; (sender)(black_frame); } diff --git a/crates/rendering/src/decoder/ffmpeg.rs b/crates/rendering/src/decoder/ffmpeg.rs index 52cc65af5c..6a16856959 100644 --- a/crates/rendering/src/decoder/ffmpeg.rs +++ b/crates/rendering/src/decoder/ffmpeg.rs @@ -1,4 +1,4 @@ -use ffmpeg::{frame, sys::AVHWDeviceType}; +use ffmpeg::{format, frame, sys::AVHWDeviceType}; use log::debug; use std::{ cell::RefCell, @@ -9,7 +9,7 @@ use std::{ }; use tokio::sync::oneshot; -use crate::DecodedFrame; +use crate::{DecodedFrame, PixelFormat}; use super::{FRAME_CACHE_SIZE, VideoDecoderMessage, frame_converter::FrameConverter, pts_to_frame}; @@ -19,18 +19,98 @@ struct ProcessedFrame { data: Arc>, width: u32, height: u32, + format: PixelFormat, + y_stride: u32, + uv_stride: u32, +} + +impl ProcessedFrame { + fn to_decoded_frame(&self) -> DecodedFrame { + match self.format { + PixelFormat::Rgba => DecodedFrame::new((*self.data).clone(), self.width, self.height), + PixelFormat::Nv12 => DecodedFrame::new_nv12( + (*self.data).clone(), + self.width, + self.height, + self.y_stride, + self.uv_stride, + ), + PixelFormat::Yuv420p => DecodedFrame::new_yuv420p( + (*self.data).clone(), + self.width, + self.height, + self.y_stride, + self.uv_stride, + ), + } + } +} + +fn extract_yuv_planes(frame: &frame::Video) -> Option<(Vec, PixelFormat, u32, u32)> { + let height = frame.height(); + + match frame.format() { + format::Pixel::YUV420P => { + let y_stride = frame.stride(0) as u32; + let u_stride = frame.stride(1) as u32; + let v_stride = frame.stride(2) as u32; + + let y_size = (y_stride * height) as usize; + let uv_height = height / 2; + let u_size = (u_stride * uv_height) as usize; + let v_size = (v_stride * uv_height) as usize; + + let mut data = Vec::with_capacity(y_size + u_size + v_size); + data.extend_from_slice(&frame.data(0)[..y_size]); + data.extend_from_slice(&frame.data(1)[..u_size]); + data.extend_from_slice(&frame.data(2)[..v_size]); + + Some((data, PixelFormat::Yuv420p, y_stride, u_stride)) + } + format::Pixel::NV12 => { + let y_stride = frame.stride(0) as u32; + let uv_stride = frame.stride(1) as u32; + + let y_size = (y_stride * height) as usize; + let uv_size = (uv_stride * (height / 2)) as usize; + + let mut data = Vec::with_capacity(y_size + uv_size); + data.extend_from_slice(&frame.data(0)[..y_size]); + data.extend_from_slice(&frame.data(1)[..uv_size]); + + Some((data, PixelFormat::Nv12, y_stride, uv_stride)) + } + _ => None, + } } impl CachedFrame { fn process(&mut self, converter: &mut FrameConverter) -> ProcessedFrame { match self { Self::Raw { frame, number } => { - let frame_buffer = converter.convert(frame); - let data = ProcessedFrame { - data: Arc::new(frame_buffer), - number: *number, - width: frame.width(), - height: frame.height(), + let data = if let Some((yuv_data, pixel_format, y_stride, uv_stride)) = + extract_yuv_planes(frame) + { + ProcessedFrame { + data: Arc::new(yuv_data), + number: *number, + width: frame.width(), + height: frame.height(), + format: pixel_format, + y_stride, + uv_stride, + } + } else { + let frame_buffer = converter.convert(frame); + ProcessedFrame { + data: Arc::new(frame_buffer), + number: *number, + width: frame.width(), + height: frame.height(), + format: PixelFormat::Rgba, + y_stride: frame.width() * 4, + uv_stride: 0, + } }; *self = Self::Processed(data.clone()); @@ -100,6 +180,10 @@ impl FfmpegDecoder { while let Ok(r) = rx.recv() { match r { VideoDecoderMessage::GetFrame(requested_time, sender) => { + if sender.is_closed() { + continue; + } + let requested_frame = (requested_time * fps as f32).floor() as u32; // sender.send(black_frame.clone()).ok(); // continue; @@ -107,22 +191,23 @@ impl FfmpegDecoder { let mut sender = if let Some(cached) = cache.get_mut(&requested_frame) { let data = cached.process(&mut converter); - let _ = sender.send(DecodedFrame { - data: data.data.clone(), - width: data.width, - height: data.height, - }); + if sender.send(data.to_decoded_frame()).is_err() { + log::warn!( + "Failed to send cached frame {requested_frame}: receiver dropped" + ); + } *last_sent_frame.borrow_mut() = Some(data); continue; } else { let last_sent_frame = last_sent_frame.clone(); Some(move |data: ProcessedFrame| { + let frame_number = data.number; *last_sent_frame.borrow_mut() = Some(data.clone()); - let _ = sender.send(DecodedFrame { - data: data.data.clone(), - width: data.width, - height: data.height, - }); + if sender.send(data.to_decoded_frame()).is_err() { + log::warn!( + "Failed to send decoded frame {frame_number}: receiver dropped" + ); + } }) }; @@ -257,6 +342,9 @@ impl FfmpegDecoder { data: Arc::new(black_frame_data), width: video_width, height: video_height, + format: PixelFormat::Rgba, + y_stride: video_width * 4, + uv_stride: 0, }; (sender)(black_frame); } diff --git a/crates/rendering/src/decoder/mod.rs b/crates/rendering/src/decoder/mod.rs index 61f48e5968..c0ebef38a1 100644 --- a/crates/rendering/src/decoder/mod.rs +++ b/crates/rendering/src/decoder/mod.rs @@ -1,19 +1,69 @@ use ::ffmpeg::Rational; use std::{ + fmt, path::PathBuf, sync::{Arc, mpsc}, }; use tokio::sync::oneshot; +use tracing::debug; #[cfg(target_os = "macos")] mod avassetreader; mod ffmpeg; mod frame_converter; +#[cfg(target_os = "macos")] +use cidre::{arc::R, cv}; + +#[cfg(target_os = "macos")] +pub struct SendableImageBuf(R); + +#[cfg(target_os = "macos")] +unsafe impl Send for SendableImageBuf {} +#[cfg(target_os = "macos")] +unsafe impl Sync for SendableImageBuf {} + +#[cfg(target_os = "macos")] +impl SendableImageBuf { + pub fn new(image_buf: R) -> Self { + Self(image_buf) + } + + pub fn inner(&self) -> &cv::ImageBuf { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PixelFormat { + Rgba, + Nv12, + Yuv420p, +} + +#[derive(Clone)] pub struct DecodedFrame { data: Arc>, width: u32, height: u32, + format: PixelFormat, + y_stride: u32, + uv_stride: u32, + #[cfg(target_os = "macos")] + iosurface_backing: Option>, +} + +impl fmt::Debug for DecodedFrame { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DecodedFrame") + .field("data_len", &self.data.len()) + .field("width", &self.width) + .field("height", &self.height) + .field("format", &self.format) + .field("y_stride", &self.y_stride) + .field("uv_stride", &self.uv_stride) + .finish() + } } impl DecodedFrame { @@ -22,9 +72,71 @@ impl DecodedFrame { data: Arc::new(data), width, height, + format: PixelFormat::Rgba, + y_stride: width * 4, + uv_stride: 0, + #[cfg(target_os = "macos")] + iosurface_backing: None, + } + } + + pub fn new_nv12(data: Vec, width: u32, height: u32, y_stride: u32, uv_stride: u32) -> Self { + Self { + data: Arc::new(data), + width, + height, + format: PixelFormat::Nv12, + y_stride, + uv_stride, + #[cfg(target_os = "macos")] + iosurface_backing: None, } } + pub fn new_yuv420p( + data: Vec, + width: u32, + height: u32, + y_stride: u32, + uv_stride: u32, + ) -> Self { + Self { + data: Arc::new(data), + width, + height, + format: PixelFormat::Yuv420p, + y_stride, + uv_stride, + #[cfg(target_os = "macos")] + iosurface_backing: None, + } + } + + #[cfg(target_os = "macos")] + pub fn new_nv12_with_iosurface( + data: Vec, + width: u32, + height: u32, + y_stride: u32, + uv_stride: u32, + image_buf: R, + ) -> Self { + Self { + data: Arc::new(data), + width, + height, + format: PixelFormat::Nv12, + y_stride, + uv_stride, + iosurface_backing: Some(Arc::new(SendableImageBuf::new(image_buf))), + } + } + + #[cfg(target_os = "macos")] + pub fn iosurface_backing(&self) -> Option<&cv::ImageBuf> { + self.iosurface_backing.as_ref().map(|b| b.inner()) + } + pub fn data(&self) -> &[u8] { &self.data } @@ -36,6 +148,80 @@ impl DecodedFrame { pub fn height(&self) -> u32 { self.height } + + pub fn format(&self) -> PixelFormat { + self.format + } + + pub fn y_plane(&self) -> Option<&[u8]> { + match self.format { + PixelFormat::Nv12 | PixelFormat::Yuv420p => { + let y_size = self + .y_stride + .checked_mul(self.height) + .and_then(|v| usize::try_from(v).ok())?; + self.data.get(..y_size) + } + PixelFormat::Rgba => None, + } + } + + pub fn uv_plane(&self) -> Option<&[u8]> { + match self.format { + PixelFormat::Nv12 => { + let y_size = self + .y_stride + .checked_mul(self.height) + .and_then(|v| usize::try_from(v).ok())?; + self.data.get(y_size..) + } + PixelFormat::Yuv420p | PixelFormat::Rgba => None, + } + } + + pub fn u_plane(&self) -> Option<&[u8]> { + match self.format { + PixelFormat::Yuv420p => { + let y_size = self + .y_stride + .checked_mul(self.height) + .and_then(|v| usize::try_from(v).ok())?; + let u_size = self + .uv_stride + .checked_mul(self.height / 2) + .and_then(|v| usize::try_from(v).ok())?; + let u_end = y_size.checked_add(u_size)?; + self.data.get(y_size..u_end) + } + _ => None, + } + } + + pub fn v_plane(&self) -> Option<&[u8]> { + match self.format { + PixelFormat::Yuv420p => { + let y_size = self + .y_stride + .checked_mul(self.height) + .and_then(|v| usize::try_from(v).ok())?; + let u_size = self + .uv_stride + .checked_mul(self.height / 2) + .and_then(|v| usize::try_from(v).ok())?; + let v_start = y_size.checked_add(u_size)?; + self.data.get(v_start..) + } + _ => None, + } + } + + pub fn y_stride(&self) -> u32 { + self.y_stride + } + + pub fn uv_stride(&self) -> u32 { + self.uv_stride + } } pub enum VideoDecoderMessage { @@ -47,7 +233,7 @@ pub fn pts_to_frame(pts: i64, time_base: Rational, fps: u32) -> u32 { .round() as u32 } -pub const FRAME_CACHE_SIZE: usize = 500; +pub const FRAME_CACHE_SIZE: usize = 750; #[derive(Clone)] pub struct AsyncVideoDecoderHandle { @@ -58,9 +244,17 @@ pub struct AsyncVideoDecoderHandle { impl AsyncVideoDecoderHandle { pub async fn get_frame(&self, time: f32) -> Option { let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send(VideoDecoderMessage::GetFrame(self.get_time(time), tx)) - .unwrap(); + let adjusted_time = self.get_time(time); + + if self + .sender + .send(VideoDecoderMessage::GetFrame(adjusted_time, tx)) + .is_err() + { + debug!("Decoder channel closed, receiver dropped"); + return None; + } + rx.await.ok() } diff --git a/crates/rendering/src/frame_pipeline.rs b/crates/rendering/src/frame_pipeline.rs index 2c17424e23..bbeeef8c3f 100644 --- a/crates/rendering/src/frame_pipeline.rs +++ b/crates/rendering/src/frame_pipeline.rs @@ -1,6 +1,279 @@ +use std::sync::Arc; +use tokio::sync::oneshot; use wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; -use crate::{ProjectUniforms, RenderSession, RenderingError}; +use crate::{ProjectUniforms, RenderingError}; + +pub struct PendingReadback { + rx: oneshot::Receiver>, + buffer: Arc, + padded_bytes_per_row: u32, + width: u32, + height: u32, +} + +impl PendingReadback { + pub async fn wait(mut self, device: &wgpu::Device) -> Result { + let mut poll_count = 0u32; + + loop { + match self.rx.try_recv() { + Ok(result) => { + result?; + break; + } + Err(oneshot::error::TryRecvError::Empty) => { + match device.poll(wgpu::PollType::Poll) { + Ok(maintained) => { + if maintained.is_queue_empty() { + break; + } + } + Err(e) => return Err(e.into()), + } + poll_count += 1; + if poll_count.is_multiple_of(3) { + tokio::task::yield_now().await; + } + } + Err(oneshot::error::TryRecvError::Closed) => { + return Err(RenderingError::BufferMapWaitingFailed); + } + } + } + + let buffer_slice = self.buffer.slice(..); + let data = buffer_slice.get_mapped_range(); + let data_vec = data.to_vec(); + + drop(data); + self.buffer.unmap(); + + Ok(RenderedFrame { + data: data_vec, + padded_bytes_per_row: self.padded_bytes_per_row, + width: self.width, + height: self.height, + }) + } +} + +pub struct PipelinedGpuReadback { + buffers: [Arc; 3], + buffer_size: u64, + current_index: usize, + pending: Option, +} + +impl PipelinedGpuReadback { + pub fn new(device: &wgpu::Device, initial_size: u64) -> Self { + let make_buffer = || { + Arc::new(device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Pipelined Readback Buffer"), + size: initial_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + })) + }; + + Self { + buffers: [make_buffer(), make_buffer(), make_buffer()], + buffer_size: initial_size, + current_index: 0, + pending: None, + } + } + + pub fn ensure_size(&mut self, device: &wgpu::Device, required_size: u64) { + if self.buffer_size < required_size { + let make_buffer = || { + Arc::new(device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Pipelined Readback Buffer"), + size: required_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + })) + }; + + self.buffers = [make_buffer(), make_buffer(), make_buffer()]; + self.buffer_size = required_size; + self.current_index = 0; + } + } + + fn next_buffer(&mut self) -> Arc { + let buffer = self.buffers[self.current_index].clone(); + self.current_index = (self.current_index + 1) % self.buffers.len(); + buffer + } + + pub fn submit_readback( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + uniforms: &ProjectUniforms, + mut render_encoder: wgpu::CommandEncoder, + ) -> Result<(), RenderingError> { + let padded_bytes_per_row = padded_bytes_per_row(uniforms.output_size); + let output_buffer_size = (padded_bytes_per_row * uniforms.output_size.1) as u64; + + self.ensure_size(device, output_buffer_size); + let buffer = self.next_buffer(); + + let output_texture_size = wgpu::Extent3d { + width: uniforms.output_size.0, + height: uniforms.output_size.1, + depth_or_array_layers: 1, + }; + + render_encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(uniforms.output_size.1), + }, + }, + output_texture_size, + ); + + queue.submit(std::iter::once(render_encoder.finish())); + + let (tx, rx) = oneshot::channel(); + buffer + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + if let Err(e) = tx.send(result) { + tracing::error!("Failed to send map_async result: {:?}", e); + } + }); + + self.pending = Some(PendingReadback { + rx, + buffer, + padded_bytes_per_row, + width: uniforms.output_size.0, + height: uniforms.output_size.1, + }); + + Ok(()) + } + + pub fn take_pending(&mut self) -> Option { + self.pending.take() + } + + pub fn has_pending(&self) -> bool { + self.pending.is_some() + } +} + +pub struct RenderSession { + pub textures: (wgpu::Texture, wgpu::Texture), + texture_views: (wgpu::TextureView, wgpu::TextureView), + pub current_is_left: bool, + pub pipelined_readback: PipelinedGpuReadback, +} + +impl RenderSession { + pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let make_texture = || { + device.create_texture(&wgpu::TextureDescriptor { + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + label: Some("Intermediate Texture"), + view_formats: &[], + }) + }; + + let textures = (make_texture(), make_texture()); + let padded = padded_bytes_per_row((width, height)); + let initial_buffer_size = (padded * height) as u64; + + Self { + current_is_left: true, + texture_views: ( + textures.0.create_view(&Default::default()), + textures.1.create_view(&Default::default()), + ), + textures, + pipelined_readback: PipelinedGpuReadback::new(device, initial_buffer_size), + } + } + + pub fn update_texture_size(&mut self, device: &wgpu::Device, width: u32, height: u32) { + let make_texture = || { + device.create_texture(&wgpu::TextureDescriptor { + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + label: Some("Intermediate Texture"), + view_formats: &[], + }) + }; + + self.textures = (make_texture(), make_texture()); + self.texture_views = ( + self.textures.0.create_view(&Default::default()), + self.textures.1.create_view(&Default::default()), + ); + } + + pub fn current_texture(&self) -> &wgpu::Texture { + if self.current_is_left { + &self.textures.0 + } else { + &self.textures.1 + } + } + + pub fn current_texture_view(&self) -> &wgpu::TextureView { + if self.current_is_left { + &self.texture_views.0 + } else { + &self.texture_views.1 + } + } + + pub fn other_texture_view(&self) -> &wgpu::TextureView { + if self.current_is_left { + &self.texture_views.1 + } else { + &self.texture_views.0 + } + } + + pub fn swap_textures(&mut self) { + self.current_is_left = !self.current_is_left; + } +} // pub struct FramePipelineState<'a> { // pub constants: &'a RenderVideoConstants, @@ -68,69 +341,40 @@ pub async fn finish_encoder( uniforms: &ProjectUniforms, encoder: wgpu::CommandEncoder, ) -> Result { - let padded_bytes_per_row = padded_bytes_per_row(uniforms.output_size); - - queue.submit(std::iter::once(encoder.finish())); + let previous_pending = session.pipelined_readback.take_pending(); - let output_texture_size = wgpu::Extent3d { - width: uniforms.output_size.0, - height: uniforms.output_size.1, - depth_or_array_layers: 1, + let texture = if session.current_is_left { + &session.textures.0 + } else { + &session.textures.1 }; - let output_buffer_size = (padded_bytes_per_row * uniforms.output_size.1) as u64; - session.ensure_readback_buffers(device, output_buffer_size); - let output_buffer = session.current_readback_buffer(); - - let mut encoder = device.create_command_encoder( - &(wgpu::CommandEncoderDescriptor { - label: Some("Copy Encoder"), - }), - ); - - encoder.copy_texture_to_buffer( - wgpu::TexelCopyTextureInfo { - texture: session.current_texture(), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyBufferInfo { - buffer: output_buffer, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(padded_bytes_per_row), - rows_per_image: Some(uniforms.output_size.1), - }, - }, - output_texture_size, - ); + session + .pipelined_readback + .submit_readback(device, queue, texture, uniforms, encoder)?; - queue.submit(std::iter::once(encoder.finish())); + let result = if let Some(pending) = previous_pending { + pending.wait(device).await? + } else { + let pending = session + .pipelined_readback + .take_pending() + .expect("just submitted a readback"); + let frame = pending.wait(device).await?; - let buffer_slice = output_buffer.slice(..); - let (tx, rx) = tokio::sync::oneshot::channel(); - buffer_slice.map_async(wgpu::MapMode::Read, move |result| { - let _ = tx.send(result); - }); + let prime_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Pipeline Priming Encoder"), + }); + session.pipelined_readback.submit_readback( + device, + queue, + texture, + uniforms, + prime_encoder, + )?; - device.poll(wgpu::PollType::Wait)?; - - rx.await - .map_err(|_| RenderingError::BufferMapWaitingFailed)??; - - let data = buffer_slice.get_mapped_range(); - let data_vec = data.to_vec(); - - drop(data); - output_buffer.unmap(); - - session.swap_readback_buffers(); + frame + }; - Ok(RenderedFrame { - data: data_vec, - padded_bytes_per_row, - width: uniforms.output_size.0, - height: uniforms.output_size.1, - }) + Ok(result) } diff --git a/crates/rendering/src/iosurface_texture.rs b/crates/rendering/src/iosurface_texture.rs new file mode 100644 index 0000000000..4403146da5 --- /dev/null +++ b/crates/rendering/src/iosurface_texture.rs @@ -0,0 +1,190 @@ +#[cfg(target_os = "macos")] +use cidre::{arc::R, cv, io, mtl}; + +#[cfg(target_os = "macos")] +use foreign_types::ForeignType; + +#[cfg(target_os = "macos")] +use wgpu_hal::api::Metal as MtlApi; + +#[derive(Debug)] +pub enum IOSurfaceTextureError { + NoIOSurface, + NoMetalDevice, + TextureCreationFailed, + WgpuImportFailed(String), + UnsupportedFormat, +} + +impl std::fmt::Display for IOSurfaceTextureError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoIOSurface => write!(f, "CVPixelBuffer has no IOSurface backing"), + Self::NoMetalDevice => write!(f, "Failed to get Metal device"), + Self::TextureCreationFailed => { + write!(f, "Failed to create Metal texture from IOSurface") + } + Self::WgpuImportFailed(e) => write!(f, "Failed to import texture to wgpu: {e}"), + Self::UnsupportedFormat => write!(f, "Unsupported pixel format for zero-copy"), + } + } +} + +impl std::error::Error for IOSurfaceTextureError {} + +#[cfg(target_os = "macos")] +pub struct IOSurfaceTextureCache { + metal_device: R, +} + +#[cfg(target_os = "macos")] +impl IOSurfaceTextureCache { + pub fn new() -> Option { + let metal_device = mtl::Device::sys_default()?; + Some(Self { metal_device }) + } + + pub fn create_y_texture( + &self, + io_surface: &io::Surf, + width: u32, + height: u32, + ) -> Result, IOSurfaceTextureError> { + let mut desc = mtl::TextureDesc::new_2d( + mtl::PixelFormat::R8UNorm, + width as usize, + height as usize, + false, + ); + desc.set_storage_mode(mtl::StorageMode::Shared); + desc.set_usage(mtl::TextureUsage::SHADER_READ); + + self.metal_device + .new_texture_with_surf(&desc, io_surface, 0) + .ok_or(IOSurfaceTextureError::TextureCreationFailed) + } + + pub fn create_uv_texture( + &self, + io_surface: &io::Surf, + width: u32, + height: u32, + ) -> Result, IOSurfaceTextureError> { + let mut desc = mtl::TextureDesc::new_2d( + mtl::PixelFormat::Rg8UNorm, + (width / 2) as usize, + (height / 2) as usize, + false, + ); + desc.set_storage_mode(mtl::StorageMode::Shared); + desc.set_usage(mtl::TextureUsage::SHADER_READ); + + self.metal_device + .new_texture_with_surf(&desc, io_surface, 1) + .ok_or(IOSurfaceTextureError::TextureCreationFailed) + } + + pub fn create_rgba_texture( + &self, + io_surface: &io::Surf, + width: u32, + height: u32, + ) -> Result, IOSurfaceTextureError> { + let mut desc = mtl::TextureDesc::new_2d( + mtl::PixelFormat::Rgba8UNorm, + width as usize, + height as usize, + false, + ); + desc.set_storage_mode(mtl::StorageMode::Shared); + desc.set_usage(mtl::TextureUsage::SHADER_READ); + + self.metal_device + .new_texture_with_surf(&desc, io_surface, 0) + .ok_or(IOSurfaceTextureError::TextureCreationFailed) + } +} + +#[cfg(target_os = "macos")] +pub struct IOSurfaceYuvTextures { + pub y_texture: R, + pub uv_texture: R, + pub width: u32, + pub height: u32, +} + +#[cfg(target_os = "macos")] +impl IOSurfaceYuvTextures { + pub fn from_cv_image_buf( + cache: &IOSurfaceTextureCache, + image_buf: &cv::ImageBuf, + ) -> Result { + let io_surface = image_buf + .io_surf() + .ok_or(IOSurfaceTextureError::NoIOSurface)?; + + let width = image_buf.width() as u32; + let height = image_buf.height() as u32; + + let y_texture = cache.create_y_texture(io_surface, width, height)?; + let uv_texture = cache.create_uv_texture(io_surface, width, height)?; + + Ok(Self { + y_texture, + uv_texture, + width, + height, + }) + } +} + +#[cfg(target_os = "macos")] +pub fn import_metal_texture_to_wgpu( + device: &wgpu::Device, + metal_texture: &mtl::Texture, + format: wgpu::TextureFormat, + width: u32, + height: u32, + label: Option<&str>, +) -> Result { + let desc = wgpu::TextureDescriptor { + label, + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }; + + let raw_ptr = metal_texture as *const mtl::Texture as *mut std::ffi::c_void; + + let metal_texture_owned = unsafe { + let ptr = raw_ptr as *mut objc2::runtime::AnyObject; + objc2::ffi::objc_retain(ptr); + metal::Texture::from_ptr(raw_ptr as *mut metal::MTLTexture) + }; + + let hal_texture = unsafe { + wgpu_hal::metal::Device::texture_from_raw( + metal_texture_owned, + format, + metal::MTLTextureType::D2, + 1, + 1, + wgpu_hal::CopyExtent { + width, + height, + depth: 1, + }, + ) + }; + + let texture = unsafe { device.create_texture_from_hal::(hal_texture, &desc) }; + Ok(texture) +} diff --git a/crates/rendering/src/layers/camera.rs b/crates/rendering/src/layers/camera.rs index ce5f99b550..85ec8a4bab 100644 --- a/crates/rendering/src/layers/camera.rs +++ b/crates/rendering/src/layers/camera.rs @@ -2,22 +2,28 @@ use cap_project::XY; use wgpu::util::DeviceExt; use crate::{ - CompositeVideoFrameUniforms, DecodedFrame, composite_frame::CompositeVideoFramePipeline, + CompositeVideoFrameUniforms, DecodedFrame, PixelFormat, + composite_frame::CompositeVideoFramePipeline, yuv_converter::YuvToRgbaConverter, }; pub struct CameraLayer { - frame_texture: wgpu::Texture, - frame_texture_view: wgpu::TextureView, + frame_textures: [wgpu::Texture; 2], + frame_texture_views: [wgpu::TextureView; 2], + current_texture: usize, uniforms_buffer: wgpu::Buffer, - bind_group: Option, + bind_groups: [Option; 2], pipeline: CompositeVideoFramePipeline, hidden: bool, + last_frame_ptr: usize, + yuv_converter: YuvToRgbaConverter, } impl CameraLayer { pub fn new(device: &wgpu::Device) -> Self { - let frame_texture = CompositeVideoFramePipeline::create_frame_texture(device, 1920, 1080); - let frame_texture_view = frame_texture.create_view(&Default::default()); + let frame_texture_0 = CompositeVideoFramePipeline::create_frame_texture(device, 1920, 1080); + let frame_texture_1 = CompositeVideoFramePipeline::create_frame_texture(device, 1920, 1080); + let frame_texture_view_0 = frame_texture_0.create_view(&Default::default()); + let frame_texture_view_1 = frame_texture_1.create_view(&Default::default()); let pipeline = CompositeVideoFramePipeline::new(device); @@ -29,15 +35,23 @@ impl CameraLayer { }), ); - let bind_group = Some(pipeline.bind_group(device, &uniforms_buffer, &frame_texture_view)); + let bind_group_0 = + Some(pipeline.bind_group(device, &uniforms_buffer, &frame_texture_view_0)); + let bind_group_1 = + Some(pipeline.bind_group(device, &uniforms_buffer, &frame_texture_view_1)); + + let yuv_converter = YuvToRgbaConverter::new(device); Self { - frame_texture, - frame_texture_view, + frame_textures: [frame_texture_0, frame_texture_1], + frame_texture_views: [frame_texture_view_0, frame_texture_view_1], + current_texture: 0, uniforms_buffer, - bind_group, + bind_groups: [bind_group_0, bind_group_1], pipeline, hidden: false, + last_frame_ptr: 0, + yuv_converter, } } @@ -53,48 +67,149 @@ impl CameraLayer { return; }; - if self.frame_texture.width() != frame_size.x || self.frame_texture.height() != frame_size.y - { - self.frame_texture = CompositeVideoFramePipeline::create_frame_texture( - device, - frame_size.x, - frame_size.y, - ); - self.frame_texture_view = self.frame_texture.create_view(&Default::default()); + let frame_data = camera_frame.data(); + let frame_ptr = frame_data.as_ptr() as usize; + let format = camera_frame.format(); - self.bind_group = Some(self.pipeline.bind_group( - device, - &self.uniforms_buffer, - &self.frame_texture_view, - )); - } + if frame_ptr != self.last_frame_ptr { + let next_texture = 1 - self.current_texture; - queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &self.frame_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - camera_frame.data(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(frame_size.x * 4), - rows_per_image: None, - }, - wgpu::Extent3d { - width: frame_size.x, - height: frame_size.y, - depth_or_array_layers: 1, - }, - ); + if self.frame_textures[next_texture].width() != frame_size.x + || self.frame_textures[next_texture].height() != frame_size.y + { + self.frame_textures[next_texture] = + CompositeVideoFramePipeline::create_frame_texture( + device, + frame_size.x, + frame_size.y, + ); + self.frame_texture_views[next_texture] = + self.frame_textures[next_texture].create_view(&Default::default()); + + self.bind_groups[next_texture] = Some(self.pipeline.bind_group( + device, + &self.uniforms_buffer, + &self.frame_texture_views[next_texture], + )); + } + + match format { + PixelFormat::Rgba => { + let src_bytes_per_row = frame_size.x * 4; + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.frame_textures[next_texture], + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + frame_data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(src_bytes_per_row), + rows_per_image: Some(frame_size.y), + }, + wgpu::Extent3d { + width: frame_size.x, + height: frame_size.y, + depth_or_array_layers: 1, + }, + ); + } + PixelFormat::Nv12 => { + if let (Some(y_data), Some(uv_data)) = + (camera_frame.y_plane(), camera_frame.uv_plane()) + && self + .yuv_converter + .convert_nv12( + device, + queue, + y_data, + uv_data, + frame_size.x, + frame_size.y, + camera_frame.y_stride(), + camera_frame.uv_stride(), + ) + .is_ok() + { + self.copy_from_yuv_output(device, queue, next_texture, frame_size); + } + } + PixelFormat::Yuv420p => { + if let (Some(y_data), Some(u_data), Some(v_data)) = ( + camera_frame.y_plane(), + camera_frame.u_plane(), + camera_frame.v_plane(), + ) && self + .yuv_converter + .convert_yuv420p( + device, + queue, + y_data, + u_data, + v_data, + frame_size.x, + frame_size.y, + camera_frame.y_stride(), + camera_frame.uv_stride(), + ) + .is_ok() + { + self.copy_from_yuv_output(device, queue, next_texture, frame_size); + } + } + } + + self.last_frame_ptr = frame_ptr; + self.current_texture = next_texture; + } queue.write_buffer(&self.uniforms_buffer, 0, bytemuck::cast_slice(&[uniforms])); } + fn copy_from_yuv_output( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + next_texture: usize, + frame_size: XY, + ) { + if let Some(output_texture) = self.yuv_converter.output_texture() { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Camera YUV Copy Encoder"), + }); + + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: output_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &self.frame_textures[next_texture], + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: frame_size.x, + height: frame_size.y, + depth_or_array_layers: 1, + }, + ); + + let _submission_index = queue.submit(std::iter::once(encoder.finish())); + } + } + + pub fn copy_to_texture(&mut self, _encoder: &mut wgpu::CommandEncoder) {} + pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { if !self.hidden - && let Some(bind_group) = &self.bind_group + && let Some(bind_group) = &self.bind_groups[self.current_texture] { pass.set_pipeline(&self.pipeline.render_pipeline); pass.set_bind_group(0, bind_group, &[]); diff --git a/crates/rendering/src/layers/display.rs b/crates/rendering/src/layers/display.rs index 6ad8f7f2ac..f6aee5362e 100644 --- a/crates/rendering/src/layers/display.rs +++ b/crates/rendering/src/layers/display.rs @@ -1,33 +1,55 @@ use cap_project::XY; use crate::{ - DecodedSegmentFrames, + DecodedSegmentFrames, PixelFormat, composite_frame::{CompositeVideoFramePipeline, CompositeVideoFrameUniforms}, + yuv_converter::YuvToRgbaConverter, }; +struct PendingTextureCopy { + width: u32, + height: u32, + dst_texture_index: usize, +} + pub struct DisplayLayer { - frame_texture: wgpu::Texture, - frame_texture_view: wgpu::TextureView, + frame_textures: [wgpu::Texture; 2], + frame_texture_views: [wgpu::TextureView; 2], + current_texture: usize, uniforms_buffer: wgpu::Buffer, pipeline: CompositeVideoFramePipeline, - bind_group: Option, + bind_groups: [Option; 2], + last_recording_time: Option, + yuv_converter: YuvToRgbaConverter, + pending_copy: Option, } impl DisplayLayer { pub fn new(device: &wgpu::Device) -> Self { - let frame_texture = CompositeVideoFramePipeline::create_frame_texture(device, 1920, 1080); - let frame_texture_view = frame_texture.create_view(&Default::default()); + let frame_texture_0 = CompositeVideoFramePipeline::create_frame_texture(device, 1920, 1080); + let frame_texture_1 = CompositeVideoFramePipeline::create_frame_texture(device, 1920, 1080); + let frame_texture_view_0 = frame_texture_0.create_view(&Default::default()); + let frame_texture_view_1 = frame_texture_1.create_view(&Default::default()); let uniforms_buffer = CompositeVideoFrameUniforms::default().to_buffer(device); let pipeline = CompositeVideoFramePipeline::new(device); - let bind_group = Some(pipeline.bind_group(device, &uniforms_buffer, &frame_texture_view)); + let bind_group_0 = + Some(pipeline.bind_group(device, &uniforms_buffer, &frame_texture_view_0)); + let bind_group_1 = + Some(pipeline.bind_group(device, &uniforms_buffer, &frame_texture_view_1)); + + let yuv_converter = YuvToRgbaConverter::new(device); Self { - frame_texture_view, - frame_texture, + frame_textures: [frame_texture_0, frame_texture_1], + frame_texture_views: [frame_texture_view_0, frame_texture_view_1], + current_texture: 0, uniforms_buffer, pipeline, - bind_group, + bind_groups: [bind_group_0, bind_group_1], + last_recording_time: None, + yuv_converter, + pending_copy: None, } } @@ -38,49 +60,264 @@ impl DisplayLayer { segment_frames: &DecodedSegmentFrames, frame_size: XY, uniforms: CompositeVideoFrameUniforms, - ) { - if self.frame_texture.width() != frame_size.x || self.frame_texture.height() != frame_size.y - { - self.frame_texture = CompositeVideoFramePipeline::create_frame_texture( - device, - frame_size.x, - frame_size.y, - ); - self.frame_texture_view = self.frame_texture.create_view(&Default::default()); - - self.bind_group = Some(self.pipeline.bind_group( - device, - &self.uniforms_buffer, - &self.frame_texture_view, - )); + ) -> (bool, u32, u32) { + self.pending_copy = None; + + let frame_data = segment_frames.screen_frame.data(); + let actual_width = segment_frames.screen_frame.width(); + let actual_height = segment_frames.screen_frame.height(); + let format = segment_frames.screen_frame.format(); + let current_recording_time = segment_frames.recording_time; + + let skipped = self + .last_recording_time + .is_some_and(|last| (last - current_recording_time).abs() < f32::EPSILON); + + if !skipped { + let next_texture = 1 - self.current_texture; + + if self.frame_textures[next_texture].width() != frame_size.x + || self.frame_textures[next_texture].height() != frame_size.y + { + self.frame_textures[next_texture] = + CompositeVideoFramePipeline::create_frame_texture( + device, + frame_size.x, + frame_size.y, + ); + self.frame_texture_views[next_texture] = + self.frame_textures[next_texture].create_view(&Default::default()); + + self.bind_groups[next_texture] = Some(self.pipeline.bind_group( + device, + &self.uniforms_buffer, + &self.frame_texture_views[next_texture], + )); + } + + let frame_uploaded = match format { + PixelFormat::Rgba => { + let src_bytes_per_row = frame_size.x * 4; + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.frame_textures[next_texture], + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + frame_data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(src_bytes_per_row), + rows_per_image: Some(frame_size.y), + }, + wgpu::Extent3d { + width: frame_size.x, + height: frame_size.y, + depth_or_array_layers: 1, + }, + ); + true + } + PixelFormat::Nv12 => { + let screen_frame = &segment_frames.screen_frame; + + #[cfg(target_os = "macos")] + let iosurface_result = screen_frame.iosurface_backing().map(|image_buf| { + self.yuv_converter + .convert_nv12_from_iosurface(device, queue, image_buf) + }); + + #[cfg(target_os = "macos")] + if let Some(Ok(_)) = iosurface_result { + if self.yuv_converter.output_texture().is_some() { + self.pending_copy = Some(PendingTextureCopy { + width: frame_size.x, + height: frame_size.y, + dst_texture_index: next_texture, + }); + true + } else { + false + } + } else if let (Some(y_data), Some(uv_data)) = + (screen_frame.y_plane(), screen_frame.uv_plane()) + { + let y_stride = screen_frame.y_stride(); + let uv_stride = screen_frame.uv_stride(); + let convert_result = self.yuv_converter.convert_nv12( + device, + queue, + y_data, + uv_data, + frame_size.x, + frame_size.y, + y_stride, + uv_stride, + ); + + match convert_result { + Ok(_) => { + if self.yuv_converter.output_texture().is_some() { + self.pending_copy = Some(PendingTextureCopy { + width: frame_size.x, + height: frame_size.y, + dst_texture_index: next_texture, + }); + true + } else { + tracing::debug!( + width = frame_size.x, + height = frame_size.y, + y_stride, + "NV12 conversion succeeded but output texture is None, skipping copy" + ); + false + } + } + Err(e) => { + tracing::debug!( + error = ?e, + width = frame_size.x, + height = frame_size.y, + y_stride, + "NV12 to RGBA conversion failed" + ); + false + } + } + } else { + false + } + + #[cfg(not(target_os = "macos"))] + if let (Some(y_data), Some(uv_data)) = + (screen_frame.y_plane(), screen_frame.uv_plane()) + { + let y_stride = screen_frame.y_stride(); + let uv_stride = screen_frame.uv_stride(); + let convert_result = self.yuv_converter.convert_nv12( + device, + queue, + y_data, + uv_data, + frame_size.x, + frame_size.y, + y_stride, + uv_stride, + ); + + match convert_result { + Ok(_) => { + if self.yuv_converter.output_texture().is_some() { + self.pending_copy = Some(PendingTextureCopy { + width: frame_size.x, + height: frame_size.y, + dst_texture_index: next_texture, + }); + true + } else { + tracing::debug!( + width = frame_size.x, + height = frame_size.y, + y_stride, + "NV12 conversion succeeded but output texture is None, skipping copy" + ); + false + } + } + Err(e) => { + tracing::debug!( + error = ?e, + width = frame_size.x, + height = frame_size.y, + y_stride, + "NV12 to RGBA conversion failed" + ); + false + } + } + } else { + false + } + } + PixelFormat::Yuv420p => { + let screen_frame = &segment_frames.screen_frame; + if let (Some(y_data), Some(u_data), Some(v_data)) = ( + screen_frame.y_plane(), + screen_frame.u_plane(), + screen_frame.v_plane(), + ) && self + .yuv_converter + .convert_yuv420p( + device, + queue, + y_data, + u_data, + v_data, + frame_size.x, + frame_size.y, + screen_frame.y_stride(), + screen_frame.uv_stride(), + ) + .is_ok() + && self.yuv_converter.output_texture().is_some() + { + self.pending_copy = Some(PendingTextureCopy { + width: frame_size.x, + height: frame_size.y, + dst_texture_index: next_texture, + }); + true + } else { + false + } + } + }; + + if frame_uploaded { + self.last_recording_time = Some(current_recording_time); + self.current_texture = next_texture; + } } - queue.write_texture( + uniforms.write_to_buffer(queue, &self.uniforms_buffer); + (skipped, actual_width, actual_height) + } + + pub fn copy_to_texture(&mut self, encoder: &mut wgpu::CommandEncoder) { + let Some(pending) = self.pending_copy.take() else { + return; + }; + + let Some(src_texture) = self.yuv_converter.output_texture() else { + return; + }; + + encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: &self.frame_texture, + texture: src_texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - segment_frames.screen_frame.data(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(frame_size.x * 4), - rows_per_image: None, + wgpu::TexelCopyTextureInfo { + texture: &self.frame_textures[pending.dst_texture_index], + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, }, wgpu::Extent3d { - width: frame_size.x, - height: frame_size.y, + width: pending.width, + height: pending.height, depth_or_array_layers: 1, }, ); - - // Update existing uniform buffer in place; bind group remains valid. - uniforms.write_to_buffer(queue, &self.uniforms_buffer); } pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { - if let Some(bind_group) = &self.bind_group { + if let Some(bind_group) = &self.bind_groups[self.current_texture] { pass.set_pipeline(&self.pipeline.render_pipeline); pass.set_bind_group(0, bind_group, &[]); pass.draw(0..4, 0..1); diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index f95158a90b..7aa34d19a4 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -7,7 +7,7 @@ use composite_frame::CompositeVideoFrameUniforms; use core::f64; use cursor_interpolation::{InterpolatedCursorPosition, interpolate_cursor}; use decoder::{AsyncVideoDecoderHandle, spawn_decoder}; -use frame_pipeline::finish_encoder; +use frame_pipeline::{RenderSession, finish_encoder}; use futures::FutureExt; use futures::future::OptionFuture; use layers::{ @@ -19,23 +19,25 @@ use spring_mass_damper::SpringMassDamperSimulationConfig; use std::{collections::HashMap, sync::Arc}; use std::{path::PathBuf, time::Instant}; use tokio::sync::mpsc; -use tracing::error; mod composite_frame; mod coord; mod cursor_interpolation; pub mod decoder; mod frame_pipeline; +#[cfg(target_os = "macos")] +pub mod iosurface_texture; mod layers; mod mask; mod project_recordings; mod scene; mod spring_mass_damper; mod text; +pub mod yuv_converter; mod zoom; pub use coord::*; -pub use decoder::DecodedFrame; +pub use decoder::{DecodedFrame, PixelFormat}; pub use frame_pipeline::RenderedFrame; pub use project_recordings::{ProjectRecordingsMeta, SegmentRecordings, Video}; @@ -198,9 +200,19 @@ impl RecordingSegmentDecoders { ) ); + let camera_frame = camera.flatten(); + + if needs_camera && camera_frame.is_none() { + tracing::debug!( + segment_time, + has_camera_decoder = self.camera.is_some(), + "camera frame missing" + ); + } + Some(DecodedSegmentFrames { screen_frame: screen?, - camera_frame: camera.flatten(), + camera_frame, segment_time, recording_time: segment_time + self.segment_offset as f32, }) @@ -1518,6 +1530,7 @@ impl ProjectUniforms { } } +#[derive(Clone)] pub struct DecodedSegmentFrames { pub screen_frame: DecodedFrame, pub camera_frame: Option, @@ -1676,7 +1689,7 @@ impl RendererLayers { } pub fn render( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, @@ -1702,6 +1715,10 @@ impl RendererLayers { }; } + self.display.copy_to_texture(encoder); + self.camera.copy_to_texture(encoder); + self.camera_only.copy_to_texture(encoder); + { let mut pass = render_pass!( session.current_texture_view(), @@ -1760,147 +1777,6 @@ impl RendererLayers { } } -pub struct RenderSession { - textures: (wgpu::Texture, wgpu::Texture), - texture_views: (wgpu::TextureView, wgpu::TextureView), - current_is_left: bool, - readback_buffers: (Option, Option), - readback_buffer_size: u64, - current_readback_is_left: bool, -} - -impl RenderSession { - pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { - let make_texture = || { - device.create_texture(&wgpu::TextureDescriptor { - size: wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::COPY_SRC, - label: Some("Intermediate Texture"), - view_formats: &[], - }) - }; - - let textures = (make_texture(), make_texture()); - - Self { - current_is_left: true, - texture_views: ( - textures.0.create_view(&Default::default()), - textures.1.create_view(&Default::default()), - ), - textures, - readback_buffers: (None, None), - readback_buffer_size: 0, - current_readback_is_left: true, - } - } - - pub fn update_texture_size(&mut self, device: &wgpu::Device, width: u32, height: u32) { - let make_texture = || { - device.create_texture(&wgpu::TextureDescriptor { - size: wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::COPY_SRC, - label: Some("Intermediate Texture"), - view_formats: &[], - }) - }; - - self.textures = (make_texture(), make_texture()); - self.texture_views = ( - self.textures.0.create_view(&Default::default()), - self.textures.1.create_view(&Default::default()), - ); - } - - pub fn current_texture(&self) -> &wgpu::Texture { - if self.current_is_left { - &self.textures.0 - } else { - &self.textures.1 - } - } - - pub fn current_texture_view(&self) -> &wgpu::TextureView { - if self.current_is_left { - &self.texture_views.0 - } else { - &self.texture_views.1 - } - } - - pub fn other_texture_view(&self) -> &wgpu::TextureView { - if self.current_is_left { - &self.texture_views.1 - } else { - &self.texture_views.0 - } - } - - pub fn swap_textures(&mut self) { - self.current_is_left = !self.current_is_left; - } - - pub(crate) fn ensure_readback_buffers(&mut self, device: &wgpu::Device, size: u64) { - let needs_new = self - .readback_buffers - .0 - .as_ref() - .is_none_or(|_| self.readback_buffer_size < size); - - if needs_new { - let make_buffer = || { - device.create_buffer(&wgpu::BufferDescriptor { - label: Some("RenderSession Readback Buffer"), - size, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }) - }; - - self.readback_buffers = (Some(make_buffer()), Some(make_buffer())); - self.readback_buffer_size = size; - } - } - - pub(crate) fn current_readback_buffer(&self) -> &wgpu::Buffer { - if self.current_readback_is_left { - self.readback_buffers - .0 - .as_ref() - .expect("readback buffer should be initialised") - } else { - self.readback_buffers - .1 - .as_ref() - .expect("readback buffer should be initialised") - } - } - - pub(crate) fn swap_readback_buffers(&mut self) { - self.current_readback_is_left = !self.current_readback_is_left; - } -} - async fn produce_frame( constants: &RenderVideoConstants, segment_frames: DecodedSegmentFrames, diff --git a/crates/rendering/src/shaders/nv12_to_rgba.wgsl b/crates/rendering/src/shaders/nv12_to_rgba.wgsl new file mode 100644 index 0000000000..e6265357d2 --- /dev/null +++ b/crates/rendering/src/shaders/nv12_to_rgba.wgsl @@ -0,0 +1,37 @@ +@group(0) @binding(0) var y_plane: texture_2d; +@group(0) @binding(1) var uv_plane: texture_2d; +@group(0) @binding(2) var output: texture_storage_2d; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) global_id: vec3) { + let coords = global_id.xy; + let dims = textureDimensions(output); + + if (coords.x >= dims.x || coords.y >= dims.y) { + return; + } + + let y_raw = textureLoad(y_plane, coords, 0).r; + + let uv_coords = coords / 2; + let uv_dims = textureDimensions(uv_plane); + let uv_clamped = min(uv_coords, uv_dims - vec2(1, 1)); + let uv_raw = textureLoad(uv_plane, uv_clamped, 0).rg; + + let y = (y_raw - 0.0625) * 1.164; + let u = uv_raw.r - 0.5; + let v = uv_raw.g - 0.5; + + let r = y + 1.596 * v; + let g = y - 0.391 * u - 0.813 * v; + let b = y + 2.018 * u; + + let color = vec4( + clamp(r, 0.0, 1.0), + clamp(g, 0.0, 1.0), + clamp(b, 0.0, 1.0), + 1.0 + ); + + textureStore(output, coords, color); +} diff --git a/crates/rendering/src/shaders/yuv420p_to_rgba.wgsl b/crates/rendering/src/shaders/yuv420p_to_rgba.wgsl new file mode 100644 index 0000000000..0d04d30fed --- /dev/null +++ b/crates/rendering/src/shaders/yuv420p_to_rgba.wgsl @@ -0,0 +1,41 @@ +@group(0) @binding(0) var y_plane: texture_2d; +@group(0) @binding(1) var u_plane: texture_2d; +@group(0) @binding(2) var v_plane: texture_2d; +@group(0) @binding(3) var output: texture_storage_2d; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) global_id: vec3) { + let coords = global_id.xy; + let dims = textureDimensions(output); + + if (coords.x >= dims.x || coords.y >= dims.y) { + return; + } + + let y_raw = textureLoad(y_plane, coords, 0).r; + + let uv_coords = coords / 2; + let u_dims = textureDimensions(u_plane); + let v_dims = textureDimensions(v_plane); + let u_clamped = min(uv_coords, u_dims - vec2(1, 1)); + let v_clamped = min(uv_coords, v_dims - vec2(1, 1)); + let u_raw = textureLoad(u_plane, u_clamped, 0).r; + let v_raw = textureLoad(v_plane, v_clamped, 0).r; + + let y = (y_raw - 0.0625) * 1.164; + let u = u_raw - 0.5; + let v = v_raw - 0.5; + + let r = y + 1.596 * v; + let g = y - 0.391 * u - 0.813 * v; + let b = y + 2.018 * u; + + let color = vec4( + clamp(r, 0.0, 1.0), + clamp(g, 0.0, 1.0), + clamp(b, 0.0, 1.0), + 1.0 + ); + + textureStore(output, coords, color); +} diff --git a/crates/rendering/src/yuv_converter.rs b/crates/rendering/src/yuv_converter.rs new file mode 100644 index 0000000000..cb5786bda1 --- /dev/null +++ b/crates/rendering/src/yuv_converter.rs @@ -0,0 +1,661 @@ +use crate::decoder::PixelFormat; + +#[cfg(target_os = "macos")] +use crate::iosurface_texture::{ + IOSurfaceTextureCache, IOSurfaceTextureError, import_metal_texture_to_wgpu, +}; + +#[cfg(target_os = "macos")] +use cidre::cv; + +#[derive(Debug, thiserror::Error)] +pub enum YuvConversionError { + #[error("{plane} plane size mismatch: expected {expected}, got {actual}")] + PlaneSizeMismatch { + plane: &'static str, + expected: usize, + actual: usize, + }, + #[cfg(target_os = "macos")] + #[error("IOSurface error: {0}")] + IOSurfaceError(#[from] IOSurfaceTextureError), +} + +fn upload_plane_with_stride( + queue: &wgpu::Queue, + texture: &wgpu::Texture, + data: &[u8], + width: u32, + height: u32, + stride: u32, + plane_name: &'static str, +) -> Result<(), YuvConversionError> { + let expected_data_size = (stride * height) as usize; + if data.len() < expected_data_size { + return Err(YuvConversionError::PlaneSizeMismatch { + plane: plane_name, + expected: expected_data_size, + actual: data.len(), + }); + } + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(stride), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + Ok(()) +} + +pub struct YuvToRgbaConverter { + nv12_pipeline: wgpu::ComputePipeline, + yuv420p_pipeline: wgpu::ComputePipeline, + nv12_bind_group_layout: wgpu::BindGroupLayout, + yuv420p_bind_group_layout: wgpu::BindGroupLayout, + y_texture: Option, + uv_texture: Option, + u_texture: Option, + v_texture: Option, + output_texture: Option, + _y_view: Option, + _uv_view: Option, + _u_view: Option, + _v_view: Option, + output_view: Option, + cached_width: u32, + cached_height: u32, + cached_format: Option, + #[cfg(target_os = "macos")] + iosurface_cache: Option, +} + +impl YuvToRgbaConverter { + pub fn new(device: &wgpu::Device) -> Self { + let nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("NV12 to RGBA Converter"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "shaders/nv12_to_rgba.wgsl" + ))), + }); + + let yuv420p_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("YUV420P to RGBA Converter"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "shaders/yuv420p_to_rgba.wgsl" + ))), + }); + + let nv12_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("NV12 Converter Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba8Unorm, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + + let yuv420p_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("YUV420P Converter Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: wgpu::TextureFormat::Rgba8Unorm, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + + let nv12_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("NV12 Converter Pipeline Layout"), + bind_group_layouts: &[&nv12_bind_group_layout], + push_constant_ranges: &[], + }); + + let yuv420p_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("YUV420P Converter Pipeline Layout"), + bind_group_layouts: &[&yuv420p_bind_group_layout], + push_constant_ranges: &[], + }); + + let nv12_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("NV12 Converter Pipeline"), + layout: Some(&nv12_pipeline_layout), + module: &nv12_shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + + let yuv420p_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("YUV420P Converter Pipeline"), + layout: Some(&yuv420p_pipeline_layout), + module: &yuv420p_shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + + Self { + nv12_pipeline, + yuv420p_pipeline, + nv12_bind_group_layout, + yuv420p_bind_group_layout, + y_texture: None, + uv_texture: None, + u_texture: None, + v_texture: None, + output_texture: None, + _y_view: None, + _uv_view: None, + _u_view: None, + _v_view: None, + output_view: None, + cached_width: 0, + cached_height: 0, + cached_format: None, + #[cfg(target_os = "macos")] + iosurface_cache: IOSurfaceTextureCache::new(), + } + } + + fn ensure_textures( + &mut self, + device: &wgpu::Device, + width: u32, + height: u32, + format: PixelFormat, + ) { + if self.cached_width == width + && self.cached_height == height + && self.cached_format == Some(format) + { + return; + } + + self.y_texture = Some(device.create_texture(&wgpu::TextureDescriptor { + label: Some("Y Plane Texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + })); + + match format { + PixelFormat::Nv12 => { + self.uv_texture = Some(device.create_texture(&wgpu::TextureDescriptor { + label: Some("UV Plane Texture"), + size: wgpu::Extent3d { + width: width / 2, + height: height / 2, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rg8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + })); + self.u_texture = None; + self.v_texture = None; + } + PixelFormat::Yuv420p => { + self.u_texture = Some(device.create_texture(&wgpu::TextureDescriptor { + label: Some("U Plane Texture"), + size: wgpu::Extent3d { + width: width / 2, + height: height / 2, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + })); + self.v_texture = Some(device.create_texture(&wgpu::TextureDescriptor { + label: Some("V Plane Texture"), + size: wgpu::Extent3d { + width: width / 2, + height: height / 2, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + })); + self.uv_texture = None; + } + PixelFormat::Rgba => {} + } + + self.output_texture = Some(device.create_texture(&wgpu::TextureDescriptor { + label: Some("RGBA Output Texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + })); + + self.output_view = Some( + self.output_texture + .as_ref() + .unwrap() + .create_view(&Default::default()), + ); + + self.cached_width = width; + self.cached_height = height; + self.cached_format = Some(format); + } + + #[allow(clippy::too_many_arguments)] + pub fn convert_nv12( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + y_data: &[u8], + uv_data: &[u8], + width: u32, + height: u32, + y_stride: u32, + uv_stride: u32, + ) -> Result<&wgpu::TextureView, YuvConversionError> { + self.ensure_textures(device, width, height, PixelFormat::Nv12); + + let y_texture = self.y_texture.as_ref().unwrap(); + let uv_texture = self.uv_texture.as_ref().unwrap(); + let output_texture = self.output_texture.as_ref().unwrap(); + + upload_plane_with_stride(queue, y_texture, y_data, width, height, y_stride, "Y")?; + + let half_height = height / 2; + let expected_uv_size = (uv_stride * half_height) as usize; + if uv_data.len() < expected_uv_size { + return Err(YuvConversionError::PlaneSizeMismatch { + plane: "UV", + expected: expected_uv_size, + actual: uv_data.len(), + }); + } + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: uv_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + uv_data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(uv_stride), + rows_per_image: Some(half_height), + }, + wgpu::Extent3d { + width: width / 2, + height: half_height, + depth_or_array_layers: 1, + }, + ); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("NV12 Converter Bind Group"), + layout: &self.nv12_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &y_texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &uv_texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &output_texture.create_view(&Default::default()), + ), + }, + ], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("NV12 Conversion Encoder"), + }); + + { + let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("NV12 Conversion Pass"), + ..Default::default() + }); + compute_pass.set_pipeline(&self.nv12_pipeline); + compute_pass.set_bind_group(0, &bind_group, &[]); + compute_pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } + + queue.submit(std::iter::once(encoder.finish())); + + Ok(self.output_view.as_ref().unwrap()) + } + + #[cfg(target_os = "macos")] + pub fn convert_nv12_from_iosurface( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + image_buf: &cv::ImageBuf, + ) -> Result<&wgpu::TextureView, YuvConversionError> { + let cache = self + .iosurface_cache + .as_ref() + .ok_or(IOSurfaceTextureError::NoMetalDevice)?; + + let io_surface = image_buf + .io_surf() + .ok_or(IOSurfaceTextureError::NoIOSurface)?; + + let width = image_buf.width() as u32; + let height = image_buf.height() as u32; + + let y_metal_texture = cache.create_y_texture(io_surface, width, height)?; + let uv_metal_texture = cache.create_uv_texture(io_surface, width, height)?; + + let y_wgpu_texture = import_metal_texture_to_wgpu( + device, + &y_metal_texture, + wgpu::TextureFormat::R8Unorm, + width, + height, + Some("IOSurface Y Plane"), + )?; + + let uv_wgpu_texture = import_metal_texture_to_wgpu( + device, + &uv_metal_texture, + wgpu::TextureFormat::Rg8Unorm, + width / 2, + height / 2, + Some("IOSurface UV Plane"), + )?; + + if self.cached_width != width + || self.cached_height != height + || self.cached_format != Some(PixelFormat::Nv12) + { + self.output_texture = Some(device.create_texture(&wgpu::TextureDescriptor { + label: Some("RGBA Output Texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + })); + + self.output_view = Some( + self.output_texture + .as_ref() + .unwrap() + .create_view(&Default::default()), + ); + + self.cached_width = width; + self.cached_height = height; + self.cached_format = Some(PixelFormat::Nv12); + } + + let output_texture = self.output_texture.as_ref().unwrap(); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("NV12 IOSurface Converter Bind Group"), + layout: &self.nv12_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &y_wgpu_texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &uv_wgpu_texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &output_texture.create_view(&Default::default()), + ), + }, + ], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("NV12 IOSurface Conversion Encoder"), + }); + + { + let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("NV12 IOSurface Conversion Pass"), + ..Default::default() + }); + compute_pass.set_pipeline(&self.nv12_pipeline); + compute_pass.set_bind_group(0, &bind_group, &[]); + compute_pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } + + queue.submit(std::iter::once(encoder.finish())); + + Ok(self.output_view.as_ref().unwrap()) + } + + #[allow(clippy::too_many_arguments)] + pub fn convert_yuv420p( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + y_data: &[u8], + u_data: &[u8], + v_data: &[u8], + width: u32, + height: u32, + y_stride: u32, + uv_stride: u32, + ) -> Result<&wgpu::TextureView, YuvConversionError> { + self.ensure_textures(device, width, height, PixelFormat::Yuv420p); + + let y_texture = self.y_texture.as_ref().unwrap(); + let u_texture = self.u_texture.as_ref().unwrap(); + let v_texture = self.v_texture.as_ref().unwrap(); + let output_texture = self.output_texture.as_ref().unwrap(); + + upload_plane_with_stride(queue, y_texture, y_data, width, height, y_stride, "Y")?; + + let half_width = width / 2; + let half_height = height / 2; + + upload_plane_with_stride( + queue, + u_texture, + u_data, + half_width, + half_height, + uv_stride, + "U", + )?; + upload_plane_with_stride( + queue, + v_texture, + v_data, + half_width, + half_height, + uv_stride, + "V", + )?; + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("YUV420P Converter Bind Group"), + layout: &self.yuv420p_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &y_texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + &u_texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &v_texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView( + &output_texture.create_view(&Default::default()), + ), + }, + ], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("YUV420P Conversion Encoder"), + }); + + { + let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("YUV420P Conversion Pass"), + ..Default::default() + }); + compute_pass.set_pipeline(&self.yuv420p_pipeline); + compute_pass.set_bind_group(0, &bind_group, &[]); + compute_pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } + + queue.submit(std::iter::once(encoder.finish())); + + Ok(self.output_view.as_ref().unwrap()) + } + + pub fn output_texture(&self) -> Option<&wgpu::Texture> { + self.output_texture.as_ref() + } +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 7b0ed42f28..39eca69fa4 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -89,9 +89,9 @@ pub fn ensure_unique_filename_with_attempts( loop { let numbered_filename = if extension.is_empty() { - format!("{} ({})", name_without_ext, counter) + format!("{name_without_ext} ({counter})") } else { - format!("{} ({}){}", name_without_ext, counter, &extension) + format!("{name_without_ext} ({counter}){extension}") }; let test_path = parent_dir.join(&numbered_filename); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96c04f15a2..acf6280246 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ importers: version: 0.14.10(solid-js@1.9.6) '@solidjs/start': specifier: ^1.1.3 - version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) + version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) '@tanstack/solid-query': specifier: ^5.51.21 version: 5.75.4(solid-js@1.9.6) @@ -173,6 +173,9 @@ importers: effect: specifier: ^3.18.4 version: 3.18.4 + lz4-wasm: + specifier: ^0.9.2 + version: 0.9.2 mp4box: specifier: ^0.5.2 version: 0.5.4 @@ -205,7 +208,7 @@ importers: version: 9.0.1 vinxi: specifier: ^0.5.6 - version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) webcodecs: specifier: ^0.1.0 version: 0.1.0 @@ -231,6 +234,9 @@ importers: '@types/uuid': specifier: ^9.0.8 version: 9.0.8 + '@webgpu/types': + specifier: ^0.1.44 + version: 0.1.68 cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -240,6 +246,12 @@ importers: vite: specifier: ^6.3.5 version: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite-plugin-top-level-await: + specifier: ^1.4.4 + version: 1.6.0(@swc/helpers@0.5.17)(rollup@4.40.2)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + vite-plugin-wasm: + specifier: ^3.4.1 + version: 3.5.0(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) vite-tsconfig-paths: specifier: ^5.0.1 version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) @@ -637,7 +649,7 @@ importers: version: 15.5.7(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.7(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.7(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-contentlayer2: specifier: ^0.5.3 version: 0.5.8(acorn@8.15.0)(contentlayer2@0.5.8(acorn@8.15.0)(esbuild@0.25.5))(esbuild@0.25.5)(next@15.5.7(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -700,7 +712,7 @@ importers: version: 2.6.0 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))) tldts: specifier: ^7.0.11 version: 7.0.11 @@ -761,7 +773,7 @@ importers: version: 10.4.21(postcss@8.5.3) babel-loader: specifier: ^10.0.0 - version: 10.0.0(@babel/core@7.27.1)(webpack@5.101.3(esbuild@0.25.5)) + version: 10.0.0(@babel/core@7.27.1)(webpack@5.101.3(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5)) eslint: specifier: ^9.30.1 version: 9.30.1(jiti@2.6.1) @@ -773,7 +785,7 @@ importers: version: 8.5.3 tailwindcss: specifier: ^3 - version: 3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) + version: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) typescript: specifier: ^5.8.3 version: 5.8.3 @@ -827,7 +839,7 @@ importers: version: 0.15.6(typescript@5.8.3) tsup: specifier: ^8.5.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.8.1) + version: 8.5.0(@swc/core@1.15.5(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.8.1) devDependencies: concurrently: specifier: ^9.2.1 @@ -840,13 +852,13 @@ importers: dependencies: '@pulumi/github': specifier: ^6.7.0 - version: 6.7.2(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + version: 6.7.2(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) '@pulumi/pulumi': specifier: ^3.201.0 - version: 3.201.0(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + version: 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) '@pulumiverse/vercel': specifier: ^1.14.3 - version: 1.14.3(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + version: 1.14.3(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) zod: specifier: ^3 version: 3.25.76 @@ -902,7 +914,7 @@ importers: version: 4.6.2(eslint@8.57.1) eslint-plugin-tailwindcss: specifier: ^3.12.0 - version: 3.18.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))) + version: 3.18.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))) eslint-utils: specifier: ^3.0.0 version: 3.0.0(eslint@8.57.1) @@ -959,7 +971,7 @@ importers: version: 15.5.7(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.7(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.7(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-email: specifier: ^4.0.16 version: 4.0.16(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1034,7 +1046,7 @@ importers: version: link:../utils '@kobalte/tailwindcss': specifier: ^0.9.0 - version: 0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))) + version: 0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))) '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1061,7 +1073,7 @@ importers: version: 1.2.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@tailwindcss/typography': specifier: ^0.5.9 - version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))) + version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))) class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -1119,13 +1131,13 @@ importers: version: 6.30.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) tailwind-scrollbar: specifier: ^3.1.0 - version: 3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))) + version: 3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))) tailwindcss: specifier: ^3.3.3 - version: 3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) + version: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) tailwindcss-animate: specifier: ^1.0.6 - version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))) tauri-plugin-context-menu: specifier: ^0.5.0 version: 0.5.0 @@ -1158,7 +1170,7 @@ importers: version: 1.9.6 tailwindcss: specifier: ^3.4.10 - version: 3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)) + version: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) zod: specifier: ^3 version: 3.25.76 @@ -1171,10 +1183,10 @@ importers: version: 2.2.336 '@kobalte/tailwindcss': specifier: ^0.9.0 - version: 0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))) + version: 0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))) '@tailwindcss/typography': specifier: ^0.5.9 - version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))) + version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))) autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.3) @@ -1186,10 +1198,10 @@ importers: version: 1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(solid-js@1.9.6)(storybook@8.6.12(prettier@3.5.3)) tailwind-scrollbar: specifier: ^3.1.0 - version: 3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))) + version: 3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))) tailwindcss-animate: specifier: ^1.0.6 - version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))) unplugin-auto-import: specifier: ^0.18.2 version: 0.18.6(rollup@4.40.2) @@ -4316,8 +4328,8 @@ packages: resolution: {integrity: sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==} engines: {node: '>=14'} - '@oxc-project/types@0.101.0': - resolution: {integrity: sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==} + '@oxc-project/types@0.102.0': + resolution: {integrity: sha512-8Skrw405g+/UJPKWJ1twIk3BIH2nXdiVlVNtYT23AXVwpsd79es4K+KYt06Fbnkc5BaTvk/COT2JuCLYdwnCdA==} '@oxc-project/types@0.94.0': resolution: {integrity: sha512-+UgQT/4o59cZfH6Cp7G0hwmqEQ0wE+AdIwhikdwnhWI9Dp8CgSY081+Q3O67/wq3VJu8mgUEB93J9EHHn70fOw==} @@ -5261,8 +5273,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.0.0-beta.53': - resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==} + '@rolldown/binding-android-arm64@1.0.0-beta.54': + resolution: {integrity: sha512-zZRx/ur3Fai3fxiEmVp48+6GCBR48PRWJR1X3TTMn9yiq2bBHlYPgBaQtDOYWXv5H3J5dXujeTyGnuoY+kdGCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -5273,8 +5285,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.0-beta.53': - resolution: {integrity: sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.54': + resolution: {integrity: sha512-zMyFEJmbIs91x22HAA/eUvmZHgjX8tGsD3TJ+WC9aY4bCdl3w84H9vMZmChSHAF1dYvGNH4KQDI2IubeZaCYtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -5285,8 +5297,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.53': - resolution: {integrity: sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ==} + '@rolldown/binding-darwin-x64@1.0.0-beta.54': + resolution: {integrity: sha512-Ex7QttdaVnEpmE/zroUT5Qm10e2+Vjd9q0LX9eXm59SitxDODMpC8GI1Rct5RrLf4GLU4DzdXBj6DGzuR+6g6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -5297,8 +5309,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.0.0-beta.53': - resolution: {integrity: sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.54': + resolution: {integrity: sha512-E1XO10ryM/Vxw3Q1wvs9s2mSpVBfbHtzkbJcdu26qh17ZmVwNWLiIoqEcbkXm028YwkReG4Gd2gCZ3NxgTQ28Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -5309,8 +5321,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53': - resolution: {integrity: sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.54': + resolution: {integrity: sha512-oS73Uks8jczQR9pg0Bj718vap/x71exyJ5yuxu4X5V4MhwRQnky7ANSPm6ARUfraxOqt49IBfcMeGnw2rTSqdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -5321,8 +5333,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53': - resolution: {integrity: sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.54': + resolution: {integrity: sha512-pY8N2X5C+/ZQcy0eRdfOzOP//OFngP1TaIqDjFwfBPws2UNavKS8SpxhPEgUaYIaT0keVBd/TB+eVy9z+CIOtw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -5333,8 +5345,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53': - resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.54': + resolution: {integrity: sha512-cgTooAFm2MUmFriB7IYaWBNyqrGlRPKG+yaK2rGFl2rcdOcO24urY4p3eyB0ogqsRLvJbIxwjjYiWiIP7Eo1Cw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -5345,8 +5357,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53': - resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.54': + resolution: {integrity: sha512-nGyLT1Qau0W+kEL44V2jhHmvfS3wyJW08E4WEu2E6NuIy+uChKN1X0aoxzFIDi2owDsYaZYez/98/f268EupIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -5357,8 +5369,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.53': - resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.54': + resolution: {integrity: sha512-KH374P0TUjDXssROT/orvzaWrzGOptD13PTrltgKwbDprJTMknoLiYsOD6Ttz92O2VuAcCtFuJ1xbyFM2Uo/Xg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -5369,8 +5381,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.53': - resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.54': + resolution: {integrity: sha512-oMAVO4wbfAbhpBxPsSp8R7ntL2DchpNfO+tGhN8/sI9jsbYwOv78uIW1fTwOBslhjTVFltGJ+l23mubNQcYNaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -5380,8 +5392,8 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.53': - resolution: {integrity: sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.54': + resolution: {integrity: sha512-MYY/FmY+HehHiQkNx04W5oLy/Fqd1hXYqZmmorSDXvAHnxMbSgmdFicKsSYOg/sVGHBMEP1tTn6kV5sWrS45rA==} engines: {node: '>=14.0.0'} cpu: [wasm32] @@ -5391,8 +5403,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53': - resolution: {integrity: sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.54': + resolution: {integrity: sha512-66o3uKxUmcYskT9exskxs3OVduXf5x0ndlMkYOjSpBgqzhLtkub136yDvZkNT1OkNDET0odSwcU7aWdpnwzAyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -5409,8 +5421,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53': - resolution: {integrity: sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.54': + resolution: {integrity: sha512-FbbbrboChLBXfeEsOfaypBGqzbdJ/CcSA2BPLCggojnIHy58Jo+AXV7HATY8opZk7194rRbokIT8AfPJtZAWtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5418,8 +5430,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.42': resolution: {integrity: sha512-N7pQzk9CyE7q0bBN/q0J8s6Db279r5kUZc6d7/wWRe9/zXqC52HQovVyu6iXPIDY4BEzzgbVLhVFXrOuGJ22ZQ==} - '@rolldown/pluginutils@1.0.0-beta.53': - resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + '@rolldown/pluginutils@1.0.0-beta.54': + resolution: {integrity: sha512-AHgcZ+w7RIRZ65ihSQL8YuoKcpD9Scew4sEeP1BBUT9QdTo6KjwHrZZXjID6nL10fhKessCH6OPany2QKwAwTQ==} '@rollup/plugin-alias@5.1.1': resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} @@ -5484,6 +5496,15 @@ packages: rollup: optional: true + '@rollup/plugin-virtual@3.0.2': + resolution: {integrity: sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/pluginutils@5.1.4': resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} engines: {node: '>=14.0.0'} @@ -6299,10 +6320,10 @@ packages: react-dom: optional: true - '@storybook/builder-vite@10.2.0-alpha.3': - resolution: {integrity: sha512-ix9mF8UvsCykGQ/9+9JgvLgrP91hcuwsoAQ35+ilUGsV2WVcQ8pqhvQklwsyjJPGm1lWqM0/et2doucITl/W6A==} + '@storybook/builder-vite@10.2.0-alpha.6': + resolution: {integrity: sha512-DI96oVURX5u0I2x6fepMDSC1Q6/z3WanKHMOu1wfWawDyhS+/hR8Krnqu7i9mGBR4blQU8HPIKQD7XdPfIoXlw==} peerDependencies: - storybook: ^10.2.0-alpha.3 + storybook: ^10.2.0-alpha.6 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 '@storybook/core@8.6.12': @@ -6313,12 +6334,12 @@ packages: prettier: optional: true - '@storybook/csf-plugin@10.2.0-alpha.3': - resolution: {integrity: sha512-t+wMFO2H/sfpQtN6ismI2uqY3wuk9FSkO85un7UprANoNg2F0pLdE1v08NzhgPAjh3rCNXWX6s88iGUtW+qFxQ==} + '@storybook/csf-plugin@10.2.0-alpha.6': + resolution: {integrity: sha512-7+n6c0wplOcLIQeRn0f6YkVs+o4rH7t0Fis8pymWKyU0k2DBHHPl+nYhyVK6Ii2UgnsgDj9ngKMpG3nvPDVxxA==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.2.0-alpha.3 + storybook: ^10.2.0-alpha.6 vite: '*' webpack: '*' peerDependenciesMeta: @@ -6396,12 +6417,90 @@ packages: resolution: {integrity: sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==} engines: {node: '>=12.16'} + '@swc/core-darwin-arm64@1.15.5': + resolution: {integrity: sha512-RvdpUcXrIz12yONzOdQrJbEnq23cOc2IHOU1eB8kPxPNNInlm4YTzZEA3zf3PusNpZZLxwArPVLCg0QsFQoTYw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.5': + resolution: {integrity: sha512-ufJnz3UAff/8G5OfqZZc5cTQfGtXyXVLTB8TGT0xjkvEbfFg8jZUMDBnZT/Cn0k214JhMjiLCNl0A8aY/OKsYQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.5': + resolution: {integrity: sha512-Yqu92wIT0FZKLDWes+69kBykX97hc8KmnyFwNZGXJlbKUGIE0hAIhbuBbcY64FGSwey4aDWsZ7Ojk89KUu9Kzw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.5': + resolution: {integrity: sha512-3gR3b5V1abe/K1GpD0vVyZgqgV+ykuB5QNecDYzVroX4QuN+amCzQaNSsVM8Aj6DbShQCBTh3hGHd2f3vZ8gCw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.5': + resolution: {integrity: sha512-Of+wmVh5h47tTpN9ghHVjfL0CJrgn99XmaJjmzWFW7agPdVY6gTDgkk6zQ6q4hcDQ7hXb0BGw6YFpuanBzNPow==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.5': + resolution: {integrity: sha512-98kuPS0lZVgjmc/2uTm39r1/OfwKM0PM13ZllOAWi5avJVjRd/j1xA9rKeUzHDWt+ocH9mTCQsAT1jjKSq45bg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.5': + resolution: {integrity: sha512-Rk+OtNQP3W/dZExL74LlaakXAQn6/vbrgatmjFqJPO4RZkq+nLo5g7eDUVjyojuERh7R2yhqNvZ/ZZQe8JQqqA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.5': + resolution: {integrity: sha512-e3RTdJ769+PrN25iCAlxmsljEVu6iIWS7sE21zmlSiipftBQvSAOWuCDv2A8cH9lm5pSbZtwk8AUpIYCNsj2oQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.5': + resolution: {integrity: sha512-NmOdl6kyAw6zMz36zCdopTgaK2tcLA53NhUsTRopBc/796Fp87XdsslRHglybQ1HyXIGOQOKv2Y14IUbeci4BA==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.5': + resolution: {integrity: sha512-EPXJRf0A8eOi8woXf/qgVIWRl9yeSl0oN1ykGZNCGI7oElsfxUobJFmpJFJoVqKFfd1l0c+GPmWsN2xavTFkNw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.5': + resolution: {integrity: sha512-VRy+AEO0zqUkwV9uOgqXtdI5tNj3y3BZI+9u28fHNjNVTtWYVNIq3uYhoGgdBOv7gdzXlqfHKuxH5a9IFAvopQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + + '@swc/wasm@1.15.5': + resolution: {integrity: sha512-SyVggtYY5MYMZMMbH3naojEeAha2ssfo9rwfJ0q3ZQXBi5hjR6zixjop7Z/QpwYXwZvtpqz7+ncHm5gIPmsTSQ==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -7373,6 +7472,9 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@webgpu/types@0.1.68': + resolution: {integrity: sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==} + '@whatwg-node/disposablestack@0.0.6': resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} engines: {node: '>=18.0.0'} @@ -10664,6 +10766,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + lz4-wasm@0.9.2: + resolution: {integrity: sha512-8Y/OvG/nTQNWR242NhLmjI7VzLMRKFaxg/+Pxi/vkczgFdrS4qiJ8l6n30XyNJyc0TB5C66Ncdjq345Kx5XsvQ==} + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -12338,8 +12443,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.0.0-beta.53: - resolution: {integrity: sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw==} + rolldown@1.0.0-beta.54: + resolution: {integrity: sha512-3lIvjCWgjPL3gmiATUdV1NeVBGJZy6FdtwgLPol25tAkn46Q/MsVGfCSNswXwFOxGrxglPaN20IeALSIFuFyEg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -13723,6 +13828,10 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -13798,6 +13907,16 @@ packages: '@testing-library/jest-dom': optional: true + vite-plugin-top-level-await@1.6.0: + resolution: {integrity: sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==} + peerDependencies: + vite: '>=2.8' + + vite-plugin-wasm@3.5.0: + resolution: {integrity: sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 + vite-tsconfig-paths@4.3.2: resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} peerDependencies: @@ -16976,13 +17095,13 @@ snapshots: solid-presence: 0.1.8(solid-js@1.9.6) solid-prevent-scroll: 0.1.10(solid-js@1.9.6) - '@kobalte/tailwindcss@0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)))': + '@kobalte/tailwindcss@0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)))': dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) - '@kobalte/tailwindcss@0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)))': + '@kobalte/tailwindcss@0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)))': dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) '@kobalte/utils@0.9.1(solid-js@1.9.6)': dependencies: @@ -17911,7 +18030,7 @@ snapshots: '@opentelemetry/semantic-conventions@1.37.0': {} - '@oxc-project/types@0.101.0': {} + '@oxc-project/types@0.102.0': {} '@oxc-project/types@0.94.0': {} @@ -18049,16 +18168,16 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@pulumi/github@6.7.2(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': + '@pulumi/github@6.7.2(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': dependencies: - '@pulumi/pulumi': 3.201.0(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + '@pulumi/pulumi': 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) transitivePeerDependencies: - bluebird - supports-color - ts-node - typescript - '@pulumi/pulumi@3.201.0(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': + '@pulumi/pulumi@3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': dependencies: '@grpc/grpc-js': 1.13.3 '@logdna/tail-file': 2.2.0 @@ -18089,15 +18208,15 @@ snapshots: tmp: 0.2.5 upath: 1.2.0 optionalDependencies: - ts-node: 10.9.2(@types/node@22.15.17)(typescript@5.8.3) + ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - bluebird - supports-color - '@pulumiverse/vercel@1.14.3(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': + '@pulumiverse/vercel@1.14.3(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': dependencies: - '@pulumi/pulumi': 3.201.0(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + '@pulumi/pulumi': 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) transitivePeerDependencies: - bluebird - supports-color @@ -18886,61 +19005,61 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-android-arm64@1.0.0-beta.53': + '@rolldown/binding-android-arm64@1.0.0-beta.54': optional: true '@rolldown/binding-darwin-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.53': + '@rolldown/binding-darwin-arm64@1.0.0-beta.54': optional: true '@rolldown/binding-darwin-x64@1.0.0-beta.42': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.53': + '@rolldown/binding-darwin-x64@1.0.0-beta.54': optional: true '@rolldown/binding-freebsd-x64@1.0.0-beta.42': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.53': + '@rolldown/binding-freebsd-x64@1.0.0-beta.54': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.54': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.54': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.54': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.54': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-beta.42': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.53': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.54': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-beta.42': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.53': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.54': optional: true '@rolldown/binding-wasm32-wasi@1.0.0-beta.42': @@ -18948,7 +19067,7 @@ snapshots: '@napi-rs/wasm-runtime': 1.0.6 optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.53': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.54': dependencies: '@napi-rs/wasm-runtime': 1.1.0 optional: true @@ -18956,7 +19075,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.54': optional: true '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.42': @@ -18965,12 +19084,12 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.0-beta.42': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.54': optional: true '@rolldown/pluginutils@1.0.0-beta.42': {} - '@rolldown/pluginutils@1.0.0-beta.53': {} + '@rolldown/pluginutils@1.0.0-beta.54': {} '@rollup/plugin-alias@5.1.1(rollup@4.40.2)': optionalDependencies: @@ -19027,6 +19146,10 @@ snapshots: optionalDependencies: rollup: 4.40.2 + '@rollup/plugin-virtual@3.0.2(rollup@4.40.2)': + optionalDependencies: + rollup: 4.40.2 + '@rollup/pluginutils@5.1.4(rollup@4.40.2)': dependencies: '@types/estree': 1.0.7 @@ -19949,11 +20072,11 @@ snapshots: dependencies: solid-js: 1.9.6 - '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': + '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': dependencies: '@tanstack/server-functions-plugin': 1.119.2(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) defu: 6.1.4 error-stack-parser: 2.1.4 html-to-image: 1.11.13 @@ -19964,7 +20087,7 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.0.6(solid-js@1.9.6) tinyglobby: 0.2.13 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) vite-plugin-solid: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -20094,9 +20217,9 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - '@storybook/builder-vite@10.2.0-alpha.3(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/builder-vite@10.2.0-alpha.6(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: - '@storybook/csf-plugin': 10.2.0-alpha.3(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/csf-plugin': 10.2.0-alpha.6(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) storybook: 8.6.12(prettier@3.5.3) ts-dedent: 2.2.0 @@ -20128,7 +20251,7 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.2.0-alpha.3(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/csf-plugin@10.2.0-alpha.6(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: storybook: 8.6.12(prettier@3.5.3) unplugin: 2.3.10 @@ -20201,6 +20324,55 @@ snapshots: '@stripe/stripe-js@3.5.0': {} + '@swc/core-darwin-arm64@1.15.5': + optional: true + + '@swc/core-darwin-x64@1.15.5': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.5': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.5': + optional: true + + '@swc/core-linux-arm64-musl@1.15.5': + optional: true + + '@swc/core-linux-x64-gnu@1.15.5': + optional: true + + '@swc/core-linux-x64-musl@1.15.5': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.5': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.5': + optional: true + + '@swc/core-win32-x64-msvc@1.15.5': + optional: true + + '@swc/core@1.15.5(@swc/helpers@0.5.17)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.5 + '@swc/core-darwin-x64': 1.15.5 + '@swc/core-linux-arm-gnueabihf': 1.15.5 + '@swc/core-linux-arm64-gnu': 1.15.5 + '@swc/core-linux-arm64-musl': 1.15.5 + '@swc/core-linux-x64-gnu': 1.15.5 + '@swc/core-linux-x64-musl': 1.15.5 + '@swc/core-win32-arm64-msvc': 1.15.5 + '@swc/core-win32-ia32-msvc': 1.15.5 + '@swc/core-win32-x64-msvc': 1.15.5 + '@swc/helpers': 0.5.17 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -20209,6 +20381,12 @@ snapshots: dependencies: tslib: 2.8.1 + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + + '@swc/wasm@1.15.5': {} + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -20227,21 +20405,21 @@ snapshots: valibot: 1.0.0-rc.1(typescript@5.8.3) zod: 3.25.76 - '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)))': + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) - '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)))': + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) '@tanstack/devtools-event-bus@0.3.2': dependencies: @@ -21211,7 +21389,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: '@babel/parser': 7.27.2 acorn: 8.14.1 @@ -21222,18 +21400,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) - '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) acorn: 8.14.1 acorn-loose: 8.5.0 acorn-typescript: 1.4.13(acorn@8.14.1) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) '@virtual-grid/core@2.0.1': {} @@ -21412,6 +21590,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@webgpu/types@0.1.68': {} + '@whatwg-node/disposablestack@0.0.6': dependencies: '@whatwg-node/promise-helpers': 1.3.1 @@ -21843,11 +22023,11 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@10.0.0(@babel/core@7.27.1)(webpack@5.101.3(esbuild@0.25.5)): + babel-loader@10.0.0(@babel/core@7.27.1)(webpack@5.101.3(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5)): dependencies: '@babel/core': 7.27.1 find-up: 5.0.0 - webpack: 5.101.3(esbuild@0.25.5) + webpack: 5.101.3(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5) babel-plugin-jsx-dom-expressions@0.39.8(@babel/core@7.27.1): dependencies: @@ -23455,11 +23635,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-tailwindcss@3.18.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))): + eslint-plugin-tailwindcss@3.18.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))): dependencies: fast-glob: 3.3.3 postcss: 8.5.3 - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) eslint-plugin-turbo@1.13.4(eslint@8.57.1): dependencies: @@ -25156,6 +25336,8 @@ snapshots: lz-string@1.5.0: {} + lz4-wasm@0.9.2: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -25847,7 +26029,7 @@ snapshots: p-wait-for: 5.0.2 qs: 6.14.0 - next-auth@4.24.11(next@15.5.7(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next-auth@4.24.11(next@15.5.7(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@babel/runtime': 7.27.1 '@panva/hkdf': 1.2.1 @@ -25922,7 +26104,7 @@ snapshots: cors: 2.8.5 next: 15.5.7(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - nitropack@2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(xml2js@0.6.2): + nitropack@2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@netlify/functions': 3.1.5(encoding@0.1.13)(rollup@4.40.2) @@ -25976,7 +26158,7 @@ snapshots: pretty-bytes: 6.1.1 radix3: 1.1.2 rollup: 4.40.2 - rollup-plugin-visualizer: 5.14.0(rollup@4.40.2) + rollup-plugin-visualizer: 5.14.0(rolldown@1.0.0-beta.54)(rollup@4.40.2) scule: 1.3.0 semver: 7.7.2 serve-placeholder: 2.0.2 @@ -26567,21 +26749,21 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.3 - postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)): + postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)): dependencies: lilconfig: 3.1.3 yaml: 2.7.1 optionalDependencies: postcss: 8.5.3 - ts-node: 10.9.2(@types/node@20.17.43)(typescript@5.8.3) + ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3) - postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)): + postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)): dependencies: lilconfig: 3.1.3 yaml: 2.7.1 optionalDependencies: postcss: 8.5.3 - ts-node: 10.9.2(@types/node@22.15.17)(typescript@5.8.3) + ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3) postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.3)(yaml@2.8.1): dependencies: @@ -27283,7 +27465,7 @@ snapshots: dependencies: glob: 7.2.3 - rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.53)(typescript@5.8.3): + rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.54)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.4 @@ -27294,7 +27476,7 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.11.0 magic-string: 0.30.19 - rolldown: 1.0.0-beta.53 + rolldown: 1.0.0-beta.54 optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -27322,24 +27504,24 @@ snapshots: '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.42 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.42 - rolldown@1.0.0-beta.53: + rolldown@1.0.0-beta.54: dependencies: - '@oxc-project/types': 0.101.0 - '@rolldown/pluginutils': 1.0.0-beta.53 + '@oxc-project/types': 0.102.0 + '@rolldown/pluginutils': 1.0.0-beta.54 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.53 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.53 - '@rolldown/binding-darwin-x64': 1.0.0-beta.53 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.53 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.53 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.53 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.53 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.53 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.53 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.53 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.53 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.53 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.53 + '@rolldown/binding-android-arm64': 1.0.0-beta.54 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.54 + '@rolldown/binding-darwin-x64': 1.0.0-beta.54 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.54 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.54 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.54 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.54 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.54 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.54 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.54 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.54 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.54 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.54 rollup-plugin-inject@3.0.2: dependencies: @@ -27351,13 +27533,14 @@ snapshots: dependencies: rollup-plugin-inject: 3.0.2 - rollup-plugin-visualizer@5.14.0(rollup@4.40.2): + rollup-plugin-visualizer@5.14.0(rolldown@1.0.0-beta.54)(rollup@4.40.2): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: + rolldown: 1.0.0-beta.54 rollup: 4.40.2 rollup-pluginutils@2.8.2: @@ -27940,7 +28123,7 @@ snapshots: storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.5.3))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)): dependencies: - '@storybook/builder-vite': 10.2.0-alpha.3(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/builder-vite': 10.2.0-alpha.6(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.5.3))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) '@storybook/types': 9.0.0-alpha.1(storybook@8.6.12(prettier@3.5.3)) magic-string: 0.30.17 solid-js: 1.9.6 @@ -28167,23 +28350,23 @@ snapshots: tailwind-merge@2.6.0: {} - tailwind-scrollbar@3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))): + tailwind-scrollbar@3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))): dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) - tailwind-scrollbar@3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))): + tailwind-scrollbar@3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))): dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) - tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3))): + tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))): dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) - tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3))): + tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))): dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)): + tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -28202,7 +28385,7 @@ snapshots: postcss: 8.5.3 postcss-import: 15.1.0(postcss@8.5.3) postcss-js: 4.0.1(postcss@8.5.3) - postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3)) + postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) postcss-nested: 6.2.0(postcss@8.5.3) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -28210,7 +28393,7 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)): + tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -28229,7 +28412,7 @@ snapshots: postcss: 8.5.3 postcss-import: 15.1.0(postcss@8.5.3) postcss-js: 4.0.1(postcss@8.5.3) - postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3)) + postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) postcss-nested: 6.2.0(postcss@8.5.3) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -28284,6 +28467,18 @@ snapshots: solid-js: 1.9.6 solid-use: 0.9.1(solid-js@1.9.6) + terser-webpack-plugin@5.3.14(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5)(webpack@5.101.3(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.44.0 + webpack: 5.101.3(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5) + optionalDependencies: + '@swc/core': 1.15.5(@swc/helpers@0.5.17) + esbuild: 0.25.5 + terser-webpack-plugin@5.3.14(esbuild@0.25.5)(webpack@5.101.3(esbuild@0.25.5)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -28294,6 +28489,7 @@ snapshots: webpack: 5.101.3(esbuild@0.25.5) optionalDependencies: esbuild: 0.25.5 + optional: true terser@5.39.0: dependencies: @@ -28417,7 +28613,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.2(@types/node@20.17.43)(typescript@5.8.3): + ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -28434,9 +28630,12 @@ snapshots: typescript: 5.8.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.15.5(@swc/helpers@0.5.17) + '@swc/wasm': 1.15.5 optional: true - ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.3): + ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -28453,6 +28652,9 @@ snapshots: typescript: 5.8.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.15.5(@swc/helpers@0.5.17) + '@swc/wasm': 1.15.5 optional: true ts-pattern@5.7.0: {} @@ -28485,8 +28687,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.53 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.53)(typescript@5.8.3) + rolldown: 1.0.0-beta.54 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.54)(typescript@5.8.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -28507,7 +28709,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(jiti@2.6.1)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.8.1): + tsup@8.5.0(@swc/core@1.15.5(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.5) cac: 6.7.14 @@ -28527,6 +28729,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + '@swc/core': 1.15.5(@swc/helpers@0.5.17) postcss: 8.5.3 typescript: 5.8.3 transitivePeerDependencies: @@ -29019,6 +29222,8 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.19 + uuid@10.0.0: {} + uuid@11.1.0: {} uuid@8.0.0: {} @@ -29079,7 +29284,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): + vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@babel/core': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) @@ -29101,7 +29306,7 @@ snapshots: hookable: 5.5.3 http-proxy: 1.18.1 micromatch: 4.0.8 - nitropack: 2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(xml2js@0.6.2) + nitropack: 2.11.11(@planetscale/database@1.19.0)(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(mysql2@3.15.2))(encoding@0.1.13)(mysql2@3.15.2)(rolldown@1.0.0-beta.54)(xml2js@0.6.2) node-fetch-native: 1.6.6 path-to-regexp: 6.3.0 pathe: 1.1.2 @@ -29190,6 +29395,21 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-top-level-await@1.6.0(@swc/helpers@0.5.17)(rollup@4.40.2)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + dependencies: + '@rollup/plugin-virtual': 3.0.2(rollup@4.40.2) + '@swc/core': 1.15.5(@swc/helpers@0.5.17) + '@swc/wasm': 1.15.5 + uuid: 10.0.0 + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + transitivePeerDependencies: + - '@swc/helpers' + - rollup + + vite-plugin-wasm@3.5.0(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + dependencies: + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): dependencies: debug: 4.4.0 @@ -29341,6 +29561,38 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webpack@5.101.3(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.26.3 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.14(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5)(webpack@5.101.3(@swc/core@1.15.5(@swc/helpers@0.5.17))(esbuild@0.25.5)) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.101.3(esbuild@0.25.5): dependencies: '@types/eslint-scope': 3.7.7 @@ -29372,6 +29624,7 @@ snapshots: - '@swc/core' - esbuild - uglify-js + optional: true whatwg-encoding@3.1.1: dependencies: