diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index e189df4ea3d..b9df7cbb901 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1494,6 +1494,15 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "cap-cli-install" +version = "0.1.0" +dependencies = [ + "dirs 6.0.0", + "serde", + "workspace-hack", +] + [[package]] name = "cap-cursor-capture" version = "0.1.0" @@ -1532,10 +1541,12 @@ dependencies = [ "anyhow", "ashpd 0.11.1", "base64 0.22.1", + "block2 0.5.1", "bytes", "cap-audio", "cap-camera", "cap-camera-effects", + "cap-cli-install", "cap-cursor-info", "cap-editor", "cap-enc-ffmpeg", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 7ecb3a4dbfd..f496ed30e58 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -25,6 +25,7 @@ path = "../../crates/recording/examples/studio-finalization-benchmark.rs" [dependencies] cap-audio = { path = "../../crates/audio" } +cap-cli-install = { path = "../../crates/cli-install" } cap-gpui-kernels = { path = "kernels" } # gpui — the wingleeio/zed fork Comet ships. The pinned rev carries f596cde # ("destination alpha on transparent windows: Porter-Duff OVER, not additive"), @@ -209,10 +210,11 @@ x11rb = "0.13" objc2 = "0.5.2" # `NSVisualEffectView` is the pre-macOS-26 window-material fallback. Features # only -- the versions stay pinned, or a second objc2 universe gets built. -objc2-app-kit = { version = "0.2.2", features = ["NSAppearance", "NSWindow", "NSView", "NSResponder", "NSGraphics", "NSPasteboard", "NSVisualEffectView"] } +objc2-app-kit = { version = "0.2.2", features = ["NSAppearance", "NSWindow", "NSView", "NSResponder", "NSGraphics", "NSPasteboard", "NSVisualEffectView", "NSWorkspace", "NSAccessibility"] } # NSRect/NSPoint/NSSize for `setFrame:display:`. Already in the tree as # objc2-app-kit's own dependency, so this adds nothing to the build. -objc2-foundation = "0.2.2" +objc2-foundation = { version = "0.2.2", features = ["NSNotification", "NSOperation", "NSString", "block2"] } +block2 = "0.5.1" raw-window-handle = "0.6" core-foundation = "0.10" core-video = { version = "0.5.2", features = ["metal"] } diff --git a/apps/desktop-gpui/examples/export-benchmark.rs b/apps/desktop-gpui/examples/export-benchmark.rs new file mode 100644 index 00000000000..58b842ff2a5 --- /dev/null +++ b/apps/desktop-gpui/examples/export-benchmark.rs @@ -0,0 +1,128 @@ +//! Run: cargo run --release --example export-benchmark -- [WIDTH] [HEIGHT] [FPS] [RUNS] + +use std::{ + path::PathBuf, + sync::{Arc, atomic::AtomicBool}, + time::Instant, +}; + +use cap_export::{ + ExporterBase, + mp4::{ExportCompression, Mp4ExportSettings}, +}; +use cap_project::XY; + +#[cfg(unix)] +fn cpu_secs() -> f64 { + let mut usage = std::mem::MaybeUninit::::zeroed(); + unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + let usage = unsafe { usage.assume_init() }; + let secs = |time: libc::timeval| time.tv_sec as f64 + time.tv_usec as f64 / 1_000_000.0; + secs(usage.ru_utime) + secs(usage.ru_stime) +} + +#[cfg(windows)] +fn cpu_secs() -> f64 { + use windows_sys::Win32::{ + Foundation::FILETIME, + System::Threading::{GetCurrentProcess, GetProcessTimes}, + }; + + let empty = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let (mut creation, mut exit, mut kernel, mut user) = (empty, empty, empty, empty); + unsafe { + GetProcessTimes( + GetCurrentProcess(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + }; + let secs = |time: FILETIME| { + ((u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)) as f64 + / 10_000_000.0 + }; + secs(kernel) + secs(user) +} + +async fn run(project_path: PathBuf, size: XY, fps: u32, runs: u32) -> anyhow::Result<()> { + let output_dir = tempfile::tempdir()?; + for run in 0..runs { + let output_path = output_dir.path().join(format!("run-{run}.mp4")); + let cancel = Arc::new(AtomicBool::new(false)); + let started = Instant::now(); + let cpu_started = cpu_secs(); + let base = ExporterBase::builder(project_path.clone()) + .with_output_path(output_path.clone()) + .build_for_mp4(cancel) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let total = base.total_frames(fps); + let prepared_ms = started.elapsed().as_secs_f64() * 1000.0; + let path = Mp4ExportSettings { + fps, + resolution_base: size, + compression: ExportCompression::Maximum, + custom_bpp: None, + force_ffmpeg_decoder: false, + optimize_filesize: false, + } + .export_prepared(base, |_| true) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let wall = started.elapsed().as_secs_f64(); + let cpu = cpu_secs() - cpu_started; + let bytes = std::fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0); + println!( + "{}", + serde_json::json!({ + "run": run, + "frames": total, + "width": size.x, + "height": size.y, + "fps": fps, + "prepareMs": prepared_ms, + "wallSecs": wall, + "exportFps": f64::from(total) / wall, + "cpuSecs": cpu, + "bytes": bytes, + }) + ); + } + Ok(()) +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()), + ) + .with_writer(std::io::stderr) + .init(); + + let mut arguments = std::env::args().skip(1); + let project_path = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("Pass a studio .cap project path"))?; + let mut number = |default: u32| { + arguments + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) + }; + let size = XY::new(number(1920), number(1080)); + let fps = number(60); + let runs = number(3).max(1); + + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(16 * 1024 * 1024) + .enable_all() + .build()? + .block_on(run(project_path, size, fps, runs)) +} diff --git a/apps/desktop-gpui/examples/video-only-preparation.rs b/apps/desktop-gpui/examples/video-only-preparation.rs new file mode 100644 index 00000000000..dd5ef48ef53 --- /dev/null +++ b/apps/desktop-gpui/examples/video-only-preparation.rs @@ -0,0 +1,209 @@ +use std::{ + collections::HashMap, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU32, AtomicU64, Ordering}, + }, + time::Instant, +}; + +use cap_export::{ + ExporterBase, gif::GifExportSettings, make_cursor_only_project, mov::MovExportSettings, +}; +use cap_project::{ProjectConfiguration, XY}; + +#[derive(Clone, Copy, Debug)] +enum Mode { + Baseline, + VideoOnly, +} + +#[derive(Clone, Copy, Debug)] +enum Format { + Gif, + CursorMov, +} + +#[derive(Debug)] +struct Args { + project: PathBuf, + output: PathBuf, + mode: Mode, + format: Format, + width: u32, + height: u32, + fps: u32, + build_only: bool, + force_ffmpeg: bool, +} + +impl Args { + fn parse() -> Result> { + let mut arguments = std::env::args().skip(1); + let mut values = HashMap::new(); + let mut build_only = false; + let mut force_ffmpeg = false; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--build-only" if !build_only => build_only = true, + "--force-ffmpeg" if !force_ffmpeg => force_ffmpeg = true, + "--project" | "--output" | "--mode" | "--format" | "--width" | "--height" + | "--fps" => { + let value = arguments + .next() + .ok_or_else(|| format!("Missing value for {argument}"))?; + if values.insert(argument.clone(), value).is_some() { + return Err(format!("Duplicate argument: {argument}").into()); + } + } + "--help" | "-h" => { + println!( + "Required: --project PATH --output PATH --mode baseline|video-only --format gif|cursor-mov\nOptional: --width 640 --height 360 --fps 15 --build-only --force-ffmpeg" + ); + std::process::exit(0); + } + _ => return Err(format!("Unknown or duplicate argument: {argument}").into()), + } + } + let project = PathBuf::from(values.remove("--project").ok_or("--project is required")?); + let output = PathBuf::from(values.remove("--output").ok_or("--output is required")?); + let mode = match values + .remove("--mode") + .ok_or("--mode is required")? + .as_str() + { + "baseline" => Mode::Baseline, + "video-only" => Mode::VideoOnly, + _ => return Err("--mode must be baseline or video-only".into()), + }; + let format = match values + .remove("--format") + .ok_or("--format is required")? + .as_str() + { + "gif" => Format::Gif, + "cursor-mov" => Format::CursorMov, + _ => return Err("--format must be gif or cursor-mov".into()), + }; + let width = values + .remove("--width") + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(640); + let height = values + .remove("--height") + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(360); + let fps = values + .remove("--fps") + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(15); + if width == 0 || height == 0 || fps == 0 { + return Err("Width, height, and FPS must be positive".into()); + } + Ok(Self { + project, + output, + mode, + format, + width, + height, + fps, + build_only, + force_ffmpeg, + }) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse()?; + let extension = match args.format { + Format::Gif => "gif", + Format::CursorMov => "mov", + }; + if args.output.extension().and_then(|value| value.to_str()) != Some(extension) { + return Err(format!("Output must end with .{extension}").into()); + } + if args.output.exists() { + return Err("Refusing to overwrite an existing output".into()); + } + let mut config = ProjectConfiguration::load(&args.project)?; + if matches!(args.format, Format::CursorMov) { + config = make_cursor_only_project(config); + } + let started = Instant::now(); + let builder = ExporterBase::builder(args.project.clone()) + .with_config(config) + .with_output_path(args.output.clone()) + .with_force_ffmpeg_decoder(args.force_ffmpeg); + let base = match args.mode { + Mode::Baseline => builder.build().await?, + Mode::VideoOnly => builder.build_video_only().await?, + }; + let build_secs = started.elapsed().as_secs_f64(); + let expected_frames = base.total_frames(args.fps); + if args.build_only { + println!( + "{}", + serde_json::json!({ + "scope": "export_builder_only", "mode": format!("{:?}", args.mode), + "format": format!("{:?}", args.format), "build_secs": build_secs, + "expected_frames": expected_frames, "project": args.project, + }) + ); + drop(base); + return Ok(()); + } + let frames = Arc::new(AtomicU32::new(0)); + let first_progress_us = Arc::new(AtomicU64::new(u64::MAX)); + let count = frames.clone(); + let first = first_progress_us.clone(); + let on_progress = move |_frame: u32| { + count.fetch_add(1, Ordering::Relaxed); + let _ = first.compare_exchange( + u64::MAX, + started.elapsed().as_micros() as u64, + Ordering::Relaxed, + Ordering::Relaxed, + ); + true + }; + let resolution_base = XY::new(args.width, args.height); + let output = match args.format { + Format::Gif => { + GifExportSettings { + fps: args.fps, + resolution_base, + quality: None, + } + .export(base, on_progress) + .await? + } + Format::CursorMov => { + MovExportSettings { + fps: args.fps, + resolution_base, + cursor_only: true, + } + .export(base, on_progress) + .await? + } + }; + println!( + "{}", + serde_json::json!({ + "scope": "gpui_equivalent_export", "mode": format!("{:?}", args.mode), + "format": format!("{:?}", args.format), "build_secs": build_secs, + "total_secs": started.elapsed().as_secs_f64(), + "first_progress_us": first_progress_us.load(Ordering::Relaxed), + "progress_frames": frames.load(Ordering::Relaxed), + "expected_frames": expected_frames, "output": output, + "output_bytes": std::fs::metadata(&output)?.len(), + }) + ); + Ok(()) +} diff --git a/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs b/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs new file mode 100644 index 00000000000..0dfc358085f --- /dev/null +++ b/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs @@ -0,0 +1,185 @@ +use cap_gpui_kernels::mask_cache::{MaskEntry, MaskFrame, MaskRequest, mask_region, update_masks}; +use std::{hint::black_box, sync::Arc, time::Instant}; + +const SIZE: (u32, u32) = (3840, 2160); +const WARMUP: usize = 12; +const EVENTS: usize = 120; +#[derive(Clone)] +struct Overlay { + rect: [f64; 4], + image: Arc, +} + +type CachedOverlay = MaskEntry; + +fn overlay(request: &MaskRequest, rgba: &[u8]) -> Option { + let processed = request.filter(rgba)?; + Some(Overlay { + rect: [ + f64::from(request.region.0), + f64::from(request.region.1), + f64::from(processed.width()), + f64::from(processed.height()), + ], + image: Arc::new(processed), + }) +} + +fn requests(count: usize, pixelate: bool, event: usize) -> Vec { + let content = [0., 0., f64::from(SIZE.0), f64::from(SIZE.1)]; + (0..count) + .map(|index| { + let x = 80. + (index % 4) as f64 * 720. + if index == 0 { event as f64 } else { 0. }; + let y = 80. + (index / 4) as f64 * 440.; + MaskRequest { + id: index.to_string(), + frame: MaskFrame { + revision: 1, + size: SIZE, + content: content.map(f64::to_bits), + padding: 0_f64.to_bits(), + crop: None, + }, + region: mask_region([x, y, 640., 360.], content, SIZE).unwrap(), + pixelate, + level: 16_f64.to_bits(), + } + }) + .collect() +} + +fn rebuild_all(requests: &[MaskRequest], rgba: &[u8], calls: &mut usize) -> Vec { + let mut overlays = Vec::new(); + for request in requests { + *calls += 1; + if let Some(overlay) = overlay(request, rgba) { + overlays.push(overlay); + } + } + overlays +} + +fn cached( + previous: &[CachedOverlay], + requests: &[MaskRequest], + rgba: &[u8], + calls: &mut usize, +) -> Vec { + update_masks(previous, requests, |request| { + *calls += 1; + overlay(request, rgba) + }) +} + +fn stats(samples: &mut [f64]) -> (f64, f64) { + samples.sort_by(f64::total_cmp); + ( + samples.iter().sum::() / samples.len() as f64, + (samples[samples.len() / 2 - 1] + samples[samples.len() / 2]) / 2., + ) +} + +fn main() { + let rgba: Vec = (0..SIZE.0 * SIZE.1) + .flat_map(|i| { + let x = i % SIZE.0; + let y = i / SIZE.0; + [ + (x * 13 + y * 7) as u8, + (x * 3 + y * 17) as u8, + (x ^ (y * 11)) as u8, + (128 + (x * 5 + y * 3) % 128) as u8, + ] + }) + .collect(); + println!( + "Screenshot mask overlay preparation: {} / {}", + std::env::consts::OS, + std::env::consts::ARCH + ); + println!( + "3840x2160 RGBA; 640x360 masks; level 16; {WARMUP} warmup pairs; {EVENTS} measured drag events" + ); + println!( + "Alternating old/new execution order; warm cache; geometry and byte comparison after every event outside timing." + ); + println!( + "Includes cache diff, filtering, Arc allocation and retired CPU image release. Excludes GPUI conversion, scheduling, atlas and painting." + ); + println!( + "| Mode | Masks | Old mean ms | New mean ms | Old median ms | New median ms | Mean speedup | Old calls | New calls |" + ); + println!("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); + for pixelate in [false, true] { + for count in [1, 4, 8, 16] { + let initial = requests(count, pixelate, 0); + let mut old = rebuild_all(&initial, &rgba, &mut 0); + let mut new = cached(&[], &initial, &rgba, &mut 0); + let mut old_samples = Vec::with_capacity(EVENTS); + let mut new_samples = Vec::with_capacity(EVENTS); + let mut old_calls = 0; + let mut new_calls = 0; + for event in 1..=WARMUP + EVENTS { + let requests = requests(count, pixelate, event); + let mut run_old = || { + let start = Instant::now(); + old = rebuild_all(black_box(&requests), black_box(&rgba), &mut old_calls); + start.elapsed().as_secs_f64() * 1000. + }; + let mut run_new = || { + let start = Instant::now(); + new = cached( + black_box(&new), + black_box(&requests), + black_box(&rgba), + &mut new_calls, + ); + start.elapsed().as_secs_f64() * 1000. + }; + let (old_ms, new_ms) = if event % 2 == 0 { + let new_ms = run_new(); + (run_old(), new_ms) + } else { + let old_ms = run_old(); + (old_ms, run_new()) + }; + assert_eq!(old.len(), count); + assert_eq!(new.len(), count); + for (old, new) in old.iter().zip(&new) { + let expected_rect = [ + f64::from(new.request.region.0), + f64::from(new.request.region.1), + f64::from(new.request.region.2), + f64::from(new.request.region.3), + ]; + assert_eq!(old.rect, expected_rect); + let new_overlay = new.value.as_ref().unwrap(); + assert_eq!(old.rect, new_overlay.rect); + assert_eq!( + old.image.as_raw(), + new_overlay.image.as_raw(), + "event {event}, mask {}", + new.request.id + ); + } + if event <= WARMUP { + old_calls = 0; + new_calls = 0; + } else { + old_samples.push(old_ms); + new_samples.push(new_ms); + } + } + assert_eq!(old_calls, count * EVENTS); + assert_eq!(new_calls, EVENTS); + let (old_mean, old_median) = stats(&mut old_samples); + let (new_mean, new_median) = stats(&mut new_samples); + let mode = if pixelate { "pixelate" } else { "blur" }; + println!( + "| {mode} | {count} | {old_mean:.3} | {new_mean:.3} | {old_median:.3} | {new_median:.3} | {:.2}x | {old_calls} | {new_calls} |", + old_mean / new_mean + ); + } + } + println!("All overlay geometry and bytes matched at every warmup and measured event."); +} diff --git a/apps/desktop-gpui/kernels/src/lib.rs b/apps/desktop-gpui/kernels/src/lib.rs index e552c447100..3971ea2da35 100644 --- a/apps/desktop-gpui/kernels/src/lib.rs +++ b/apps/desktop-gpui/kernels/src/lib.rs @@ -1,5 +1,6 @@ pub mod codec; pub mod frame; +pub mod mask_cache; pub mod png; pub mod screenshot; pub mod texture; diff --git a/apps/desktop-gpui/kernels/src/mask_cache.rs b/apps/desktop-gpui/kernels/src/mask_cache.rs new file mode 100644 index 00000000000..b9e3afdcf03 --- /dev/null +++ b/apps/desktop-gpui/kernels/src/mask_cache.rs @@ -0,0 +1,525 @@ +use crate::screenshot::mask_filter; +use std::{collections::HashSet, hash::Hash}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MaskFrame { + pub revision: u64, + pub size: (u32, u32), + pub content: [u64; 4], + pub padding: u64, + pub crop: Option<[u32; 4]>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MaskRequest { + pub id: String, + pub frame: MaskFrame, + pub region: (u32, u32, u32, u32), + pub pixelate: bool, + pub level: u64, +} + +impl MaskRequest { + pub fn filter(&self, rgba: &[u8]) -> Option { + mask_filter( + rgba, + self.frame.size.0, + self.frame.size.1, + self.region, + self.pixelate, + f64::from_bits(self.level), + ) + } +} + +#[derive(Clone)] +pub struct MaskEntry { + pub request: MaskRequest, + pub value: Option, +} + +pub fn should_cancel_mask_build(previous: &[MaskRequest], requests: &[MaskRequest]) -> bool { + let Some(first) = previous.first() else { + return true; + }; + if previous + .iter() + .chain(requests) + .any(|request| request.frame != first.frame) + { + return true; + } + let mut used = vec![false; requests.len()]; + previous.iter().any(|request| { + let duplicated = previous + .iter() + .filter(|other| other.id == request.id) + .count() + > 1 + || requests + .iter() + .filter(|other| other.id == request.id) + .count() + > 1; + let matched = requests.iter().enumerate().position(|(index, next)| { + !used[index] && next.id == request.id && (!duplicated || next == request) + }); + match matched { + Some(index) => { + used[index] = true; + false + } + None => true, + } + }) +} + +pub fn plan_masks(previous: &[MaskEntry], requests: &[MaskRequest]) -> Vec> { + let mut used = vec![false; previous.len()]; + requests + .iter() + .map(|request| { + let index = previous + .iter() + .enumerate() + .position(|(index, entry)| !used[index] && entry.request == *request)?; + used[index] = true; + Some(index) + }) + .collect() +} + +fn match_masks(previous: &[MaskEntry], requests: &[MaskRequest]) -> Vec> { + let mut matches = plan_masks(previous, requests); + let mut used = vec![false; previous.len()]; + for index in matches.iter().flatten() { + used[*index] = true; + } + for (request, matched) in requests.iter().zip(&mut matches) { + if matched.is_none() + && let Some(index) = previous + .iter() + .enumerate() + .position(|(index, entry)| !used[index] && entry.request.id == request.id) + { + used[index] = true; + *matched = Some(index); + } + } + matches +} + +pub fn retain_masks( + previous: &[MaskEntry], + requests: &[MaskRequest], +) -> Vec> { + match_masks(previous, requests) + .into_iter() + .flatten() + .map(|index| previous[index].clone()) + .collect() +} + +pub fn finish_mask_build( + current: &[MaskEntry], + completed: Vec>, +) -> Vec> { + let requests: Vec<_> = completed + .iter() + .map(|entry| entry.request.clone()) + .collect(); + let matches = match_masks(current, &requests); + completed + .into_iter() + .zip(matches) + .map(|(entry, matched)| match matched { + Some(index) if current[index].request == entry.request => current[index].clone(), + _ => entry, + }) + .collect() +} + +pub fn retired_mask_values<'a, T, K: Eq + Hash>( + previous: &'a [MaskEntry], + next: &[MaskEntry], + key: impl Fn(&T) -> K, +) -> Vec<&'a T> { + let mut referenced: HashSet<_> = next + .iter() + .filter_map(|entry| entry.value.as_ref()) + .map(&key) + .collect(); + previous + .iter() + .filter_map(|entry| entry.value.as_ref()) + .filter(|value| referenced.insert(key(value))) + .collect() +} + +pub fn update_masks( + previous: &[MaskEntry], + requests: &[MaskRequest], + mut compute: impl FnMut(&MaskRequest) -> Option, +) -> Vec> { + let plan = plan_masks(previous, requests); + requests + .iter() + .zip(plan) + .map(|(request, reuse)| MaskEntry { + request: request.clone(), + value: match reuse { + Some(index) => previous[index].value.clone(), + None => compute(request), + }, + }) + .collect() +} + +pub fn mask_region( + mask: [f64; 4], + content: [f64; 4], + frame: (u32, u32), +) -> Option<(u32, u32, u32, u32)> { + let [x, y, width, height] = mask; + let [content_x, content_y, content_width, content_height] = content; + let left = content_x.max(x.min(x + width)); + let top = content_y.max(y.min(y + height)); + let right = (content_x + content_width).min(x.max(x + width)); + let bottom = (content_y + content_height).min(y.max(y + height)); + if right <= left || bottom <= top { + return None; + } + let x0 = (left.round().max(0.) as u32).min(frame.0); + let y0 = (top.round().max(0.) as u32).min(frame.1); + let x1 = (right.round().max(0.) as u32).min(frame.0); + let y1 = (bottom.round().max(0.) as u32).min(frame.1); + (x1 > x0 && y1 > y0).then(|| (x0, y0, x1 - x0, y1 - y0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + fn requests(count: usize) -> Vec { + (0..count) + .map(|index| MaskRequest { + id: index.to_string(), + frame: MaskFrame { + revision: 1, + size: (64, 48), + content: [0., 0., 64., 48.].map(f64::to_bits), + padding: 0_f64.to_bits(), + crop: None, + }, + region: (index as u32 * 4, 4, 32, 24), + pixelate: false, + level: 16_f64.to_bits(), + }) + .collect() + } + + fn cached(requests: &[MaskRequest]) -> Vec>> { + update_masks(&[], requests, |request| Some(Arc::new(request.id.clone()))) + } + + #[test] + fn mask_build_keeps_moves_styles_additions_and_reorders() { + let original = requests(3); + for change in 0..5 { + let mut next = original.clone(); + match change { + 0 => next[0].region.0 += 1, + 1 => next[0].level = 20_f64.to_bits(), + 2 => next[0].pixelate = true, + 3 => next.push(requests(4).pop().unwrap()), + _ => next.reverse(), + } + assert!(!should_cancel_mask_build(&original, &next)); + } + assert!(!should_cancel_mask_build(&original, &original)); + } + + #[test] + fn mask_build_cancels_removals_even_when_another_mask_is_added() { + let original = requests(3); + assert!(should_cancel_mask_build(&original, &[])); + assert!(should_cancel_mask_build(&original, &original[1..])); + let mut next = original[1..].to_vec(); + next.push(requests(4).pop().unwrap()); + assert!(should_cancel_mask_build(&original, &next)); + } + + #[test] + fn mask_build_cancels_frame_and_layout_changes() { + let original = requests(2); + for change in 0..5 { + let mut next = original.clone(); + for request in &mut next { + match change { + 0 => request.frame.revision += 1, + 1 => request.frame.size.0 += 1, + 2 => request.frame.content[0] = 0.25_f64.to_bits(), + 3 => request.frame.padding = 8_f64.to_bits(), + _ => request.frame.crop = Some([0, 0, 32, 24]), + } + } + assert!(should_cancel_mask_build(&original, &next)); + } + } + + #[test] + fn mask_build_cancels_ambiguous_duplicate_changes_and_removals() { + let mut original = requests(2); + original[1].id = original[0].id.clone(); + let mut next = original.clone(); + next.reverse(); + assert!(!should_cancel_mask_build(&original, &next)); + next[0].region.0 += 1; + assert!(should_cancel_mask_build(&original, &next)); + assert!(should_cancel_mask_build(&original, &original[..1])); + next[0] = requests(3).pop().unwrap(); + assert!(should_cancel_mask_build(&original, &next)); + let identical = vec![original[0].clone(); 2]; + assert!(!should_cancel_mask_build(&identical, &identical)); + assert!(should_cancel_mask_build(&identical, &identical[..1])); + } + + #[test] + fn mask_build_checks_removal_against_the_latest_coalesced_request() { + let initial = requests(1); + let added = requests(2); + assert!(!should_cancel_mask_build(&initial, &added)); + assert!(should_cancel_mask_build(&added, &initial)); + assert!(!should_cancel_mask_build(&initial, &added)); + } + + #[test] + fn mask_build_cancels_inconsistent_frames_and_empty_builds() { + let original = requests(2); + let mut inconsistent = original.clone(); + inconsistent[1].frame.revision += 1; + assert!(should_cancel_mask_build(&original, &inconsistent)); + assert!(should_cancel_mask_build(&inconsistent, &original)); + assert!(should_cancel_mask_build(&[], &original)); + } + + #[test] + fn completed_mask_build_matches_current_duplicates_before_retiring_images() { + let mut requests = requests(2); + requests[1].id = requests[0].id.clone(); + let snapshot = cached(&requests); + let mut current = cached(&requests); + current.reverse(); + let mut built_requests = requests.clone(); + built_requests[0].region.0 += 1; + let completed = update_masks(&snapshot, &built_requests, |request| { + Some(Arc::new(request.id.clone())) + }); + let changed = completed[0].value.clone().unwrap(); + let installed = finish_mask_build(¤t, completed); + assert!(Arc::ptr_eq(installed[0].value.as_ref().unwrap(), &changed)); + assert!(Arc::ptr_eq( + installed[1].value.as_ref().unwrap(), + current[0].value.as_ref().unwrap() + )); + let retired = retired_mask_values(¤t, &installed, Arc::as_ptr); + assert_eq!(retired.len(), 1); + assert!(Arc::ptr_eq(retired[0], current[1].value.as_ref().unwrap())); + } + + #[test] + fn completed_mask_build_does_not_retire_shared_current_images() { + let requests = requests(2); + let image = Arc::new("shared".to_string()); + let current = update_masks(&[], &requests, |_| Some(image.clone())); + let mut built_requests = requests.clone(); + built_requests[0].region.0 += 1; + let installed = finish_mask_build(¤t, cached(&built_requests)); + assert!(retired_mask_values(¤t, &installed, Arc::as_ptr).is_empty()); + assert!(Arc::ptr_eq(installed[1].value.as_ref().unwrap(), &image)); + } + + #[test] + fn moving_one_mask_reuses_the_other_images() { + let mut requests = requests(4); + let previous = cached(&requests); + requests[1].region.0 += 1; + assert_eq!( + plan_masks(&previous, &requests), + vec![Some(0), None, Some(2), Some(3)] + ); + let mut calls = 0; + let next = update_masks(&previous, &requests, |request| { + calls += 1; + Some(Arc::new(request.id.clone())) + }); + assert_eq!(calls, 1); + for index in [0, 2, 3] { + assert!(Arc::ptr_eq( + previous[index].value.as_ref().unwrap(), + next[index].value.as_ref().unwrap() + )); + } + } + + #[test] + fn deleting_and_reordering_evicts_only_the_deleted_mask() { + let mut requests = requests(4); + let previous = cached(&requests); + requests.remove(1); + requests.reverse(); + assert_eq!( + plan_masks(&previous, &requests), + vec![Some(3), Some(2), Some(0)] + ); + let next = update_masks(&previous, &requests, |_| panic!("unexpected filter")); + assert_eq!( + next.iter() + .map(|entry| entry.request.id.as_str()) + .collect::>(), + ["3", "2", "0"] + ); + assert_eq!(retired_mask_values(&next, &[], Arc::as_ptr).len(), 3); + } + + #[test] + fn new_and_changed_styles_are_computed() { + let mut requests = requests(4); + let previous = cached(&requests[..3]); + requests[0].pixelate = true; + requests[1].level = 20_f64.to_bits(); + assert_eq!( + plan_masks(&previous, &requests), + vec![None, None, Some(2), None] + ); + } + + #[test] + fn frame_and_layout_changes_invalidate_every_mask() { + let original = requests(4); + let previous = cached(&original); + for change in 0..5 { + let mut requests = original.clone(); + for request in &mut requests { + match change { + 0 => request.frame.revision += 1, + 1 => request.frame.size.0 += 1, + 2 => request.frame.content[0] = 0.25_f64.to_bits(), + 3 => request.frame.padding = 1_f64.to_bits(), + _ => request.frame.crop = Some([1, 0, 64, 48]), + } + } + assert_eq!(plan_masks(&previous, &requests), vec![None; 4]); + } + } + + #[test] + fn identical_requests_reuse_even_a_failed_filter() { + let requests = requests(4); + let previous = update_masks::>(&[], &requests, |_| None); + let next = update_masks(&previous, &requests, |_| panic!("unexpected filter")); + assert!(next.iter().all(|entry| entry.value.is_none())); + assert!(retired_mask_values(&previous, &next, Arc::as_ptr).is_empty()); + } + + #[test] + fn duplicate_ids_reserve_exact_matches_before_retaining_changed_masks() { + let mut requests = requests(3); + requests[1].id = requests[0].id.clone(); + let previous = cached(&requests); + requests.swap(0, 1); + requests[0].region.0 += 1; + requests.pop(); + let retained = retain_masks(&previous, &requests); + assert_eq!(retained[0].request, previous[1].request); + assert_eq!(retained[1].request, previous[0].request); + let retired = retired_mask_values(&previous, &retained, Arc::as_ptr); + assert_eq!(retired.len(), 1); + assert!(Arc::ptr_eq(retired[0], previous[2].value.as_ref().unwrap())); + } + + #[test] + fn identical_duplicate_requests_use_each_previous_entry_once() { + let request = requests(1).remove(0); + let requests = vec![request; 3]; + let previous = cached(&requests[..2]); + assert_eq!(plan_masks(&previous, &requests), [Some(0), Some(1), None]); + assert_eq!(retain_masks(&previous, &requests).len(), 2); + } + + #[test] + fn retirement_preserves_shared_images_and_retires_them_only_once() { + let requests = requests(2); + let image = Arc::new("shared".to_string()); + let previous = update_masks(&[], &requests, |_| Some(image.clone())); + assert!(retired_mask_values(&previous, &previous[1..], Arc::as_ptr).is_empty()); + let retired = retired_mask_values(&previous, &[], Arc::as_ptr); + assert_eq!(retired.len(), 1); + assert!(Arc::ptr_eq(retired[0], &image)); + } + + #[test] + fn regions_round_after_clipping_and_normalize_negative_sizes() { + let content = [10.25, 8.75, 40.5, 30.5]; + assert_eq!( + mask_region([-2.4, 1.2, 80., 60.], content, (48, 36)), + Some((10, 9, 38, 27)) + ); + assert_eq!( + mask_region([40.4, 30.4, -20.2, -10.2], content, (64, 48)), + Some((20, 20, 20, 10)) + ); + assert_eq!(mask_region([10.1, 10.1, 0.2, 0.2], content, (64, 48)), None); + assert_eq!(mask_region([60., 10., 10., 10.], content, (64, 48)), None); + assert_eq!(mask_region([0., 0., 20., 20.], content, (0, 0)), None); + } + + #[test] + fn fractional_moves_with_the_same_filtered_pixels_reuse() { + let mut requests = requests(1); + let content = [0., 0., 64., 48.]; + requests[0].region = mask_region([0.1, 4.1, 32., 24.], content, (64, 48)).unwrap(); + let previous = cached(&requests); + requests[0].region = mask_region([0.2, 4.2, 32., 24.], content, (64, 48)).unwrap(); + assert_eq!(plan_masks(&previous, &requests), [Some(0)]); + requests[0].region = mask_region([0.6, 4.2, 32., 24.], content, (64, 48)).unwrap(); + assert_eq!(plan_masks(&previous, &requests), [None]); + } + + #[test] + fn overlapping_masks_always_sample_the_unmodified_frame() { + let rgba: Vec = (0..64 * 48 * 4).map(|i| (i * 37 % 251) as u8).collect(); + let mut requests = requests(4); + requests[1].pixelate = true; + let previous = update_masks(&[], &requests, |request| { + request.filter(&rgba).map(Arc::new) + }); + requests[1].region.0 += 1; + requests.reverse(); + let mut calls = 0; + let next = update_masks(&previous, &requests, |request| { + calls += 1; + request.filter(&rgba).map(Arc::new) + }); + assert_eq!(calls, 1); + for entry in next { + let expected = mask_filter( + &rgba, + 64, + 48, + entry.request.region, + entry.request.pixelate, + 16., + ) + .unwrap(); + assert_eq!(entry.value.unwrap().as_ref(), &expected); + } + } + + #[test] + fn invalid_buffers_do_not_produce_images() { + let request = requests(1).remove(0); + assert!(request.filter(&[0; 16]).is_none()); + } +} diff --git a/apps/desktop-gpui/patches/zed-idle-frames.patch b/apps/desktop-gpui/patches/zed-idle-frames.patch new file mode 100644 index 00000000000..e9f7d7cb714 --- /dev/null +++ b/apps/desktop-gpui/patches/zed-idle-frames.patch @@ -0,0 +1,438 @@ +diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs +index 0294b7d..09adcdd 100644 +--- a/crates/gpui/src/platform.rs ++++ b/crates/gpui/src/platform.rs +@@ -793,6 +793,18 @@ pub enum TextInputStateChange { + ContentChanged, + } + ++/// What [`PlatformWindow::frame_request_toggle`] asks of the platform. ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum FrameRequests { ++ /// Stop requesting frames. ++ Stop, ++ /// Request a frame every display refresh again. ++ Start, ++ /// Deliver one frame as soon as possible without restarting the ++ /// per-refresh requests. ++ Once, ++} ++ + #[expect(missing_docs)] + pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { + #[cfg(all(target_os = "linux", feature = "wayland"))] +@@ -843,6 +855,12 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { + fn toggle_fullscreen(&self); + fn is_fullscreen(&self) -> bool; + fn on_request_frame(&self, callback: Box); ++ /// Returns a switch over the frame requests delivered to ++ /// [`Self::on_request_frame`]. Windows stop them while they have nothing to ++ /// draw. Platforms without one keep requesting frames. ++ fn frame_request_toggle(&self) -> Option> { ++ None ++ } + fn on_input(&self, callback: Box DispatchEventResult>); + fn on_active_status_change(&self, callback: Box); + fn on_hover_status_change(&self, callback: Box); +diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs +index 393e517..de95b7d 100644 +--- a/crates/gpui/src/window.rs ++++ b/crates/gpui/src/window.rs +@@ -9,7 +9,8 @@ use crate::{ + Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, + KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, + MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, +- PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, ++ FrameRequests, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, ++ PolychromeSprite, + Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, + RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, + SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, BackdropBlur, ScaledPixels, Scene, Shadow, SharedString, Size, +@@ -120,6 +121,69 @@ struct WindowInvalidatorInner { + pub dirty_views: FxHashSet, + pub update_count: usize, + pub frame_dirty: FrameDirtyAccumulator, ++ pub frame_pacer: Rc, ++} ++ ++/// Stops the platform's frame requests once a window has had nothing to draw ++/// for [`FramePacer::IDLE_DELAY`]. While stopped, an invalidation or a ++/// next-frame callback asks for a single frame, and the per-refresh requests ++/// only restart if work is still pending after it (an animation). Without this ++/// an idle window wakes the main thread on every display refresh, and a window ++/// that redraws a few times a second (a level meter, a clock) keeps the ++/// requests running between its redraws. ++pub(crate) struct FramePacer { ++ toggle: Option>, ++ paused: Cell, ++ once_pending: Cell, ++ busy_at: Cell, ++} ++ ++impl FramePacer { ++ /// Long enough that steady low-rate redraws (30fps video, timers) never ++ /// stop and restart the frame source between frames. ++ const IDLE_DELAY: Duration = Duration::from_millis(100); ++ ++ fn new(toggle: Option>) -> Self { ++ FramePacer { ++ toggle, ++ paused: Cell::new(false), ++ once_pending: Cell::new(false), ++ busy_at: Cell::new(Instant::now()), ++ } ++ } ++ ++ fn wake(&self) { ++ if let Some(toggle) = &self.toggle ++ && self.paused.get() ++ && !self.once_pending.replace(true) ++ { ++ toggle(FrameRequests::Once); ++ } ++ } ++ ++ /// `busy`: the frame did work. `pending`: work is left for the next frame. ++ fn frame_finished(&self, busy: bool, pending: bool, now: Instant) { ++ let Some(toggle) = &self.toggle else { ++ return; ++ }; ++ if self.paused.get() { ++ self.once_pending.set(false); ++ if pending { ++ self.paused.set(false); ++ self.busy_at.set(now); ++ toggle(FrameRequests::Start); ++ } else { ++ // Also stops a link the platform restarted on its own (a window ++ // shown again while it had nothing to draw). ++ toggle(FrameRequests::Stop); ++ } ++ } else if busy || pending { ++ self.busy_at.set(now); ++ } else if now.duration_since(self.busy_at.get()) >= Self::IDLE_DELAY { ++ self.paused.set(true); ++ toggle(FrameRequests::Stop); ++ } ++ } + } + + /// Per-frame invalidation bookkeeping, drained at draw time and emitted to the +@@ -138,7 +202,7 @@ pub(crate) struct WindowInvalidator { + } + + impl WindowInvalidator { +- pub fn new() -> Self { ++ pub fn new(frame_pacer: Rc) -> Self { + WindowInvalidator { + inner: Rc::new(RefCell::new(WindowInvalidatorInner { + dirty: true, +@@ -146,6 +210,7 @@ impl WindowInvalidator { + dirty_views: FxHashSet::default(), + update_count: 0, + frame_dirty: FrameDirtyAccumulator::default(), ++ frame_pacer, + })), + } + } +@@ -157,6 +222,9 @@ impl WindowInvalidator { + if inner.draw_phase == DrawPhase::None { + Self::record_frame_dirty(&mut inner); + inner.dirty = true; ++ let frame_pacer = inner.frame_pacer.clone(); ++ drop(inner); ++ frame_pacer.wake(); + cx.push_effect(Effect::Notify { emitter: entity }); + true + } else { +@@ -174,6 +242,9 @@ impl WindowInvalidator { + if dirty { + inner.update_count += 1; + Self::record_frame_dirty(&mut inner); ++ let frame_pacer = inner.frame_pacer.clone(); ++ drop(inner); ++ frame_pacer.wake(); + } + } + +@@ -1390,7 +1461,8 @@ impl Window { + let scale_factor = platform_window.scale_factor(); + let appearance = platform_window.appearance(); + let text_system = Arc::new(WindowTextSystem::new(cx.text_system().clone())); +- let invalidator = WindowInvalidator::new(); ++ let frame_pacer = Rc::new(FramePacer::new(platform_window.frame_request_toggle())); ++ let invalidator = WindowInvalidator::new(frame_pacer.clone()); + let active = Rc::new(Cell::new(platform_window.is_active())); + let hovered = Rc::new(Cell::new(platform_window.is_hovered())); + let needs_present = Rc::new(Cell::new(false)); +@@ -1507,12 +1579,17 @@ impl Window { + let invalidator = invalidator.clone(); + let active = active.clone(); + let needs_present = needs_present.clone(); +- let next_frame_callbacks = next_frame_callbacks.clone(); ++ let pending_frame_callbacks = next_frame_callbacks.clone(); + let input_rate_tracker = input_rate_tracker.clone(); + move |request_frame_options| { +- let thermal_state = handle +- .update(&mut cx, |_, _, cx| cx.thermal_state()) +- .log_err(); ++ let next_frame_callbacks = &pending_frame_callbacks; ++ // A frame can still arrive for a window that was just closed ++ // (its display link or a pending wake); there is nothing to draw. ++ let Ok(thermal_state) = handle.update(&mut cx, |_, _, cx| cx.thermal_state()) ++ else { ++ return; ++ }; ++ let thermal_state = Some(thermal_state); + + // Throttle frame rate based on conditions: + // - Thermal pressure (Serious/Critical): cap to ~60fps +@@ -1546,13 +1623,17 @@ impl Window { + handle + .update(&mut cx, |_, window, _| window.complete_frame()) + .log_err(); ++ frame_pacer.frame_finished(true, true, now); + return; + } + } + last_frame_time.set(Some(now)); + ++ let mut busy = request_frame_options.force_render ++ || request_frame_options.require_presentation; + let next_frame_callbacks = next_frame_callbacks.take(); + if !next_frame_callbacks.is_empty() { ++ busy = true; + handle + .update(&mut cx, |_, window, cx| { + for callback in next_frame_callbacks { +@@ -1570,6 +1651,7 @@ impl Window { + || (active.get() && input_rate_tracker.borrow_mut().is_high_rate()); + + if invalidator.is_dirty() || request_frame_options.force_render { ++ busy = true; + measure("frame duration", || { + handle + .update(&mut cx, |_, window, cx| { +@@ -1585,6 +1667,7 @@ impl Window { + .log_err(); + }) + } else if needs_present { ++ busy = true; + handle + .update(&mut cx, |_, window, _| window.present()) + .log_err(); +@@ -1595,6 +1678,9 @@ impl Window { + window.complete_frame(); + }) + .log_err(); ++ ++ let pending = invalidator.is_dirty() || !pending_frame_callbacks.borrow().is_empty(); ++ frame_pacer.frame_finished(busy, pending, now); + } + })); + platform_window.on_resize(Box::new({ +@@ -2263,6 +2349,8 @@ impl Window { + /// Schedule the given closure to be run directly after the current frame is rendered. + pub fn on_next_frame(&self, callback: impl FnOnce(&mut Window, &mut App) + 'static) { + RefCell::borrow_mut(&self.next_frame_callbacks).push(Box::new(callback)); ++ let frame_pacer = self.invalidator.inner.borrow().frame_pacer.clone(); ++ frame_pacer.wake(); + } + + /// Schedule a frame to be drawn on the next animation frame. +@@ -6807,6 +6895,98 @@ pub fn outline( + } + } + ++#[cfg(test)] ++mod frame_pacer_tests { ++ use super::{FramePacer, FrameRequests, Instant}; ++ use std::{cell::RefCell, rc::Rc, time::Duration}; ++ ++ fn recording_pacer() -> (FramePacer, Rc>>, Instant) { ++ let calls = Rc::new(RefCell::new(Vec::new())); ++ let recorded = calls.clone(); ++ let pacer = FramePacer::new(Some(Box::new(move |request| { ++ recorded.borrow_mut().push(request) ++ }))); ++ let created = pacer.busy_at.get(); ++ (pacer, calls, created) ++ } ++ ++ fn stopped_pacer() -> (FramePacer, Rc>>, Instant) { ++ let (pacer, calls, start) = recording_pacer(); ++ pacer.frame_finished(false, false, start + FramePacer::IDLE_DELAY); ++ calls.borrow_mut().clear(); ++ (pacer, calls, start + FramePacer::IDLE_DELAY) ++ } ++ ++ #[test] ++ fn idle_frames_stop_requests_only_after_the_delay() { ++ let (pacer, calls, start) = recording_pacer(); ++ pacer.frame_finished(false, false, start + Duration::from_millis(99)); ++ assert!(calls.borrow().is_empty()); ++ pacer.frame_finished(false, false, start + FramePacer::IDLE_DELAY); ++ assert_eq!(*calls.borrow(), [FrameRequests::Stop]); ++ } ++ ++ #[test] ++ fn busy_or_pending_frames_restart_the_delay() { ++ let (pacer, calls, start) = recording_pacer(); ++ pacer.frame_finished(true, false, start + Duration::from_millis(90)); ++ pacer.frame_finished(false, true, start + Duration::from_millis(150)); ++ pacer.frame_finished(false, false, start + Duration::from_millis(240)); ++ assert!(calls.borrow().is_empty()); ++ pacer.frame_finished(false, false, start + Duration::from_millis(250)); ++ assert_eq!(*calls.borrow(), [FrameRequests::Stop]); ++ } ++ ++ #[test] ++ fn a_stopped_window_asks_for_one_frame_per_wake() { ++ let (pacer, calls, now) = stopped_pacer(); ++ pacer.wake(); ++ pacer.wake(); ++ assert_eq!(*calls.borrow(), [FrameRequests::Once]); ++ // The single frame drew everything: requests stay stopped. ++ pacer.frame_finished(true, false, now + Duration::from_millis(50)); ++ pacer.wake(); ++ assert_eq!( ++ *calls.borrow(), ++ [FrameRequests::Once, FrameRequests::Stop, FrameRequests::Once] ++ ); ++ } ++ ++ #[test] ++ fn work_left_after_a_single_frame_restarts_requests() { ++ let (pacer, calls, now) = stopped_pacer(); ++ pacer.wake(); ++ pacer.frame_finished(true, true, now + Duration::from_millis(5)); ++ assert_eq!(*calls.borrow(), [FrameRequests::Once, FrameRequests::Start]); ++ // Running again: wakes ask for nothing, and the idle delay applies anew. ++ pacer.wake(); ++ pacer.frame_finished(false, false, now + Duration::from_millis(50)); ++ assert_eq!(*calls.borrow(), [FrameRequests::Once, FrameRequests::Start]); ++ pacer.frame_finished(false, false, now + Duration::from_millis(105)); ++ assert_eq!( ++ *calls.borrow(), ++ [FrameRequests::Once, FrameRequests::Start, FrameRequests::Stop] ++ ); ++ } ++ ++ #[test] ++ fn a_frame_from_a_link_the_platform_restarted_stops_it_again() { ++ let (pacer, calls, now) = stopped_pacer(); ++ pacer.frame_finished(false, false, now + Duration::from_millis(8)); ++ assert_eq!(*calls.borrow(), [FrameRequests::Stop]); ++ } ++ ++ #[test] ++ fn without_a_platform_toggle_nothing_is_tracked() { ++ let pacer = FramePacer::new(None); ++ let start = pacer.busy_at.get(); ++ pacer.frame_finished(false, false, start + FramePacer::IDLE_DELAY); ++ assert!(!pacer.paused.get()); ++ pacer.wake(); ++ assert!(!pacer.once_pending.get()); ++ } ++} ++ + #[cfg(test)] + mod tests { + use crate::{ +diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs +index f5b2c1a..96f7826 100644 +--- a/crates/gpui_macos/src/window.rs ++++ b/crates/gpui_macos/src/window.rs +@@ -24,7 +24,7 @@ use cocoa::{ + }; + use dispatch2::DispatchQueue; + use gpui::{ +- AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, CursorStyle, ExternalPaths, ++ AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, CursorStyle, ExternalPaths, FrameRequests, + FileDropEvent, ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, + PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, +@@ -499,6 +499,7 @@ struct MacWindowState { + frame_source: Option, + renderer: renderer::Renderer, + request_frame_callback: Option>, ++ frame_requests_paused: bool, + event_callback: Option gpui::DispatchEventResult>>, + activate_callback: Option>, + resize_callback: Option, f32)>>, +@@ -657,6 +658,9 @@ impl MacWindowState { + + fn start_display_link(&mut self) { + self.stop_display_link(); ++ if self.frame_requests_paused { ++ return; ++ } + unsafe { + if !self + .native_window +@@ -896,6 +900,7 @@ impl MacWindow { + frame_source: None, + renderer, + request_frame_callback: None, ++ frame_requests_paused: false, + event_callback: None, + activate_callback: None, + resize_callback: None, +@@ -1670,6 +1675,57 @@ impl PlatformWindow for MacWindow { + self.0.as_ref().lock().request_frame_callback = Some(callback); + } + ++ fn frame_request_toggle(&self) -> Option> { ++ let window_state = Arc::downgrade(&self.0); ++ Some(Box::new(move |request| { ++ let Some(window_state) = window_state.upgrade() else { ++ return; ++ }; ++ let mut lock = window_state.lock(); ++ match request { ++ FrameRequests::Stop => { ++ lock.frame_requests_paused = true; ++ lock.stop_display_link(); ++ } ++ FrameRequests::Start => { ++ lock.frame_requests_paused = false; ++ lock.start_display_link(); ++ } ++ FrameRequests::Once => { ++ let visible = unsafe { ++ lock.native_window ++ .occlusionState() ++ .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) ++ }; ++ if !visible { ++ // A hidden window draws nothing now; let the occlusion ++ // handler start its link when it is shown, and the ++ // first frame after that draws what changed meanwhile. ++ lock.frame_requests_paused = false; ++ return; ++ } ++ // Draw the frame that woke the window now: a restarted ++ // display link first fires a refresh or two later. ++ let window_state = Arc::downgrade(&window_state); ++ lock.foreground_executor ++ .spawn(async move { ++ let Some(window_state) = window_state.upgrade() else { ++ return; ++ }; ++ let Some(mut callback) = ++ window_state.lock().request_frame_callback.take() ++ else { ++ return; ++ }; ++ callback(Default::default()); ++ window_state.lock().request_frame_callback = Some(callback); ++ }) ++ .detach(); ++ } ++ } ++ })) ++ } ++ + fn on_input(&self, callback: Box gpui::DispatchEventResult>) { + self.0.as_ref().lock().event_callback = Some(callback); + } diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 028fcc3ab01..61619c06965 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -5385,7 +5385,9 @@ fn load_editor_project_candidate( drive_auto_sidebar(handle, cx).await; drive_auto_playback(path, handle, cx).await; + crate::editor_bench::run(handle, cx).await; drive_auto_export(handle, cx).await; + crate::editor_hover_bench::run(handle, cx).await; }) .detach(); } @@ -6119,6 +6121,11 @@ pub fn reload_editor(project_path: &Path, cx: &mut App) { let index = editors.iter().position(|(path, _)| path == &key); index.map(|index| editors.remove(index).1) }; + let transform = handle.and_then(|handle| { + handle + .update(cx, |view, _window, _cx| view.timeline_transform()) + .ok() + }); if let Some(handle) = handle { if let Ok(pending) = handle.update(cx, |view, _window, _cx| view.pending_save()) { @@ -6137,7 +6144,16 @@ pub fn reload_editor(project_path: &Path, cx: &mut App) { } tracing::info!(path = %key.display(), "reloading editor after import"); - open_editor(key, cx); + open_editor(key.clone(), cx); + if let Some(transform) = transform + && let Some(handle) = editor_window_handle(&key, cx) + { + handle + .update(cx, |view, _window, _cx| { + view.restore_timeline_transform(transform) + }) + .ok(); + } } /// `editor_window_for_path` (`src-tauri/src/windows.rs:3699-3706`): the live diff --git a/apps/desktop-gpui/src/camera_bench.rs b/apps/desktop-gpui/src/camera_bench.rs index 79b79d34319..9fb27fba982 100644 --- a/apps/desktop-gpui/src/camera_bench.rs +++ b/apps/desktop-gpui/src/camera_bench.rs @@ -16,7 +16,10 @@ use std::time::{Duration, Instant}; use gpui::App; -use crate::app_windows::{self, AppWindows}; +use crate::{ + app_windows::{self, AppWindows}, + draw_bench, +}; const FRAME_WIDTH: usize = 1280; const FRAME_HEIGHT: usize = 720; @@ -43,7 +46,7 @@ pub fn run(cx: &mut App) { let frame = native_frame(&sources[index % sources.len()]); let delivered = cx.update(|cx| { let delivered = app_windows::deliver_camera_frame(frame, cx); - let _ = draw_camera_window(handle, cx); + let _ = draw_bench::timed_draw(handle.into(), cx); delivered }); if !delivered { @@ -58,7 +61,7 @@ pub fn run(cx: &mut App) { let frame = native_frame(&sources[index % sources.len()]); let elapsed = cx.update(|cx| { app_windows::deliver_camera_frame(frame, cx); - draw_camera_window(handle, cx) + draw_bench::timed_draw(handle.into(), cx) }); if let Some(elapsed) = elapsed { draw_us.push(elapsed.as_secs_f64() * 1e6); @@ -79,7 +82,7 @@ pub fn run(cx: &mut App) { let mut clean_us = Vec::with_capacity(MEASURED_FRAMES); for _ in 0..MEASURED_FRAMES { cx.background_executor().timer(interval).await; - let elapsed = cx.update(|cx| draw_camera_window(handle, cx)); + let elapsed = cx.update(|cx| draw_bench::timed_draw(handle.into(), cx)); if let Some(elapsed) = elapsed { clean_us.push(elapsed.as_secs_f64() * 1e6); } @@ -89,40 +92,13 @@ pub fn run(cx: &mut App) { "camera-bench: {} frames at {FPS}fps ({FRAME_WIDTH}x{FRAME_HEIGHT} 420v camera frames)", draw_us.len() ); - report("frame draw", &mut draw_us); - report("clean draw", &mut clean_us); + draw_bench::report("camera-bench", "frame draw", &mut draw_us); + draw_bench::report("camera-bench", "clean draw", &mut clean_us); std::process::exit(0); }) .detach(); } -fn report(label: &str, samples: &mut [f64]) { - samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN draw times")); - let mean = samples.iter().sum::() / samples.len() as f64; - let p50 = samples[samples.len() / 2]; - let p95 = samples[(samples.len() as f64 * 0.95) as usize]; - let max = samples[samples.len() - 1]; - println!( - "camera-bench: {label} {mean:.1} mean / {p50:.1} p50 / {p95:.1} p95 / {max:.1} max us" - ); -} - -/// One synchronous `Window::draw`, timed. `AnyWindowHandle::update` passes the -/// root as an unleased `AnyView`; the typed handle would lease the root entity -/// that `draw()` re-renders. -fn draw_camera_window( - handle: gpui::WindowHandle, - cx: &mut App, -) -> Option { - gpui::AnyWindowHandle::from(handle) - .update(cx, |_, window, cx| { - let started = Instant::now(); - window.draw(cx).clear(); - started.elapsed() - }) - .ok() -} - fn native_frame( sample_buf: &cidre::arc::R, ) -> cap_recording::NativeCameraFrame { diff --git a/apps/desktop-gpui/src/diagnostics.rs b/apps/desktop-gpui/src/diagnostics.rs index 3e8eb5f775c..af250bff901 100644 --- a/apps/desktop-gpui/src/diagnostics.rs +++ b/apps/desktop-gpui/src/diagnostics.rs @@ -816,12 +816,16 @@ pub fn write_report(report: &Value) -> Result { /// [`write_report`] with the directory lifted into a parameter, so the naming /// and pruning are testable without writing into the real store. fn write_report_into(dir: &Path, report: &Value) -> Result { - std::fs::create_dir_all(dir) - .map_err(|error| format!("Failed to create the diagnostics folder: {error}"))?; let stamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|since| since.as_secs()) .unwrap_or(0); + write_report_at(dir, report, stamp) +} + +fn write_report_at(dir: &Path, report: &Value, stamp: u64) -> Result { + std::fs::create_dir_all(dir) + .map_err(|error| format!("Failed to create the diagnostics folder: {error}"))?; // A run with no sidecar finishes in well under a second, so two of them // would otherwise write the same name and the second would overwrite the // first. The prefix and suffix stay exactly as the pruning filter expects. @@ -1194,8 +1198,9 @@ mod tests { #[test] fn reports_in_the_same_second_do_not_collide() { let dir = temp_dir("collide"); - let first = write_report_into(&dir, &json!({ "first": true })).unwrap(); - let second = write_report_into(&dir, &json!({ "second": true })).unwrap(); + let at = 1_792_431_796; + let first = write_report_at(&dir, &json!({ "first": true }), at).unwrap(); + let second = write_report_at(&dir, &json!({ "second": true }), at).unwrap(); assert_ne!(first, second); assert!(first.exists() && second.exists()); @@ -1206,8 +1211,8 @@ mod tests { // Both parse back to the same second, so pruning still orders by time // first and falls back to the name. let stamp = |path: &Path| report_file_stamp(path.file_name().unwrap().to_str().unwrap()); - assert_eq!(stamp(&first), stamp(&second)); - assert!(stamp(&first).is_some_and(|secs| secs > 0)); + assert_eq!(stamp(&first), Some(at)); + assert_eq!(stamp(&second), Some(at)); std::fs::remove_dir_all(&dir).ok(); } diff --git a/apps/desktop-gpui/src/draw_bench.rs b/apps/desktop-gpui/src/draw_bench.rs new file mode 100644 index 00000000000..efbc8ae8a74 --- /dev/null +++ b/apps/desktop-gpui/src/draw_bench.rs @@ -0,0 +1,25 @@ +use std::time::{Duration, Instant}; + +use gpui::{AnyWindowHandle, App}; + +/// One synchronous `Window::draw`, timed. `AnyWindowHandle::update` passes the +/// root as an unleased `AnyView`; the typed handle would lease the root entity +/// that `draw()` re-renders. +pub(crate) fn timed_draw(handle: AnyWindowHandle, cx: &mut App) -> Option { + handle + .update(cx, |_, window, cx| { + let started = Instant::now(); + window.draw(cx).clear(); + started.elapsed() + }) + .ok() +} + +pub(crate) fn report(prefix: &str, label: &str, samples: &mut [f64]) { + samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN draw times")); + let mean = samples.iter().sum::() / samples.len() as f64; + let p50 = samples[samples.len() / 2]; + let p95 = samples[(samples.len() as f64 * 0.95) as usize]; + let max = samples[samples.len() - 1]; + println!("{prefix}: {label} {mean:.1} mean / {p50:.1} p50 / {p95:.1} p95 / {max:.1} max us"); +} diff --git a/apps/desktop-gpui/src/editor_bench.rs b/apps/desktop-gpui/src/editor_bench.rs new file mode 100644 index 00000000000..c1c9c5f31fa --- /dev/null +++ b/apps/desktop-gpui/src/editor_bench.rs @@ -0,0 +1,206 @@ +//! Needs a clip of at least 30 s so the `follow` scenario does not run off the +//! end. Run from `apps/desktop-gpui`: +//! CAP_GPUI_AUTO_EDITOR= CAP_GPUI_AUTO_TIMELINE_BENCH=1 \ +//! CAP_GPUI_NO_MENUS=1 CAP_GPUI_NO_TRAY=1 CAP_GPUI_MUTE_AUDIO=1 \ +//! cargo run --release + +use std::time::{Duration, Instant}; + +use gpui::{AsyncApp, Context, Window, WindowHandle}; + +use crate::{draw_bench, editor_timeline, editor_window::EditorWindow}; + +const WARMUP_FRAMES: usize = 60; +const MEASURED_FRAMES: usize = 300; +const FRAME_INTERVAL: Duration = Duration::from_millis(16); +const READY_POLL: Duration = Duration::from_millis(50); +const READY_TIMEOUT: Duration = Duration::from_secs(30); +const FOLLOW_ZOOM_SECS: f64 = 20.0; +const FOLLOW_START_FRACTION: f64 = 0.82; + +#[derive(Clone, Copy)] +enum Scenario { + Tick, + TickZoomedOut, + Hover, + Follow, + Clean, +} + +impl Scenario { + fn label(self) -> &'static str { + match self { + Self::Tick => "tick", + Self::TickZoomedOut => "tick-zoomed-out", + Self::Hover => "hover", + Self::Follow => "follow", + Self::Clean => "clean", + } + } + + fn prepare(self, view: &mut EditorWindow, window: &mut Window, cx: &mut Context) { + match self { + Self::Tick => { + if !view.playing { + view.toggle_play(window, cx); + } + } + Self::TickZoomedOut => { + view.bench_zoom_to(view.total_duration(), cx); + if !view.playing { + view.toggle_play(window, cx); + } + } + Self::Hover => { + if view.playing { + view.stop_for_measurement(cx); + } + view.bench_zoom_to(view.total_duration(), cx); + view.seek_fraction(0., window, cx); + } + Self::Follow => { + if view.playing { + view.stop_for_measurement(cx); + } + view.bench_zoom_to(FOLLOW_ZOOM_SECS, cx); + view.seek_fraction(FOLLOW_START_FRACTION, window, cx); + view.toggle_play(window, cx); + } + Self::Clean => { + if view.playing { + view.stop_for_measurement(cx); + } + view.bench_zoom_to(view.total_duration(), cx); + } + } + } + + fn step( + self, + iteration: usize, + view: &mut EditorWindow, + window: &mut Window, + cx: &mut Context, + ) { + match self { + Self::Tick | Self::TickZoomedOut | Self::Follow => { + view.invalidate_playback_chrome(window, cx) + } + Self::Hover => { + let viewport_width: f32 = window.viewport_size().width.into(); + let fraction = iteration as f32 / (WARMUP_FRAMES + MEASURED_FRAMES) as f32; + let x = editor_timeline::content_left() + + editor_timeline::content_width(viewport_width) * fraction; + view.timeline_hover(x, window, cx); + } + Self::Clean => {} + } + } +} + +pub(crate) async fn run(handle: WindowHandle, cx: &mut AsyncApp) { + if !std::env::var("CAP_GPUI_AUTO_TIMELINE_BENCH").is_ok_and(|value| value == "1") { + return; + } + + let started = Instant::now(); + loop { + let ready = handle + .update(cx, |view, _, _| { + let (mic, system) = view.has_waveforms(); + view.latest_frame.is_some() && (mic || system) + }) + .unwrap_or(false); + if ready { + break; + } + if started.elapsed() > READY_TIMEOUT { + eprintln!( + "timeline-bench: no rendered frame and waveforms within {}s", + READY_TIMEOUT.as_secs() + ); + std::process::exit(1); + } + cx.background_executor().timer(READY_POLL).await; + } + + let Ok(header) = handle.update(cx, |view, window, _| { + let viewport_width: f32 = window.viewport_size().width.into(); + let content_width = editor_timeline::content_width(viewport_width); + let (mic, system) = view.has_waveforms(); + format!( + "timeline-bench: viewport {viewport_width:.0}px, content {content_width:.0}px, \ + clip {:.1}s, mic {mic}, system {system}, target {} samples/path", + view.total_duration(), + editor_timeline::waveform_sample_count(f64::from(content_width)), + ) + }) else { + eprintln!("timeline-bench: the editor window closed"); + std::process::exit(1); + }; + println!("{header}"); + + for scenario in [ + Scenario::Tick, + Scenario::TickZoomedOut, + Scenario::Hover, + Scenario::Follow, + Scenario::Clean, + ] { + if handle + .update(cx, |view, window, cx| scenario.prepare(view, window, cx)) + .is_err() + { + eprintln!("timeline-bench: the editor window closed"); + std::process::exit(1); + } + + let mut draw_us = Vec::with_capacity(MEASURED_FRAMES); + let mut built = 0; + let mut reused = 0; + for iteration in 0..WARMUP_FRAMES + MEASURED_FRAMES { + cx.background_executor().timer(FRAME_INTERVAL).await; + let elapsed = cx.update(|cx| { + let before = handle + .update(cx, |view, window, cx| { + scenario.step(iteration, view, window, cx); + view.waveform_cache_stats() + }) + .ok()?; + let elapsed = draw_bench::timed_draw(handle.into(), cx)?; + let after = handle + .update(cx, |view, _, _| view.waveform_cache_stats()) + .ok()?; + Some(( + elapsed, + after.builds.saturating_sub(before.builds), + after.hits.saturating_sub(before.hits), + )) + }); + if iteration >= WARMUP_FRAMES + && let Some((elapsed, draw_built, draw_reused)) = elapsed + { + draw_us.push(elapsed.as_secs_f64() * 1e6); + built += draw_built; + reused += draw_reused; + } + } + + if draw_us.len() < MEASURED_FRAMES { + eprintln!( + "timeline-bench: only {} of {MEASURED_FRAMES} {} draws completed", + draw_us.len(), + scenario.label() + ); + std::process::exit(1); + } + draw_bench::report("timeline-bench", scenario.label(), &mut draw_us); + println!( + "timeline-bench: {} waveform paths built {built} / reused {reused} in {} timed draws", + scenario.label(), + draw_us.len() + ); + } + + std::process::exit(0); +} diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index ac3a273155e..51c4bfa521d 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -700,58 +700,38 @@ impl EditorWindow { // -- Thumbnails ------------------------------------------------------------ - /// Queue background decodes for every card without a picture, at most - /// [`MAX_THUMBNAIL_LOADS`] in flight -- each completion notifies, which - /// re-runs this and pulls the next one off the "queue". - fn request_clip_thumbnails(&mut self, cx: &mut Context) { - let wanted: Vec<(ThumbKey, f64)> = self - .clip_segments() - .iter() - .map(|segment| { - ( - ( - segment.recording_clip, - (segment.start * 1000.0).round() as i64, - ), - segment.start, - ) - }) - .collect(); - for (key, start) in wanted { - if self.clips.thumbs_inflight >= MAX_THUMBNAIL_LOADS { - break; - } - if self.clips.thumbs.contains_key(&key) { - continue; - } - self.clips.thumbs.insert(key, ClipThumb::Loading); - self.clips.thumbs_inflight += 1; - let project_path = self.project_path.clone(); - cx.spawn(async move |this, cx| { - let decoded = cx - .background_executor() - .spawn(async move { decode_clip_thumbnail(&project_path, key.0, start) }) - .await; - this.update(cx, |this, cx| { - this.clips.thumbs_inflight = this.clips.thumbs_inflight.saturating_sub(1); - let entry = match decoded { - Ok(image) => ClipThumb::Ready(image), - Err(error) => { - tracing::warn!( - segment = key.0, - start_ms = key.1, - "clip thumbnail decode failed: {error}" - ); - ClipThumb::Failed - } - }; - this.clips.thumbs.insert(key, entry); - cx.notify(); - }) - .ok(); - }) - .detach(); + fn request_clip_thumbnail(&mut self, key: ThumbKey, start: f64, cx: &mut Context) { + if self.clips.thumbs_inflight >= MAX_THUMBNAIL_LOADS || self.clips.thumbs.contains_key(&key) + { + return; } + self.clips.thumbs.insert(key, ClipThumb::Loading); + self.clips.thumbs_inflight += 1; + let project_path = self.project_path.clone(); + cx.spawn(async move |this, cx| { + let decoded = cx + .background_executor() + .spawn(async move { decode_clip_thumbnail(&project_path, key.0, start) }) + .await; + this.update(cx, |this, cx| { + this.clips.thumbs_inflight = this.clips.thumbs_inflight.saturating_sub(1); + let entry = match decoded { + Ok(image) => ClipThumb::Ready(image), + Err(error) => { + tracing::warn!( + segment = key.0, + start_ms = key.1, + "clip thumbnail decode failed: {error}" + ); + ClipThumb::Failed + } + }; + this.clips.thumbs.insert(key, entry); + cx.notify(); + }) + .ok(); + }) + .detach(); } // -- The sidebar ------------------------------------------------------------ @@ -760,8 +740,6 @@ impl EditorWindow { /// mode is open. Same `ml-2 w-104` wrapper the config sidebar carries, and /// the same card. pub(crate) fn render_clips_sidebar(&mut self, cx: &mut Context) -> impl IntoElement { - self.request_clip_thumbnails(cx); - div() .w(px(crate::editor_window::SIDEBAR_WIDTH)) .flex_none() @@ -1064,9 +1042,18 @@ impl EditorWindow { let group: SharedString = SharedString::from(format!("clip-card-{index}")); let probe = { let cell = self.clips.card_bounds.clone(); + let editor = cx.weak_entity(); + let start = segment.start; gpui::canvas( - move |bounds, _window, _cx| { + move |bounds, window, cx| { cell.borrow_mut().push(ClipCardBounds { index, bounds }); + if bounds.intersects(&window.content_mask().bounds) { + editor + .update(cx, |editor, cx| { + editor.request_clip_thumbnail(thumb_key, start, cx); + }) + .ok(); + } }, |_, _, _, _| {}, ) diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index f971fa5b7e1..b23bc733215 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -2892,7 +2892,10 @@ async fn run_export( let total = base.total_frames(fps); (PreparedBase::Mp4(base), total) } else { - let base = builder.build().await.map_err(|error| error.to_string())?; + let base = builder + .build_video_only() + .await + .map_err(|error| error.to_string())?; let total = base.total_frames(fps); (PreparedBase::Other(base), total) }; diff --git a/apps/desktop-gpui/src/editor_hover_bench.rs b/apps/desktop-gpui/src/editor_hover_bench.rs new file mode 100644 index 00000000000..4325410ada8 --- /dev/null +++ b/apps/desktop-gpui/src/editor_hover_bench.rs @@ -0,0 +1,144 @@ +//! `CAP_GPUI_AUTO_HOVER_BENCH=1` (with `CAP_GPUI_AUTO_EDITOR`): the editor's +//! per-move cost while the pointer glides over a paused timeline. The pointer +//! sweeps the timeline at a fixed speed and moves arrive at +//! `CAP_GPUI_AUTO_HOVER_BENCH_HZ` (default 120); a move that lands while the +//! previous one is still being handled is coalesced into the next, as the OS +//! does. Each move runs the timeline's own hover handler, then one synchronous +//! `Window::draw` (view renders + layout + prepaint + paint) is timed with it. +//! `CAP_GPUI_AUTO_SIDEBAR` picks the sidebar tab that stays open meanwhile. +//! +//! Run: +//! CAP_GPUI_AUTO_EDITOR= CAP_GPUI_AUTO_HOVER_BENCH=1 \ +//! CAP_GPUI_AUTO_SIDEBAR=captions CAP_GPUI_NO_MENUS=1 CAP_GPUI_NO_TRAY=1 \ +//! cargo run --release + +use std::time::{Duration, Instant}; + +use gpui::{App, WindowHandle}; + +use crate::editor_window::EditorWindow; + +const DEFAULT_MOUSE_HZ: u64 = 120; +const DEFAULT_SWEEP_SECS: f32 = 3.; +const WARMUP: Duration = Duration::from_secs(1); +const MEASURED: Duration = Duration::from_secs(6); + +pub async fn run(handle: WindowHandle, cx: &mut gpui::AsyncApp) { + if !std::env::var("CAP_GPUI_AUTO_HOVER_BENCH").is_ok_and(|value| value == "1") { + return; + } + cx.background_executor() + .timer(Duration::from_millis(2500)) + .await; + cx.update(|cx| { + cx.activate(true); + handle + .update(cx, |_, window, _| window.activate_window()) + .ok(); + }); + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + + let mouse_hz = std::env::var("CAP_GPUI_AUTO_HOVER_BENCH_HZ") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|hz| *hz > 0) + .unwrap_or(DEFAULT_MOUSE_HZ); + let interval = Duration::from_micros(1_000_000 / mouse_hz); + let sweep_secs = std::env::var("CAP_GPUI_AUTO_HOVER_BENCH_SWEEP_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|secs| *secs > 0.) + .unwrap_or(DEFAULT_SWEEP_SECS); + let started = Instant::now(); + let mut next_move = started; + let mut move_us = Vec::new(); + let mut frames_at_start = None; + loop { + next_move += interval; + let now = Instant::now(); + if next_move > now { + cx.background_executor() + .timer(next_move.saturating_duration_since(now)) + .await; + } else { + next_move = now; + // An overloaded loop must still give the preview pump and the run + // loop a turn between moves, as real event dispatch does. + tokio::task::yield_now().await; + } + let elapsed = started.elapsed(); + if elapsed >= WARMUP + MEASURED { + break; + } + let measuring = elapsed >= WARMUP; + if measuring && frames_at_start.is_none() { + frames_at_start = cx.update(|cx| frames_presented(handle, cx)); + } + let phase = (elapsed.as_secs_f32() / sweep_secs).fract(); + // Two separate updates: observers of the editor (its section views) + // only hear about the move once the first update flushes its effects, + // and nothing yields to the run loop in between, so the platform + // cannot draw the frame before it is timed here. + let move_started = Instant::now(); + let hovered = cx.update(|cx| { + handle + .update(cx, |view, window, cx| { + let width = f32::from(window.viewport_size().width); + let x = 140. + (width - 180.).max(1.) * phase; + view.timeline_hover(x, window, cx); + }) + .is_ok() + }); + let drawn = cx.update(|cx| draw_editor_window(handle, cx)); + if !hovered || drawn.is_none() { + eprintln!("hover-bench: the editor window went away"); + std::process::exit(1); + } + if measuring { + move_us.push(move_started.elapsed().as_secs_f64() * 1e6); + } + } + let frames = cx + .update(|cx| frames_presented(handle, cx)) + .zip(frames_at_start) + .map(|(end, start)| end.saturating_sub(start)); + + println!( + "hover-bench: sidebar tab {}, pointer at {mouse_hz}Hz crossing the timeline in {sweep_secs}s, {} moves handled and {frames:?} preview frames presented in {:.1}s", + std::env::var("CAP_GPUI_AUTO_SIDEBAR").unwrap_or_else(|_| "default".into()), + move_us.len(), + MEASURED.as_secs_f64(), + ); + report("move", &mut move_us); + std::process::exit(0); +} + +fn frames_presented(handle: WindowHandle, cx: &mut App) -> Option { + handle + .update(cx, |view, _, _| view.preview_frames_presented()) + .ok() + .flatten() +} + +fn report(label: &str, samples: &mut [f64]) { + if samples.is_empty() { + println!("hover-bench: no {label} samples"); + return; + } + samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN draw times")); + let mean = samples.iter().sum::() / samples.len() as f64; + let p50 = samples[samples.len() / 2]; + let p95 = samples[(samples.len() as f64 * 0.95) as usize]; + let max = samples[samples.len() - 1]; + println!("hover-bench: {label} {mean:.1} mean / {p50:.1} p50 / {p95:.1} p95 / {max:.1} max us"); +} + +/// `AnyWindowHandle::update` hands over the root as an unleased `AnyView`; +/// the typed handle would lease the root entity that `draw()` re-renders. +fn draw_editor_window(handle: WindowHandle, cx: &mut App) -> Option<()> { + gpui::AnyWindowHandle::from(handle) + .update(cx, |_, window, cx| window.draw(cx).clear()) + .ok() +} diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index 78757d14ded..c9eb39230e7 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -31,7 +31,10 @@ //! A segment wider than the viewport has its true centre off screen, so //! [`visible_box`] clamps it (`useSegmentVisibleBox`, `TL/Track.tsx:147-181`). -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; mod playback_follow; @@ -283,6 +286,16 @@ impl Transform { } } + pub fn restored(saved: Transform, total_duration: f64) -> Option { + if saved.zoom <= 0. { + return None; + } + let mut transform = Self::initial(total_duration); + transform.update_zoom(saved.zoom, 0., total_duration); + transform.set_position(saved.position, total_duration); + Some(transform) + } + /// `secsPerPixel = transform.zoom / timelineBounds.width` /// (`TL/index.tsx:189`, `TL/context.ts:91-92`). /// Floored at the smallest positive double so a transform with no project @@ -563,18 +576,18 @@ fn waveform_amplitude(peaks: &[f32], source_time: Option) -> f64 { /// it by `(canvasWidth, canvasHeight * scale)` after translating down by /// `canvasHeight * (1 - scale)` (`:285-290`); gpui has no path transform on /// `paint_path`, so the same maths is applied to each point as it is emitted. -#[allow(clippy::too_many_arguments)] -pub fn waveform_path( - peaks: &[f32], - range: (f64, f64), - target_samples: usize, - holds: &[(f64, f64)], - segment_start: f64, - origin: gpui::Point, - size: gpui::Size, - scale: f64, - clip_bounds: gpui::Bounds, -) -> Option> { +pub fn waveform_path(geometry: &WaveformGeometry) -> Option> { + let &WaveformGeometry { + ref peaks, + range, + target_samples, + ref holds, + source_start: segment_start, + origin, + size, + scale, + clip_bounds, + } = geometry; if peaks.is_empty() || scale <= 0. { return None; } @@ -669,6 +682,129 @@ pub fn waveform_path( builder.build().ok() } +/// Everything [`waveform_path`] reads, and the [`WaveformPaths`] key. The peak +/// table is compared by pointer: an entry keeps a strong reference, so the +/// allocation can't be reused while it is cached. +#[derive(Debug, Clone)] +pub struct WaveformGeometry { + pub peaks: Arc>, + pub range: (f64, f64), + pub target_samples: usize, + pub holds: Vec<(f64, f64)>, + pub source_start: f64, + pub origin: gpui::Point, + pub size: gpui::Size, + pub scale: f64, + pub clip_bounds: gpui::Bounds, +} + +impl PartialEq for WaveformGeometry { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.peaks, &other.peaks) + && self.range == other.range + && self.target_samples == other.target_samples + && self.holds == other.holds + && self.source_start == other.source_start + && self.origin == other.origin + && self.size == other.size + && self.scale == other.scale + && self.clip_bounds == other.clip_bounds + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WaveformTrack { + Mic, + System, +} + +/// Shared by every clone of the model, so a drag preview reuses the committed +/// paths and an edit keeps the paths whose slot and geometry it left alone. +#[derive(Default)] +pub struct WaveformPaths(Mutex); + +#[derive(Default)] +struct WaveformPathsInner { + entries: HashMap<(usize, WaveformTrack), WaveformEntry>, + builds: u64, + hits: u64, +} + +struct WaveformEntry { + geometry: WaveformGeometry, + path: gpui::Path, + painted: bool, +} + +#[derive(Debug, Clone, Copy)] +pub struct WaveformPathStats { + pub builds: u64, + pub hits: u64, +} + +impl std::fmt::Debug for WaveformPaths { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0.try_lock() { + Ok(inner) => f + .debug_struct("WaveformPaths") + .field("entries", &inner.entries.len()) + .field("builds", &inner.builds) + .field("hits", &inner.hits) + .finish(), + Err(_) => f.write_str("WaveformPaths()"), + } + } +} + +impl WaveformPaths { + fn inner(&self) -> std::sync::MutexGuard<'_, WaveformPathsInner> { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn get_or_build( + &self, + slot: (usize, WaveformTrack), + geometry: WaveformGeometry, + ) -> Option> { + let mut inner = self.inner(); + if let Some(entry) = inner.entries.get_mut(&slot) + && entry.geometry == geometry + { + entry.painted = true; + let path = entry.path.clone(); + inner.hits += 1; + return Some(path); + } + let path = waveform_path(&geometry)?; + inner.builds += 1; + inner.entries.insert( + slot, + WaveformEntry { + geometry, + path: path.clone(), + painted: true, + }, + ); + Some(path) + } + + pub fn sweep(&self) { + self.inner() + .entries + .retain(|_, entry| std::mem::take(&mut entry.painted)); + } + + pub fn stats(&self) -> WaveformPathStats { + let inner = self.inner(); + WaveformPathStats { + builds: inner.builds, + hits: inner.hits, + } + } +} + fn rounded_corner_inset(x: f64, width: f64, radius: f64) -> f64 { let distance = x.min(width - x).clamp(0., radius); if distance >= radius { @@ -1063,6 +1199,7 @@ pub struct TimelineModel { /// (`TL/ClipTrack.tsx:713-730`). pub mic_waveforms: Vec>>, pub system_waveforms: Vec>>, + pub waveform_paths: Arc, /// The span a live ghost trim is removing, in output time. Drawn as a gap /// with a red duration badge, the way Blip's ghost resize marks the cut. pub clip_ghost_gap: Option<(f64, f64)>, @@ -1292,6 +1429,7 @@ impl TimelineModel { system_volume_db: config.audio.system_volume_db as f64, mic_waveforms: Vec::new(), system_waveforms: Vec::new(), + waveform_paths: Arc::default(), clip_ghost_gap: None, }; model.rows = build_rows( @@ -2025,6 +2163,7 @@ fn render_track_content( theme, model, row.kind, + index, segment, view, secs_per_pixel, @@ -2444,6 +2583,7 @@ fn render_segment( theme: &Theme, model: &TimelineModel, kind: TrackKind, + index: usize, segment: &Segment, view: TimelineView, secs_per_pixel: f64, @@ -2508,6 +2648,7 @@ fn render_segment( if segment.detail.shows_waveform() { fill = fill.child(render_waveform( model, + index, segment, *recording_clip, holds, @@ -3028,6 +3169,7 @@ fn render_hold( #[allow(clippy::too_many_arguments)] fn render_waveform( model: &TimelineModel, + index: usize, segment: &Segment, recording_clip: u32, holds: &[(f64, f64)], @@ -3070,6 +3212,7 @@ fn render_waveform( let full_width = width.max(1.) as f64; let wave_height = height.min(WAVEFORM_MAX_HEIGHT); let wave_color = waveform_color(color); + let paths = model.waveform_paths.clone(); gpui::canvas( |bounds, _window, _cx| bounds, @@ -3092,21 +3235,27 @@ fn render_waveform( let size = gpui::size(px(slice_width), px(wave_height)); let samples = waveform_sample_count(slice_width as f64); - for (peaks, scale) in [(&mic, mic_scale), (&system, system_scale)] { - if let Some(path) = waveform_path( - peaks, - (visible_start, visible_end), - samples, - &holds, - segment_start, + let clip_bounds = gpui::Bounds { + origin: gpui::point(bounds.origin.x, bounds.bottom() - px(height)), + size: gpui::size(px(width), px(height)), + }; + + for (track, peaks, scale) in [ + (WaveformTrack::Mic, &mic, mic_scale), + (WaveformTrack::System, &system, system_scale), + ] { + let geometry = WaveformGeometry { + peaks: peaks.clone(), + range: (visible_start, visible_end), + target_samples: samples, + holds: holds.clone(), + source_start: segment_start, origin, size, scale, - gpui::Bounds { - origin: gpui::point(bounds.origin.x, bounds.bottom() - px(height)), - size: gpui::size(px(width), px(height)), - }, - ) { + clip_bounds, + }; + if let Some(path) = paths.get_or_build((index, track), geometry) { window.with_content_mask(Some(gpui::ContentMask { bounds }), |window| { window.paint_path(path, wave_color) }); @@ -3634,6 +3783,19 @@ mod tests { assert_eq!(Transform::initial(3_600.0).zoom, 600.0); } + #[test] + fn restored_transform_keeps_the_saved_view_and_clamps_to_the_timeline() { + let saved = Transform { + zoom: 17.05, + position: 12.5, + }; + assert_eq!(Transform::restored(saved, 61.0), Some(saved)); + let shorter = Transform::restored(saved, 10.0).unwrap(); + assert_eq!(shorter.zoom, 10.0); + assert_eq!(shorter.position, 4.0); + assert_eq!(Transform::restored(Transform::default(), 61.0), None); + } + #[test] fn zoom_clamps_to_max_zoom_in_and_the_zoom_out_limit() { let total = 60.0; @@ -4383,21 +4545,22 @@ mod tests { }; let mut peaks = vec![-60.; 220]; peaks[200..].fill(-30.); + let peaks = Arc::new(peaks); + let geometry = |scale| WaveformGeometry { + peaks: peaks.clone(), + range: (0., 2.), + target_samples: 400, + holds: Vec::new(), + source_start: 20., + origin: gpui::point(px(0.), px(26.)), + size: gpui::size(px(200.), px(18.)), + scale, + clip_bounds: bounds, + }; for volume in [0.25, 0.5, 1.0, 2.0] { for gain in [-15., 0.] { let scale = gain_to_scale(gain) * volume; - let path = waveform_path( - &peaks, - (0., 2.), - 400, - &[], - 20., - gpui::point(px(0.), px(26.)), - gpui::size(px(200.), px(18.)), - scale, - bounds, - ) - .unwrap(); + let path = waveform_path(&geometry(scale)).unwrap(); let top = path .vertices .iter() @@ -4411,20 +4574,7 @@ mod tests { assert!((top - expected_top).abs() < 0.01, "{top} != {expected_top}"); } } - assert!( - waveform_path( - &peaks, - (0., 2.), - 400, - &[], - 20., - gpui::point(px(0.), px(26.)), - gpui::size(px(200.), px(18.)), - 0., - bounds, - ) - .is_none() - ); + assert!(waveform_path(&geometry(0.)).is_none()); } #[test] @@ -4472,6 +4622,219 @@ mod tests { assert!(waveform_peaks(&[], 1).is_empty()); } + fn waveform_vertices(path: &gpui::Path) -> Vec<(f32, f32)> { + path.vertices + .iter() + .map(|vertex| { + ( + f32::from(vertex.xy_position.x), + f32::from(vertex.xy_position.y), + ) + }) + .collect() + } + + fn speech_like_peaks(len: usize, period: f32) -> Arc> { + Arc::new( + (0..len) + .map(|i| -40. + 30. * ((i as f32 / period).sin().abs())) + .collect(), + ) + } + + fn waveform_geometry(peaks: Arc>) -> WaveformGeometry { + WaveformGeometry { + peaks, + range: (0., 20.), + target_samples: 400, + holds: Vec::new(), + source_start: 5., + origin: gpui::point(px(10.), px(26.)), + size: gpui::size(px(300.), px(18.)), + scale: 0.8, + clip_bounds: gpui::Bounds { + origin: gpui::point(px(10.), px(0.)), + size: gpui::size(px(300.), px(44.)), + }, + } + } + + #[test] + fn cached_waveform_matches_an_uncached_build_after_every_input_change() { + let paths = WaveformPaths::default(); + let slot = (0, WaveformTrack::Mic); + let base = waveform_geometry(speech_like_peaks(300, 7.)); + let first = paths.get_or_build(slot, base.clone()).unwrap(); + let again = paths.get_or_build(slot, base.clone()).unwrap(); + assert_eq!(waveform_vertices(&first), waveform_vertices(&again)); + assert_eq!(paths.stats().builds, 1); + assert_eq!(paths.stats().hits, 1); + + let same_contents_new_table = Arc::new((*base.peaks).clone()); + let mutations: Vec<(&str, WaveformGeometry)> = vec![ + ( + "range", + WaveformGeometry { + range: (2., 20.), + ..base.clone() + }, + ), + ( + "samples", + WaveformGeometry { + target_samples: 200, + ..base.clone() + }, + ), + ( + "hold added", + WaveformGeometry { + holds: vec![(4., 6.)], + ..base.clone() + }, + ), + ( + "source start", + WaveformGeometry { + source_start: 6., + ..base.clone() + }, + ), + ( + "origin", + WaveformGeometry { + origin: gpui::point(px(12.), px(26.)), + ..base.clone() + }, + ), + ( + "size", + WaveformGeometry { + size: gpui::size(px(280.), px(18.)), + ..base.clone() + }, + ), + ( + "scale", + WaveformGeometry { + scale: 0.4, + ..base.clone() + }, + ), + ( + "clip bounds", + WaveformGeometry { + clip_bounds: gpui::Bounds { + origin: gpui::point(px(10.), px(0.)), + size: gpui::size(px(40.), px(44.)), + }, + ..base.clone() + }, + ), + ( + "new table, new contents", + WaveformGeometry { + peaks: speech_like_peaks(300, 3.), + ..base.clone() + }, + ), + ]; + for (label, mutated) in mutations { + let paths = WaveformPaths::default(); + paths.get_or_build(slot, base.clone()).unwrap(); + let cached = paths.get_or_build(slot, mutated.clone()).unwrap(); + let uncached = waveform_path(&mutated).unwrap(); + assert_eq!( + waveform_vertices(&cached), + waveform_vertices(&uncached), + "{label}" + ); + assert_eq!(paths.stats().builds, 2, "{label} must rebuild"); + assert_ne!( + waveform_vertices(&cached), + waveform_vertices(&first), + "{label} must change the mesh" + ); + } + + let paths = WaveformPaths::default(); + paths.get_or_build(slot, base.clone()).unwrap(); + let rebuilt = paths + .get_or_build( + slot, + WaveformGeometry { + peaks: same_contents_new_table, + ..base + }, + ) + .unwrap(); + assert_eq!(waveform_vertices(&rebuilt), waveform_vertices(&first)); + assert_eq!(paths.stats().builds, 2); + } + + #[test] + fn split_segments_from_one_recording_clip_keep_their_own_paths() { + let paths = WaveformPaths::default(); + let peaks = speech_like_peaks(300, 7.); + let first_half = WaveformGeometry { + range: (0., 10.), + ..waveform_geometry(peaks.clone()) + }; + let second_half = WaveformGeometry { + source_start: 15., + origin: gpui::point(px(320.), px(26.)), + ..waveform_geometry(peaks) + }; + for _ in 0..4 { + let a = paths + .get_or_build((0, WaveformTrack::Mic), first_half.clone()) + .unwrap(); + let b = paths + .get_or_build((1, WaveformTrack::Mic), second_half.clone()) + .unwrap(); + assert_eq!( + waveform_vertices(&a), + waveform_vertices(&waveform_path(&first_half).unwrap()) + ); + assert_eq!( + waveform_vertices(&b), + waveform_vertices(&waveform_path(&second_half).unwrap()) + ); + } + assert_eq!(paths.stats().builds, 2); + assert_eq!(paths.stats().hits, 6); + } + + #[test] + fn waveform_cache_keeps_only_paths_painted_last_frame() { + let paths = WaveformPaths::default(); + let geometry = waveform_geometry(speech_like_peaks(300, 7.)); + for index in 0..4 { + paths + .get_or_build((index, WaveformTrack::Mic), geometry.clone()) + .unwrap(); + paths + .get_or_build((index, WaveformTrack::System), geometry.clone()) + .unwrap(); + } + paths.sweep(); + paths + .get_or_build((1, WaveformTrack::Mic), geometry.clone()) + .unwrap(); + paths.sweep(); + let stats = paths.stats(); + assert_eq!((stats.builds, stats.hits), (8, 1)); + + paths + .get_or_build((1, WaveformTrack::Mic), geometry.clone()) + .unwrap(); + paths + .get_or_build((0, WaveformTrack::Mic), geometry) + .unwrap(); + let stats = paths.stats(); + assert_eq!((stats.builds, stats.hits), (9, 2)); + } + #[test] fn waveform_mesh_stays_inside_real_clip_corners() { for width in [4., 16., 100., 10_000.] { @@ -4483,17 +4846,17 @@ mod tests { for gain in [0.1, 0.5, 1., 2.] { let x = start / 10. * width as f64; let span = (end - start) / 10. * width as f64; - let path = waveform_path( - &[-6.; 200], - (start, end), - 200, - &[], - 0., - gpui::point(px(50. + x as f32), px(56.)), - gpui::size(px(span as f32), px(18.)), - gain, - bounds, - ) + let path = waveform_path(&WaveformGeometry { + peaks: Arc::new(vec![-6.; 200]), + range: (start, end), + target_samples: 200, + holds: Vec::new(), + source_start: 0., + origin: gpui::point(px(50. + x as f32), px(56.)), + size: gpui::size(px(span as f32), px(18.)), + scale: gain, + clip_bounds: bounds, + }) .unwrap(); assert!(!path.vertices.is_empty()); for vertex in &path.vertices { diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 086f10a0f22..e295f24fab6 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -1435,6 +1435,7 @@ pub struct EditorWindow { /// width (`TL/index.tsx:689-703`). There is no mount hook here, so the /// first render that knows both the width and the duration does it. fitted: bool, + restore_transform: Option, /// The transport's zoom slider track rect, written by the slider's own /// prepaint canvas and read by the pointer maths. zoom_slider_track: ui::SliderTrack, @@ -2090,6 +2091,7 @@ impl EditorWindow { view: TimelineView::default(), playback_follow: timeline::PlaybackFollow::default(), fitted: false, + restore_transform: None, zoom_slider_track: ui::SliderTrack::default(), zoom_slider_drag: false, project: ProjectConfiguration::default(), @@ -2292,6 +2294,7 @@ impl EditorWindow { dismiss_indexed_sidebar_menu(&mut self.sidebar.menu); let mic = std::mem::take(&mut self.timeline.mic_waveforms); let system = std::mem::take(&mut self.timeline.system_waveforms); + let waveform_paths = self.timeline.waveform_paths.clone(); self.timeline = TimelineModel::build_with_lanes( &self.project, self.has_camera, @@ -2300,6 +2303,7 @@ impl EditorWindow { ); self.timeline.mic_waveforms = mic; self.timeline.system_waveforms = system; + self.timeline.waveform_paths = waveform_paths; if self.timeline.total_duration > 0.0 { self.total = self.timeline.total_duration; } @@ -2911,6 +2915,14 @@ impl EditorWindow { self.instance.take() } + pub fn timeline_transform(&self) -> Transform { + self.view.transform + } + + pub fn restore_timeline_transform(&mut self, transform: Transform) { + self.restore_transform = Some(transform); + } + /// Hand the window its transport, the pump's counters, and the duration /// the engine will actually stop at. pub fn set_transport( @@ -3308,6 +3320,26 @@ impl EditorWindow { self.stop_playback(cx); } + pub(crate) fn has_waveforms(&self) -> (bool, bool) { + let present = |tables: &[Arc>]| tables.iter().any(|peaks| !peaks.is_empty()); + ( + present(&self.timeline.mic_waveforms), + present(&self.timeline.system_waveforms), + ) + } + + pub(crate) fn waveform_cache_stats(&self) -> crate::editor_timeline::WaveformPathStats { + self.timeline.waveform_paths.stats() + } + + pub(crate) fn bench_zoom_to(&mut self, seconds: f64, cx: &mut Context) { + let total = self.total_duration(); + self.view + .transform + .update_zoom(seconds, self.playhead, total); + cx.notify(); + } + fn capture_playback_key( &mut self, event: &gpui::KeyDownEvent, @@ -3751,7 +3783,11 @@ impl EditorWindow { cx.notify(); } - fn invalidate_playback_chrome(&mut self, _window: &mut Window, cx: &mut Context) { + pub(crate) fn invalidate_playback_chrome( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) { self.timeline_view.update(cx, |_, cx| cx.notify()); self.transport_controls.update(cx, |_, cx| cx.notify()); // While playing, nothing else the parent renders reads the playhead: @@ -3759,9 +3795,11 @@ impl EditorWindow { // scene-mode pane gates on it too. Notifying the parent here would // rebuild every cached section -- header, toolbar, sidebar included -- // at the 60Hz playhead rate, which is the same class of rebuild that - // measured 40ms/frame on the text-panel sidebar. Paused paths (hover, - // seeks) keep the full invalidation: the overlay's element boxes - // appear and disappear as the playhead crosses segments. + // measured 40ms/frame on the text-panel sidebar. A paused playhead + // change keeps the full invalidation: the overlay's element boxes + // appear and disappear as the playhead crosses segments. Pointer hover + // goes through `invalidate_hover_chrome` instead, and an arriving + // paused frame whose layout changed still notifies the whole editor. if !self.playing { cx.notify(); } @@ -3782,10 +3820,16 @@ impl EditorWindow { ); } + pub(crate) fn preview_frames_presented(&self) -> Option { + self.stats + .as_ref() + .map(|stats| stats.presented.load(Ordering::Relaxed)) + } + /// `onMouseMove` on the timeline container (`TL/index.tsx:1170-1188`): /// while paused, the pointer's time becomes `previewTime`; outside the /// content column, and at all times while playing, it is cleared. - fn timeline_hover(&mut self, x: f32, window: &mut Window, cx: &mut Context) { + pub(crate) fn timeline_hover(&mut self, x: f32, window: &mut Window, cx: &mut Context) { // `if (editorState.playing) return;` -- the handler bails *before* it // writes, so a preview time set while paused survives a play rather // than being cleared. The ghost is hidden by the render's own @@ -3798,17 +3842,28 @@ impl EditorWindow { if next != self.view.preview_time { self.view.preview_time = next; self.emit_preview_frame(); - self.invalidate_playback_chrome(window, cx); + self.invalidate_hover_chrome(cx); } } - fn timeline_hover_leave(&mut self, window: &mut Window, cx: &mut Context) { + fn timeline_hover_leave(&mut self, _window: &mut Window, cx: &mut Context) { if self.playing || self.view.preview_time.is_none() { return; } self.view.preview_time = None; self.emit_preview_frame(); - self.invalidate_playback_chrome(window, cx); + self.invalidate_hover_chrome(cx); + } + + /// Pointer-hover state that only the timeline, the transport and the + /// canvas overlay read. Notifying `self` would rebuild the header, toolbar + /// and sidebar on every pointer move, because each section observes the + /// editor. Notifying a child view marks its ancestors dirty instead, so + /// this window and its canvas overlay still re-render while the untouched + /// sections are reused from their cache. + fn invalidate_hover_chrome(&mut self, cx: &mut Context) { + self.timeline_view.update(cx, |_, cx| cx.notify()); + self.transport_controls.update(cx, |_, cx| cx.notify()); } fn set_hovered_track( @@ -5912,7 +5967,7 @@ impl EditorWindow { changed = true; } if changed { - cx.notify(); + self.invalidate_hover_chrome(cx); } } @@ -10196,6 +10251,7 @@ impl EditorWindow { cx: &mut Context, ) -> impl IntoElement { let theme = self.theme; + self.timeline.waveform_paths.sweep(); if !self.project_ready() { if self .preparing_presentation @@ -11204,9 +11260,17 @@ impl Render for EditorWindow { if !self.fitted && self.total_duration() > 0.0 { self.fitted = true; let total = self.total_duration(); - self.view - .transform - .fit_on_mount(timeline::content_width(viewport_width), total); + match self + .restore_transform + .take() + .and_then(|saved| Transform::restored(saved, total)) + { + Some(transform) => self.view.transform = transform, + None => self + .view + .transform + .fit_on_mount(timeline::content_width(viewport_width), total), + } } if self.export.is_some() { diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 066acdd740a..10cdb7b09fb 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -21,13 +21,16 @@ mod dev_restore; mod devices; mod diagnostics; mod doodle; +mod draw_bench; mod editor_audio; +mod editor_bench; mod editor_canvas; mod editor_clips; mod editor_color; mod editor_crop; mod editor_edits; mod editor_export; +mod editor_hover_bench; #[cfg(target_os = "linux")] mod editor_modal; mod editor_panels; @@ -265,6 +268,14 @@ fn main() { } } + // The AppImage CLI launcher re-enters the AppImage with `--cap-cli`; hand + // it to the bundled CLI before logging, single-instance or the GUI start. + #[cfg(target_os = "linux")] + if let Err(error) = cap_cli_install::appimage::dispatch_cli() { + eprintln!("{error}"); + std::process::exit(1); + } + let _log_guard = init_logging(); single_instance::acquire(); @@ -316,6 +327,7 @@ fn main() { // the path runs (`gpui/src/window.rs:5280-5296`). ui::bind_text_input_keys(cx); crate::theme::init(cx); + platform::follow_system_reduce_motion(cx); let session = RecordingSession::init(cx); crate::sign_in::SignIn::init(cx); diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 8bf5a7ee58e..6189355ea70 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -587,6 +587,15 @@ pub struct PanelBehavior { #[cfg(target_os = "macos")] pub use mac::*; +/// GPUI keeps its own Reduce Motion flag and never reads the OS setting, so +/// every animation ignored it until this fed it in. +pub fn follow_system_reduce_motion(cx: &mut gpui::App) { + #[cfg(target_os = "macos")] + mac::follow_system_reduce_motion(cx); + #[cfg(not(target_os = "macos"))] + let _ = cx; +} + #[cfg(target_os = "macos")] mod macos_exception_guard; @@ -595,6 +604,42 @@ mod macos_occlusion; #[cfg(target_os = "macos")] mod mac { + pub fn follow_system_reduce_motion(cx: &mut gpui::App) { + use objc2_app_kit::{ + NSWorkspace, NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification, + }; + use objc2_foundation::{NSNotification, NSOperationQueue}; + + let reduce_motion = || { + let reduce = + unsafe { NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion() }; + tracing::debug!(reduce, "system reduce motion"); + reduce + }; + cx.set_reduce_motion(reduce_motion()); + + let (changed, changes) = flume::unbounded::<()>(); + let block = block2::RcBlock::new(move |_: std::ptr::NonNull| { + let _ = changed.send(()); + }); + unsafe { + let center = NSWorkspace::sharedWorkspace().notificationCenter(); + let observer = center.addObserverForName_object_queue_usingBlock( + Some(NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification), + None, + Some(&NSOperationQueue::mainQueue()), + &block, + ); + std::mem::forget(observer); + } + cx.spawn(async move |cx| { + while changes.recv_async().await.is_ok() { + cx.update(|cx| cx.set_reduce_motion(reduce_motion())); + } + }) + .detach(); + } + use gpui::Window; use objc2::rc::Id; use objc2::runtime::{AnyObject, Sel}; diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index 30118d7e579..2bd7b539353 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -25,10 +25,13 @@ use std::cell::Cell; use std::collections::HashMap; -use std::hash::{Hash as _, Hasher as _}; use std::rc::Rc; use std::sync::Arc; +use cap_gpui_kernels::mask_cache::{ + MaskEntry, MaskFrame, MaskRequest, finish_mask_build, retain_masks, retired_mask_values, + should_cancel_mask_build, update_masks, +}; use cap_project::{Annotation, AnnotationType, AspectRatio, Crop, MaskType}; use gpui::{ AnyElement, App, AppContext as _, Bounds, Context, Corners, CursorStyle, Entity, FontWeight, @@ -1201,11 +1204,8 @@ pub struct AnnotationState { /// The layers list's rows container, for the reorder drag's hit testing. list: BoundsCell, reorder: Option, - masks: Vec, - /// The inputs the painted overlays were computed from, and the ones a - /// computation in flight is for. - mask_key: u64, - mask_pending: Option, + masks: Vec>, + mask_pending: Option>, mask_task: Option>, } @@ -2241,88 +2241,89 @@ impl ScreenshotEditorWindow { // -- Mask overlays --------------------------------------------------------- - /// What the painted overlays were computed from: the frame, the image rect, - /// and every mask's geometry and style. - fn mask_key(&self) -> u64 { - let mut hasher = std::hash::DefaultHasher::new(); - match &self.frame_rgba { - Some(rgba) => { - (Arc::as_ptr(rgba) as usize).hash(&mut hasher); - rgba.len().hash(&mut hasher); - } - None => 0usize.hash(&mut hasher), + fn mask_requests(&self) -> Vec { + if self.frame_rgba.is_none() { + return Vec::new(); } - self.frame_size.0.to_bits().hash(&mut hasher); - self.frame_size.1.to_bits().hash(&mut hasher); + let size = ( + self.frame_size.0.max(0.) as u32, + self.frame_size.1.max(0.) as u32, + ); let rect = self.annotation_image_rect(); - for value in [rect.x, rect.y, rect.width, rect.height] { - value.to_bits().hash(&mut hasher); - } - for annotation in &self.project.annotations { - if annotation.annotation_type != AnnotationType::Mask { - continue; - } - annotation.id.hash(&mut hasher); - for value in [ - annotation.x, - annotation.y, - annotation.width, - annotation.height, - annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL), - ] { - value.to_bits().hash(&mut hasher); - } - matches!(annotation.mask_type, Some(MaskType::Pixelate)).hash(&mut hasher); - } - hasher.finish() + let frame = MaskFrame { + revision: self.mask_frame_revision, + size, + content: [rect.x, rect.y, rect.width, rect.height].map(f64::to_bits), + padding: self.project.background.padding.to_bits(), + crop: self + .project + .background + .crop + .as_ref() + .map(|crop| [crop.position.x, crop.position.y, crop.size.x, crop.size.y]), + }; + self.project + .annotations + .iter() + .filter_map(|mask| mask_request(mask, frame)) + .collect() } /// `renderMaskOverlays` (`Preview.tsx:579-687`), off the main thread. The /// stale overlay keeps painting until the new one lands, so a drag never /// blocks on a resample. pub(crate) fn refresh_mask_overlays(&mut self, window: &mut Window, cx: &mut Context) { - let key = self.mask_key(); - if self.annotation_state.mask_key == key || self.annotation_state.mask_pending == Some(key) - { + let requests = self.mask_requests(); + if self.annotation_state.mask_pending.as_ref() == Some(&requests) { return; } - - let masks: Vec = self - .project - .annotations - .iter() - .filter(|annotation| annotation.annotation_type == AnnotationType::Mask) - .cloned() - .collect(); - let frame = self.frame_rgba.clone(); - - let (Some(rgba), false) = (frame, masks.is_empty()) else { - self.drop_mask_overlays(window); - // Dropping the task matters as much as dropping the tiles: a - // resample still in flight would re-install the regions of a mask - // that has just been deleted, and stamp its key over this one. + let keep_build = self + .annotation_state + .mask_pending + .as_ref() + .is_some_and(|pending| !should_cancel_mask_build(pending, &requests)); + if keep_build { + // Cancelling on each drag tick can starve installation even when filtering is fast. + self.annotation_state.mask_pending = Some(requests.clone()); + } else { self.annotation_state.mask_task = None; - self.annotation_state.mask_key = key; self.annotation_state.mask_pending = None; + } + + let retained = retain_masks(&self.annotation_state.masks, &requests); + if !retained.iter().map(|entry| &entry.request).eq(self + .annotation_state + .masks + .iter() + .map(|entry| &entry.request)) + { + self.install_mask_overlays(retained, window); cx.notify(); + } + if keep_build + || self + .annotation_state + .masks + .iter() + .map(|entry| &entry.request) + .eq(requests.iter()) + { return; - }; + } - let size = ( - self.frame_size.0.max(0.) as u32, - self.frame_size.1.max(0.) as u32, - ); - let rect = self.annotation_image_rect(); - self.annotation_state.mask_pending = Some(key); + let previous = self.annotation_state.masks.clone(); + let Some(rgba) = self.frame_rgba.clone() else { + return; + }; + self.annotation_state.mask_pending = Some(requests.clone()); self.annotation_state.mask_task = Some(cx.spawn_in(window, async move |this, cx| { let overlays = cx .background_executor() - .spawn(async move { build_mask_overlays(&rgba, size, &masks, rect) }) + .spawn(async move { build_mask_overlays(&rgba, &previous, &requests) }) .await; this.update_in(cx, |this, window, cx| { - this.drop_mask_overlays(window); - this.annotation_state.masks = overlays; - this.annotation_state.mask_key = key; + let overlays = finish_mask_build(&this.annotation_state.masks, overlays); + this.install_mask_overlays(overlays, window); this.annotation_state.mask_pending = None; cx.notify(); // The masks may have moved on while this was in flight. @@ -2332,11 +2333,17 @@ impl ScreenshotEditorWindow { })); } - /// Hand the atlas back the tiles the old overlays held. - fn drop_mask_overlays(&mut self, window: &mut Window) { - for overlay in self.annotation_state.masks.drain(..) { - let _ = window.drop_image(overlay.image); + fn install_mask_overlays( + &mut self, + overlays: Vec>, + window: &mut Window, + ) { + for previous in retired_mask_values(&self.annotation_state.masks, &overlays, |overlay| { + Arc::as_ptr(&overlay.image) + }) { + let _ = window.drop_image(previous.image.clone()); } + self.annotation_state.masks = overlays; } } @@ -2365,7 +2372,12 @@ impl ScreenshotEditorWindow { let paint = LayerPaint { annotations: self.project.annotations.clone(), temp, - masks: self.annotation_state.masks.clone(), + masks: self + .annotation_state + .masks + .iter() + .filter_map(|entry| entry.value.clone()) + .collect(), selected: self.selected_annotation.clone(), editing: self .annotation_state @@ -3943,34 +3955,49 @@ fn checker_quarters(size: f32) -> Vec { /// pixelate over a 4K screenshot is still not main-thread work. fn build_mask_overlays( rgba: &[u8], - size: (u32, u32), - masks: &[Annotation], - rect: Rect, -) -> Vec { - let mut overlays = Vec::new(); - for mask in masks { - let Some((x0, y0, processed)) = masked_region_image(rgba, size, mask, rect) else { - continue; - }; - overlays.push(MaskOverlay { + previous: &[MaskEntry], + requests: &[MaskRequest], +) -> Vec> { + update_masks(previous, requests, |request| { + let processed = request.filter(rgba)?; + Some(MaskOverlay { rect: Rect { - x: x0 as f64, - y: y0 as f64, + x: request.region.0 as f64, + y: request.region.1 as f64, width: processed.width() as f64, height: processed.height() as f64, }, image: crate::library::rgba_to_render_image(processed), - }); + }) + }) +} + +fn mask_request(mask: &Annotation, frame: MaskFrame) -> Option { + if mask.annotation_type != AnnotationType::Mask { + return None; } - overlays + Some(MaskRequest { + id: mask.id.clone(), + region: cap_gpui_kernels::mask_cache::mask_region( + [mask.x, mask.y, mask.width, mask.height], + frame.content.map(f64::from_bits), + frame.size, + )?, + frame, + pixelate: mask.mask_type == Some(MaskType::Pixelate), + level: mask + .mask_level + .unwrap_or(MASK_FALLBACK_LEVEL) + .max(1.) + .to_bits(), + }) } /// One mask's blurred or pixelated pixels, sampled from `rgba` (an unmodified /// copy of the frame) and returned with the region's top-left in frame -/// coordinates. This is the single implementation of the two filters -- the -/// preview's overlay tiles come through it, and so does the export's -/// `applyMaskAnnotations` pass, which calls it with `rect` set to the whole -/// canvas (`screenshotExport.ts:290-295` passes `{x:0,y:0,w,h}`). +/// coordinates. The export's `applyMaskAnnotations` pass calls it with +/// `rect` set to the whole canvas (`screenshotExport.ts:290-295` passes +/// `{x:0,y:0,w,h}`). pub(crate) fn masked_region_image( rgba: &[u8], size: (u32, u32), @@ -4003,18 +4030,11 @@ pub(crate) fn masked_region_image( /// One mask's region in whole frame pixels: its own rect, clipped to the /// screenshot's content rect and then to the frame. fn mask_region(mask: &Annotation, rect: Rect, frame: (u32, u32)) -> Option<(u32, u32, u32, u32)> { - let left = rect.x.max(mask.x.min(mask.x + mask.width)); - let top = rect.y.max(mask.y.min(mask.y + mask.height)); - let right = (rect.x + rect.width).min(mask.x.max(mask.x + mask.width)); - let bottom = (rect.y + rect.height).min(mask.y.max(mask.y + mask.height)); - if right <= left || bottom <= top { - return None; - } - let x0 = (left.round().max(0.) as u32).min(frame.0); - let y0 = (top.round().max(0.) as u32).min(frame.1); - let x1 = (right.round().max(0.) as u32).min(frame.0); - let y1 = (bottom.round().max(0.) as u32).min(frame.1); - (x1 > x0 && y1 > y0).then(|| (x0, y0, x1 - x0, y1 - y0)) + cap_gpui_kernels::mask_cache::mask_region( + [mask.x, mask.y, mask.width, mask.height], + [rect.x, rect.y, rect.width, rect.height], + frame, + ) } #[cfg(test)] @@ -4523,10 +4543,21 @@ mod tests { pixelate.mask_type = Some(MaskType::Pixelate); pixelate.mask_level = Some(7.); - let overlays = build_mask_overlays(&rgba, frame, &[blur, pixelate], rect); + let input = MaskFrame { + revision: 1, + size: frame, + content: [rect.x, rect.y, rect.width, rect.height].map(f64::to_bits), + padding: 0_f64.to_bits(), + crop: None, + }; + let requests: Vec<_> = [blur, pixelate] + .iter() + .filter_map(|mask| mask_request(mask, input)) + .collect(); + let overlays = build_mask_overlays(&rgba, &[], &requests); assert_eq!(overlays.len(), 2); assert_eq!( - overlays[0].rect, + overlays[0].value.as_ref().unwrap().rect, Rect { x: 8., y: 8., @@ -4535,7 +4566,7 @@ mod tests { } ); assert_eq!( - overlays[1].rect, + overlays[1].value.as_ref().unwrap().rect, Rect { x: 40., y: 30., @@ -4546,7 +4577,134 @@ mod tests { // A frame buffer that does not match the size it was handed is not // sliced at all. - assert!(build_mask_overlays(&rgba[..16], frame, &[], rect).is_empty()); + assert!( + build_mask_overlays(&rgba[..16], &[], &requests) + .iter() + .all(|entry| entry.value.is_none()) + ); + let reused = build_mask_overlays(&rgba, &overlays, &requests); + for (previous, next) in overlays.iter().zip(&reused) { + assert!(Arc::ptr_eq( + &previous.value.as_ref().unwrap().image, + &next.value.as_ref().unwrap().image + )); + } + } + + #[test] + fn duplicate_mask_ids_keep_both_regions_when_another_mask_is_deleted() { + let frame = MaskFrame { + revision: 1, + size: (64, 48), + content: [0., 0., 64., 48.].map(f64::to_bits), + padding: 0_f64.to_bits(), + crop: None, + }; + let a = shape(AnnotationType::Mask, 0., 0., 16., 16.); + let b = shape(AnnotationType::Mask, 32., 24., 16., 16.); + let mut c = shape(AnnotationType::Mask, 48., 0., 16., 16.); + c.id = "unique".into(); + assert_eq!(a.id, b.id); + let mut requests: Vec<_> = [a, b, c] + .iter() + .filter_map(|mask| mask_request(mask, frame)) + .collect(); + let rgba = vec![128; 64 * 48 * 4]; + let previous = build_mask_overlays(&rgba, &[], &requests); + requests.pop(); + for reorder in [false, true] { + if reorder { + requests.reverse(); + } + let retained = retain_masks(&previous, &requests); + assert_eq!(retained.len(), 2); + for (entry, request) in retained.iter().zip(&requests) { + assert_eq!(&entry.request, request); + let overlay = entry.value.as_ref().unwrap(); + assert_eq!(overlay.rect.x, f64::from(request.region.0)); + assert_eq!(overlay.rect.y, f64::from(request.region.1)); + } + let retired = + retired_mask_values(&previous, &retained, |overlay| Arc::as_ptr(&overlay.image)); + assert_eq!(retired.len(), 1); + assert!(Arc::ptr_eq( + &retired[0].image, + &previous[2].value.as_ref().unwrap().image + )); + let rebuilt = build_mask_overlays(&rgba, &retained, &requests); + assert!( + retired_mask_values(&retained, &rebuilt, |overlay| { + Arc::as_ptr(&overlay.image) + }) + .is_empty() + ); + } + } + + #[test] + fn coalesced_mask_build_installs_progress_then_catches_up_without_retiring_reused_images() { + let frame = MaskFrame { + revision: 1, + size: (64, 48), + content: [0., 0., 64., 48.].map(f64::to_bits), + padding: 0_f64.to_bits(), + crop: None, + }; + let a = shape(AnnotationType::Mask, 0., 0., 16., 16.); + let b = shape(AnnotationType::Mask, 32., 24., 16., 16.); + let mut moving = shape(AnnotationType::Mask, 40., 0., 16., 16.); + moving.id = "moving".into(); + let original: Vec<_> = [a, b, moving] + .iter() + .filter_map(|mask| mask_request(mask, frame)) + .collect(); + let rgba = vec![128; 64 * 48 * 4]; + let previous = build_mask_overlays(&rgba, &[], &original); + let mut started = original.clone(); + started[2].region.0 += 1; + let mut latest = started.clone(); + for _ in 0..8 { + let mut next = latest.clone(); + next[2].region.0 -= 1; + assert!(!should_cancel_mask_build(&latest, &next)); + latest = next; + } + latest.reverse(); + assert!(!should_cancel_mask_build(&started, &latest)); + let current = retain_masks(&previous, &latest); + let completed = build_mask_overlays(&rgba, &previous, &started); + let installed = finish_mask_build(¤t, completed); + assert_eq!(installed[2].request, started[2]); + let retired = + retired_mask_values(¤t, &installed, |overlay| Arc::as_ptr(&overlay.image)); + assert_eq!(retired.len(), 1); + assert!(Arc::ptr_eq( + &retired[0].image, + &previous[2].value.as_ref().unwrap().image + )); + for index in 0..2 { + assert!(Arc::ptr_eq( + &installed[index].value.as_ref().unwrap().image, + &previous[index].value.as_ref().unwrap().image + )); + } + let retained = retain_masks(&installed, &latest); + assert!( + retired_mask_values(&installed, &retained, |overlay| Arc::as_ptr(&overlay.image)) + .is_empty() + ); + let completed = build_mask_overlays(&rgba, &retained, &latest); + let caught_up = finish_mask_build(&retained, completed); + assert!( + caught_up + .iter() + .map(|entry| &entry.request) + .eq(latest.iter()) + ); + assert_eq!( + retired_mask_values(&retained, &caught_up, |overlay| Arc::as_ptr(&overlay.image)).len(), + 1 + ); } /// A finished stroke stores fractions of its own box, so a later resize diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index ecf953bdf69..8e123c37854 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -918,6 +918,7 @@ pub struct ScreenshotEditorWindow { /// back to blur/pixelate mask regions (`:537-687`); the mask overlay /// resamples this instead of the atlas image. pub(crate) frame_rgba: Option>>, + pub(crate) mask_frame_revision: u64, pub(crate) frame_size: (f32, f32), /// `originalImageSize` -- the source PNG's dimensions, which the crop /// button gates on and the frame-space transform needs. @@ -1003,6 +1004,7 @@ impl ScreenshotEditorWindow { export_status: ExportStatus::Idle, frame: None, frame_rgba: None, + mask_frame_revision: 0, frame_size: (1., 1.), image_size: None, previous_transform: None, @@ -1133,6 +1135,10 @@ impl ScreenshotEditorWindow { self.rescale_annotations_for(next); self.frame_size = next; self.frame_rgba = Some(rgba); + self.mask_frame_revision = self + .mask_frame_revision + .checked_add(1) + .expect("frame revision"); self.ready = true; if let Some(previous) = self.frame.replace(image) { let _ = window.drop_image(previous); diff --git a/apps/desktop-gpui/src/settings_pages.rs b/apps/desktop-gpui/src/settings_pages.rs index 62b3f926bc3..c0d5d6bb421 100644 --- a/apps/desktop-gpui/src/settings_pages.rs +++ b/apps/desktop-gpui/src/settings_pages.rs @@ -25,6 +25,7 @@ use gpui::{ MouseButton, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, rgb, svg, }; +use cap_cli_install as cli_install; use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -8110,647 +8111,6 @@ async fn http_json( /// depend on the crate, and the whole point is that `status`/`install`/ /// `uninstall` behave byte-for-byte like the Tauri commands /// (`src-tauri/src/cli.rs`) so the two apps manage the same shim. -mod cli_install { - #[cfg(unix)] - use std::ffi::OsStr; - use std::{ - env, fs, - path::{Path, PathBuf}, - }; - - const CAP_DIR_NAME: &str = ".cap"; - const BIN_DIR_NAME: &str = "bin"; - const CLI_BINARY_STEM: &str = "cap-cli"; - - #[cfg(windows)] - const SHIM_NAME: &str = "cap.cmd"; - #[cfg(not(windows))] - const SHIM_NAME: &str = "cap"; - - #[cfg(windows)] - const CLI_BINARY_NAME: &str = "cap-cli.exe"; - #[cfg(not(windows))] - const CLI_BINARY_NAME: &str = "cap-cli"; - - /// `CliInstallStatus`, minus the serde/specta derives the commands need - /// and minus `install_dir`, which neither cli.tsx nor this page displays - /// (`path_entry` carries the same string). - #[derive(Clone, Debug)] - pub(crate) struct CliInstallStatus { - pub shim_path: String, - pub target_path: String, - pub installed: bool, - pub on_path: bool, - pub conflict: Option, - pub path_entry: String, - pub shell_command: String, - pub path_configured: bool, - } - - fn home_dir() -> Result { - dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string()) - } - - fn install_dir() -> Result { - let home = home_dir()?; - let cap_bin = home.join(CAP_DIR_NAME).join(BIN_DIR_NAME); - let local_bin = home.join(".local/bin"); - - // Prefer whichever candidate already holds a Cap-managed shim, so - // status and install agree regardless of the launching PATH. - if let Ok(target) = target_path() { - for candidate in [&cap_bin, &local_bin] { - let shim = candidate.join(SHIM_NAME); - if shim_points_to(&shim, &target).unwrap_or(false) || shim_is_cap_managed(&shim) { - return Ok(candidate.clone()); - } - } - } - - if path_is_present(&cap_bin.join(SHIM_NAME)) || cfg!(windows) { - return Ok(cap_bin); - } - - if path_contains_install_dir(&local_bin) { - return Ok(local_bin); - } - - Ok(cap_bin) - } - - fn shim_path() -> Result { - Ok(install_dir()?.join(SHIM_NAME)) - } - - fn target_path() -> Result { - let exe = - env::current_exe().map_err(|e| format!("Could not locate Cap executable: {e}"))?; - let exe = resolve_path_for_target_lookup(exe); - let dir = exe - .parent() - .ok_or_else(|| "Could not locate Cap executable directory".to_string())?; - - for candidate in cli_binary_candidates(dir) { - if candidate.exists() { - return Ok(resolve_path_for_target_lookup(candidate)); - } - } - - Ok(dir.join(CLI_BINARY_NAME)) - } - - #[cfg(windows)] - fn resolve_path_for_target_lookup(path: PathBuf) -> PathBuf { - path - } - - #[cfg(not(windows))] - fn resolve_path_for_target_lookup(path: PathBuf) -> PathBuf { - fs::canonicalize(&path).unwrap_or(path) - } - - fn cli_binary_candidates(dir: &Path) -> Vec { - let mut names = vec![CLI_BINARY_NAME.to_string()]; - if let Some(target_triple) = current_target_triple() { - names.push(target_specific_cli_binary_name(target_triple)); - } - - let dirs = [ - dir.to_path_buf(), - dir.join("../MacOS"), - dir.join("../Resources"), - ]; - let mut candidates = Vec::new(); - for dir in dirs { - for name in &names { - candidates.push(dir.join(name)); - } - } - candidates - } - - fn target_specific_cli_binary_name(target_triple: &str) -> String { - format!( - "{CLI_BINARY_STEM}-{target_triple}{}", - exe_suffix_for_target(target_triple) - ) - } - - fn exe_suffix_for_target(target_triple: &str) -> &'static str { - if target_triple.contains("windows") { - ".exe" - } else { - "" - } - } - - fn current_target_triple() -> Option<&'static str> { - if cfg!(all( - target_os = "windows", - target_arch = "x86_64", - target_env = "msvc" - )) { - Some("x86_64-pc-windows-msvc") - } else if cfg!(all( - target_os = "windows", - target_arch = "aarch64", - target_env = "msvc" - )) { - Some("aarch64-pc-windows-msvc") - } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { - Some("aarch64-apple-darwin") - } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { - Some("x86_64-apple-darwin") - } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { - Some("x86_64-unknown-linux-gnu") - } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { - Some("aarch64-unknown-linux-gnu") - } else { - None - } - } - - #[cfg(unix)] - fn cli_binary_file_name_is_cap_managed(name: &OsStr) -> bool { - if name == CLI_BINARY_NAME { - return true; - } - - let Some(name) = name.to_str() else { - return false; - }; - - current_target_triple().is_some_and(|target_triple| { - name.eq_ignore_ascii_case(&target_specific_cli_binary_name(target_triple)) - }) - } - - fn display_path(path: &Path) -> String { - path.to_string_lossy().into_owned() - } - - fn path_is_present(path: &Path) -> bool { - fs::symlink_metadata(path).is_ok() - } - - fn path_contains_install_dir(install_dir: &Path) -> bool { - let Some(path) = env::var_os("PATH") else { - return false; - }; - - env::split_paths(&path).any(|entry| entry == install_dir) - } - - #[cfg(unix)] - fn shim_points_to(shim_path: &Path, target_path: &Path) -> Result { - match fs::read_link(shim_path) { - Ok(link) => Ok(link == target_path || same_file(&link, target_path)), - Err(err) - if matches!( - err.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidInput - ) => - { - Ok(false) - } - Err(err) => Err(format!("Could not read CLI shim: {err}")), - } - } - - #[cfg(unix)] - fn same_file(a: &Path, b: &Path) -> bool { - match (fs::canonicalize(a), fs::canonicalize(b)) { - (Ok(a), Ok(b)) => a == b, - _ => false, - } - } - - #[cfg(windows)] - fn shim_points_to(shim_path: &Path, target_path: &Path) -> Result { - let contents = match fs::read(shim_path) { - Ok(contents) => contents, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(err) => return Err(format!("Could not read CLI shim: {err}")), - }; - - let target = windows_command_path(target_path); - Ok(windows_shim_target(&contents).is_some_and(|shim_target| { - windows_shim_target_matches(shim_target, &target, |name| env::var(name).ok()) - })) - } - - #[cfg(unix)] - fn shim_is_cap_managed(shim_path: &Path) -> bool { - match fs::read_link(shim_path) { - Ok(link) => link - .file_name() - .is_some_and(cli_binary_file_name_is_cap_managed), - Err(_) => false, - } - } - - #[cfg(windows)] - fn shim_is_cap_managed(shim_path: &Path) -> bool { - fs::read(shim_path).is_ok_and(|contents| windows_shim_target(&contents).is_some()) - } - - #[cfg(windows)] - fn windows_shim_target(contents: &[u8]) -> Option<&[u8]> { - let contents = contents.strip_prefix(b"\xef\xbb\xbf").unwrap_or(contents); - let mut lines = contents.split(|byte| *byte == b'\n'); - if !trim_ascii_whitespace(lines.next()?).eq_ignore_ascii_case(b"@echo off") { - return None; - } - - let command = trim_ascii_whitespace(lines.next()?); - if lines.any(|line| !trim_ascii_whitespace(line).is_empty()) { - return None; - } - - let target = command.strip_prefix(b"\"")?.strip_suffix(b"\" %*")?; - if windows_cli_binary_file_name_is_cap_managed(windows_path_file_name(target)) { - Some(target) - } else { - None - } - } - - #[cfg(windows)] - fn windows_path_file_name(path: &[u8]) -> &[u8] { - path.rsplit(|byte| *byte == b'\\' || *byte == b'/') - .next() - .unwrap_or(path) - } - - #[cfg(windows)] - fn windows_cli_binary_file_name_is_cap_managed(name: &[u8]) -> bool { - name.eq_ignore_ascii_case(b"cap-cli.exe") - || name.eq_ignore_ascii_case(b"cap-cli-x86_64-pc-windows-msvc.exe") - || name.eq_ignore_ascii_case(b"cap-cli-aarch64-pc-windows-msvc.exe") - } - - #[cfg(windows)] - fn windows_command_path(path: &Path) -> String { - let path = display_path(path); - - if let Some(rest) = path.strip_prefix("\\\\?\\UNC\\") { - return format!("\\\\{rest}"); - } - - if let Some(rest) = path.strip_prefix("\\\\?\\") { - return rest.to_string(); - } - - path - } - - #[cfg(windows)] - fn windows_shim_target_matches(shim_target: &[u8], target: &str, env_value: F) -> bool - where - F: FnMut(&str) -> Option, - { - if shim_target.eq_ignore_ascii_case(target.as_bytes()) { - return true; - } - - let Ok(shim_target) = std::str::from_utf8(shim_target) else { - return false; - }; - - windows_expand_env_prefix(shim_target, env_value) - .is_some_and(|expanded| expanded.eq_ignore_ascii_case(target)) - } - - #[cfg(windows)] - fn windows_expand_env_prefix(target: &str, mut env_value: F) -> Option - where - F: FnMut(&str) -> Option, - { - let rest = target.strip_prefix('%')?; - let (name, suffix) = rest.split_once('%')?; - if name.is_empty() { - return None; - } - - env_value(name).map(|value| format!("{value}{suffix}")) - } - - #[cfg(windows)] - fn windows_env_prefixed_path(target: &str, mut env_value: F) -> Option - where - F: FnMut(&str) -> Option, - { - for name in ["LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"] { - let Some(value) = env_value(name) else { - continue; - }; - let Some(suffix) = strip_ascii_prefix(target, &value) else { - continue; - }; - if suffix.is_empty() || suffix.starts_with('\\') || suffix.starts_with('/') { - return Some(format!("%{name}%{suffix}")); - } - } - - None - } - - #[cfg(windows)] - fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { - let head = value.get(..prefix.len())?; - let tail = value.get(prefix.len()..)?; - head.eq_ignore_ascii_case(prefix).then_some(tail) - } - - #[cfg(windows)] - fn trim_ascii_whitespace(mut value: &[u8]) -> &[u8] { - while value.first().is_some_and(|byte| byte.is_ascii_whitespace()) { - value = &value[1..]; - } - - while value.last().is_some_and(|byte| byte.is_ascii_whitespace()) { - value = &value[..value.len() - 1]; - } - - value - } - - fn shell_command(install_dir: &Path) -> String { - let install_dir = display_path(install_dir); - - if cfg!(windows) { - let install_dir = install_dir.replace('\'', "''"); - format!( - r#"powershell -NoProfile -Command "[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path', 'User') + ';{install_dir}', 'User')""# - ) - } else { - format!(r#"export PATH="{install_dir}:$PATH""#) - } - } - - pub(crate) fn status() -> Result { - let install_dir = install_dir()?; - let shim_path = shim_path()?; - let target_path = target_path()?; - let target_exists = target_path.exists(); - let shim_exists = path_is_present(&shim_path); - let installed = target_exists && shim_points_to(&shim_path, &target_path)?; - let conflict = if shim_exists && !installed && !shim_is_cap_managed(&shim_path) { - Some(format!( - "{} already exists and is not managed by Cap", - display_path(&shim_path) - )) - } else if !target_exists { - Some(format!( - "Bundled CLI binary not found at {}", - display_path(&target_path) - )) - } else { - None - }; - - let on_path = path_contains_install_dir(&install_dir); - - Ok(CliInstallStatus { - shim_path: display_path(&shim_path), - target_path: display_path(&target_path), - installed, - on_path, - conflict, - path_entry: display_path(&install_dir), - shell_command: shell_command(&install_dir), - path_configured: path_persisted(&install_dir, on_path), - }) - } - - #[cfg(unix)] - fn write_shim(shim_path: &Path, target_path: &Path) -> Result<(), String> { - std::os::unix::fs::symlink(target_path, shim_path) - .map_err(|e| format!("Could not create CLI symlink: {e}")) - } - - #[cfg(windows)] - fn write_shim(shim_path: &Path, target_path: &Path) -> Result<(), String> { - let target = windows_command_path(target_path); - let target = - windows_env_prefixed_path(&target, |name| env::var(name).ok()).unwrap_or(target); - let contents = format!( - r#"@echo off -"{target}" %* -"# - ); - fs::write(shim_path, contents).map_err(|e| format!("Could not write CLI shim: {e}")) - } - - pub(crate) fn install() -> Result { - let install_dir = install_dir()?; - let shim_path = shim_path()?; - let target_path = target_path()?; - - if !target_path.exists() { - return Err(format!( - "Bundled CLI binary not found at {}", - display_path(&target_path) - )); - } - - fs::create_dir_all(&install_dir) - .map_err(|e| format!("Could not create CLI directory: {e}"))?; - - if path_is_present(&shim_path) { - // Repoint our own shim and any other Cap-managed one; only refuse - // to clobber a genuinely foreign file. - if !shim_points_to(&shim_path, &target_path)? && !shim_is_cap_managed(&shim_path) { - return Err(format!( - "{} already exists and is not managed by Cap", - display_path(&shim_path) - )); - } - - match fs::remove_file(&shim_path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("Could not replace CLI shim: {e}")), - } - } - - write_shim(&shim_path, &target_path)?; - - let mut status = status()?; - if !status.on_path && ensure_path_persisted(&install_dir) { - status.path_configured = true; - } - Ok(status) - } - - #[cfg(unix)] - fn ensure_path_persisted(install_dir: &Path) -> bool { - if env::var_os("CAP_NO_MODIFY_PATH").is_some() { - return false; - } - let Some(home) = dirs::home_dir() else { - return false; - }; - let needle = display_path(install_dir); - if profile_mentions_dir(&home, &needle) { - return true; - } - let shell = env::var("SHELL").unwrap_or_default(); - let shell_name = Path::new(&shell) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(""); - append_path_export(&shell_profile(&home, shell_name), &needle) - } - - #[cfg(windows)] - fn ensure_path_persisted(install_dir: &Path) -> bool { - if env::var_os("CAP_NO_MODIFY_PATH").is_some() { - return false; - } - let dir = display_path(install_dir).replace('\'', "''"); - let script = format!( - "$d = '{dir}'; \ - $u = [Environment]::GetEnvironmentVariable('Path', 'User'); \ - $e = if ($u) {{ $u -split ';' }} else {{ @() }}; \ - if ($e -notcontains $d) {{ \ - $n = if ($u) {{ \"$d;$u\" }} else {{ $d }}; \ - [Environment]::SetEnvironmentVariable('Path', $n, 'User') }}" - ); - let mut command = powershell_command(); - command - .args(["-NoProfile", "-NonInteractive", "-Command", &script]) - .status() - .map(|status| status.success()) - .unwrap_or(false) - } - - #[cfg(unix)] - fn path_persisted(install_dir: &Path, on_path: bool) -> bool { - on_path - || dirs::home_dir() - .is_some_and(|home| profile_mentions_dir(&home, &display_path(install_dir))) - } - - #[cfg(windows)] - fn path_persisted(install_dir: &Path, on_path: bool) -> bool { - on_path || windows_user_path_contains(install_dir) - } - - #[cfg(windows)] - fn windows_user_path_contains(install_dir: &Path) -> bool { - let mut command = powershell_command(); - let Ok(output) = command - .args([ - "-NoProfile", - "-NonInteractive", - "-Command", - "[Environment]::GetEnvironmentVariable('Path', 'User')", - ]) - .output() - else { - return false; - }; - if !output.status.success() { - return false; - } - let needle = display_path(install_dir); - let needle = needle.trim(); - String::from_utf8_lossy(&output.stdout) - .split(';') - .any(|entry| entry.trim().eq_ignore_ascii_case(needle)) - } - - #[cfg(windows)] - fn powershell_command() -> std::process::Command { - use std::os::windows::process::CommandExt; - - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - - let mut command = std::process::Command::new("powershell"); - command.creation_flags(CREATE_NO_WINDOW); - command - } - - #[cfg(unix)] - fn shell_profile(home: &Path, shell_name: &str) -> PathBuf { - match shell_name { - "zsh" => home.join(".zshrc"), - "bash" => home.join(".bashrc"), - "" if home.join(".zshrc").exists() => home.join(".zshrc"), - _ => home.join(".profile"), - } - } - - #[cfg(unix)] - fn profile_mentions_dir(home: &Path, needle: &str) -> bool { - [".zshrc", ".bashrc", ".bash_profile", ".profile"] - .iter() - .any(|file| fs::read_to_string(home.join(file)).is_ok_and(|c| c.contains(needle))) - } - - #[cfg(unix)] - fn append_path_export(profile: &Path, install_dir: &str) -> bool { - use std::io::Write; - let line = format!("export PATH=\"{install_dir}:$PATH\""); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(profile) - .and_then(|mut file| writeln!(file, "\n# Added by Cap\n{line}")) - .is_ok() - } - - pub(crate) fn uninstall() -> Result { - let shim_path = shim_path()?; - let target_path = target_path()?; - - if shim_points_to(&shim_path, &target_path)? { - fs::remove_file(&shim_path).map_err(|e| format!("Could not remove CLI shim: {e}"))?; - } - - status() - } - - #[cfg(all(test, unix))] - mod tests { - use super::*; - - #[test] - fn shell_profile_selection() { - let home = Path::new("/home/u"); - assert_eq!(shell_profile(home, "zsh"), home.join(".zshrc")); - assert_eq!(shell_profile(home, "bash"), home.join(".bashrc")); - assert_eq!(shell_profile(home, "fish"), home.join(".profile")); - } - - #[test] - fn cap_managed_shim_detection() { - let dir = - std::env::temp_dir().join(format!("cap-gpui-cli-test-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - let shim = dir.join(SHIM_NAME); - - // A symlink to a cap-cli binary is Cap-managed even when it points - // at a different install (the target need not exist). - std::os::unix::fs::symlink("/elsewhere/Cap.app/Contents/MacOS/cap-cli", &shim).unwrap(); - assert!(shim_is_cap_managed(&shim)); - - // A symlink to anything else is not. - fs::remove_file(&shim).unwrap(); - std::os::unix::fs::symlink("/bin/ls", &shim).unwrap(); - assert!(!shim_is_cap_managed(&shim)); - - // Nor is a regular file, so install refuses to clobber it. - fs::remove_file(&shim).unwrap(); - fs::write(&shim, b"#!/bin/sh\n").unwrap(); - assert!(!shim_is_cap_managed(&shim)); - - let _ = fs::remove_dir_all(&dir); - } - } -} // --------------------------------------------------------------------------- // Tests diff --git a/apps/desktop-gpui/src/updates.rs b/apps/desktop-gpui/src/updates.rs index 2a16bf13ed2..ae48b344e1f 100644 --- a/apps/desktop-gpui/src/updates.rs +++ b/apps/desktop-gpui/src/updates.rs @@ -91,6 +91,18 @@ fn qualifies( && remote != current) } +/// A check the user asked for answers now; a scheduled one waits for the +/// work in flight to finish before showing its modal prompt. +fn holds_scheduled_prompt(manual: bool, busy: bool) -> bool { + !manual && busy +} + +/// A manual check or a channel change queued while an automatic prompt waits +/// replaces it, so neither has to wait for the work in flight to finish. +fn supersedes_held_prompt(pending: &PendingUpdateRequests) -> bool { + pending.manual || pending.channel.is_some() +} + fn current_version() -> Result { Version::parse(env!("CARGO_PKG_VERSION")).map_err(|error| error.to_string()) } @@ -373,6 +385,30 @@ pub(crate) fn schedule_startup_check(cx: &mut App) { continue; } + // The prompt is modal and brings Cap forward, so an automatic + // check holds it until a recording, export, import or + // transcription has finished rather than interrupting it. + let mut superseded = false; + while holds_scheduled_prompt(manual, cx.update(work_in_flight)) { + let timer = cx.background_executor().timer(BUSY_RETRY_DELAY); + let request = requests.recv_async(); + futures_util::pin_mut!(timer, request); + if let Either::Right((Err(_), _)) = select(timer, request).await { + return; + } + superseded = cx.update(|cx| { + supersedes_held_prompt(&cx.global::().pending) + }); + if superseded { + break; + } + } + if superseded { + // Run the queued manual check or channel change straight away. + delay = Some(Duration::ZERO); + continue; + } + crate::platform::activate_app(); let accepted = crate::platform::confirm_dialog( "Update Cap", @@ -700,6 +736,27 @@ mod tests { } } + #[test] + fn only_scheduled_checks_wait_for_work_in_flight() { + assert!(holds_scheduled_prompt(false, true)); + assert!(!holds_scheduled_prompt(false, false)); + assert!(!holds_scheduled_prompt(true, true)); + assert!(!holds_scheduled_prompt(true, false)); + } + + #[test] + fn queued_manual_checks_and_channel_changes_replace_a_held_prompt() { + assert!(!supersedes_held_prompt(&PendingUpdateRequests::default())); + assert!(supersedes_held_prompt(&PendingUpdateRequests { + manual: true, + ..PendingUpdateRequests::default() + })); + assert!(supersedes_held_prompt(&PendingUpdateRequests { + channel: Some(UpdateChannel::Nightly), + ..PendingUpdateRequests::default() + })); + } + #[test] fn stable_channel_accepts_newer_versions_and_explicit_prerelease_downgrades() { assert!(qualifies( diff --git a/apps/web/__tests__/integration/loom-concierge-database.test.ts b/apps/web/__tests__/integration/loom-concierge-database.test.ts new file mode 100644 index 00000000000..f296f046cfa --- /dev/null +++ b/apps/web/__tests__/integration/loom-concierge-database.test.ts @@ -0,0 +1,953 @@ +import { randomUUID } from "node:crypto"; +import { db } from "@cap/database"; +import { + importedVideos, + loomMigrationImports, + loomMigrationRequests, + organizationMembers, + organizations, + users, + videos, + videoUploads, +} from "@cap/database/schema"; +import { + Organisation, + type Storage as StorageDomain, + User, + Video, +} from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { createConnection } from "mysql2/promise"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + type LoomMigrationResult, + unwrapLoomMigrationResult, +} from "@/lib/loom-migration-state"; + +const databaseUrl = process.env.CAP_LOOM_CONCIERGE_TEST_DATABASE_URL; + +const fixture = vi.hoisted(() => { + const url = process.env.CAP_LOOM_CONCIERGE_TEST_DATABASE_URL; + if (url) process.env.DATABASE_URL = url; + return { + currentUserId: null as string | null, + sendEmail: vi.fn(async (_input: unknown) => ({})), + start: vi.fn(async (_workflow: unknown, _args: unknown) => ({ + runId: "run_fixture", + })), + }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/headers", () => ({ + cookies: async () => ({ get: () => undefined }), + headers: async () => new Headers(), +})); +vi.mock("workflow/api", () => ({ start: fixture.start })); +vi.mock("@cap/database/emails/config", () => ({ + sendEmail: fixture.sendEmail, +})); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => { + if (!fixture.currentUserId) return null; + const [user] = await db() + .select() + .from(users) + .where(eq(users.id, User.UserId.make(fixture.currentUserId))) + .limit(1); + return user ?? null; + }, +})); + +const OPERATOR_EMAIL = "richie@cap.so"; +const realFetch = globalThis.fetch; + +type StorageUploadTarget = StorageDomain.UploadTarget; + +function id() { + return randomUUID().replaceAll("-", "").slice(0, 15); +} + +function loomId() { + return randomUUID().replaceAll("-", ""); +} + +function unwrapped( + action: (...args: Args) => Promise>, +) { + return async (...args: Args) => + unwrapLoomMigrationResult(await action(...args)); +} + +function signInAs(userId: string | null) { + fixture.currentUserId = userId; +} + +async function makeUser({ + email = `${id()}@example.com`, + pro = false, + activeOrganizationId = Organisation.OrganisationId.make(id()), +}: { + email?: string; + pro?: boolean; + activeOrganizationId?: Organisation.OrganisationId; +} = {}) { + const userId = User.UserId.make(id()); + await db() + .insert(users) + .values({ + id: userId, + email, + activeOrganizationId, + stripeSubscriptionStatus: pro ? "active" : null, + stripeSubscriptionId: pro ? `sub_fixture_${id()}` : null, + }); + return userId; +} + +async function makeOrganization({ + pro = true, + privateByDefault = false, +}: { + pro?: boolean; + privateByDefault?: boolean; +} = {}) { + const organizationId = Organisation.OrganisationId.make(id()); + const ownerId = await makeUser({ pro, activeOrganizationId: organizationId }); + await db() + .insert(organizations) + .values({ + id: organizationId, + ownerId, + name: "Concierge fixture", + defaultVideoVisibility: privateByDefault ? "private" : null, + }); + await db().insert(organizationMembers).values({ + id: id(), + userId: ownerId, + organizationId, + role: "owner", + }); + return { ownerId, organizationId }; +} + +async function addMember( + organizationId: Organisation.OrganisationId, + role: "member" | "admin", +) { + const userId = await makeUser(); + await db().insert(organizationMembers).values({ + id: id(), + userId, + organizationId, + role, + }); + return userId; +} + +async function getOperatorId() { + const [existing] = await db() + .select({ id: users.id }) + .from(users) + .where(eq(users.email, OPERATOR_EMAIL)) + .limit(1); + return existing?.id ?? (await makeUser({ email: OPERATOR_EMAIL })); +} + +async function getRequest(requestId: string) { + const [request] = await db() + .select() + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.id, requestId)) + .limit(1); + if (!request) throw new Error("Missing request fixture"); + return request; +} + +async function setUploadPhase( + videoId: string, + phase: "uploading" | "processing" | "complete" | "error", +) { + await db() + .update(videoUploads) + .set({ phase }) + .where(eq(videoUploads.videoId, Video.VideoId.make(videoId))); +} + +async function uploadToTarget(target: StorageUploadTarget) { + const body = new Uint8Array(4096).fill(7); + if (target.type === "put") { + const response = await realFetch(target.url, { + method: "PUT", + headers: target.headers, + body, + }); + expect(response.ok).toBe(true); + } else if (target.type === "s3Post") { + const form = new FormData(); + for (const [key, value] of Object.entries(target.fields)) { + form.append(key, value); + } + form.append("file", new Blob([body], { type: "video/mp4" })); + const response = await realFetch(target.url, { + method: "POST", + body: form, + }); + expect(response.ok).toBe(true); + } else { + throw new Error(`Unexpected upload target ${target.type}`); + } +} + +function stubLoomFetch() { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/transcoded-url") || url.includes("/raw-url")) { + return new Response( + JSON.stringify({ url: "https://cdn.loom.com/sessions/video.mp4" }), + { status: 200 }, + ); + } + if (url.startsWith("https://www.loom.com/graphql")) { + return Response.json({ data: { getVideo: { name: "Loom fixture" } } }); + } + if (url.startsWith("https://www.loom.com/v1/oembed")) { + return Response.json({ duration: 12, width: 1280, height: 720 }); + } + return realFetch(input, init); + }), + ); +} + +describe.skipIf(!databaseUrl)("Loom concierge migration database", () => { + let rawActions: typeof import("@/actions/loom-concierge"); + let rawLoom: typeof import("@/actions/loom"); + let operatorId: User.UserId; + + const actions = { + getLoomMigrationDashboard: ( + ...args: Parameters + ) => rawActions.getLoomMigrationDashboard(...args), + getLoomMigrationOperatorQueue: () => + rawActions.getLoomMigrationOperatorQueue(), + requestLoomMigration: unwrapped( + (...args: Parameters) => + rawActions.requestLoomMigration(...args), + ), + confirmLoomMigrationInvite: unwrapped((requestId: string) => + rawActions.confirmLoomMigrationInvite(requestId), + ), + answerLoomMigrationQuestion: unwrapped( + (...args: Parameters) => + rawActions.answerLoomMigrationQuestion(...args), + ), + updateLoomMigrationStatus: unwrapped( + (...args: Parameters) => + rawActions.updateLoomMigrationStatus(...args), + ), + }; + const loom = { + importFromLoomCsvForConcierge: unwrapped( + (...args: Parameters) => + rawLoom.importFromLoomCsvForConcierge(...args), + ), + createConciergeLoomFileUpload: unwrapped( + (...args: Parameters) => + rawLoom.createConciergeLoomFileUpload(...args), + ), + finishConciergeLoomFileUpload: unwrapped( + (...args: Parameters) => + rawLoom.finishConciergeLoomFileUpload(...args), + ), + }; + + beforeAll(async () => { + rawActions = await import("@/actions/loom-concierge"); + rawLoom = await import("@/actions/loom"); + operatorId = await getOperatorId(); + }); + + beforeEach(() => { + signInAs(null); + stubLoomFetch(); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + + async function requestMigration( + organizationId: Organisation.OrganisationId, + ownerId: User.UserId, + ) { + signInAs(ownerId); + const result = await actions.requestLoomMigration({ + organizationId, + workspaceName: " Acme Loom ", + note: "Two workspaces", + }); + return result.id; + } + + it("lets a Pro organization manager request one active migration", async () => { + const { ownerId, organizationId } = await makeOrganization(); + signInAs(ownerId); + const first = await actions.requestLoomMigration({ + organizationId, + workspaceName: " Acme Loom ", + note: "", + }); + const second = await actions.requestLoomMigration({ + organizationId, + workspaceName: "Again", + note: "", + }); + + expect(first.alreadyRequested).toBe(false); + expect(second).toEqual({ id: first.id, alreadyRequested: true }); + const request = await getRequest(first.id); + expect(request.workspaceName).toBe("Acme Loom"); + expect(request.status).toBe("pending"); + expect(request.activeOrganizationId).toBe(organizationId); + expect(fixture.sendEmail).toHaveBeenCalledTimes(1); + expect(fixture.sendEmail.mock.calls[0]?.[0]).toMatchObject({ + email: "hello@cap.so", + }); + + const dashboard = await actions.getLoomMigrationDashboard(organizationId); + expect(dashboard.isPro).toBe(true); + expect(dashboard.requests.map((item) => item.id)).toEqual([first.id]); + }); + + it("rejects free organizations, members, outsiders, and signed-out users", async () => { + const free = await makeOrganization({ pro: false }); + signInAs(free.ownerId); + await expect( + actions.requestLoomMigration({ + organizationId: free.organizationId, + workspaceName: "", + note: "", + }), + ).rejects.toThrow("requires Cap Pro"); + + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + + const member = await addMember(organizationId, "member"); + signInAs(member); + await expect( + actions.getLoomMigrationDashboard(organizationId), + ).rejects.toThrow(); + await expect( + actions.confirmLoomMigrationInvite(requestId), + ).rejects.toThrow(); + + const outsider = await makeOrganization(); + signInAs(outsider.ownerId); + await expect( + actions.getLoomMigrationDashboard(organizationId), + ).rejects.toThrow(); + await expect( + actions.confirmLoomMigrationInvite(requestId), + ).rejects.toThrow(); + await expect( + actions.answerLoomMigrationQuestion({ requestId, answer: "hi" }), + ).rejects.toThrow(); + + signInAs(null); + await expect(actions.confirmLoomMigrationInvite(requestId)).rejects.toThrow( + "Unauthorized", + ); + + const admin = await addMember(organizationId, "admin"); + signInAs(admin); + await actions.confirmLoomMigrationInvite(requestId); + expect((await getRequest(requestId)).invitedAt).not.toBeNull(); + }); + + it("limits every operator action to the Cap operator account", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + + signInAs(ownerId); + await expect(actions.getLoomMigrationOperatorQueue()).rejects.toThrow( + "Unauthorized", + ); + await expect( + actions.updateLoomMigrationStatus({ + requestId, + nextStatus: "in_progress", + message: "", + verified: false, + expectedVideoCount: null, + importedVideoCount: 0, + }), + ).rejects.toThrow("Unauthorized"); + await expect( + loom.importFromLoomCsvForConcierge({ requestId, rows: [] }), + ).rejects.toThrow("Unauthorized"); + await expect( + loom.createConciergeLoomFileUpload({ + requestId, + loomUrl: `https://www.loom.com/share/${loomId()}`, + userEmail: "someone@example.com", + spaceName: "", + videoTitle: "Title", + }), + ).rejects.toThrow("Unauthorized"); + await expect( + loom.finishConciergeLoomFileUpload({ + requestId, + videoId: Video.VideoId.make(id()), + }), + ).rejects.toThrow("Unauthorized"); + + signInAs(operatorId); + const queue = await actions.getLoomMigrationOperatorQueue(); + expect(queue.find((item) => item.id === requestId)).toMatchObject({ + organizationName: "Concierge fixture", + status: "pending", + }); + }); + + it("returns expected failures as data so production clients can show them", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + + signInAs(operatorId); + await expect( + rawActions.updateLoomMigrationStatus({ + requestId, + nextStatus: "completed", + message: "", + verified: false, + expectedVideoCount: 1, + importedVideoCount: 1, + }), + ).resolves.toEqual({ + ok: false, + error: "Verify the migrated library before marking it complete.", + }); + await expect( + rawLoom.finishConciergeLoomFileUpload({ + requestId: "not-a-request", + videoId: Video.VideoId.make(id()), + }), + ).resolves.toEqual({ ok: false, error: "Invalid migration request." }); + + signInAs(ownerId); + await expect( + rawLoom.importFromLoomCsvForConcierge({ requestId, rows: [] }), + ).resolves.toEqual({ ok: false, error: "Unauthorized" }); + }); + + it("keeps an open customer question while imports run and only emails visible changes", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + + signInAs(operatorId); + await actions.updateLoomMigrationStatus({ + requestId, + nextStatus: "needs_information", + message: "Please share the Sales folder", + verified: false, + expectedVideoCount: null, + importedVideoCount: 0, + }); + await loom.importFromLoomCsvForConcierge({ + requestId, + rows: [ + { + rowNumber: 2, + loomUrl: `https://www.loom.com/share/${loomId()}`, + userEmail: `${id()}@example.com`, + spaceName: "", + }, + ], + }); + expect((await getRequest(requestId)).status).toBe("needs_information"); + + fixture.sendEmail.mockClear(); + await actions.updateLoomMigrationStatus({ + requestId, + nextStatus: "needs_information", + message: "Please share the Sales folder", + verified: false, + expectedVideoCount: 5, + importedVideoCount: 1, + }); + expect(fixture.sendEmail).not.toHaveBeenCalled(); + expect((await getRequest(requestId)).expectedVideoCount).toBe(5); + + signInAs(ownerId); + await actions.answerLoomMigrationQuestion({ + requestId, + answer: "Shared it", + }); + expect((await getRequest(requestId)).status).toBe("pending"); + }); + + it("round-trips an information request with the customer", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + + signInAs(operatorId); + await expect( + actions.updateLoomMigrationStatus({ + requestId, + nextStatus: "needs_information", + message: " ", + verified: false, + expectedVideoCount: null, + importedVideoCount: 0, + }), + ).rejects.toThrow("what information you need"); + await actions.updateLoomMigrationStatus({ + requestId, + nextStatus: "needs_information", + message: "Which Loom workspace?", + verified: false, + expectedVideoCount: null, + importedVideoCount: 0, + }); + + signInAs(ownerId); + await actions.answerLoomMigrationQuestion({ + requestId, + answer: "The Acme one", + }); + const answered = await getRequest(requestId); + expect(answered.status).toBe("pending"); + expect(answered.customerReply).toBe("The Acme one"); + await expect( + actions.answerLoomMigrationQuestion({ requestId, answer: "Again" }), + ).rejects.toThrow("changed"); + }); + + it("imports CSV rows into the customer workspace under the request", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + const firstRow = { + rowNumber: 2, + loomUrl: `https://www.loom.com/share/${loomId()}`, + userEmail: `${id()}@example.com`, + spaceName: "Sales", + }; + const rows = [ + firstRow, + { + rowNumber: 3, + loomUrl: `https://www.loom.com/share/${loomId()}`, + userEmail: `${id()}@example.com`, + spaceName: "", + }, + ]; + + signInAs(operatorId); + await expect( + loom.importFromLoomCsvForConcierge({ + requestId, + rows: Array.from({ length: 11 }, () => firstRow), + }), + ).rejects.toThrow("batches of 10"); + + const result = await loom.importFromLoomCsvForConcierge({ + requestId, + rows, + }); + expect(result.importedCount).toBe(2); + expect(fixture.start).toHaveBeenCalledTimes(2); + + const mapped = await db() + .select({ videoId: loomMigrationImports.videoId, orgId: videos.orgId }) + .from(loomMigrationImports) + .innerJoin(videos, eq(videos.id, loomMigrationImports.videoId)) + .where(eq(loomMigrationImports.requestId, requestId)); + expect(mapped).toHaveLength(2); + expect(mapped.every((row) => row.orgId === organizationId)).toBe(true); + + const request = await getRequest(requestId); + expect(request.queuedVideoCount).toBe(2); + expect(request.status).toBe("in_progress"); + expect(request.activeImportCount).toBe(0); + expect(request.activeImportLeaseToken).toBeNull(); + + const repeat = await loom.importFromLoomCsvForConcierge({ + requestId, + rows: [firstRow], + }); + expect(repeat.importedCount).toBe(0); + expect((await getRequest(requestId)).queuedVideoCount).toBe(2); + }); + + it("serializes imports and recovers expired reservations", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + const row = { + rowNumber: 2, + loomUrl: `https://www.loom.com/share/${loomId()}`, + userEmail: `${id()}@example.com`, + spaceName: "", + }; + + await db() + .update(loomMigrationRequests) + .set({ + activeImportCount: 1, + activeImportLeaseToken: randomUUID(), + activeImportLeaseUntil: new Date(Date.now() + 60_000), + }) + .where(eq(loomMigrationRequests.id, requestId)); + + signInAs(operatorId); + await expect( + loom.importFromLoomCsvForConcierge({ requestId, rows: [row] }), + ).rejects.toThrow("another import is starting"); + await expect( + actions.updateLoomMigrationStatus({ + requestId, + nextStatus: "completed", + message: "", + verified: true, + expectedVideoCount: 0, + importedVideoCount: 0, + }), + ).rejects.toThrow("imports are starting"); + + await db() + .update(loomMigrationRequests) + .set({ activeImportLeaseUntil: new Date(Date.now() - 1_000) }) + .where(eq(loomMigrationRequests.id, requestId)); + const result = await loom.importFromLoomCsvForConcierge({ + requestId, + rows: [row], + }); + expect(result.importedCount).toBe(1); + expect((await getRequest(requestId)).activeImportLeaseToken).toBeNull(); + }); + + it("only completes reconciled requests with finished imports", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + + signInAs(operatorId); + await loom.importFromLoomCsvForConcierge({ + requestId, + rows: [ + { + rowNumber: 2, + loomUrl: `https://www.loom.com/share/${loomId()}`, + userEmail: `${id()}@example.com`, + spaceName: "", + }, + ], + }); + const [mapped] = await db() + .select({ videoId: loomMigrationImports.videoId }) + .from(loomMigrationImports) + .where(eq(loomMigrationImports.requestId, requestId)); + if (!mapped) throw new Error("Missing mapped import"); + + const complete = { + requestId, + nextStatus: "completed" as const, + message: "All done", + verified: true, + expectedVideoCount: 1, + importedVideoCount: 1, + }; + await expect( + actions.updateLoomMigrationStatus({ ...complete, verified: false }), + ).rejects.toThrow("Verify"); + await expect( + actions.updateLoomMigrationStatus({ + ...complete, + expectedVideoCount: null, + }), + ).rejects.toThrow("agreed source video count"); + await expect( + actions.updateLoomMigrationStatus({ ...complete, expectedVideoCount: 2 }), + ).rejects.toThrow("Reconcile"); + + for (const phase of ["uploading", "processing", "error"] as const) { + await setUploadPhase(mapped.videoId, phase); + await expect(actions.updateLoomMigrationStatus(complete)).rejects.toThrow( + "still processing", + ); + } + + const unrelated = await makeOrganization(); + signInAs(unrelated.ownerId); + const unrelatedRequest = await requestMigration( + unrelated.organizationId, + unrelated.ownerId, + ); + expect(unrelatedRequest).not.toBe(requestId); + + signInAs(operatorId); + await setUploadPhase(mapped.videoId, "complete"); + fixture.sendEmail.mockClear(); + await actions.updateLoomMigrationStatus(complete); + + const completed = await getRequest(requestId); + expect(completed.status).toBe("completed"); + expect(completed.activeOrganizationId).toBeNull(); + expect(completed.completedAt).not.toBeNull(); + expect(fixture.sendEmail).toHaveBeenCalledTimes(1); + + await expect( + actions.updateLoomMigrationStatus({ + ...complete, + nextStatus: "in_progress", + }), + ).rejects.toThrow("cannot be changed"); + await expect( + loom.importFromLoomCsvForConcierge({ requestId, rows: [] }), + ).rejects.toThrow("no longer active"); + + const queue = await actions.getLoomMigrationOperatorQueue(); + expect(queue.some((item) => item.id === requestId)).toBe(false); + + signInAs(ownerId); + const reopened = await actions.requestLoomMigration({ + organizationId, + workspaceName: "", + note: "One more workspace", + }); + expect(reopened.alreadyRequested).toBe(false); + expect(reopened.id).not.toBe(requestId); + }); + + it("uploads restricted MP4 files to storage and starts processing", async () => { + const { ownerId, organizationId } = await makeOrganization({ + privateByDefault: true, + }); + const requestId = await requestMigration(organizationId, ownerId); + const ownerEmail = `${id()}@example.com`; + const loomUrl = `https://www.loom.com/share/${loomId()}`; + + signInAs(operatorId); + await expect( + loom.createConciergeLoomFileUpload({ + requestId, + loomUrl: "http://evil.example.com/share/abcdefghijk", + userEmail: ownerEmail, + spaceName: "", + videoTitle: "Kickoff", + }), + ).rejects.toThrow("valid Loom video URL"); + + const prepared = await loom.createConciergeLoomFileUpload({ + requestId, + loomUrl, + userEmail: ownerEmail, + spaceName: "Imported", + videoTitle: "Kickoff", + }); + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, prepared.videoId)); + if (!video) throw new Error("Missing uploaded video"); + expect(video).toMatchObject({ orgId: organizationId, public: false }); + const [owner] = await db() + .select({ email: users.email }) + .from(users) + .where(eq(users.id, video.ownerId)); + expect(owner?.email).toBe(ownerEmail); + const [imported] = await db() + .select() + .from(importedVideos) + .where(eq(importedVideos.id, prepared.videoId)); + expect(imported?.source).toBe("loom"); + + await expect( + loom.finishConciergeLoomFileUpload({ + requestId, + videoId: prepared.videoId, + }), + ).rejects.toThrow("not found in storage"); + + const retry = await loom.createConciergeLoomFileUpload({ + requestId, + loomUrl, + userEmail: ownerEmail, + spaceName: "Imported", + videoTitle: "Kickoff", + }); + expect(retry.videoId).toBe(prepared.videoId); + await expect( + loom.createConciergeLoomFileUpload({ + requestId, + loomUrl, + userEmail: `${id()}@example.com`, + spaceName: "", + videoTitle: "Kickoff", + }), + ).rejects.toThrow("already in the Cap import inventory"); + + await uploadToTarget(retry.uploadTarget); + + fixture.start.mockClear(); + const finished = await loom.finishConciergeLoomFileUpload({ + requestId, + videoId: prepared.videoId, + }); + expect(finished.status).toBe("started"); + expect(fixture.start).toHaveBeenCalledTimes(1); + const [upload] = await db() + .select({ phase: videoUploads.phase }) + .from(videoUploads) + .where(eq(videoUploads.videoId, prepared.videoId)); + expect(upload?.phase).toBe("processing"); + expect((await getRequest(requestId)).queuedVideoCount).toBe(1); + + await expect( + loom.createConciergeLoomFileUpload({ + requestId, + loomUrl, + userEmail: ownerEmail, + spaceName: "", + videoTitle: "Kickoff", + }), + ).rejects.toThrow("already in the Cap import inventory"); + }); + + it("recovers a failed automatic import with a restricted MP4", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + const ownerEmail = `${id()}@example.com`; + const loomUrl = `https://www.loom.com/share/${loomId()}`; + + signInAs(operatorId); + const imported = await loom.importFromLoomCsvForConcierge({ + requestId, + rows: [{ rowNumber: 2, loomUrl, userEmail: ownerEmail, spaceName: "" }], + }); + const videoId = imported.results[0]?.videoId; + if (!videoId) throw new Error("Missing automatic import"); + await setUploadPhase(videoId, "error"); + const [failed] = await db() + .select({ rawFileKey: videoUploads.rawFileKey }) + .from(videoUploads) + .where(eq(videoUploads.videoId, videoId)); + expect(failed?.rawFileKey).toBeNull(); + + const prepared = await loom.createConciergeLoomFileUpload({ + requestId, + loomUrl, + userEmail: ownerEmail, + spaceName: "", + videoTitle: "Recovered", + }); + expect(prepared.videoId).toBe(videoId); + const [video] = await db() + .select({ ownerId: videos.ownerId }) + .from(videos) + .where(eq(videos.id, videoId)); + const [recovering] = await db() + .select({ rawFileKey: videoUploads.rawFileKey }) + .from(videoUploads) + .where(eq(videoUploads.videoId, videoId)); + expect(recovering?.rawFileKey).toBe( + `${video?.ownerId}/${videoId}/raw-upload.mp4`, + ); + expect((await getRequest(requestId)).queuedVideoCount).toBe(1); + + await uploadToTarget(prepared.uploadTarget); + fixture.start.mockClear(); + const finished = await loom.finishConciergeLoomFileUpload({ + requestId, + videoId, + }); + expect(finished.status).toBe("started"); + expect(fixture.start).toHaveBeenCalledTimes(1); + }); + + it("waits for an in-flight import transaction before completing", async () => { + const { ownerId, organizationId } = await makeOrganization(); + const requestId = await requestMigration(organizationId, ownerId); + if (!databaseUrl) throw new Error("Missing test database"); + const connection = await createConnection(databaseUrl); + const videoId = id(); + try { + await connection.beginTransaction(); + await connection.query( + "SELECT id FROM loom_migration_requests WHERE id = ? FOR UPDATE", + [requestId], + ); + await connection.query( + "INSERT INTO videos (id, ownerId, orgId, name, source, createdAt, updatedAt) VALUES (?, ?, ?, 'In flight', ?, NOW(), NOW())", + [videoId, ownerId, organizationId, JSON.stringify({ type: "webMP4" })], + ); + await connection.query( + "INSERT INTO video_uploads (video_id, phase, mode) VALUES (?, 'uploading', 'singlepart')", + [videoId], + ); + await connection.query( + "INSERT INTO loom_migration_imports (videoId, requestId, createdAt) VALUES (?, ?, NOW())", + [videoId, requestId], + ); + + signInAs(operatorId); + const completion = actions.updateLoomMigrationStatus({ + requestId, + nextStatus: "completed", + message: "", + verified: true, + expectedVideoCount: 0, + importedVideoCount: 0, + }); + const settled = completion.then( + () => "completed", + (error: Error) => error.message, + ); + await new Promise((resolve) => setTimeout(resolve, 300)); + await connection.commit(); + + expect(await settled).toContain("still processing"); + expect((await getRequest(requestId)).status).toBe("pending"); + } finally { + await connection.end(); + } + }); + + it("refuses to map another request's video", async () => { + const first = await makeOrganization(); + const firstRequest = await requestMigration( + first.organizationId, + first.ownerId, + ); + const second = await makeOrganization(); + const secondRequest = await requestMigration( + second.organizationId, + second.ownerId, + ); + + signInAs(operatorId); + const prepared = await loom.createConciergeLoomFileUpload({ + requestId: firstRequest, + loomUrl: `https://www.loom.com/share/${loomId()}`, + userEmail: `${id()}@example.com`, + spaceName: "", + videoTitle: "First", + }); + await expect( + loom.finishConciergeLoomFileUpload({ + requestId: secondRequest, + videoId: prepared.videoId, + }), + ).rejects.toThrow("no longer available"); + }); +}); diff --git a/apps/web/__tests__/unit/loom-csv.test.ts b/apps/web/__tests__/unit/loom-csv.test.ts new file mode 100644 index 00000000000..90d8db8f19e --- /dev/null +++ b/apps/web/__tests__/unit/loom-csv.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { parseConciergeLoomCsv } from "@/lib/loom-csv"; + +describe("concierge Loom CSV", () => { + it("keeps quoted workspace names and source row numbers", () => { + const rows = parseConciergeLoomCsv( + "\uFEFFuser_email,space_name,loom_video_url\r\n" + + "owner@example.com," + + JSON.stringify("Sales, Europe") + + ",https://www.loom.com/share/0123456789abcdef\r\n", + ); + expect(rows).toEqual([ + { + rowNumber: 2, + loomUrl: "https://www.loom.com/share/0123456789abcdef", + userEmail: "owner@example.com", + spaceName: "Sales, Europe", + }, + ]); + }); + + it("rejects a file without the mapping columns", () => { + expect(() => parseConciergeLoomCsv("url,email\nlink,person@x.com")).toThrow( + "loom_video_url and user_email", + ); + }); +}); diff --git a/apps/web/__tests__/unit/loom-import-ui.test.ts b/apps/web/__tests__/unit/loom-import-ui.test.ts index d92f8e90913..09e290dc5a2 100644 --- a/apps/web/__tests__/unit/loom-import-ui.test.ts +++ b/apps/web/__tests__/unit/loom-import-ui.test.ts @@ -96,6 +96,15 @@ vi.mock("next/link", () => ({ }: React.PropsWithChildren>) => React.createElement("a", props, children), })); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, +})); +vi.mock("@/actions/loom-concierge", () => ({ + answerLoomMigrationQuestion: vi.fn(), + confirmLoomMigrationInvite: vi.fn(), + getLoomMigrationDashboard: vi.fn(async () => ({ isPro: true, requests: [] })), + requestLoomMigration: vi.fn(), +})); vi.mock("@/actions/loom", () => ({ getLoomImportFolders: mocks.folders, importFromLoom: mocks.import, @@ -186,6 +195,9 @@ describe("Loom importer component", () => { it("shows the inherited subfolder and submits it then returns there", async () => { await render({ folderId: Folder.FolderId.make("child") }); await ready(); + await waitFor(() => + expect(container.textContent).toContain("Moving a whole Loom workspace?"), + ); expect( getByRole(container, "combobox", { name: "Import to" }).textContent, ).toContain("My Caps / Course / Live Calls - Two"); diff --git a/apps/web/__tests__/unit/loom-migration-state.test.ts b/apps/web/__tests__/unit/loom-migration-state.test.ts new file mode 100644 index 00000000000..87b1f90a544 --- /dev/null +++ b/apps/web/__tests__/unit/loom-migration-state.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { validateOperatorMigrationUpdate } from "@/lib/loom-migration-state"; + +const base = { + currentStatus: "in_progress" as const, + nextStatus: "completed" as const, + message: "Migration finished", + verified: true, + expectedVideoCount: 12, + importedVideoCount: 12, +}; + +describe("Loom migration completion", () => { + it("requires a verified library and reconciled counts", () => { + expect(() => + validateOperatorMigrationUpdate({ ...base, verified: false }), + ).toThrow("Verify the migrated library"); + expect(() => + validateOperatorMigrationUpdate({ ...base, importedVideoCount: 11 }), + ).toThrow("Reconcile the expected video count"); + expect(() => + validateOperatorMigrationUpdate({ ...base, expectedVideoCount: null }), + ).toThrow("Enter the agreed source video count"); + }); + + it("requires a useful message when Cap asks for information", () => { + expect(() => + validateOperatorMigrationUpdate({ + ...base, + nextStatus: "needs_information", + message: " ", + }), + ).toThrow("Tell the customer"); + }); + + it("accepts completion after verification", () => { + expect(validateOperatorMigrationUpdate(base)).toBe("Migration finished"); + }); +}); diff --git a/apps/web/__tests__/unit/signed-baa-card.test.ts b/apps/web/__tests__/unit/signed-baa-card.test.ts index 2a71b78f169..4cba9556995 100644 --- a/apps/web/__tests__/unit/signed-baa-card.test.ts +++ b/apps/web/__tests__/unit/signed-baa-card.test.ts @@ -422,11 +422,10 @@ describe("Signed BAA payment and signing", () => { it("refreshes payment status after a failed purchase instead of offering another charge", async () => { vi.mocked(getSignedBaaStatus).mockResolvedValue(createStatus({ details })); - vi.mocked(purchaseSignedBaa).mockRejectedValue( - new Error( - "The payment succeeded but the agreement could not be completed.", - ), - ); + vi.mocked(purchaseSignedBaa).mockResolvedValue({ + success: false, + error: "The payment succeeded but the agreement could not be completed.", + }); await render(); await waitFor(() => expect(button("Get Signed BAA")?.disabled).toBe(false)); await click("Get Signed BAA"); diff --git a/apps/web/__tests__/unit/signed-baa.test.ts b/apps/web/__tests__/unit/signed-baa.test.ts index 2a0320ef10a..6d23bc24520 100644 --- a/apps/web/__tests__/unit/signed-baa.test.ts +++ b/apps/web/__tests__/unit/signed-baa.test.ts @@ -103,6 +103,11 @@ vi.mock("@/lib/baa/generate-signed-baa-pdf", () => ({ const mockGetCurrentUser = getCurrentUser as ReturnType; +const failedWith = (message: string) => ({ + success: false, + error: expect.stringContaining(message), +}); + function resetMockDb() { for (const key of Object.keys(mockDb)) { const fn = mockDb[key as keyof typeof mockDb]; @@ -185,7 +190,7 @@ describe("Signed BAA entitlement", () => { noticesEmail: "legal@acme.com", signatureDataUrl: `data:image/png;base64,${"A".repeat(200)}`, }), - ).rejects.toThrow("active Cap Pro subscription"); + ).resolves.toEqual(failedWith("active Cap Pro subscription")); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); }); @@ -220,7 +225,7 @@ describe("Signed BAA entitlement", () => { noticesEmail: "legal@acme.com", signatureDataUrl: `data:image/png;base64,${"A".repeat(200)}`, }), - ).rejects.toThrow("active Cap Pro subscription"); + ).resolves.toEqual(failedWith("active Cap Pro subscription")); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); }); @@ -248,7 +253,7 @@ describe("Signed BAA entitlement", () => { noticesEmail: "legal@acme.com", signatureDataUrl: `data:image/png;base64,${"A".repeat(200)}`, }), - ).rejects.toThrow("active Cap Pro subscription"); + ).resolves.toEqual(failedWith("active Cap Pro subscription")); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); }); @@ -267,7 +272,7 @@ describe("Signed BAA entitlement", () => { ); await expect( purchaseSignedBaa("org-1" as never, VALID_INPUT), - ).rejects.toThrow("active Cap Pro subscription"); + ).resolves.toEqual(failedWith("active Cap Pro subscription")); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled(); @@ -315,9 +320,14 @@ describe("Signed BAA Stripe recovery", () => { beforeEach(() => { vi.clearAllMocks(); resetMockDb(); + mockStripe.customers.retrieve.mockReset().mockResolvedValue({ + id: "cus_1", + currency: "usd", + invoice_settings: {}, + }); }); - it("uses a stable Stripe idempotency key for the record", async () => { + it("scopes the Stripe idempotency key to the purchase claim", async () => { await setupPurchaseAttempt(); mockStripe.subscriptions.list.mockResolvedValue({ data: [] }); mockStripe.subscriptions.create.mockResolvedValue(recoveredSubscription); @@ -331,10 +341,53 @@ describe("Signed BAA Stripe recovery", () => { expect.objectContaining({ customer: "cus_1", }), - { idempotencyKey: "signed-baa-baa-record-1" }, + { + idempotencyKey: expect.stringMatching(/^signed-baa-baa-record-1-\d+$/), + }, ); }); + it("bills the BAA in the customer's existing currency", async () => { + await setupPurchaseAttempt(); + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_1", + currency: "eur", + invoice_settings: {}, + }); + mockStripe.subscriptions.list.mockResolvedValue({ data: [] }); + mockStripe.subscriptions.create.mockResolvedValue(recoveredSubscription); + const { purchaseSignedBaa } = await import( + "@/actions/organization/signed-baa" + ); + + await expect( + purchaseSignedBaa("org-1" as never, VALID_INPUT), + ).resolves.toEqual({ success: true, emailSent: true }); + + expect(mockStripe.subscriptions.create).toHaveBeenCalledWith( + expect.objectContaining({ currency: "eur" }), + expect.anything(), + ); + }); + + it("returns a generic message instead of leaking unexpected errors", async () => { + await setupPurchaseAttempt(); + mockDb.limit.mockReset(); + mockDb.limit + .mockResolvedValueOnce([{ id: "org-1", ownerId: "owner-1" }]) + .mockRejectedValueOnce( + new Error("Deadlock found when trying to get lock"), + ); + const { purchaseSignedBaa } = await import( + "@/actions/organization/signed-baa" + ); + + const result = await purchaseSignedBaa("org-1" as never, VALID_INPUT); + + expect(result).toEqual(failedWith("couldn't complete your Signed BAA")); + expect(JSON.stringify(result)).not.toContain("Deadlock"); + }); + it("activates an existing Stripe subscription after a lost create response", async () => { await setupPurchaseAttempt(); mockStripe.subscriptions.list @@ -370,7 +423,9 @@ describe("Signed BAA Stripe recovery", () => { await expect( purchaseSignedBaa("org-1" as never, VALID_INPUT), - ).rejects.toThrow("couldn't confirm the Signed BAA subscription"); + ).resolves.toEqual( + failedWith("couldn't confirm the Signed BAA subscription"), + ); expect(mockDb.set).toHaveBeenCalledWith( expect.objectContaining({ status: "pending" }), ); @@ -452,7 +507,7 @@ describe("Signed BAA Stripe recovery", () => { ); await expect( purchaseSignedBaa("org-1" as never, VALID_INPUT), - ).rejects.toThrow("Cap Pro ended"); + ).resolves.toEqual(failedWith("Cap Pro ended")); expect(mockStripe.subscriptions.cancel).toHaveBeenCalledWith( recoveredSubscription.id, ); @@ -498,7 +553,7 @@ describe("Signed BAA Stripe recovery", () => { ); await expect( purchaseSignedBaa("org-1" as never, VALID_INPUT), - ).rejects.toThrow("couldn't confirm"); + ).resolves.toEqual(failedWith("couldn't confirm")); expect(mockDb.set).toHaveBeenCalledWith({ status: "paid" }); expect(mockDb.set).not.toHaveBeenCalledWith({ status: "pending" }); }); @@ -519,7 +574,7 @@ describe("Signed BAA Stripe recovery", () => { ); await expect( purchaseSignedBaa("org-1" as never, VALID_INPUT), - ).rejects.toThrow("changed while confirming"); + ).resolves.toEqual(failedWith("changed while confirming")); expect(mockStripe.subscriptions.cancel).toHaveBeenCalledExactlyOnceWith( recoveredSubscription.id, ); @@ -733,8 +788,8 @@ describe("Paid BAA signing", () => { stripeSubscriptionId: "12345", }, ); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "active Cap Pro subscription", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("active Cap Pro subscription"), ); expect(mockDb.update).not.toHaveBeenCalled(); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); @@ -754,8 +809,8 @@ describe("Paid BAA signing", () => { }, { stripeSubscriptionId: "12345" }, ); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "active Cap Pro subscription", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("active Cap Pro subscription"), ); expect(mockDb.update).not.toHaveBeenCalled(); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); @@ -770,8 +825,8 @@ describe("Paid BAA signing", () => { }, { stripeSubscriptionId: "12345" }, ); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "payment is not confirmed", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("payment is not confirmed"), ); expect(mockDb.update).not.toHaveBeenCalled(); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); @@ -786,8 +841,8 @@ describe("Paid BAA signing", () => { { ...waivedSubscription, status }, { stripeSubscriptionId: "12345" }, ); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "payment is not confirmed", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("payment is not confirmed"), ); expect(mockDb.update).not.toHaveBeenCalled(); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); @@ -860,8 +915,8 @@ describe("Paid BAA signing", () => { it("keeps payment when the new signature cannot generate a PDF", async () => { const { signPaidBaa } = await setupPaidSigning(); vi.mocked(generateSignedBaaPdf).mockRejectedValueOnce(new Error("bad PNG")); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "generate the agreement", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("generate the agreement"), ); expect(mockDb.set).toHaveBeenCalledWith({ status: "paid" }); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); @@ -873,8 +928,8 @@ describe("Paid BAA signing", () => { status: "pending", stripeSubscriptionId: null, }); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "confirmed BAA payment is required", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("confirmed BAA payment is required"), ); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); expect(mockDb.update).not.toHaveBeenCalled(); @@ -887,8 +942,8 @@ describe("Paid BAA signing", () => { ...paidSubscription, status: "canceled", }); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "payment is not confirmed", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("payment is not confirmed"), ); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); }); @@ -901,8 +956,8 @@ describe("Paid BAA signing", () => { .mockReturnValueOnce({ affectedRows: 1 }) .mockReturnValueOnce({ affectedRows: 1 }) .mockReturnValueOnce({ affectedRows: 0 }); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "changed while signing", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("changed while signing"), ); expect(sendEmail).not.toHaveBeenCalled(); }); @@ -929,8 +984,8 @@ describe("Paid BAA signing", () => { .mockReturnValueOnce(mockDb) .mockReturnValueOnce(mockDb) .mockReturnValueOnce({ affectedRows: 0 }); - await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( - "already in progress", + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + failedWith("already in progress"), ); expect(mockDb.where).toHaveBeenCalledWith([ { field: "signedBaaId", value: "baa-record-1" }, diff --git a/apps/web/actions/loom-concierge.tsx b/apps/web/actions/loom-concierge.tsx new file mode 100644 index 00000000000..5b9452dd102 --- /dev/null +++ b/apps/web/actions/loom-concierge.tsx @@ -0,0 +1,390 @@ +"use server"; + +import { db } from "@cap/database"; +import { sendEmail } from "@cap/database/emails/config"; +import { + LoomMigrationRequestEmail, + LoomMigrationStatusEmail, +} from "@cap/database/emails/loom-migration"; +import { nanoId } from "@cap/database/helpers"; +import { + loomMigrationImports, + loomMigrationRequests, + organizations, + users, + videoUploads, +} from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import type { Organisation } from "@cap/web-domain"; +import { and, eq, inArray, isNotNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { + getCustomerMigrationRequests, + getOperatorMigrationQueue, + requireCustomerMigrationAccess, + requireMigrationOperator, + requireProCustomerMigrationAccess, + toLoomMigrationResult, +} from "@/lib/loom-concierge"; +import { + LOOM_MIGRATION_STATUS_LABELS, + LoomMigrationError, + type LoomMigrationStatus, + normalizeMigrationText, + validateOperatorMigrationUpdate, +} from "@/lib/loom-migration-state"; +import { isOrganizationOwnerPro } from "@/lib/org-pro"; + +const SUPPORT_EMAIL = "hello@cap.so"; + +function affectedRows(result: unknown) { + if (Array.isArray(result)) { + return ( + (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0 + ); + } + return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0; +} + +function refreshMigrationPages() { + revalidatePath("/dashboard/import/loom"); + revalidatePath("/dashboard/admin/loom-migrations"); +} + +async function findMigration(requestId: string) { + if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) { + throw new LoomMigrationError("Invalid migration request."); + } + const [request] = await db() + .select() + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.id, requestId)) + .limit(1); + if (!request) throw new LoomMigrationError("Migration request not found."); + return request; +} + +export async function getLoomMigrationDashboard( + organizationId: Organisation.OrganisationId, +) { + await requireCustomerMigrationAccess(organizationId); + const [isPro, requests] = await Promise.all([ + isOrganizationOwnerPro(organizationId), + getCustomerMigrationRequests(organizationId), + ]); + return { isPro, requests }; +} + +type LoomMigrationRequestInput = { + organizationId: Organisation.OrganisationId; + workspaceName: string; + note: string; +}; + +export async function requestLoomMigration(input: LoomMigrationRequestInput) { + return toLoomMigrationResult(() => createMigrationRequest(input)); +} + +async function createMigrationRequest({ + organizationId, + workspaceName, + note, +}: LoomMigrationRequestInput) { + const user = await requireProCustomerMigrationAccess(organizationId); + const normalizedWorkspaceName = normalizeMigrationText( + workspaceName, + "Workspace name", + 255, + ); + const normalizedNote = normalizeMigrationText(note, "Note", 2000); + const [existing] = await db() + .select({ id: loomMigrationRequests.id }) + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.activeOrganizationId, organizationId)) + .limit(1); + if (existing) return { id: existing.id, alreadyRequested: true }; + + const id = nanoId(); + try { + await db() + .insert(loomMigrationRequests) + .values({ + id, + organizationId, + activeOrganizationId: organizationId, + requestedByUserId: user.id, + workspaceName: normalizedWorkspaceName || null, + customerNote: normalizedNote || null, + }); + } catch (error) { + const [racedRequest] = await db() + .select({ id: loomMigrationRequests.id }) + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.activeOrganizationId, organizationId)) + .limit(1); + if (racedRequest) { + return { id: racedRequest.id, alreadyRequested: true }; + } + throw error; + } + + const [organization] = await db() + .select({ name: organizations.name }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + try { + const delivery = await sendEmail({ + email: SUPPORT_EMAIL, + subject: `New Loom migration request: ${organization?.name ?? "Cap workspace"}`, + react: ( + + ), + idempotencyKey: `loom-migration-request-${id}`, + }); + if (delivery?.error) { + console.error( + "Failed to send Loom migration request notice", + delivery.error, + ); + } + } catch (error) { + console.error("Failed to send Loom migration request notice", error); + } + + refreshMigrationPages(); + return { id, alreadyRequested: false }; +} + +export async function confirmLoomMigrationInvite(requestId: string) { + return toLoomMigrationResult(() => markMigrationInviteSent(requestId)); +} + +async function markMigrationInviteSent(requestId: string) { + const request = await findMigration(requestId); + await requireCustomerMigrationAccess(request.organizationId); + if (request.status === "completed") { + throw new LoomMigrationError("This migration is already complete."); + } + if (request.invitedAt) return; + const result = await db() + .update(loomMigrationRequests) + .set({ invitedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(loomMigrationRequests.id, requestId), + isNotNull(loomMigrationRequests.activeOrganizationId), + ), + ); + if (affectedRows(result) === 0) { + throw new LoomMigrationError( + "The migration request changed. Refresh and try again.", + ); + } + refreshMigrationPages(); +} + +type LoomMigrationAnswerInput = { requestId: string; answer: string }; + +export async function answerLoomMigrationQuestion( + input: LoomMigrationAnswerInput, +) { + return toLoomMigrationResult(() => saveMigrationAnswer(input)); +} + +async function saveMigrationAnswer({ + requestId, + answer, +}: LoomMigrationAnswerInput) { + const request = await findMigration(requestId); + await requireCustomerMigrationAccess(request.organizationId); + const normalizedAnswer = normalizeMigrationText(answer, "Answer", 2000); + if (!normalizedAnswer) + throw new LoomMigrationError("Enter the information Cap asked for."); + const result = await db() + .update(loomMigrationRequests) + .set({ + customerReply: normalizedAnswer, + status: "pending", + updatedAt: new Date(), + }) + .where( + and( + eq(loomMigrationRequests.id, requestId), + eq(loomMigrationRequests.status, "needs_information"), + ), + ); + if (affectedRows(result) === 0) { + throw new LoomMigrationError( + "The migration request changed. Refresh and try again.", + ); + } + refreshMigrationPages(); +} + +export async function getLoomMigrationOperatorQueue() { + return getOperatorMigrationQueue(); +} + +type LoomMigrationStatusInput = { + requestId: string; + nextStatus: LoomMigrationStatus; + message: string; + verified: boolean; + expectedVideoCount: number | null; + importedVideoCount: number; +}; + +export async function updateLoomMigrationStatus( + input: LoomMigrationStatusInput, +) { + return toLoomMigrationResult(() => saveMigrationStatus(input)); +} + +async function saveMigrationStatus({ + requestId, + nextStatus, + message, + verified, + expectedVideoCount, + importedVideoCount, +}: LoomMigrationStatusInput) { + const operator = await requireMigrationOperator(); + const request = await findMigration(requestId); + const normalizedMessage = validateOperatorMigrationUpdate({ + currentStatus: request.status, + nextStatus, + message, + verified, + expectedVideoCount, + importedVideoCount, + }); + if ( + nextStatus === request.status && + (normalizedMessage || null) === request.capMessage && + expectedVideoCount === request.expectedVideoCount && + importedVideoCount === request.importedVideoCount + ) { + return; + } + const [organization] = await db() + .select({ name: organizations.name }) + .from(organizations) + .where(eq(organizations.id, request.organizationId)) + .limit(1); + await db().transaction(async (tx) => { + const now = new Date(); + const [current] = await tx + .select({ + status: loomMigrationRequests.status, + activeOrganizationId: loomMigrationRequests.activeOrganizationId, + activeImportCount: loomMigrationRequests.activeImportCount, + activeImportLeaseUntil: loomMigrationRequests.activeImportLeaseUntil, + }) + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.id, requestId)) + .limit(1) + .for("update"); + const importStarting = + !!current && + current.activeImportCount > 0 && + (!current.activeImportLeaseUntil || current.activeImportLeaseUntil > now); + if ( + !current?.activeOrganizationId || + current.status !== request.status || + (nextStatus === "completed" && importStarting) + ) { + throw new LoomMigrationError( + "The migration request changed or imports are starting. Refresh and try again.", + ); + } + if (nextStatus === "completed") { + const [unfinishedImport] = await tx + .select({ videoId: videoUploads.videoId }) + .from(loomMigrationImports) + .innerJoin( + videoUploads, + eq(videoUploads.videoId, loomMigrationImports.videoId), + ) + .where( + and( + eq(loomMigrationImports.requestId, requestId), + inArray(videoUploads.phase, [ + "uploading", + "processing", + "generating_thumbnail", + "error", + ]), + ), + ) + .limit(1) + .for("share"); + if (unfinishedImport) { + throw new LoomMigrationError( + "Loom imports are still processing or have errors. Resolve them before completion.", + ); + } + } + await tx + .update(loomMigrationRequests) + .set({ + status: nextStatus, + capMessage: normalizedMessage || null, + expectedVideoCount, + importedVideoCount, + activeImportCount: nextStatus === "completed" ? 0 : undefined, + activeImportLeaseToken: nextStatus === "completed" ? null : undefined, + activeImportLeaseUntil: nextStatus === "completed" ? null : undefined, + activeOrganizationId: + nextStatus === "completed" ? null : request.organizationId, + completedAt: nextStatus === "completed" ? now : null, + lastOperatorUserId: operator.id, + lastOperatorAt: now, + updatedAt: now, + }) + .where(eq(loomMigrationRequests.id, requestId)); + }); + + const customerVisibleChange = + nextStatus !== request.status || + (normalizedMessage || null) !== request.capMessage; + const [requester] = customerVisibleChange + ? await db() + .select({ email: users.email }) + .from(users) + .where(eq(users.id, request.requestedByUserId)) + .limit(1) + : []; + if (requester) { + try { + const delivery = await sendEmail({ + email: requester.email, + subject: `Loom migration: ${LOOM_MIGRATION_STATUS_LABELS[nextStatus]}`, + react: ( + + ), + replyTo: SUPPORT_EMAIL, + idempotencyKey: `loom-migration-status-${requestId}-${Date.now()}`, + }); + if (delivery?.error) { + console.error( + "Failed to send Loom migration status notice", + delivery.error, + ); + } + } catch (error) { + console.error("Failed to send Loom migration status notice", error); + } + } + refreshMigrationPages(); +} diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 80386e189c7..96dca601f55 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -7,7 +7,10 @@ import { nanoId } from "@cap/database/helpers"; import { folders, importedVideos, + loomMigrationImports, + loomMigrationRequests, organizationMembers, + organizations, sharedVideos, spaceMembers, spaces, @@ -24,11 +27,12 @@ import { type Organisation, Space, SpaceMemberId, + type Storage as StorageDomain, type User, Video, } from "@cap/web-domain"; -import { and, asc, eq, isNull } from "drizzle-orm"; -import { Option } from "effect"; +import { and, asc, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import { Effect, Option, Schedule } from "effect"; import { revalidatePath } from "next/cache"; import { start } from "workflow/api"; import { @@ -37,10 +41,24 @@ import { requireOrganizationSettingsManager, } from "@/actions/organization/authorization"; import { requireSpaceManager } from "@/actions/organization/space-authorization"; +import { + releaseConciergeImport, + renewConciergeImport, + requireMigrationOperator, + reserveConciergeImport, + toLoomMigrationResult, +} from "@/lib/loom-concierge"; import type { LoomImportDestination } from "@/lib/loom-import-destination"; +import { + LoomMigrationError, + type LoomMigrationResult, +} from "@/lib/loom-migration-state"; +import { isOrganizationOwnerPro } from "@/lib/org-pro"; import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; import { canManageOrganizationSettings } from "@/lib/permissions/roles"; import { runPromise } from "@/lib/server"; +import { startVideoProcessingWorkflow } from "@/lib/video-processing"; +import { decodeStorageVideo } from "@/lib/video-storage"; import { importLoomVideoWorkflow } from "@/workflows/import-loom-video"; interface LoomUrlResponse { @@ -97,22 +115,26 @@ const MAX_LOOM_SPACE_NAME_LENGTH = 255; const LOOM_CSV_LIMIT_ERROR = `CSV imports are limited to ${MAX_LOOM_CSV_ROWS} rows at a time. Contact support to raise this limit.`; const LOOM_CSV_PERMISSION_ERROR = "Only organization admins and owners can import Loom videos from a CSV."; +const LOOM_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{10,64}$/; function extractLoomVideoId(url: string): string | null { try { const parsed = new URL(url); - if (!parsed.hostname.includes("loom.com")) { + if ( + parsed.protocol !== "https:" || + (parsed.hostname !== "loom.com" && !parsed.hostname.endsWith(".loom.com")) + ) { return null; } const pathParts = parsed.pathname.split("/").filter(Boolean); const id = pathParts[pathParts.length - 1] ?? null; - if (!id || id.length < 10) { + if (!id || !LOOM_VIDEO_ID_PATTERN.test(id)) { return null; } - return id.split("?")[0] ?? null; + return id; } catch { return null; } @@ -120,7 +142,7 @@ function extractLoomVideoId(url: string): string | null { async function fetchLoomEndpoint( videoId: string, - endpoint: string, + endpoint: "transcoded-url" | "raw-url", includeBody = true, ): Promise { try { @@ -139,7 +161,7 @@ async function fetchLoomEndpoint( } const response = await fetch( - `https://www.loom.com/api/campaigns/sessions/${videoId}/${endpoint}`, + `https://www.loom.com/api/campaigns/sessions/${encodeURIComponent(videoId)}/${endpoint}`, options, ); @@ -201,7 +223,10 @@ function isDirectMp4Url(url: string): boolean { } async function getLoomDownloadUrl(loomVideoId: string): Promise { - const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [ + const requestVariants: Array<{ + endpoint: "transcoded-url" | "raw-url"; + includeBody: boolean; + }> = [ { endpoint: "transcoded-url", includeBody: true }, { endpoint: "raw-url", includeBody: true }, { endpoint: "transcoded-url", includeBody: false }, @@ -227,7 +252,7 @@ async function fetchLoomOEmbed( ): Promise<{ duration?: number; width?: number; height?: number } | null> { try { const response = await fetch( - `https://www.loom.com/v1/oembed?url=https://www.loom.com/share/${loomVideoId}`, + `https://www.loom.com/v1/oembed?url=${encodeURIComponent(`https://www.loom.com/share/${loomVideoId}`)}`, { headers: { Accept: "application/json" } }, ); if (!response.ok) return null; @@ -301,11 +326,15 @@ async function importLoomVideoForOwner({ orgId, ownerId, destination = {}, + migrationRequestId, + migrationLeaseToken, }: { loomUrl: string; orgId: Organisation.OrganisationId; ownerId: User.UserId; destination?: LoomImportDestination; + migrationRequestId?: string; + migrationLeaseToken?: string; }): Promise { const loomVideoId = extractLoomVideoId(loomUrl.trim()); if (!loomVideoId) { @@ -396,6 +425,29 @@ async function importLoomVideoForOwner({ `Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`; await db().transaction(async (tx) => { + if (migrationRequestId) { + const [reservation] = await tx + .select({ + activeOrganizationId: loomMigrationRequests.activeOrganizationId, + leaseToken: loomMigrationRequests.activeImportLeaseToken, + leaseUntil: loomMigrationRequests.activeImportLeaseUntil, + }) + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.id, migrationRequestId)) + .limit(1) + .for("update"); + if ( + !migrationLeaseToken || + !reservation?.activeOrganizationId || + reservation.leaseToken !== migrationLeaseToken || + !reservation.leaseUntil || + reservation.leaseUntil <= new Date() + ) { + throw new LoomMigrationError( + "The concierge import expired. Retry this video.", + ); + } + } await tx.insert(videos).values({ id: videoId, name, @@ -424,6 +476,19 @@ async function importLoomVideoForOwner({ source: "loom", sourceId: loomVideoId, }); + if (migrationRequestId) { + await tx.insert(loomMigrationImports).values({ + videoId, + requestId: migrationRequestId, + }); + await tx + .update(loomMigrationRequests) + .set({ + queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`, + updatedAt: new Date(), + }) + .where(eq(loomMigrationRequests.id, migrationRequestId)); + } if (destination.spaceId === orgId) { await tx.insert(sharedVideos).values({ @@ -771,6 +836,22 @@ export async function importFromLoomCsv({ }; } + return processLoomCsvRows({ rows, orgId, actorId: user.id }); +} + +async function processLoomCsvRows({ + rows, + orgId, + actorId, + migrationRequestId, + migrationLeaseToken, +}: { + rows: LoomCsvImportRow[]; + orgId: Organisation.OrganisationId; + actorId: User.UserId; + migrationRequestId?: string; + migrationLeaseToken?: string; +}): Promise { const inputRows = Array.isArray(rows) ? rows : []; const normalizedRows = inputRows .map((row, index) => ({ @@ -815,6 +896,14 @@ export async function importFromLoomCsv({ const touchedSpaceIds = new Set(); for (const row of normalizedRows) { + if (migrationRequestId) { + if (!migrationLeaseToken) { + throw new LoomMigrationError( + "The concierge import is missing its reservation.", + ); + } + await renewConciergeImport(migrationRequestId, migrationLeaseToken); + } if (!row.loomUrl) { results.push({ rowNumber: row.rowNumber, @@ -855,7 +944,7 @@ export async function importFromLoomCsv({ const provisionedMember = await provisionOrganizationInvitee({ organizationId: orgId, email: row.userEmail, - invitedByUserId: user.id, + invitedByUserId: actorId, role: "member", }); member = { @@ -879,6 +968,8 @@ export async function importFromLoomCsv({ loomUrl: row.loomUrl, orgId, ownerId: member.userId, + migrationRequestId, + migrationLeaseToken, }); let spaceName = row.spaceName || undefined; @@ -887,14 +978,14 @@ export async function importFromLoomCsv({ try { const space = await getOrCreateImportSpace({ orgId, - createdById: user.id, + createdById: actorId, name: row.spaceName, spaceCache, }); await addImportedVideoToSpace({ videoId: result.videoId, spaceId: space.id, - addedById: user.id, + addedById: actorId, }); await addImportOwnerToSpace({ spaceId: space.id, @@ -945,3 +1036,480 @@ export async function importFromLoomCsv({ error: importedCount > 0 ? undefined : "No Loom videos were imported.", }; } + +export async function importFromLoomCsvForConcierge(input: { + requestId: string; + rows: LoomCsvImportRow[]; +}): Promise> { + return toLoomMigrationResult(() => importConciergeCsvRows(input)); +} + +async function importConciergeCsvRows({ + requestId, + rows, +}: { + requestId: string; + rows: LoomCsvImportRow[]; +}): Promise { + const operator = await requireMigrationOperator(); + if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) { + throw new LoomMigrationError("Invalid migration request."); + } + const [request] = await db() + .select({ + organizationId: loomMigrationRequests.organizationId, + status: loomMigrationRequests.status, + ownerId: organizations.ownerId, + }) + .from(loomMigrationRequests) + .innerJoin( + organizations, + eq(organizations.id, loomMigrationRequests.organizationId), + ) + .where( + and( + eq(loomMigrationRequests.id, requestId), + isNotNull(loomMigrationRequests.activeOrganizationId), + isNull(organizations.tombstoneAt), + ), + ) + .limit(1); + if (!request || request.status === "completed") { + throw new LoomMigrationError("The migration request is no longer active."); + } + if (!(await isOrganizationOwnerPro(request.organizationId))) { + throw new LoomMigrationError("The destination workspace needs Cap Pro."); + } + if (!Array.isArray(rows) || rows.length > 10) { + throw new LoomMigrationError( + "Concierge CSV imports run in batches of 10 videos.", + ); + } + const leaseToken = await reserveConciergeImport(requestId, operator.id); + try { + const result = await processLoomCsvRows({ + rows, + orgId: request.organizationId, + actorId: request.ownerId, + migrationRequestId: requestId, + migrationLeaseToken: leaseToken, + }); + revalidatePath("/dashboard/import/loom"); + revalidatePath("/dashboard/admin/loom-migrations"); + return result; + } finally { + await releaseConciergeImport(requestId, leaseToken); + } +} + +type ConciergeLoomFileUploadInput = { + requestId: string; + loomUrl: string; + userEmail: string; + spaceName: string; + videoTitle: string; +}; + +type ConciergeLoomFileUpload = { + videoId: Video.VideoId; + uploadTarget: StorageDomain.UploadTarget; +}; + +export async function createConciergeLoomFileUpload( + input: ConciergeLoomFileUploadInput, +): Promise> { + return toLoomMigrationResult(() => prepareConciergeLoomFileUpload(input)); +} + +async function prepareConciergeLoomFileUpload({ + requestId, + loomUrl, + userEmail, + spaceName, + videoTitle, +}: ConciergeLoomFileUploadInput): Promise { + const operator = await requireMigrationOperator(); + if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) { + throw new LoomMigrationError("Invalid migration request."); + } + const loomVideoId = extractLoomVideoId(loomUrl.trim()); + if (!loomVideoId) + throw new LoomMigrationError("Enter a valid Loom video URL."); + const normalizedEmail = normalizeImportEmail(userEmail); + if (!isValidImportEmail(normalizedEmail)) { + throw new LoomMigrationError("Enter a valid destination owner email."); + } + const normalizedSpaceName = normalizeImportSpaceName(spaceName); + if (!isValidImportSpaceName(normalizedSpaceName)) { + throw new LoomMigrationError("Space name is too long."); + } + const normalizedTitle = videoTitle.trim(); + if (!normalizedTitle || normalizedTitle.length > 255) { + throw new LoomMigrationError( + "Enter a video title of 255 characters or fewer.", + ); + } + const [request] = await db() + .select({ + organizationId: loomMigrationRequests.organizationId, + ownerId: organizations.ownerId, + }) + .from(loomMigrationRequests) + .innerJoin( + organizations, + eq(organizations.id, loomMigrationRequests.organizationId), + ) + .where( + and( + eq(loomMigrationRequests.id, requestId), + isNotNull(loomMigrationRequests.activeOrganizationId), + isNull(organizations.tombstoneAt), + ), + ) + .limit(1); + if (!request) + throw new LoomMigrationError("The migration request is no longer active."); + if (!(await isOrganizationOwnerPro(request.organizationId))) { + throw new LoomMigrationError("The destination workspace needs Cap Pro."); + } + const leaseToken = await reserveConciergeImport(requestId, operator.id); + try { + const [existing] = await db() + .select({ + id: importedVideos.id, + ownerId: videos.ownerId, + ownerEmail: users.email, + bucket: videos.bucket, + storageIntegrationId: videos.storageIntegrationId, + rawFileKey: videoUploads.rawFileKey, + phase: videoUploads.phase, + processingMessage: videoUploads.processingMessage, + }) + .from(importedVideos) + .leftJoin( + videos, + and( + eq(videos.id, importedVideos.id), + eq(videos.orgId, importedVideos.orgId), + ), + ) + .leftJoin(users, eq(users.id, videos.ownerId)) + .leftJoin(videoUploads, eq(videoUploads.videoId, importedVideos.id)) + .where( + and( + eq(importedVideos.orgId, request.organizationId), + eq(importedVideos.source, "loom"), + eq(importedVideos.sourceId, loomVideoId), + ), + ) + .limit(1); + if (existing) { + const existingRawFileKey = + existing.rawFileKey ?? + (existing.phase === "error" && existing.ownerId + ? `${existing.ownerId}/${existing.id}/raw-upload.mp4` + : null); + if ( + !existing.ownerId || + !existing.ownerEmail || + normalizeImportEmail(existing.ownerEmail) !== normalizedEmail || + !existingRawFileKey || + (existing.phase !== "error" && + (existing.phase !== "uploading" || + existing.processingMessage !== "Uploading Loom video...")) + ) { + throw new LoomMigrationError( + "This Loom video is already in the Cap import inventory.", + ); + } + const retryUpload = await Storage.createUploadTargetForUser( + existing.ownerId, + existingRawFileKey, + { + contentType: "video/mp4", + videoTitle: normalizedTitle, + method: "put", + fields: { "x-amz-meta-userid": existing.ownerId }, + }, + request.organizationId, + ).pipe(runPromise); + if ( + Option.getOrNull(retryUpload.bucketId) !== existing.bucket || + Option.getOrNull(retryUpload.storageIntegrationId) !== + existing.storageIntegrationId + ) { + throw new LoomMigrationError( + "The Cap storage destination changed. Review this upload before retrying.", + ); + } + const [mapped] = await db() + .select({ requestId: loomMigrationImports.requestId }) + .from(loomMigrationImports) + .where( + eq(loomMigrationImports.videoId, Video.VideoId.make(existing.id)), + ) + .limit(1); + if (mapped && mapped.requestId !== requestId) { + throw new LoomMigrationError( + "This Loom import belongs to another migration request.", + ); + } + if (!mapped || !existing.rawFileKey) { + await db().transaction(async (tx) => { + const [reservation] = await tx + .select({ + activeOrganizationId: loomMigrationRequests.activeOrganizationId, + leaseToken: loomMigrationRequests.activeImportLeaseToken, + leaseUntil: loomMigrationRequests.activeImportLeaseUntil, + }) + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.id, requestId)) + .limit(1) + .for("update"); + if ( + !reservation?.activeOrganizationId || + reservation.leaseToken !== leaseToken || + !reservation.leaseUntil || + reservation.leaseUntil <= new Date() + ) { + throw new LoomMigrationError( + "The concierge import expired. Retry this video.", + ); + } + if (!existing.rawFileKey) { + await tx + .update(videoUploads) + .set({ rawFileKey: existingRawFileKey, updatedAt: new Date() }) + .where( + and( + eq(videoUploads.videoId, Video.VideoId.make(existing.id)), + eq(videoUploads.phase, "error"), + isNull(videoUploads.rawFileKey), + ), + ); + } + if (mapped) return; + await tx.insert(loomMigrationImports).values({ + videoId: Video.VideoId.make(existing.id), + requestId, + }); + await tx + .update(loomMigrationRequests) + .set({ + queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`, + updatedAt: new Date(), + }) + .where(eq(loomMigrationRequests.id, requestId)); + }); + } + return { + videoId: Video.VideoId.make(existing.id), + uploadTarget: retryUpload.upload, + }; + } + let member = await getOrganizationMemberByEmail( + request.organizationId, + normalizedEmail, + ); + if (!member) { + const provisioned = await provisionOrganizationInvitee({ + organizationId: request.organizationId, + email: normalizedEmail, + invitedByUserId: request.ownerId, + role: "member", + }); + member = { userId: provisioned.userId, email: normalizedEmail }; + } + const space = normalizedSpaceName + ? await getOrCreateImportSpace({ + orgId: request.organizationId, + createdById: request.ownerId, + name: normalizedSpaceName, + spaceCache: new Map(), + }) + : null; + if (space) { + await addImportOwnerToSpace({ spaceId: space.id, userId: member.userId }); + } + const videoId = Video.VideoId.make(nanoId()); + const rawFileKey = `${member.userId}/${videoId}/raw-upload.mp4`; + const upload = await Storage.createUploadTargetForUser( + member.userId, + rawFileKey, + { + contentType: "video/mp4", + videoTitle: normalizedTitle, + method: "put", + fields: { "x-amz-meta-userid": member.userId }, + }, + request.organizationId, + ).pipe(runPromise); + const isPublic = await getNewVideoPublic(request.organizationId); + await db().transaction(async (tx) => { + const [reservation] = await tx + .select({ + activeOrganizationId: loomMigrationRequests.activeOrganizationId, + leaseToken: loomMigrationRequests.activeImportLeaseToken, + leaseUntil: loomMigrationRequests.activeImportLeaseUntil, + }) + .from(loomMigrationRequests) + .where(eq(loomMigrationRequests.id, requestId)) + .limit(1) + .for("update"); + if ( + !reservation?.activeOrganizationId || + reservation.leaseToken !== leaseToken || + !reservation.leaseUntil || + reservation.leaseUntil <= new Date() + ) { + throw new LoomMigrationError( + "The concierge import expired. Retry this video.", + ); + } + await tx.insert(videos).values({ + id: videoId, + name: normalizedTitle, + ownerId: member.userId, + orgId: request.organizationId, + source: { type: "webMP4" as const }, + bucket: Option.getOrNull(upload.bucketId), + storageIntegrationId: Option.getOrNull(upload.storageIntegrationId), + public: isPublic, + }); + await tx.insert(videoUploads).values({ + videoId, + mode: "singlepart", + phase: "uploading", + processingProgress: 0, + processingMessage: "Uploading Loom video...", + rawFileKey, + }); + await tx.insert(importedVideos).values({ + id: videoId, + orgId: request.organizationId, + source: "loom", + sourceId: loomVideoId, + }); + await tx.insert(loomMigrationImports).values({ videoId, requestId }); + await tx + .update(loomMigrationRequests) + .set({ + queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`, + updatedAt: new Date(), + }) + .where(eq(loomMigrationRequests.id, requestId)); + if (space) { + await tx.insert(spaceVideos).values({ + id: nanoId(), + videoId, + spaceId: space.id, + addedById: request.ownerId, + }); + } + }); + revalidatePath("/dashboard/admin/loom-migrations"); + return { videoId, uploadTarget: upload.upload }; + } finally { + await releaseConciergeImport(requestId, leaseToken); + } +} + +export async function finishConciergeLoomFileUpload(input: { + requestId: string; + videoId: Video.VideoId; +}) { + return toLoomMigrationResult(() => startConciergeLoomFileProcessing(input)); +} + +async function startConciergeLoomFileProcessing({ + requestId, + videoId, +}: { + requestId: string; + videoId: Video.VideoId; +}) { + const operator = await requireMigrationOperator(); + if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) { + throw new LoomMigrationError("Invalid migration request."); + } + const leaseToken = await reserveConciergeImport(requestId, operator.id); + try { + const [record] = await db() + .select({ + video: videos, + rawFileKey: videoUploads.rawFileKey, + phase: videoUploads.phase, + processingMessage: videoUploads.processingMessage, + }) + .from(videos) + .innerJoin( + importedVideos, + and( + eq(importedVideos.id, videos.id), + eq(importedVideos.orgId, videos.orgId), + ), + ) + .innerJoin(videoUploads, eq(videoUploads.videoId, videos.id)) + .innerJoin( + loomMigrationImports, + eq(loomMigrationImports.videoId, videos.id), + ) + .innerJoin( + loomMigrationRequests, + and( + eq(loomMigrationRequests.id, loomMigrationImports.requestId), + eq(loomMigrationRequests.organizationId, importedVideos.orgId), + ), + ) + .where( + and( + eq(videos.id, videoId), + eq(loomMigrationRequests.id, requestId), + eq(importedVideos.source, "loom"), + isNotNull(loomMigrationRequests.activeOrganizationId), + ), + ) + .limit(1); + if ( + !record || + !record.rawFileKey || + (record.phase !== "error" && + (record.phase !== "uploading" || + record.processingMessage !== "Uploading Loom video...")) + ) { + throw new LoomMigrationError("This Loom upload is no longer available."); + } + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(record.video), + ).pipe(runPromise); + const head = await bucket + .headObject(record.rawFileKey) + .pipe( + Effect.retry({ + times: 3, + schedule: Schedule.exponential("100 millis"), + }), + runPromise, + ) + .catch(() => { + throw new LoomMigrationError( + "The uploaded file was not found in storage. Upload it again.", + ); + }); + if ((head.ContentLength ?? 0) <= 0) + throw new LoomMigrationError("The uploaded file is empty."); + const status = await startVideoProcessingWorkflow({ + videoId, + userId: record.video.ownerId, + rawFileKey: record.rawFileKey, + bucketId: record.video.bucket, + processingMessage: "Processing Loom video...", + startFailureMessage: "Loom file processing could not start.", + }); + revalidatePath("/dashboard/import/loom"); + revalidatePath("/dashboard/admin/loom-migrations"); + return { status }; + } finally { + await releaseConciergeImport(requestId, leaseToken); + } +} diff --git a/apps/web/actions/organization/signed-baa.ts b/apps/web/actions/organization/signed-baa.ts index 2b00763176e..f5a8c5bcddd 100644 --- a/apps/web/actions/organization/signed-baa.ts +++ b/apps/web/actions/organization/signed-baa.ts @@ -40,6 +40,10 @@ const CAP_PRO_STATUSES = new Set([ "past_due", ]); +// Production server actions replace thrown messages with a generic React error, +// so customer-facing failures are thrown as this type and returned as results. +class SignedBaaError extends Error {} + function ownerCanPurchaseSignedBaa(user: { stripeCustomerId?: string | null; stripeSubscriptionStatus?: string | null; @@ -84,7 +88,7 @@ function getBaaPriceId() { serverEnv().VERCEL_ENV === "production" ? "production" : "development"; const priceId = STRIPE_SIGNED_BAA_PRICE_IDS[environment]; if (!priceId || priceId.includes("REPLACE")) { - throw new Error( + throw new SignedBaaError( "Signed BAA billing is not configured. Please contact support.", ); } @@ -93,7 +97,7 @@ function getBaaPriceId() { async function getOwnerContext(organizationId: Organisation.OrganisationId) { const user = await getCurrentUser(); - if (!user) throw new Error("Unauthorized"); + if (!user) throw new SignedBaaError("Unauthorized"); const [organization] = await db() .select() @@ -101,9 +105,11 @@ async function getOwnerContext(organizationId: Organisation.OrganisationId) { .where(eq(organizations.id, organizationId)) .limit(1); - if (!organization) throw new Error("Organization not found"); + if (!organization) throw new SignedBaaError("Organization not found"); if (organization.ownerId !== user.id) - throw new Error("Only the organization owner can manage the Signed BAA"); + throw new SignedBaaError( + "Only the organization owner can manage the Signed BAA", + ); return { user, organization }; } @@ -166,7 +172,9 @@ export async function confirmSignedBaaPayment( ): Promise { const { user } = await getOwnerContext(organizationId); if (!sessionId.startsWith("cs_") || sessionId.length > 255) { - throw new Error("Invalid payment confirmation. Please contact support."); + throw new SignedBaaError( + "Invalid payment confirmation. Please contact support.", + ); } const session = await stripe().checkout.sessions.retrieve(sessionId); const subscriptionId = @@ -174,7 +182,7 @@ export async function confirmSignedBaaPayment( ? session.subscription : session.subscription?.id; if (!subscriptionId) { - throw new Error("No BAA subscription was found for this payment."); + throw new SignedBaaError("No BAA subscription was found for this payment."); } const subscription = await stripe().subscriptions.retrieve(subscriptionId); const record = await attachPaidBaaCheckout(session, subscription, { @@ -182,7 +190,7 @@ export async function confirmSignedBaaPayment( organizationId, }); if (!record) { - throw new Error( + throw new SignedBaaError( "We couldn't confirm a paid BAA for this organization. Please retry once payment completes, or contact support. Do not pay again.", ); } @@ -206,7 +214,9 @@ function validateInput(input: SignedBaaInput) { const requireLength = (value: string, label: string, max: number) => { if (value.length < 2 || value.length > max) { - throw new Error(`${label} must be between 2 and ${max} characters`); + throw new SignedBaaError( + `${label} must be between 2 and ${max} characters`, + ); } }; @@ -219,21 +229,21 @@ function validateInput(input: SignedBaaInput) { !EMAIL_REGEX.test(trimmed.noticesEmail) || trimmed.noticesEmail.length > 255 ) { - throw new Error("Please provide a valid notices email address"); + throw new SignedBaaError("Please provide a valid notices email address"); } if ( !trimmed.signatureDataUrl.startsWith(SIGNATURE_PREFIX) || trimmed.signatureDataUrl.length < 200 || trimmed.signatureDataUrl.length > 700_000 ) { - throw new Error("Please provide a valid signature"); + throw new SignedBaaError("Please provide a valid signature"); } return trimmed; } async function resolvePaymentMethod( - customerId: string, + customer: Stripe.Customer | Stripe.DeletedCustomer, proSubscriptionId: string | null, ): Promise<{ paymentMethod?: string }> { if (proSubscriptionId) { @@ -249,7 +259,6 @@ async function resolvePaymentMethod( } } - const customer = await stripe().customers.retrieve(customerId); if (!customer.deleted) { const invoiceDefault = customer.invoice_settings?.default_payment_method; // A customer-level default (payment method or legacy source) is applied @@ -258,14 +267,14 @@ async function resolvePaymentMethod( } const methods = await stripe().paymentMethods.list({ - customer: customerId, + customer: customer.id, type: "card", limit: 1, }); const method = methods.data[0]; if (method) return { paymentMethod: method.id }; - throw new Error( + throw new SignedBaaError( "No payment method on file. Add a card via Manage Billing first, then try again.", ); } @@ -292,15 +301,17 @@ async function findExistingBaaSubscription( function throwSignedBaaStripeError(error: unknown): never { const stripeError = error as { type?: string; message?: string }; if (stripeError.type === "StripeCardError") { - throw new Error( + throw new SignedBaaError( `Your card was declined: ${stripeError.message ?? "payment failed"}. No subscription was started.`, ); } if (error instanceof Error && error.message.includes("payment method")) { - throw error; + throw error instanceof SignedBaaError + ? error + : new SignedBaaError(error.message); } console.error("Signed BAA: subscription creation failed", error); - throw new Error( + throw new SignedBaaError( "We couldn't confirm the Signed BAA subscription. Please try again or contact support if you were charged.", ); } @@ -310,6 +321,7 @@ async function createBaaSubscription({ organizationId, userId, recordId, + claimedAt, proSubscriptionId, priceId, }: { @@ -317,6 +329,7 @@ async function createBaaSubscription({ organizationId: string; userId: string; recordId: string; + claimedAt: Date; proSubscriptionId: string | null; priceId: string; }): Promise { @@ -326,14 +339,19 @@ async function createBaaSubscription({ ); if (existing) return existing; + const customer = await stripe().customers.retrieve(customerId); const { paymentMethod } = await resolvePaymentMethod( - customerId, + customer, proSubscriptionId, ); + // Stripe bills new subscriptions in the price's default currency (USD) + // unless told otherwise, which a customer already billed in EUR/GBP rejects. + const currency = customer.deleted ? null : customer.currency; return stripe().subscriptions.create( { customer: customerId, items: [{ price: priceId, quantity: 1 }], + ...(currency ? { currency } : {}), ...(paymentMethod ? { default_payment_method: paymentMethod } : {}), payment_behavior: "error_if_incomplete", metadata: { @@ -342,22 +360,46 @@ async function createBaaSubscription({ userId, }, }, - { idempotencyKey: `signed-baa-${recordId}` }, + // Scoped to the claim so a failed attempt's stored Stripe error isn't + // replayed on retry; findExistingBaaSubscription covers lost responses. + { idempotencyKey: `signed-baa-${recordId}-${claimedAt.getTime()}` }, ); } +export type SignedBaaResult = + | { success: true; emailSent: boolean } + | { success: false; error: string }; + +async function toSignedBaaResult( + complete: Promise<{ success: true; emailSent: boolean }>, +): Promise { + try { + return await complete; + } catch (error) { + if (error instanceof SignedBaaError) { + return { success: false, error: error.message }; + } + console.error("Signed BAA: unexpected failure", error); + return { + success: false, + error: + "We couldn't complete your Signed BAA. Please try again or contact hello@cap.so.", + }; + } +} + export async function purchaseSignedBaa( organizationId: Organisation.OrganisationId, input: SignedBaaInput, -): Promise<{ success: true; emailSent: boolean }> { - return completeSignedBaa(organizationId, input, false); +): Promise { + return toSignedBaaResult(completeSignedBaa(organizationId, input, false)); } export async function signPaidBaa( organizationId: Organisation.OrganisationId, input: SignedBaaInput, -): Promise<{ success: true; emailSent: boolean }> { - return completeSignedBaa(organizationId, input, true); +): Promise { + return toSignedBaaResult(completeSignedBaa(organizationId, input, true)); } async function completeSignedBaa( @@ -377,7 +419,9 @@ async function completeSignedBaa( .limit(1); if (existing?.status === "active" && existing.emailSentAt) { - throw new Error("A Signed BAA is already active for this organization."); + throw new SignedBaaError( + "A Signed BAA is already active for this organization.", + ); } let paidSubscription: Stripe.Subscription | null = null; if (existing?.stripeSubscriptionId && existing.status !== "canceled") { @@ -392,13 +436,15 @@ async function completeSignedBaa( existing.status !== "paid" && !hasPaidBaaInvoice(paidSubscription)) ) { - throw new Error( + throw new SignedBaaError( "The existing BAA payment is not confirmed. Please contact support; no additional payment was taken.", ); } } if (requirePaid && !paidSubscription) { - throw new Error("A confirmed BAA payment is required before signing."); + throw new SignedBaaError( + "A confirmed BAA payment is required before signing.", + ); } const proRequirementWaived = @@ -411,7 +457,7 @@ async function completeSignedBaa( !user.stripeSubscriptionId || !ownerCanPurchaseSignedBaa(user) ) { - throw new Error( + throw new SignedBaaError( "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", ); } @@ -422,7 +468,7 @@ async function completeSignedBaa( user.stripeSubscriptionId, ); } catch { - throw new Error( + throw new SignedBaaError( "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", ); } @@ -430,7 +476,7 @@ async function completeSignedBaa( !CAP_PRO_STATUSES.has(liveProSubscription.status) || !isProSubscription(liveProSubscription) ) { - throw new Error( + throw new SignedBaaError( "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", ); } @@ -476,7 +522,7 @@ async function completeSignedBaa( ), ); if (getAffectedRows(claim) === 0) { - throw new Error( + throw new SignedBaaError( "A Signed BAA purchase is already in progress. Please wait a moment and refresh.", ); } @@ -494,7 +540,7 @@ async function completeSignedBaa( ...recordFields, }); } catch { - throw new Error( + throw new SignedBaaError( "A Signed BAA purchase is already in progress. Please wait a moment and refresh.", ); } @@ -562,7 +608,7 @@ async function completeSignedBaa( } catch (error) { console.error("Signed BAA: PDF generation failed", error); await revertToPending(); - throw new Error( + throw new SignedBaaError( "We couldn't generate the agreement from your signature. Please clear the signature and try again.", ); } @@ -575,6 +621,7 @@ async function completeSignedBaa( organizationId, userId: user.id, recordId, + claimedAt, proSubscriptionId: user.stripeSubscriptionId, priceId, }); @@ -585,6 +632,7 @@ async function completeSignedBaa( organizationId, userId: user.id, recordId, + claimedAt, proSubscriptionId: user.stripeSubscriptionId, priceId, }); @@ -597,7 +645,7 @@ async function completeSignedBaa( if (!subscription) { await revertToPending(); - throw new Error( + throw new SignedBaaError( "We couldn't confirm the Signed BAA subscription. Please try again or contact support if you were charged.", ); } @@ -636,13 +684,13 @@ async function completeSignedBaa( current.status !== "processing" || current.updatedAt.getTime() !== claimedAt.getTime() ) { - throw new Error( + throw new SignedBaaError( "The BAA changed while confirming payment. Please refresh or contact support; do not pay again.", ); } } if (!(await ensureBaaHasPro(user, subscription, recordIdentity))) { - throw new Error( + throw new SignedBaaError( "Cap Pro ended before the BAA could be signed. The BAA subscription has been canceled; please contact support about your payment.", ); } @@ -664,7 +712,7 @@ async function completeSignedBaa( ); if (getAffectedRows(finalized) === 0) { await cancelIfUnlinked(); - throw new Error( + throw new SignedBaaError( "The BAA changed while signing. Please refresh or contact support; do not pay again.", ); } @@ -698,7 +746,7 @@ async function completeSignedBaa( idempotencyKey: `signed-baa-email-${recordId}`, }); if (!delivery?.data?.id || delivery.error) { - throw new Error( + throw new SignedBaaError( delivery?.error?.message ?? "The email provider did not confirm delivery.", ); diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index e91138d70e5..7461c2395d5 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -46,6 +46,7 @@ import { loomImportDestinationFromPathname, loomImportPageHref, } from "@/lib/loom-import-destination"; +import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants"; import { canViewOrganizationSettings, getEffectiveOrganizationRole, @@ -78,6 +79,9 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const showDeveloperDashboard = buildEnv.NEXT_PUBLIC_IS_CAP && DEVELOPER_DASHBOARD_ALLOWED_EMAILS.includes(user.email); + const showMigrationQueue = + buildEnv.NEXT_PUBLIC_IS_CAP && + user.email.toLowerCase() === MESSENGER_ADMIN_EMAIL; const manageNavigation = [ { @@ -127,6 +131,16 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { }, ] : []), + ...(showMigrationQueue + ? [ + { + name: "Migration Queue", + href: "/dashboard/admin/loom-migrations", + icon: , + subNav: [], + }, + ] + : []), ]; const [dialogOpen, setDialogOpen] = useState(false); diff --git a/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx new file mode 100644 index 00000000000..f9d860b04d6 --- /dev/null +++ b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx @@ -0,0 +1,550 @@ +"use client"; + +import { Button } from "@cap/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { LoaderCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + createConciergeLoomFileUpload, + finishConciergeLoomFileUpload, + importFromLoomCsvForConcierge, + type LoomCsvImportRow, + type LoomCsvImportRowResult, +} from "@/actions/loom"; +import { + getLoomMigrationOperatorQueue, + updateLoomMigrationStatus, +} from "@/actions/loom-concierge"; +import type { OperatorLoomMigrationView } from "@/lib/loom-concierge"; +import { parseConciergeLoomCsv } from "@/lib/loom-csv"; +import { + LOOM_MIGRATION_STATUS_LABELS, + type LoomMigrationStatus, + unwrapLoomMigrationResult, +} from "@/lib/loom-migration-state"; +import { uploadWithTarget } from "@/utils/upload-target"; + +const BATCH_SIZE = 10; +const BATCH_DELAY_MS = 1500; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function QueueItem({ request }: { request: OperatorLoomMigrationView }) { + const queryClient = useQueryClient(); + const [nextStatus, setNextStatus] = useState( + request.status, + ); + const [message, setMessage] = useState(request.capMessage ?? ""); + const [expectedVideoCount, setExpectedVideoCount] = useState( + request.expectedVideoCount?.toString() ?? "", + ); + const [importedVideoCount, setImportedVideoCount] = useState( + request.importedVideoCount.toString(), + ); + const [verified, setVerified] = useState(false); + const [csvRows, setCsvRows] = useState([]); + const [processedRows, setProcessedRows] = useState(0); + const [startedRows, setStartedRows] = useState(0); + const [failedRows, setFailedRows] = useState([]); + const [fileLoomUrl, setFileLoomUrl] = useState(""); + const [fileOwnerEmail, setFileOwnerEmail] = useState(""); + const [fileSpaceName, setFileSpaceName] = useState(""); + const [fileTitle, setFileTitle] = useState(""); + const [file, setFile] = useState(null); + const [uploadPercent, setUploadPercent] = useState(0); + + useEffect(() => { + setNextStatus(request.status); + setMessage(request.capMessage ?? ""); + setExpectedVideoCount(request.expectedVideoCount?.toString() ?? ""); + setImportedVideoCount(request.importedVideoCount.toString()); + setVerified(false); + }, [ + request.status, + request.capMessage, + request.expectedVideoCount, + request.importedVideoCount, + ]); + + const refresh = () => + queryClient.invalidateQueries({ queryKey: ["loom-migration-queue"] }); + const statusMutation = useMutation({ + mutationFn: async () => + unwrapLoomMigrationResult( + await updateLoomMigrationStatus({ + requestId: request.id, + nextStatus, + message, + verified, + expectedVideoCount: + expectedVideoCount.trim() === "" + ? null + : Number(expectedVideoCount), + importedVideoCount: Number(importedVideoCount), + }), + ), + onSuccess: async () => { + await refresh(); + toast.success("Migration status updated."); + }, + onError: (error) => toast.error(error.message), + }); + const importMutation = useMutation({ + mutationFn: async () => { + if (csvRows.length === 0) throw new Error("Choose a Loom CSV first."); + setProcessedRows(0); + setStartedRows(0); + setFailedRows([]); + let started = 0; + const failures: LoomCsvImportRowResult[] = []; + for (let index = 0; index < csvRows.length; index += BATCH_SIZE) { + const batch = csvRows.slice(index, index + BATCH_SIZE); + const result = unwrapLoomMigrationResult( + await importFromLoomCsvForConcierge({ + requestId: request.id, + rows: batch, + }), + ); + started += result.importedCount; + failures.push( + ...result.results.filter((row) => !row.success || row.error), + ); + setProcessedRows(index + batch.length); + setStartedRows(started); + setFailedRows([...failures]); + if (index + BATCH_SIZE < csvRows.length) await delay(BATCH_DELAY_MS); + } + return { started, failures }; + }, + onSuccess: async (result) => { + await refresh(); + toast.success( + `${result.started} imports started. ${result.failures.length} rows need review. Wait for processing and reconcile the library before completion.`, + ); + }, + onError: async (error) => { + await refresh(); + toast.error( + `${error.message} Check the rows already started before retrying.`, + ); + }, + }); + const fileMutation = useMutation({ + mutationFn: async () => { + if (!file || !fileLoomUrl.trim() || !fileOwnerEmail.trim()) { + throw new Error("Choose an MP4 file, Loom URL, and destination owner."); + } + if (!file.name.toLowerCase().endsWith(".mp4")) { + throw new Error("Choose an MP4 downloaded from Loom."); + } + if (file.size === 0) throw new Error("The MP4 file is empty."); + const prepared = unwrapLoomMigrationResult( + await createConciergeLoomFileUpload({ + requestId: request.id, + loomUrl: fileLoomUrl, + userEmail: fileOwnerEmail, + spaceName: fileSpaceName, + videoTitle: fileTitle.trim() || file.name.replace(/\.mp4$/i, ""), + }), + ); + setUploadPercent(0); + await uploadWithTarget({ + target: prepared.uploadTarget, + body: file, + fileName: file.name, + onProgress: ({ loaded, total }) => { + if (total > 0) setUploadPercent(Math.round((loaded / total) * 100)); + }, + }); + return unwrapLoomMigrationResult( + await finishConciergeLoomFileUpload({ + requestId: request.id, + videoId: prepared.videoId, + }), + ); + }, + onSuccess: async (result) => { + await refresh(); + setFile(null); + setUploadPercent(0); + toast.success( + result.status === "started" + ? "Loom file uploaded. Processing has started." + : "Loom file uploaded. Processing was already underway.", + ); + }, + onError: async (error) => { + await refresh(); + toast.error( + `${error.message} The same Loom URL can be retried after checking its upload.`, + ); + }, + }); + + return ( +
+
+
+

+ {request.organizationName} +

+

+ {request.requestedByEmail} · {request.organizationId} · Requested{" "} + {new Date(request.createdAt).toLocaleDateString()} +

+
+ + {LOOM_MIGRATION_STATUS_LABELS[request.status]} + +
+
+

Loom workspace: {request.workspaceName || "Not provided"}

+

+ Loom invite:{" "} + {request.invitedAt ? "Marked as sent" : "Waiting for customer"} +

+

Videos queued: {request.queuedVideoCount}

+

Verified videos: {request.importedVideoCount}

+

Imports starting: {request.activeImportCount}

+
+ {request.customerNote && ( +
+ Customer note: {request.customerNote} +
+ )} + {request.customerReply && ( +
+ Latest customer answer: {request.customerReply} +
+ )} + +
+

Import a mapped Loom CSV

+

+ Use loom_video_url,user_email,space_name. The destination is this Cap + organization; imports run in batches of 10. Started jobs still need + terminal processing checks. +

+ + { + const file = event.target.files?.[0]; + if (!file) return; + try { + setCsvRows(parseConciergeLoomCsv(await file.text())); + setProcessedRows(0); + setStartedRows(0); + setFailedRows([]); + } catch (error) { + setCsvRows([]); + toast.error( + error instanceof Error + ? error.message + : "Could not read the CSV.", + ); + } + }} + type="file" + /> + {csvRows.length > 0 && ( +

+ {csvRows.length} rows ready. Review the source inventory and owner + mapping before import. +

+ )} + + {processedRows > 0 && ( +

+ {startedRows} jobs started;{" "} + {failedRows.filter((row) => !row.success).length} rows failed to + start; {failedRows.filter((row) => row.success).length} started rows + need space mapping. +

+ )} + {failedRows.length > 0 && ( +
    + {failedRows.map((row) => ( +
  • + Row {row.rowNumber} + {row.success && row.spaceName ? ` (${row.spaceName})` : ""}:{" "} + {row.error || "Could not start import."} +
  • + ))} +
+ )} +
+ +
{ + event.preventDefault(); + fileMutation.mutate(); + }} + > +

+ Upload a restricted Loom video +

+

+ Use this when the CSV import cannot download a video. Download an MP4 + from Loom with an account that has permission, then map it to its Loom + link and Cap owner. Restricted Library videos may need explicit + access. +

+
+ + + + +
+ + +
+ +
{ + event.preventDefault(); + statusMutation.mutate(); + }} + > +

Update customer status

+
+
+ + +
+
+ + setExpectedVideoCount(event.target.value)} + type="number" + value={expectedVideoCount} + /> +
+
+ + setImportedVideoCount(event.target.value)} + type="number" + value={importedVideoCount} + /> +
+
+
+ +