From 6fd71a225beda6760b1204299226b6a0d9022525 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:05:01 +0000 Subject: [PATCH 01/65] perf(desktop): cut per-tick CPU/RAM in the Rust capture pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Share one reqwest::Client (OnceLock) across the upload pipeline, sleep recovery, and exit-pause instead of building a new client (fresh pool + TLS config + handshake) on every 60s tick. Timeouts are unchanged, now applied per-request. - Stop round-tripping frames through base64: capture returns raw JPEG bytes, base64 is encoded exactly once for the JS preview, and the upload body is bytes::Bytes so retry clones are refcount bumps instead of full-buffer copies. Previously every tick encoded, decoded, and re-cloned the frame. - Run capture_and_upload's screenshot on spawn_blocking like the capture loop already does, keeping capture + JPEG encode off async workers. - Use Triangle instead of Lanczos3 when scaling multi-source stitches, matching the filter the (far more common) single-source path already uses; use into_rgba8() moves instead of to_rgba8() full-frame clones. - Tray ticker: only touch the native tray when the formatted title actually changes (it has minute granularity, the ticker runs at 1s), and drop the per-second "tray-timer-tick" emit nothing listens to. - Scope the macOS App Nap / idle-sleep assertion to active recordings (tray-timer lifetime) instead of the whole process. Recording behavior is unchanged — captures are still never throttled, including while paused mid-session — but an idle Lookout no longer prevents the Mac from ever sleeping. - Remove dead capture.rs wrappers (take_screenshot_raw, take_stitched_screenshots) with no callers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg --- clients/desktop/src-tauri/Cargo.lock | 2 + clients/desktop/src-tauri/Cargo.toml | 5 + clients/desktop/src-tauri/src/capture.rs | 94 +++-------- clients/desktop/src-tauri/src/lib.rs | 206 +++++++++++++++++------ 4 files changed, 181 insertions(+), 126 deletions(-) diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock index c5ca9fe0..0b9dcf3a 100644 --- a/clients/desktop/src-tauri/Cargo.lock +++ b/clients/desktop/src-tauri/Cargo.lock @@ -3156,10 +3156,12 @@ version = "0.3.5" dependencies = [ "ashpd", "base64 0.22.1", + "bytes", "gstreamer", "gstreamer-app", "gstreamer-video", "image", + "objc2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation", diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml index 3eb3e5d1..011454d6 100644 --- a/clients/desktop/src-tauri/Cargo.toml +++ b/clients/desktop/src-tauri/Cargo.toml @@ -33,6 +33,10 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } tokio = { version = "1", features = ["full"] } base64 = "0.22" +# Refcounted byte buffers for the upload pipeline — retrying an R2 PUT clones +# the request body, and `Bytes` makes that clone a refcount bump instead of a +# full JPEG copy. Already in the dependency tree via tokio/reqwest. +bytes = "1" tauri-plugin-deep-link = "2.4.7" tauri-plugin-http = "2.5.7" tauri-plugin-macos-permissions = "2.3.0" @@ -53,6 +57,7 @@ sentry = "0.38" tokio = { version = "1", features = ["test-util"] } [target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" objc2-core-foundation = "0.3.2" objc2-core-graphics = "0.3.2" objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString"] } diff --git a/clients/desktop/src-tauri/src/capture.rs b/clients/desktop/src-tauri/src/capture.rs index d52bce0f..0644381c 100644 --- a/clients/desktop/src-tauri/src/capture.rs +++ b/clients/desktop/src-tauri/src/capture.rs @@ -200,7 +200,9 @@ fn capture_to_dynamic_image_with_blacklist( if let CaptureSource::Monitor { id } = source { if !blacklisted_apps.is_empty() { if let Some(bounds) = get_monitor_screen_bounds(*id) { - let mut rgba = dynamic.to_rgba8(); + // into_rgba8 is a move (captures are always RGBA8) — avoids + // cloning a full-resolution frame just to draw on it. + let mut rgba = dynamic.into_rgba8(); let w = rgba.width(); let h = rgba.height(); redact_blacklisted_regions(&mut rgba, bounds, blacklisted_apps, w, h); @@ -235,23 +237,6 @@ pub struct RawCaptureResult { pub height: u32, } -pub fn take_screenshot_raw( - source: CaptureSource, - max_width: u32, - max_height: u32, - jpeg_quality: u8, - pipewire_fds: &std::collections::HashMap, -) -> Result { - take_screenshot_raw_with_blacklist( - source, - max_width, - max_height, - jpeg_quality, - pipewire_fds, - &[], - ) -} - pub fn take_screenshot_raw_with_blacklist( source: CaptureSource, max_width: u32, @@ -300,24 +285,6 @@ pub fn take_screenshot( max_height: u32, jpeg_quality: u8, pipewire_fds: &std::collections::HashMap, -) -> Result { - take_screenshot_with_blacklist( - source, - max_width, - max_height, - jpeg_quality, - pipewire_fds, - &[], - ) -} - -pub fn take_screenshot_with_blacklist( - source: CaptureSource, - max_width: u32, - max_height: u32, - jpeg_quality: u8, - pipewire_fds: &std::collections::HashMap, - blacklisted_apps: &[String], ) -> Result { let raw = take_screenshot_raw_with_blacklist( source, @@ -325,7 +292,7 @@ pub fn take_screenshot_with_blacklist( max_height, jpeg_quality, pipewire_fds, - blacklisted_apps, + &[], )?; let size_bytes = raw.data.len(); @@ -340,37 +307,23 @@ pub fn take_screenshot_with_blacklist( }) } -pub fn take_stitched_screenshots( - sources: &[CaptureSource], - max_width: u32, - max_height: u32, - jpeg_quality: u8, - pipewire_fds: &std::collections::HashMap, -) -> Result { - take_stitched_screenshots_with_blacklist( - sources, - max_width, - max_height, - jpeg_quality, - pipewire_fds, - &[], - ) -} - -pub fn take_stitched_screenshots_with_blacklist( +/// Capture one or more sources side-by-side and encode as a single JPEG. +/// Returns raw JPEG bytes — callers that need base64 (e.g. for the preview +/// event) encode it themselves, so the upload path never has to decode. +pub fn take_stitched_screenshots_raw_with_blacklist( sources: &[CaptureSource], max_width: u32, max_height: u32, jpeg_quality: u8, pipewire_fds: &std::collections::HashMap, blacklisted_apps: &[String], -) -> Result { +) -> Result { if sources.is_empty() { return Err("No sources provided".to_string()); } if sources.len() == 1 { - return take_screenshot_with_blacklist( + return take_screenshot_raw_with_blacklist( sources[0].clone(), max_width, max_height, @@ -409,7 +362,9 @@ pub fn take_stitched_screenshots_with_blacklist( if h != target_h && h > 0 { let scale = target_h as f64 / h as f64; let new_w = (w as f64 * scale).round() as u32; - let scaled = img.resize_exact(new_w, target_h, image::imageops::FilterType::Lanczos3); + // Triangle matches the single-source path — Lanczos3 here cost + // several times the CPU for a difference invisible at 60s/frame. + let scaled = img.resize_exact(new_w, target_h, image::imageops::FilterType::Triangle); total_w += scaled.width(); scaled_images.push(scaled); } else { @@ -419,11 +374,13 @@ pub fn take_stitched_screenshots_with_blacklist( } let mut stitched = image::RgbaImage::new(total_w, target_h); - let mut current_x = 0; + let mut current_x: i64 = 0; for img in scaled_images { - let rgba = img.to_rgba8(); - image::imageops::overlay(&mut stitched, &rgba, current_x as i64, 0); - current_x += img.width() as i64; + let w = img.width() as i64; + // into_rgba8 is a move for RGBA8 frames — no full-frame clone. + let rgba = img.into_rgba8(); + image::imageops::overlay(&mut stitched, &rgba, current_x, 0); + current_x += w; } let mut dynamic = DynamicImage::ImageRgba8(stitched); @@ -441,7 +398,7 @@ pub fn take_stitched_screenshots_with_blacklist( ); let new_w = (w as f64 * scale).round() as u32; let new_h = (h as f64 * scale).round() as u32; - dynamic = dynamic.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3); + dynamic = dynamic.resize_exact(new_w, new_h, image::imageops::FilterType::Triangle); } let (final_w, final_h) = (dynamic.width(), dynamic.height()); @@ -454,17 +411,10 @@ pub fn take_stitched_screenshots_with_blacklist( .encode_image(&rgb) .map_err(|e| format!("JPEG encoding failed: {e}"))?; - let jpeg_bytes = jpeg_buf.into_inner(); - let size_bytes = jpeg_bytes.len(); - - use base64::Engine; - let base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_bytes); - - Ok(CaptureResult { - base64, + Ok(RawCaptureResult { + data: jpeg_buf.into_inner(), width: final_w, height: final_h, - size_bytes, }) } diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 39d1986e..758cb1a1 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -6,6 +6,56 @@ mod tray; #[cfg(target_os = "windows")] mod windows_permissions; +/// Scoped App Nap / idle-system-sleep suppression (macOS). +/// +/// The assertion must be held while a session is recording (or paused +/// mid-session) so macOS never throttles the capture cadence or lets the +/// machine idle-sleep out from under an active recording. It must NOT be +/// held for the whole process lifetime — that kept the user's Mac from ever +/// idle-sleeping just because Lookout sat open on the gallery. +#[cfg(target_os = "macos")] +mod power { + use objc2::rc::Retained; + use objc2::runtime::{NSObjectProtocol, ProtocolObject}; + use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; + use std::sync::Mutex; + + struct ActivityToken(Retained>); + // SAFETY: the token is an opaque handle whose only use is being handed + // back to `NSProcessInfo::endActivity`, which is documented thread-safe. + unsafe impl Send for ActivityToken {} + + static ACTIVITY: Mutex> = Mutex::new(None); + + /// Begin the recording assertion. Idempotent — a second call while one + /// is already held is a no-op. + pub fn begin_recording_assertion() { + let mut guard = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_some() { + return; + } + let info = NSProcessInfo::processInfo(); + let reason = NSString::from_str("Periodic screenshot capture must not be throttled"); + let opts = + NSActivityOptions::LatencyCritical | NSActivityOptions::IdleSystemSleepDisabled; + *guard = Some(ActivityToken( + info.beginActivityWithOptions_reason(opts, &reason), + )); + eprintln!("[power] recording sleep/App Nap suppression ON"); + } + + /// End the recording assertion (no-op if none is held). + pub fn end_recording_assertion() { + let mut guard = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(token) = guard.take() { + // SAFETY: `token.0` came from `beginActivityWithOptions_reason`, + // so it is the correct activity type. + unsafe { NSProcessInfo::processInfo().endActivity(&token.0) }; + eprintln!("[power] recording sleep/App Nap suppression OFF"); + } + } +} + #[cfg(target_os = "macos")] use objc2_core_foundation::{CFBoolean, CFDictionary, CFNumber, CFNumberType, CFString, CGRect}; #[cfg(target_os = "macos")] @@ -818,6 +868,22 @@ fn take_screenshot( capture::take_screenshot(source, max_width, max_height, jpeg_quality, &pipewire_fds) } +/// Shared HTTP client for all server/R2 traffic. Building a `reqwest::Client` +/// allocates a fresh connection pool + TLS config, so constructing one per +/// request (as each capture tick used to) both wastes CPU and forces a new +/// TCP/TLS handshake every 60 seconds. One shared client keeps connections +/// alive between ticks. Timeouts differ per call site, so they're applied +/// per-request via `RequestBuilder::timeout` instead of on the client. +fn http_client() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_default() + }) +} + /// Free-form client telemetry string sent on every upload-url request, e.g. /// "Lookout Desktop/0.2.6 (macOS 14.3)". Computed once. NOT the HTTP /// User-Agent — explicit info for server-side telemetry/debugging. @@ -1036,24 +1102,26 @@ macro_rules! retry_upload_step { /// screenshot was actually taken. Optional — when `None`, the request /// matches the legacy bucket-mode payload byte-for-byte. When `Some`, it /// opts the session into credit-mode tracking on the first request. +/// +/// Takes the JPEG as `bytes::Bytes` (cheap refcounted clones for retries — +/// no full-buffer copy per attempt) plus its base64 form, which is only +/// carried through for the JS preview. Callers that capture natively encode +/// base64 exactly once; nothing here decodes it back. async fn upload_and_confirm( - jpeg_base64: &str, + jpeg_bytes: bytes::Bytes, + jpeg_base64: String, width: u32, height: u32, captured_at: Option<&str>, config: &SessionConfig, app: &AppHandle, ) -> Result { - let jpeg_bytes = base64_decode(jpeg_base64)?; let size_bytes = jpeg_bytes.len(); + const STEP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); // Step 1: Get presigned URL from server let _ = app.emit("capture-progress", "getting upload url from server..."); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let client = http_client(); let upload_url_url = format!( "{}/api/sessions/{}/upload-url", config.api_base_url, config.token @@ -1069,6 +1137,7 @@ async fn upload_and_confirm( let url_response = client .get(upload_url_url.as_str()) .query(&query) + .timeout(STEP_TIMEOUT) .send() .await .map_err(|e| StepError::Retryable(describe_reqwest_error(&e)))?; @@ -1108,7 +1177,9 @@ async fn upload_and_confirm( client .put(upload_url_resp.upload_url.as_str()) .header("Content-Type", "image/jpeg") + // Bytes::clone is a refcount bump, not a buffer copy. .body(jpeg_bytes.clone()) + .timeout(STEP_TIMEOUT) .send() .await .map_err(|e| StepError::Retryable(describe_reqwest_error(&e)))? @@ -1132,6 +1203,7 @@ async fn upload_and_confirm( "height": height, "fileSize": size_bytes, })) + .timeout(STEP_TIMEOUT) .send() .await .map_err(|e| StepError::Retryable(describe_reqwest_error(&e)))?; @@ -1158,7 +1230,7 @@ async fn upload_and_confirm( confirmed: confirm_resp.confirmed, tracked_seconds: confirm_resp.tracked_seconds, next_expected_at: confirm_resp.next_expected_at, - preview_base64: jpeg_base64.to_string(), + preview_base64: jpeg_base64, preview_width: width, preview_height: height, }) @@ -1383,21 +1455,27 @@ async fn capture_and_upload( pipewire_fds = guard.clone(); } - let screenshot = capture::take_stitched_screenshots_with_blacklist( - &sources, - max_width, - max_height, - jpeg_quality, - &pipewire_fds, - &blacklisted, - )?; + // Screen capture + JPEG encode is heavy blocking work — keep it off the + // async runtime's worker threads (same as the Rust capture loop does). + let screenshot = tokio::task::spawn_blocking(move || { + capture::take_stitched_screenshots_raw_with_blacklist( + &sources, + max_width, + max_height, + jpeg_quality, + &pipewire_fds, + &blacklisted, + ) + }) + .await + .map_err(|e| format!("spawn_blocking panicked: {e}"))??; let _ = app.emit( "capture-progress", format!( "captured {}x{} ({}KB jpeg)", screenshot.width, screenshot.height, - screenshot.size_bytes / 1024 + screenshot.data.len() / 1024 ), ); @@ -1406,8 +1484,10 @@ async fn capture_and_upload( } else { None }; + let jpeg_base64 = base64_encode(&screenshot.data); upload_and_confirm( - &screenshot.base64, + bytes::Bytes::from(screenshot.data), + jpeg_base64, screenshot.width, screenshot.height, captured_at.as_deref(), @@ -1444,7 +1524,9 @@ async fn upload_frame( } else { None }; - upload_and_confirm(&base64, width, height, captured_at.as_deref(), &config, &app).await + let jpeg_bytes = bytes::Bytes::from(base64_decode(&base64)?); + upload_and_confirm(jpeg_bytes, base64, width, height, captured_at.as_deref(), &config, &app) + .await } // ── Capture-loop interval (seconds) ───────────────────────────── @@ -1480,6 +1562,14 @@ async fn tray_timer_task( let mut ticker = interval(Duration::from_secs(1)); ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + // The title only changes at minute granularity, so most 1s ticks would + // rewrite the exact same string. Cache the last text and skip redundant + // native tray updates. After a paused stretch the JS side may have + // overwritten the title (paused indicator), so force one refresh on the + // first running tick after a pause even if the text matches. + let mut last_title: Option = None; + let mut was_paused = false; + loop { tokio::select! { _ = ticker.tick() => {} @@ -1490,6 +1580,7 @@ async fn tray_timer_task( } if !timer_state.is_running.load(Ordering::Relaxed) { + was_paused = true; continue; } @@ -1501,12 +1592,16 @@ async fn tray_timer_task( let display_seconds = base_seconds + elapsed; let time_text = format_tray_time(display_seconds); - if let Some(tray) = app.tray_by_id("timelapse_tray") { - let _ = tray.set_title(Some(time_text)); + if was_paused || last_title.as_deref() != Some(time_text.as_str()) { + if let Some(tray) = app.tray_by_id("timelapse_tray") { + let _ = tray.set_title(Some(&time_text)); + // Windows doesn't render tray titles — the hover tooltip is + // the only way to see the recorded time there. + let _ = tray.set_tooltip(Some(format!("Lookout — {time_text} recorded"))); + } + last_title = Some(time_text); } - - // Also emit to the tray popup window so it stays in sync - let _ = app.emit("tray-timer-tick", display_seconds); + was_paused = false; } } @@ -1520,6 +1615,12 @@ fn start_tray_timer(app: &AppHandle, state: &AppState) -> Arc { return Arc::clone(&handle.state); } + // The tray timer lives exactly as long as a session is being recorded + // (screen sessions via start_capture_loop, camera via start_tray_ticker), + // so it's the right scope for the keep-awake assertion. + #[cfg(target_os = "macos")] + power::begin_recording_assertion(); + let timer_state = Arc::new(TrayTimerState { tracked_seconds: AtomicI64::new(0), started_at: Mutex::new(StdInstant::now()), @@ -1553,6 +1654,10 @@ fn stop_tray_timer(state: &AppState) { eprintln!("[tray-timer] stopping"); let _ = handle.cancel_tx.send(true); handle.join_handle.abort(); + + // Recording is over — let macOS nap/idle-sleep normally again. + #[cfg(target_os = "macos")] + power::end_recording_assertion(); } } @@ -1668,12 +1773,10 @@ async fn capture_loop_task( app: &AppHandle, config: &SessionConfig, ) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .unwrap_or_default(); + let client = http_client(); + let status_timeout = std::time::Duration::from_secs(15); let url = format!("{}/api/sessions/{}/status", config.api_base_url, config.token); - match client.get(&url).send().await { + match client.get(&url).timeout(status_timeout).send().await { Ok(res) if res.status().is_success() => { if let Ok(data) = res.json::().await { eprintln!("[capture-loop] session status after sleep: {}", data.status); @@ -1685,7 +1788,7 @@ async fn capture_loop_task( "{}/api/sessions/{}/resume", config.api_base_url, config.token ); - let _ = client.post(&resume_url).send().await; + let _ = client.post(&resume_url).timeout(status_timeout).send().await; eprintln!("[capture-loop] session resumed after sleep"); } else if data.status != "active" && data.status != "pending" { eprintln!( @@ -1777,7 +1880,7 @@ async fn capture_loop_task( let bl = blacklisted; let pw_fds = pipewire_fds; let screenshot_result = tokio::task::spawn_blocking(move || { - capture::take_stitched_screenshots_with_blacklist( + capture::take_stitched_screenshots_raw_with_blacklist( &sources_clone, max_width, max_height, @@ -1800,8 +1903,10 @@ async fn capture_loop_task( match screenshot_result { Ok(screenshot) => { + let jpeg_base64 = base64_encode(&screenshot.data); match upload_and_confirm( - &screenshot.base64, + bytes::Bytes::from(screenshot.data), + jpeg_base64, screenshot.width, screenshot.height, captured_at.as_deref(), @@ -2079,6 +2184,11 @@ fn base64_decode(b64: &str) -> Result, String> { .map_err(|e| format!("Base64 decode failed: {e}")) } +fn base64_encode(data: &[u8]) -> String { + use base64_engine::*; + ENGINE.encode(data) +} + mod base64_engine { pub use base64::engine::general_purpose::STANDARD as ENGINE; pub use base64::Engine; @@ -2270,23 +2380,9 @@ pub fn run() { .setup(|app| { #[cfg(target_os = "macos")] { - // Disable App Nap so macOS doesn't throttle WebView timers when - // the window is occluded or Low Power Mode is on. The capture - // loop runs entirely in JS, so throttled timers = missed screenshots. - // The returned activity token is intentionally leaked (never ended) - // so the assertion lasts for the lifetime of the process. - { - use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; - let info = NSProcessInfo::processInfo(); - let reason = NSString::from_str("Periodic screenshot capture must not be throttled"); - let opts = NSActivityOptions::LatencyCritical - | NSActivityOptions::IdleSystemSleepDisabled; - let _activity = info.beginActivityWithOptions_reason(opts, &reason); - // Leak the token so the activity assertion persists. - std::mem::forget(_activity); - eprintln!("[power] App Nap suppression enabled"); - } - + // NOTE: App Nap / idle-sleep suppression is scoped to active + // recordings — see the `power` module. It is deliberately NOT + // asserted here for the whole process lifetime. use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; let app_menu = Submenu::with_items( @@ -2479,15 +2575,17 @@ pub fn run() { eprintln!("[exit] pausing session before exit"); let app_handle = app.clone(); tauri::async_runtime::spawn(async move { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap_or_default(); + let client = http_client(); let url = format!( "{}/api/sessions/{}/pause", config.api_base_url, config.token ); - match client.post(&url).send().await { + match client + .post(&url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { Ok(res) => eprintln!("[exit] pause response: {}", res.status()), Err(e) => eprintln!("[exit] pause failed (best-effort): {e}"), } From 085e595e2a1147544a35fe0e5a8aad90508c5686 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:05:01 +0000 Subject: [PATCH 02/65] perf(desktop): make frontend background work event-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the per-second tray-state sync (3 IPC calls + a broadcast event every second, all session long) with event-driven syncs on screenshot count, pause/resume, and server time corrections. The tray window already extrapolates its clock from updatedAt and the Rust ticker owns the menu-bar title, so nothing visible changes. - Fix the tray-ready fallback emitting state without updatedAt, which made the tray window compute NaN seconds until the next sync (now the next sync can be a minute away, so it mattered). - Seed a freshly started Rust tray timer with the last known tracked seconds so the menu-bar time doesn't briefly reset on pause -> resume. - Pause the screen-preview polling loop while the window is hidden — each preview frame is a full native capture + JPEG encode nobody can see. Resumes instantly on visibilitychange. - Skip the 5s running-apps refresh in Settings while the window is hidden (it enumerates every window on the system). - Stop logging preview-frame fetches; at 1/s per source they drowned the 200-entry debug buffer that error reports are built from. Failures are still logged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg --- .../src/components/DesktopRecorder.tsx | 32 +++++++++++-------- .../desktop/src/components/SettingsPage.tsx | 8 +++-- clients/desktop/src/hooks/useNativeCapture.ts | 11 +++++++ clients/desktop/src/hooks/useScreenPreview.ts | 23 +++++++++++-- clients/desktop/src/main.tsx | 8 +++-- 5 files changed, 62 insertions(+), 20 deletions(-) diff --git a/clients/desktop/src/components/DesktopRecorder.tsx b/clients/desktop/src/components/DesktopRecorder.tsx index 2d6816e7..67932069 100644 --- a/clients/desktop/src/components/DesktopRecorder.tsx +++ b/clients/desktop/src/components/DesktopRecorder.tsx @@ -336,9 +336,11 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource const screenshotCount = session.screenshotCount + capture.screenshotCount; - // Keep a ref of the latest state - const trayStateRef = useRef({ displaySeconds, screenshotCount, controlMode }); - trayStateRef.current = { displaySeconds, screenshotCount, controlMode }; + // Keep a ref of the latest state. `updatedAt` matters: the tray window + // extrapolates the ticking clock from it, so omitting it (as the tray-ready + // fallback used to) made the tray compute NaN until the next sync. + const trayStateRef = useRef({ displaySeconds, screenshotCount, controlMode, updatedAt: Date.now() }); + trayStateRef.current = { displaySeconds, screenshotCount, controlMode, updatedAt: Date.now() }; // Listen for tray requesting initial state (fallback) useEffect(() => { @@ -377,23 +379,27 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource // eslint-disable-next-line react-hooks/exhaustive-deps }, [controlMode]); - // Per-second state sync (tray window, not menu-bar title) + // Tray-window state sync — event-driven, NOT per-second. + // + // The tray window ticks its own clock by extrapolating from `updatedAt`, + // and the Rust ticker owns the menu-bar title. So the only things worth + // pushing over IPC are the ones the tray can't derive locally: screenshot + // count changes, pause/resume, and server time corrections. Syncing every + // second (3 IPC calls + a broadcast event) was pure overhead that also + // drowned the debug log in [ipc] noise. useEffect(() => { - const state = { - displaySeconds, - screenshotCount, - controlMode, - updatedAt: Date.now(), - }; + const state = trayStateRef.current; invoke("set_tray_state", { state }).catch(console.error); emit("tray-state", state).catch(console.error); + }, [screenshotCount, controlMode, capture.trackedSeconds]); - // Sync tracked seconds to Rust tray timer so it stays accurate - // after server corrections or session timer updates. + // Sync tracked seconds to the Rust tray timer when the server corrects it + // (arrives with each confirmed capture, ~once a minute). + useEffect(() => { if (capture.trackedSeconds > 0) { invoke("sync_tray_tracked_seconds", { trackedSeconds: capture.trackedSeconds }).catch(console.error); } - }, [displaySeconds, screenshotCount, controlMode]); + }, [capture.trackedSeconds]); // Hide tray on unmount or session end useEffect(() => { diff --git a/clients/desktop/src/components/SettingsPage.tsx b/clients/desktop/src/components/SettingsPage.tsx index b76fe452..e00f70b2 100644 --- a/clients/desktop/src/components/SettingsPage.tsx +++ b/clients/desktop/src/components/SettingsPage.tsx @@ -35,10 +35,14 @@ export function SettingsPage({ onBack, isWayland }: SettingsPageProps) { } }, []); - // Fetch on mount, then refresh every 5 seconds + // Fetch on mount, then refresh every 5 seconds. Each refresh enumerates + // every window on the system, so skip ticks while the window is hidden — + // the list refreshes on the next visible tick anyway. useEffect(() => { fetchRunningApps(); - refreshTimerRef.current = setInterval(fetchRunningApps, 5000); + refreshTimerRef.current = setInterval(() => { + if (!document.hidden) fetchRunningApps(); + }, 5000); return () => { if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); }; diff --git a/clients/desktop/src/hooks/useNativeCapture.ts b/clients/desktop/src/hooks/useNativeCapture.ts index 2a7a4218..63e75789 100644 --- a/clients/desktop/src/hooks/useNativeCapture.ts +++ b/clients/desktop/src/hooks/useNativeCapture.ts @@ -71,6 +71,10 @@ export function useNativeCapture( // check if the user intentionally stopped (avoids auto-resume race). const capturingRef = useRef(false); + // Latest tracked seconds, for seeding a freshly started Rust tray timer. + const trackedSecondsRef = useRef(0); + trackedSecondsRef.current = trackedSeconds; + // Track blob URL for cleanup const blobUrlRef = useRef(null); @@ -253,6 +257,13 @@ export function useNativeCapture( maxWidth: MAX_WIDTH, maxHeight: MAX_HEIGHT, jpegQuality: Math.round(JPEG_QUALITY * 100), + }).then(() => { + // A freshly started Rust tray timer counts from 0 — seed it with the + // last known tracked seconds so the menu-bar time doesn't reset while + // waiting for the first confirm (visible on pause → resume). + if (trackedSecondsRef.current > 0) { + invoke("sync_tray_tracked_seconds", { trackedSeconds: trackedSecondsRef.current }).catch(console.error); + } }).catch((err) => { console.error("[capture] failed to start Rust capture loop:", err); setError(String(err)); diff --git a/clients/desktop/src/hooks/useScreenPreview.ts b/clients/desktop/src/hooks/useScreenPreview.ts index 546a52fb..8e21808f 100644 --- a/clients/desktop/src/hooks/useScreenPreview.ts +++ b/clients/desktop/src/hooks/useScreenPreview.ts @@ -65,7 +65,8 @@ export function useScreenPreview( let cancelled = false; let timerId: ReturnType; - + let removeVisibilityListener: (() => void) | null = null; + // Convert fps to ms interval, min 16ms (60fps) const intervalMs = Math.max(16, Math.floor(1000 / targetFps)); @@ -73,10 +74,25 @@ export function useScreenPreview( const loop = async () => { if (cancelled) return; - + const s = sourceRef.current; if (!s) return; - + + // Each preview frame is a full native screen capture + JPEG encode. + // Nobody can see the result while the window is hidden/minimized, so + // park the loop until the document becomes visible again. + if (document.hidden) { + const onVisibility = () => { + document.removeEventListener("visibilitychange", onVisibility); + removeVisibilityListener = null; + if (!cancelled) loop(); + }; + document.addEventListener("visibilitychange", onVisibility); + removeVisibilityListener = () => + document.removeEventListener("visibilitychange", onVisibility); + return; + } + const startTime = performance.now(); const scheduleNext = () => { if (cancelled) return; @@ -137,6 +153,7 @@ export function useScreenPreview( return () => { cancelled = true; clearTimeout(timerId); + removeVisibilityListener?.(); console.debug("[preview] stopping preview loop"); }; }, [sourceKey, targetFps, live]); diff --git a/clients/desktop/src/main.tsx b/clients/desktop/src/main.tsx index 15c6c8a5..ab66a229 100644 --- a/clients/desktop/src/main.tsx +++ b/clients/desktop/src/main.tsx @@ -31,7 +31,11 @@ const originalFetch = window.fetch; window.fetch = function (input, init) { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; const method = init?.method || "GET"; - console.log(`[net] ${method} ${url}`); + // Preview frames fetch once per second per source — logging them floods + // the 200-entry debug buffer and buries real diagnostics. Failures are + // still logged below. + const isPreviewFrame = url.includes("lookout-preview"); + if (!isPreviewFrame) console.log(`[net] ${method} ${url}`); // On Windows, Tauri v2 uses fetch('http://ipc.localhost/...') for IPC and // 'http://asset.localhost/...' for assets. We must NOT intercept these or @@ -44,7 +48,7 @@ window.fetch = function (input, init) { return (doFetch as Promise).then( (res) => { - console.log(`[net] ${method} ${url} → ${res.status}`); + if (!isPreviewFrame) console.log(`[net] ${method} ${url} → ${res.status}`); return res; }, (err: Error) => { From c9d1f2a92805ccbba76129e2a6da316e96118426 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:05:02 +0000 Subject: [PATCH 03/65] feat(desktop): quality-of-life polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tray tooltip ("Lookout — 12m recorded"): Windows doesn't render tray titles at all, so until now Windows users had no way to see the recorded time from the tray without clicking it. - Show a pause glyph in the menu-bar title while paused, wiring up the is_paused parameter update_tray_time already received but ignored. - Remember the last monitor selection and preselect it in the source picker when every remembered monitor is still connected — multi-screen users no longer re-shift-click the same setup every session. Falls back to the primary monitor as before. - Add-session page: re-enable program buttons 15s after launching the browser flow. If the deep link never came back (closed tab, changed mind), they used to stay stuck on a spinner until you left the page. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg --- clients/desktop/src-tauri/src/tray.rs | 21 ++++++- .../desktop/src/components/AddSessionPage.tsx | 10 ++++ .../desktop/src/components/SourcePicker.tsx | 56 +++++++++++++++++-- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/clients/desktop/src-tauri/src/tray.rs b/clients/desktop/src-tauri/src/tray.rs index 7bdc6e94..90139475 100644 --- a/clients/desktop/src-tauri/src/tray.rs +++ b/clients/desktop/src-tauri/src/tray.rs @@ -42,6 +42,9 @@ pub fn show_tray(time_text: String, app: AppHandle) -> Result<(), String> { let _tray = TrayIconBuilder::with_id("timelapse_tray") .title(&time_text) + // Windows doesn't render tray titles — the tooltip is the only place + // the recorded time is visible there. + .tooltip(format!("Lookout — {time_text} recorded")) .icon(icon) .icon_as_template(true) .on_tray_icon_event(move |tray, event| { @@ -164,9 +167,23 @@ fn position_and_show_window( } #[tauri::command] -pub fn update_tray_time(time_text: String, _is_paused: bool, app: AppHandle) -> Result<(), String> { +pub fn update_tray_time(time_text: String, is_paused: bool, app: AppHandle) -> Result<(), String> { + // Show a pause glyph in the menu bar while paused. The Rust ticker skips + // its updates while the timer is paused, so this sticks until resume — + // and its first running tick force-refreshes the plain title back. + let title = if is_paused { + format!("⏸ {time_text}") + } else { + time_text.clone() + }; + let tooltip = if is_paused { + format!("Lookout — paused at {time_text}") + } else { + format!("Lookout — {time_text} recorded") + }; if let Some(tray) = app.tray_by_id("timelapse_tray") { - let _ = tray.set_title(Some(time_text)); + let _ = tray.set_title(Some(title)); + let _ = tray.set_tooltip(Some(tooltip)); } Ok(()) } diff --git a/clients/desktop/src/components/AddSessionPage.tsx b/clients/desktop/src/components/AddSessionPage.tsx index e6622694..e4dbb116 100644 --- a/clients/desktop/src/components/AddSessionPage.tsx +++ b/clients/desktop/src/components/AddSessionPage.tsx @@ -62,6 +62,16 @@ export function AddSessionPage({ onBack, onStart }: AddSessionPageProps) { const programLabel = (p: Program) => p.displayName || p.name; + // If the deep link never comes back (user closed the browser tab, changed + // their mind, the program errored), the buttons used to stay disabled with + // a spinner forever. Re-enable them after a grace period so retrying + // doesn't require leaving and re-entering the page. + useEffect(() => { + if (!launched) return; + const id = setTimeout(() => setLaunched(null), 15_000); + return () => clearTimeout(id); + }, [launched]); + const handleOpenProgram = async (program: Program) => { setError(null); setLaunched(programLabel(program)); diff --git a/clients/desktop/src/components/SourcePicker.tsx b/clients/desktop/src/components/SourcePicker.tsx index bb012b94..e9c72db0 100644 --- a/clients/desktop/src/components/SourcePicker.tsx +++ b/clients/desktop/src/components/SourcePicker.tsx @@ -50,6 +50,33 @@ function sourcesEqual(a: CaptureSource | null, b: CaptureSource | null): boolean return a.type === b.type && a.id === b.id; } +// Most people record the same screen(s) every session — remember the last +// monitor selection so it's preselected next time. Only monitors: window ids +// aren't stable across app launches, and cameras load asynchronously. +const LAST_MONITORS_KEY = "lookout-last-monitor-selection"; + +function loadLastMonitorIds(): number[] { + try { + const raw = localStorage.getItem(LAST_MONITORS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((id) => typeof id === "number")) { + return parsed; + } + } + } catch { /* corrupted entry — fall back to the primary-monitor default */ } + return []; +} + +function saveLastMonitorSelection(sources: CaptureSource[]) { + const ids = sources.filter((s) => s.type === "monitor").map((s) => s.id); + try { + if (ids.length > 0) { + localStorage.setItem(LAST_MONITORS_KEY, JSON.stringify(ids)); + } + } catch { /* storage unavailable — non-fatal */ } +} + type TabId = "screens" | "windows" | "cameras" | "cast"; function PreviewImage({ @@ -248,12 +275,20 @@ export function SourcePicker({ onSelect, submitLabel = "Start Capture" }: Source // Wait for render then check scroll setTimeout(handleScroll, 10); - // Auto-select primary monitor if nothing selected yet + // Auto-select if nothing selected yet: prefer the monitors used last + // session (when every one of them is still connected), else the primary. if (selected.length === 0 && !wayland) { - const primary = result.monitors.find((m) => m.isPrimary) ?? result.monitors[0]; - if (primary) { - console.log(`[sources] auto-selected: monitor id=${primary.id} (${primary.name})`); - setSelected([{ type: "monitor", id: primary.id }]); + const lastIds = loadLastMonitorIds(); + const remembered = lastIds.filter((id) => result.monitors.some((m) => m.id === id)); + if (lastIds.length > 0 && remembered.length === lastIds.length) { + console.log(`[sources] auto-selected remembered monitors: ${remembered.join(", ")}`); + setSelected(remembered.map((id) => ({ type: "monitor" as const, id }))); + } else { + const primary = result.monitors.find((m) => m.isPrimary) ?? result.monitors[0]; + if (primary) { + console.log(`[sources] auto-selected: monitor id=${primary.id} (${primary.name})`); + setSelected([{ type: "monitor", id: primary.id }]); + } } } } catch (err) { @@ -801,7 +836,16 @@ export function SourcePicker({ onSelect, submitLabel = "Start Capture" }: Source {/* Start button */}
{selected.length > 0 && ( - )} From 37cf1ea8958af8ab783c562c427191b530dacce0 Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:56:06 +0800 Subject: [PATCH 04/65] feat(clips): per-minute video clips + configurable desktop server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions can now opt in (clips_enabled, set at creation, immutable) to uploading per-minute webm/mp4 clips holding ~20 frames instead of a single JPEG — same cadence, credit math, and rate limits either way. - shared: capture format constants/types shared by all uploaders - server: clips schema + 0015 migration, format-aware upload-url and confirm validation, integration tests - worker: stitch clip segments into the compiled timelapse - react: clipRecorder hook (MediaRecorder) wired into the uploader - desktop: runtime-configurable server URL (Settings -> Advanced) with probe-before-save, persisted in localStorage; naming prompt on stop --- .github/workflows/tests.yml | 37 + clients/desktop/src-tauri/Cargo.lock | 2 + clients/desktop/src-tauri/Cargo.toml | 13 +- clients/desktop/src-tauri/src/lib.rs | 524 ++++++++++- clients/desktop/src-tauri/tauri.conf.json | 2 +- .../desktop/src/components/AddSessionPage.tsx | 5 +- .../src/components/DesktopRecorder.tsx | 5 +- clients/desktop/src/components/RecordPage.tsx | 5 +- .../desktop/src/components/SettingsPage.tsx | 884 ++++++++++++++---- clients/desktop/src/hooks/useAnnouncement.ts | 5 +- clients/desktop/src/serverConfig.ts | 79 ++ clients/react/package.json | 1 + clients/react/src/api/client.ts | 19 +- clients/react/src/components/Gallery.tsx | 18 +- clients/react/src/hooks/clipRecorder.ts | 284 ++++++ clients/react/src/hooks/useLookout.ts | 93 +- clients/react/src/hooks/useScreenCapture.ts | 7 +- clients/react/src/hooks/useSession.ts | 12 + clients/react/src/hooks/useUploader.ts | 63 +- clients/react/src/index.ts | 3 + docs/integration.md | 29 +- packages/server/API.md | 46 +- packages/server/drizzle/0015_clips.sql | 3 + .../server/drizzle/meta/0015_snapshot.json | 685 ++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema.ts | 15 + packages/server/src/routes/internal.ts | 9 +- packages/server/src/routes/sessions.ts | 65 +- .../server/test/clips.integration.test.ts | 311 ++++++ packages/server/test/setup.ts | 14 +- packages/shared/src/constants.ts | 52 ++ packages/shared/src/types.ts | 35 +- packages/worker/package.json | 6 +- packages/worker/src/compile.ts | 166 +++- packages/worker/src/schema.ts | 4 + packages/worker/src/segments.ts | 155 +++ packages/worker/test/segments.test.ts | 168 ++++ 37 files changed, 3526 insertions(+), 305 deletions(-) create mode 100644 clients/desktop/src/serverConfig.ts create mode 100644 clients/react/src/hooks/clipRecorder.ts create mode 100644 packages/server/drizzle/0015_clips.sql create mode 100644 packages/server/drizzle/meta/0015_snapshot.json create mode 100644 packages/server/test/clips.integration.test.ts create mode 100644 packages/worker/src/segments.ts create mode 100644 packages/worker/test/segments.test.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85e9df74..41612d6e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,6 +82,43 @@ jobs: working-directory: packages/server run: npx vitest run + # ────────────────────────────────────────────────────────────────── + # Worker: ffmpeg segment pipeline. Proves the clip-compile contract — + # every capture unit (JPEG still, VP8 webm clip, H.264 mp4 clip, any + # resolution) normalizes to exactly one second of 30fps video with + # pinned parameters, and the segments stream-copy concatenate into a + # decodable MP4. Runs real ffmpeg on synthetic inputs; no DB/R2. + # ────────────────────────────────────────────────────────────────── + worker: + name: Worker tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install ffmpeg + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: lts/* + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build shared package + run: npm run build -w packages/shared + + - name: Type-check worker + working-directory: packages/worker + run: npx tsc --noEmit + + - name: Vitest (segment pipeline) + run: npm test -w packages/worker + # ────────────────────────────────────────────────────────────────── # Desktop client: Rust unit + serde compat tests. # The 4-way matrix (legacy/new struct × legacy/new JSON) here is the diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock index 0b9dcf3a..c677d75d 100644 --- a/clients/desktop/src-tauri/Cargo.lock +++ b/clients/desktop/src-tauri/Cargo.lock @@ -3162,6 +3162,7 @@ dependencies = [ "gstreamer-video", "image", "objc2", + "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation", @@ -3187,6 +3188,7 @@ dependencies = [ "url", "webview2-com", "window-vibrancy 0.7.1", + "windows 0.62.2", "xcap", ] diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml index 011454d6..56306e97 100644 --- a/clients/desktop/src-tauri/Cargo.toml +++ b/clients/desktop/src-tauri/Cargo.toml @@ -29,7 +29,7 @@ tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" xcap = "0.9" -image = { version = "0.25", default-features = false, features = ["jpeg"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } tokio = { version = "1", features = ["full"] } base64 = "0.22" @@ -60,10 +60,19 @@ tokio = { version = "1", features = ["test-util"] } objc2 = "0.6" objc2-core-foundation = "0.3.2" objc2-core-graphics = "0.3.2" -objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString"] } +objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString", "NSArray", "NSData", "NSDictionary", "NSEnumerator", "NSBundle", "NSURL"] } +objc2-app-kit = { version = "0.3.2", features = ["NSWorkspace", "NSRunningApplication", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSGraphicsContext", "objc2-core-graphics"] } [target.'cfg(target_os = "windows")'.dependencies] webview2-com = "0.38" +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Storage_FileSystem", + "Win32_System_Com", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } [target."cfg(target_os = \"linux\")".dependencies] ashpd = { version = "0.9", default-features = false, features = ["tokio"] } diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 758cb1a1..60303b73 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -610,55 +610,522 @@ fn get_blacklisted_apps(state: State<'_, AppState>) -> Result, Strin Ok(blacklist.clone()) } -/// List unique app names from all running windows (across all spaces). -/// Returns a sorted, deduplicated list of app names. -#[tauri::command] -fn list_running_apps() -> Vec { - #[cfg(target_os = "macos")] - { - let Some(entries) = CGWindowListCopyWindowInfo( - CGWindowListOption::OptionAll | CGWindowListOption::ExcludeDesktopElements, - 0, - ) else { - return Vec::new(); +/// One entry in the app list shown on the Filtered Apps page. +#[derive(Clone, Serialize)] +pub struct AppEntry { + pub name: String, + /// Platform-specific icon lookup key, passed back to `get_app_icon`: + /// macOS = .app bundle path, Windows = Start Menu .lnk path, + /// Linux = the .desktop entry's Icon= value. + pub path: Option, + /// Whether the app is currently running (used to sort open apps first). + pub running: bool, +} + +/// Read an app bundle's display name (CFBundleDisplayName, falling back to +/// CFBundleName). These are what `kCGWindowOwnerName` reports for the app's +/// windows, so blacklist entries created from this list match redaction. +#[cfg(target_os = "macos")] +fn bundle_display_name(path: &std::path::Path) -> Option { + use objc2_foundation::{NSBundle, NSString}; + + let ns_path = NSString::from_str(path.to_str()?); + let bundle = NSBundle::bundleWithPath(&ns_path)?; + for key in ["CFBundleDisplayName", "CFBundleName"] { + let key = NSString::from_str(key); + if let Some(value) = bundle.objectForInfoDictionaryKey(&key) { + if let Ok(s) = value.downcast::() { + let s = s.to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + } + None +} + +/// Scan the standard application folders for installed .app bundles. +/// Slow-ish (reads each bundle's Info.plist), so callers cache the result. +#[cfg(target_os = "macos")] +fn scan_installed_apps() -> Vec { + let mut queue: Vec<(std::path::PathBuf, u8)> = vec![ + ("/Applications".into(), 0), + ("/System/Applications".into(), 0), + ]; + if let Ok(home) = std::env::var("HOME") { + queue.push((std::path::Path::new(&home).join("Applications"), 0)); + } + + let mut apps = Vec::new(); + // Scan one folder level deep: /Applications/Utilities/X.app and vendor + // folders like /Applications/Adobe .../X.app are common. + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if file_name.starts_with('.') { + continue; + } + if file_name.ends_with(".app") { + let name = bundle_display_name(&path) + .unwrap_or_else(|| file_name.trim_end_matches(".app").to_string()); + if name.is_empty() || name == "Lookout" || should_exclude_window(&name, "") { + continue; + } + apps.push(AppEntry { + name, + path: Some(path.to_string_lossy().into_owned()), + running: false, + }); + } else if depth < 1 && path.is_dir() { + queue.push((path, depth + 1)); + } + } + } + apps +} - let mut apps = std::collections::BTreeSet::new(); - for i in 0..entries.count() { - let dict_ref = unsafe { entries.value_at_index(i) } as *const CFDictionary; - if dict_ref.is_null() { +/// Scan Start Menu shortcuts — the canonical "installed apps" on Windows. +#[cfg(target_os = "windows")] +fn scan_installed_apps() -> Vec { + let mut queue: Vec<(std::path::PathBuf, u8)> = Vec::new(); + if let Ok(program_data) = std::env::var("ProgramData") { + queue.push(( + std::path::Path::new(&program_data).join(r"Microsoft\Windows\Start Menu\Programs"), + 0, + )); + } + if let Ok(app_data) = std::env::var("APPDATA") { + queue.push(( + std::path::Path::new(&app_data).join(r"Microsoft\Windows\Start Menu\Programs"), + 0, + )); + } + + let mut apps = Vec::new(); + while let Some((dir, depth)) = queue.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if depth < 3 { + queue.push((path, depth + 1)); + } continue; } - let dict = unsafe { &*dict_ref }; - let app_name = dict_string(dict, "kCGWindowOwnerName").unwrap_or_default(); - if app_name.is_empty() || app_name == "Lookout" { + let is_lnk = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("lnk")); + if !is_lnk { continue; } - let title = dict_string(dict, "kCGWindowName").unwrap_or_default(); - if should_exclude_window(&app_name, &title) { + let Some(name) = path.file_stem().and_then(|n| n.to_str()) else { + continue; + }; + let name = name.to_string(); + let lower = name.to_lowercase(); + if name.is_empty() + || name == "Lookout" + || should_exclude_window(&name, "") + || lower.starts_with("uninstall") + || lower.contains("uninstaller") + { + continue; + } + apps.push(AppEntry { + name, + path: Some(path.to_string_lossy().into_owned()), + running: false, + }); + } + } + apps +} + +/// Parse the fields we need from a .desktop file's [Desktop Entry] section. +/// Returns (name, icon) or None if the entry isn't a visible application. +#[cfg(target_os = "linux")] +fn parse_desktop_entry(content: &str) -> Option<(String, Option)> { + let mut in_section = false; + let mut name = None; + let mut icon = None; + for line in content.lines() { + let line = line.trim(); + if line.starts_with('[') { + if in_section { + break; // end of [Desktop Entry] + } + in_section = line == "[Desktop Entry]"; + continue; + } + if !in_section { + continue; + } + if let Some(value) = line.strip_prefix("NoDisplay=") { + if value.trim() == "true" { + return None; + } + } else if let Some(value) = line.strip_prefix("Type=") { + if value.trim() != "Application" { + return None; + } + } else if let Some(value) = line.strip_prefix("Name=") { + name = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("Icon=") { + icon = Some(value.trim().to_string()); + } + } + Some((name.filter(|n| !n.is_empty())?, icon)) +} + +/// Scan .desktop entries — the canonical "installed apps" on Linux. +#[cfg(target_os = "linux")] +fn scan_installed_apps() -> Vec { + let mut dirs: Vec = vec![ + "/usr/share/applications".into(), + "/usr/local/share/applications".into(), + "/var/lib/flatpak/exports/share/applications".into(), + ]; + if let Ok(home) = std::env::var("HOME") { + let home = std::path::Path::new(&home); + dirs.push(home.join(".local/share/applications")); + dirs.push(home.join(".local/share/flatpak/exports/share/applications")); + } + + let mut apps = Vec::new(); + for dir in dirs { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { continue; } - apps.insert(app_name); + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Some((name, icon)) = parse_desktop_entry(&content) else { + continue; + }; + if name == "Lookout" || should_exclude_window(&name, "") { + continue; + } + apps.push(AppEntry { + name, + path: icon, + running: false, + }); } - apps.into_iter().collect() + } + apps +} + +fn installed_apps_cached() -> &'static [AppEntry] { + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + CACHE.get_or_init(scan_installed_apps) +} + +/// (name, icon-lookup key) pairs for currently running apps. Names come from +/// the same source redaction matches against (kCGWindowOwnerName on macOS, +/// xcap `app_name` elsewhere), so a running app always blacklists correctly +/// even when its installed entry is named differently. +fn running_apps() -> Vec<(String, Option)> { + #[cfg(target_os = "macos")] + { + use objc2_app_kit::{NSApplicationActivationPolicy, NSWorkspace}; + + let workspace = NSWorkspace::sharedWorkspace(); + workspace + .runningApplications() + .iter() + .filter(|app| app.activationPolicy() == NSApplicationActivationPolicy::Regular) + .filter_map(|app| { + let name = app.localizedName()?.to_string(); + let path = app + .bundleURL() + .and_then(|url| url.path()) + .map(|p| p.to_string()); + Some((name, path)) + }) + .collect() } #[cfg(not(target_os = "macos"))] { - // On non-macOS, return window app names from xcap use xcap::Window; - let mut apps = std::collections::BTreeSet::new(); + let mut names = std::collections::BTreeSet::new(); if let Ok(windows) = Window::all() { for w in windows { if let Ok(name) = w.app_name() { - if !name.is_empty() && name != "Lookout" && !should_exclude_window(&name, &w.title().unwrap_or_default()) { - apps.insert(name); + if !name.is_empty() && !should_exclude_window(&name, &w.title().unwrap_or_default()) { + names.insert(name); } } } } - apps.into_iter().collect() + names.into_iter().map(|name| (name, None)).collect() + } +} + +/// List apps for the Filtered Apps page, sorted by name: every installed app +/// (scanned once per process and cached) merged with currently running apps. +/// Only real applications appear — helper/XPC processes that merely own +/// windows (e.g. "CursorUIViewService") don't. Async so scans run off the +/// main thread. +#[tauri::command] +async fn list_installed_apps() -> Vec { + // name -> (path, running); BTreeMap keeps the result sorted by name. + let mut apps: std::collections::BTreeMap, bool)> = + installed_apps_cached() + .iter() + .map(|a| (a.name.clone(), (a.path.clone(), false))) + .collect(); + + for (name, path) in running_apps() { + if name.is_empty() || name == "Lookout" || should_exclude_window(&name, "") { + continue; + } + match apps.entry(name) { + std::collections::btree_map::Entry::Occupied(mut e) => { + let (existing_path, running) = e.get_mut(); + if existing_path.is_none() { + *existing_path = path; + } + *running = true; + } + std::collections::btree_map::Entry::Vacant(e) => { + e.insert((path, true)); + } + } + } + + apps.into_iter() + .map(|(name, (path, running))| AppEntry { + name, + path, + running, + }) + .collect() +} + +/// Return a small PNG (base64) of an app's icon. `path` is the icon lookup +/// key from `AppEntry.path`. Cached per key; async so lookups run off the +/// main thread (a sync command here froze the UI while icons rasterized). +#[tauri::command] +async fn get_app_icon(path: String) -> Option { + static CACHE: std::sync::OnceLock< + std::sync::Mutex>>, + > = std::sync::OnceLock::new(); + let cache = CACHE.get_or_init(Default::default); + if let Some(hit) = cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&path) + { + return hit.clone(); + } + + let result = compute_app_icon(&path); + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(path, result.clone()); + result +} + +#[cfg(target_os = "macos")] +fn compute_app_icon(path: &str) -> Option { + use base64::Engine as _; + use objc2::AnyThread as _; + use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSWorkspace}; + use objc2_core_foundation::{CGPoint, CGSize}; + use objc2_foundation::{NSDictionary, NSString}; + + let icon = NSWorkspace::sharedWorkspace().iconForFile(&NSString::from_str(path)); + // Ask for a small rect so IconServices hands back the small icon + // representation instead of rasterizing the full 1024px artwork. + let mut rect = CGRect { + origin: CGPoint { x: 0.0, y: 0.0 }, + size: CGSize { + width: 32.0, + height: 32.0, + }, + }; + unsafe { icon.CGImageForProposedRect_context_hints(&mut rect, None, None) } + .and_then(|cg| { + let rep = NSBitmapImageRep::initWithCGImage(NSBitmapImageRep::alloc(), &cg); + unsafe { + rep.representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + ) + } + }) + .map(|png| base64::engine::general_purpose::STANDARD.encode(png.to_vec())) +} + +/// Windows: shell icon for the Start Menu .lnk (resolves to the target +/// exe's icon), converted HICON -> RGBA -> PNG. +#[cfg(target_os = "windows")] +fn compute_app_icon(path: &str) -> Option { + use base64::Engine as _; + use windows::core::PCWSTR; + use windows::Win32::Graphics::Gdi::{ + DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + }; + use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES; + use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED}; + use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON}; + use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, ICONINFO}; + + // SHGetFileInfoW needs COM for .lnk resolution; commands run on worker + // threads, so initialize per call (no-op if already initialized). + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + } + + let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); + let mut info = SHFILEINFOW::default(); + let ok = unsafe { + SHGetFileInfoW( + PCWSTR(wide.as_ptr()), + FILE_FLAGS_AND_ATTRIBUTES(0), + Some(&mut info), + std::mem::size_of::() as u32, + SHGFI_ICON | SHGFI_LARGEICON, + ) + }; + if ok == 0 || info.hIcon.is_invalid() { + return None; + } + + let png = (|| { + let mut icon_info = ICONINFO::default(); + unsafe { GetIconInfo(info.hIcon, &mut icon_info) }.ok()?; + + let result = (|| { + let mut bmp = BITMAP::default(); + let got = unsafe { + GetObjectW( + icon_info.hbmColor.into(), + std::mem::size_of::() as i32, + Some(&mut bmp as *mut _ as *mut _), + ) + }; + if got == 0 || bmp.bmWidth <= 0 || bmp.bmHeight <= 0 { + return None; + } + let (w, h) = (bmp.bmWidth, bmp.bmHeight); + + let mut bmi = BITMAPINFO::default(); + bmi.bmiHeader.biSize = std::mem::size_of::() as u32; + bmi.bmiHeader.biWidth = w; + bmi.bmiHeader.biHeight = -h; // negative = top-down rows + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB.0; + + let mut buf = vec![0u8; (w as usize) * (h as usize) * 4]; + let hdc = unsafe { GetDC(None) }; + let lines = unsafe { + GetDIBits( + hdc, + icon_info.hbmColor, + 0, + h as u32, + Some(buf.as_mut_ptr() as *mut _), + &mut bmi, + DIB_RGB_COLORS, + ) + }; + unsafe { ReleaseDC(None, hdc) }; + if lines == 0 { + return None; + } + + // BGRA -> RGBA; some icons come back with an empty alpha + // channel, which would render as fully transparent. + for px in buf.chunks_exact_mut(4) { + px.swap(0, 2); + } + if buf.chunks_exact(4).all(|px| px[3] == 0) { + for px in buf.chunks_exact_mut(4) { + px[3] = 255; + } + } + + let img = image::RgbaImage::from_raw(w as u32, h as u32, buf)?; + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut out, image::ImageFormat::Png) + .ok()?; + Some(out.into_inner()) + })(); + + unsafe { + let _ = DeleteObject(icon_info.hbmColor.into()); + let _ = DeleteObject(icon_info.hbmMask.into()); + } + result + })(); + + unsafe { + let _ = DestroyIcon(info.hIcon); + } + png.map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)) +} + +/// Linux: resolve the .desktop Icon= value against the hicolor theme and +/// pixmaps dirs (PNG only) and return the file as-is. +#[cfg(target_os = "linux")] +fn compute_app_icon(icon: &str) -> Option { + use base64::Engine as _; + + let mut candidates: Vec = Vec::new(); + if icon.starts_with('/') { + candidates.push(icon.into()); + } else { + let mut base_dirs: Vec = vec![ + "/usr/share".into(), + "/usr/local/share".into(), + "/var/lib/flatpak/exports/share".into(), + ]; + if let Ok(home) = std::env::var("HOME") { + base_dirs.push(format!("{home}/.local/share")); + base_dirs.push(format!("{home}/.local/share/flatpak/exports/share")); + } + for base in &base_dirs { + for size in ["48x48", "64x64", "32x32", "128x128", "256x256"] { + candidates.push(format!("{base}/icons/hicolor/{size}/apps/{icon}.png").into()); + } + candidates.push(format!("{base}/pixmaps/{icon}.png").into()); + } + } + + for path in candidates { + let is_png = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("png")); + if !is_png { + continue; + } + if let Ok(bytes) = std::fs::read(&path) { + return Some(base64::engine::general_purpose::STANDARD.encode(bytes)); + } } + None } /// List available capture sources (monitors + windows). @@ -2368,7 +2835,8 @@ pub fn run() { add_screencast, set_blacklisted_apps, get_blacklisted_apps, - list_running_apps, + list_installed_apps, + get_app_icon, tray::show_tray, tray::update_tray_time, tray::hide_tray, diff --git a/clients/desktop/src-tauri/tauri.conf.json b/clients/desktop/src-tauri/tauri.conf.json index f54e394f..2b225abe 100644 --- a/clients/desktop/src-tauri/tauri.conf.json +++ b/clients/desktop/src-tauri/tauri.conf.json @@ -31,7 +31,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src https://lookout.hackclub.com https://*.r2.cloudflarestorage.com http://localhost:* ws://localhost:* ipc: tauri: lookout-preview: http://lookout-preview.localhost https://*.ingest.sentry.io https://*.ingest.us.sentry.io; img-src 'self' blob: data: https://lookout.hackclub.com https://*.r2.cloudflarestorage.com lookout-preview: http://lookout-preview.localhost; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; media-src 'self' blob: https://lookout.hackclub.com https://*.r2.cloudflarestorage.com; font-src 'self' data:", + "csp": "default-src 'self'; connect-src https: http://localhost:* ws://localhost:* ipc: tauri: lookout-preview: http://lookout-preview.localhost; img-src 'self' blob: data: https: lookout-preview: http://lookout-preview.localhost; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; media-src 'self' blob: https:; font-src 'self' data:", "dangerousDisableAssetCspModification": true } }, diff --git a/clients/desktop/src/components/AddSessionPage.tsx b/clients/desktop/src/components/AddSessionPage.tsx index e4dbb116..42731ef9 100644 --- a/clients/desktop/src/components/AddSessionPage.tsx +++ b/clients/desktop/src/components/AddSessionPage.tsx @@ -13,7 +13,10 @@ import { invoke } from "../logger.js"; import { extractToken } from "../utils.js"; import { PageLayout } from "./PageLayout.js"; -const API_BASE = "https://lookout.hackclub.com"; +import { getApiBase } from "../serverConfig.js"; + +// Read once per webview load; Settings → Server reloads the view on change. +const API_BASE = getApiBase(); interface Program { name: string; diff --git a/clients/desktop/src/components/DesktopRecorder.tsx b/clients/desktop/src/components/DesktopRecorder.tsx index 67932069..04cd5074 100644 --- a/clients/desktop/src/components/DesktopRecorder.tsx +++ b/clients/desktop/src/components/DesktopRecorder.tsx @@ -32,7 +32,10 @@ interface DesktopRecorderProps { onViewSession: (token: string) => void; } -const API_BASE = "https://lookout.hackclub.com"; +import { getApiBase } from "../serverConfig.js"; + +// Read once per webview load; Settings → Server reloads the view on change. +const API_BASE = getApiBase(); function RecorderPreviewItem({ src, diff --git a/clients/desktop/src/components/RecordPage.tsx b/clients/desktop/src/components/RecordPage.tsx index 197b3d45..65380b7b 100644 --- a/clients/desktop/src/components/RecordPage.tsx +++ b/clients/desktop/src/components/RecordPage.tsx @@ -16,7 +16,10 @@ import { DesktopRecorder } from "./DesktopRecorder.js"; import { NamingModal } from "./NamingModal.js"; import { PageLayout, cardButtonStyle } from "./PageLayout.js"; -const API_BASE = "https://lookout.hackclub.com"; +import { getApiBase } from "../serverConfig.js"; + +// Read once per webview load; Settings → Server reloads the view on change. +const API_BASE = getApiBase(); interface RecordPageProps { token: string; diff --git a/clients/desktop/src/components/SettingsPage.tsx b/clients/desktop/src/components/SettingsPage.tsx index e00f70b2..b393f0df 100644 --- a/clients/desktop/src/components/SettingsPage.tsx +++ b/clients/desktop/src/components/SettingsPage.tsx @@ -1,5 +1,13 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { motion, AnimatePresence } from "motion/react"; +import { useState, useEffect, useRef, type ReactNode, type CSSProperties } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { + CaretLeftIcon, + CaretRightIcon, + CheckIcon, + FunnelIcon, + WrenchIcon, +} from "@phosphor-icons/react"; +import { confirm } from "@tauri-apps/plugin-dialog"; import { Button, colors, @@ -11,56 +19,78 @@ import { import { invoke } from "../logger.js"; import { cardButtonStyle } from "./PageLayout.js"; import { useBlacklistedApps } from "../hooks/useBlacklistedApps.js"; +import { + DEFAULT_API_BASE, + getApiBase, + isDefaultApiBase, + normalizeServerUrl, + setApiBase, +} from "../serverConfig.js"; interface SettingsPageProps { onBack: () => void; isWayland?: boolean; } -export function SettingsPage({ onBack, isWayland }: SettingsPageProps) { - const { blacklistedApps, toggleApp } = useBlacklistedApps(); - const [runningApps, setRunningApps] = useState([]); - const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(""); - const refreshTimerRef = useRef | null>(null); - - const fetchRunningApps = useCallback(async () => { - try { - const apps = await invoke("list_running_apps"); - setRunningApps(apps); - } catch (e) { - console.warn("[settings] failed to list running apps:", e); - } finally { - setLoading(false); - } - }, []); - - // Fetch on mount, then refresh every 5 seconds. Each refresh enumerates - // every window on the system, so skip ticks while the window is hidden — - // the list refreshes on the next visible tick anyway. - useEffect(() => { - fetchRunningApps(); - refreshTimerRef.current = setInterval(() => { - if (!document.hidden) fetchRunningApps(); - }, 5000); - return () => { - if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); - }; - }, [fetchRunningApps]); - - // Merge running apps with already-blacklisted apps (some may not be running) - const allApps = Array.from( - new Set([...runningApps, ...blacklistedApps]) - ).sort((a, b) => a.localeCompare(b)); +type SettingsSubpage = "menu" | "filtered-apps" | "advanced"; - const filtered = searchQuery - ? allApps.filter((app) => - app.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : allApps; +interface AppEntry { + name: string; + /** Bundle path used to look up the app's icon (macOS only). */ + path?: string | null; + /** Whether the app is currently running (open apps sort first). */ + running?: boolean; +} - const blacklistedCount = blacklistedApps.length; +/** + * 20px app icon with a plain-box placeholder. Fades in only when the icon + * arrives *after* mount (initial load) — rows remounting with a cached icon + * (e.g. while typing in search) render it instantly with no animation. + */ +function AppIcon({ icon }: { icon: string | undefined }) { + const fadeInRef = useRef(icon === undefined); + if (icon) { + return ( + + ); + } + return ( +
+ ); +} +/** Shared page scaffold: back button + title + description. */ +function PageChrome({ + title, + description, + onBack, + children, +}: { + title: string; + description: ReactNode; + onBack: () => void; + children: ReactNode; +}) { return (
← Back ) : ( - - - + )} @@ -100,7 +128,7 @@ export function SettingsPage({ onBack, isWayland }: SettingsPageProps) { marginBottom: spacing.xs, }} > - Filtered Apps + {title}

+ {description} +

+
+ + {children} +
+ ); +} + +/** A tappable settings-menu row: icon, title, description, chevron. */ +function MenuRow({ + icon, + title, + description, + onClick, +}: { + icon: ReactNode; + title: string; + description: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +export function SettingsPage({ onBack, isWayland }: SettingsPageProps) { + const [subpage, setSubpage] = useState("menu"); + // Single hook instance shared with the Filtered Apps subpage so the menu + // row's count stays live as apps are toggled. + const { blacklistedApps, toggleApp } = useBlacklistedApps(); + + const backToMenu = () => setSubpage("menu"); + + const content = (() => { + switch (subpage) { + case "filtered-apps": + return ( + + ); + case "advanced": + return ; + default: + return ( + + {/* Row hover/press styles shared with the app list */} + +
+
+
+ ); + } + })(); + + // Subpage transition — same directional slide as the app's route + // transitions in App.tsx: drilling into a subpage slides forward, + // returning to the menu slides back, and AnimatePresence keeps the + // outgoing page mounted so it animates out instead of vanishing. + // With only two levels, direction derives from the destination. + const direction = subpage === "menu" ? -1 : 1; + + return ( +
+ {/* initial={false}: on first mount the route-level transition in + App.tsx already animates the whole page in — don't double up. */} + + ({ opacity: 0, x: d > 0 ? 14 : -14 }), + center: { + opacity: 1, + x: 0, + transition: { + x: { type: "spring", stiffness: 460, damping: 36, mass: 0.7 }, + opacity: { duration: 0.16, delay: 0.04, ease: "easeOut" }, + }, + }, + exit: (d: number) => ({ + opacity: 0, + x: d > 0 ? -14 : 14, + transition: { + x: { type: "spring", stiffness: 460, damping: 36, mass: 0.7 }, + opacity: { duration: 0.14, ease: "easeOut" }, + }, + }), + }} + style={{ position: "absolute", inset: 0, overflowY: "auto" }} + > + {content} + + +
+ ); +} + +// ── Advanced subpage ──────────────────────────────────────── + +function AdvancedSettings({ onBack }: { onBack: () => void }) { + const current = getApiBase(); + const [value, setValue] = useState(current); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const normalized = normalizeServerUrl(value); + const isDirty = normalized !== current; + + /** Confirm → probe the server → persist → reload the webview so every + * module-scope API_BASE read picks up the new value. */ + const save = async (target: string | null) => { + setError(null); + if (target !== null && target !== DEFAULT_API_BASE) { + // Native dialog as a final speed bump — a wrong server means every + // recording from here on lands somewhere else. + const yes = await confirm( + `Point this app at ${target}?\n\nAll new recordings will upload there instead of the official Lookout server. Only continue if someone from Hack Club asked you to.`, + { title: "Switch Lookout server", kind: "warning" }, + ); + if (!yes) return; + } + setSaving(true); + try { + if (target !== null) { + // Probe a cheap public endpoint so a typo'd host fails here, not + // silently during the next recording. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 6_000); + try { + const res = await fetch(`${target}/api/programs`, { + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`server responded ${res.status}`); + } + } finally { + clearTimeout(timer); + } + } + setApiBase(target); + window.location.reload(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError( + msg.includes("abort") + ? "Could not reach the server (timed out)." + : `Could not reach the server: ${msg}`, + ); + setSaving(false); + } + }; + + const inputStyle: CSSProperties = { + width: "100%", + padding: `${spacing.sm}px ${spacing.md}px`, + fontSize: fontSize.md, + color: colors.text.primary, + background: colors.bg.surface, + border: `1px solid ${error ? colors.status.danger : colors.border.default}`, + borderRadius: radii.md, + outline: "none", + boxSizing: "border-box", + fontFamily: "monospace", + }; + + return ( + +
+
+
+ Lookout server +
+ { + setValue(e.target.value); + setError(null); + }} + style={inputStyle} + onFocus={(e) => { + e.currentTarget.style.borderColor = colors.border.hover; + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = error + ? colors.status.danger + : colors.border.default; + }} + /> +
+ Please enter a VALID lookout service URL. +
+
+ + {value.trim() !== "" && normalized === null && ( +
+ Enter a valid HTTPS URL (e.g. https://lookout-stage.example.com). +
+ )} + {error && ( +
+ {error} +
+ )} + +
+ + {current !== DEFAULT_API_BASE && ( + + )} +
+
+ + {current !== DEFAULT_API_BASE && ( +
+ Using a custom server. Timelapses recorded here won't appear on the + default Lookout server. +
+ )} +
+ ); +} + +// ── Filtered Apps subpage ─────────────────────────────────── + +function FilteredAppsSettings({ + onBack, + isWayland, + blacklistedApps, + toggleApp, +}: { + onBack: () => void; + isWayland?: boolean; + blacklistedApps: string[]; + toggleApp: (appName: string) => void; +}) { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + // App icons as base64 PNG keyed by app name, fetched once per app. + // Requested names live in a ref so re-renders never refetch. + const [appIcons, setAppIcons] = useState>({}); + const requestedIconsRef = useRef(new Set()); + // Apps that were already filtered when the page OPENED get pinned to the + // top. Snapshot in a ref, not live state: unchecking (or re-checking) an + // app must not reshuffle rows under the user's cursor mid-visit — the new + // order applies on the next visit. + const pinnedAppsRef = useRef>(new Set(blacklistedApps)); + const pinnedSet = pinnedAppsRef.current; + + // The installed-app list is static while the page is open — fetch once. + useEffect(() => { + let cancelled = false; + invoke("list_installed_apps") + .then((list) => { + if (!cancelled) setApps(list); + }) + .catch((e) => console.warn("[settings] failed to list apps:", e)) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + // Merge installed apps with already-blacklisted ones (which may have been + // uninstalled since); those keep the letter-tile fallback icon. + const byName = new Set(apps.map((a) => a.name)); + const allApps: AppEntry[] = [ + ...apps, + ...blacklistedApps.filter((n) => !byName.has(n)).map((n) => ({ name: n })), + ].sort((a, b) => a.name.localeCompare(b.name)); + + // Load icons lazily: a shared IntersectionObserver requests an icon only + // when its row scrolls near the viewport, instead of hitting the backend + // for every installed app at once. Each fetch is cached on both sides. + const observerRef = useRef(null); + const observedAppsRef = useRef(new Map()); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const app = observedAppsRef.current.get(entry.target); + observer.unobserve(entry.target); + observedAppsRef.current.delete(entry.target); + if (!app?.path || requestedIconsRef.current.has(app.name)) continue; + requestedIconsRef.current.add(app.name); + invoke("get_app_icon", { path: app.path }) + .then((icon) => { + if (icon) setAppIcons((prev) => ({ ...prev, [app.name]: icon })); + }) + .catch(() => {}); + } + }, + // Start fetching slightly before a row becomes visible so icons are + // usually there by the time it scrolls in. + { rootMargin: "200px" } + ); + observerRef.current = observer; + return () => observer.disconnect(); + }, []); + + const observeIcon = (el: Element | null, app: AppEntry) => { + const observer = observerRef.current; + if (!el || !observer) return undefined; + if (!app.path || requestedIconsRef.current.has(app.name)) return undefined; + observedAppsRef.current.set(el, app); + observer.observe(el); + return () => { + observer.unobserve(el); + observedAppsRef.current.delete(el); + }; + }; + + const filtered = searchQuery + ? allApps.filter((app) => + app.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : allApps; + + // Already-filtered apps first (what the user came to review), then open + // apps (the likeliest new filter targets), then everything else. + const pinnedApps = filtered.filter((app) => pinnedSet.has(app.name)); + const openApps = filtered.filter((app) => app.running && !pinnedSet.has(app.name)); + const otherApps = filtered.filter((app) => !app.running && !pinnedSet.has(app.name)); + + const blacklistedCount = blacklistedApps.length; + + const renderRow = (app: AppEntry) => { + const isBlacklisted = blacklistedApps.includes(app.name); + return ( + + ); + }; + + return ( + Selected apps will be blacked out in monitor screen captures. {blacklistedCount > 0 && ( {" "}{blacklistedCount} app{blacklistedCount !== 1 ? "s" : ""} filtered. )} -

-
- + + } + onBack={onBack} + > {/* Search + App list (hidden on Wayland) */} {isWayland ? (
) : ( <> + {/* Row hover/press animation + icon fade-in, as real CSS so rows stay + plain DOM nodes and search filtering has zero animation overhead */} + {/* Search */}
{loading ? ( -
- Loading apps... + // Skeleton rows while the app list loads +
+ {Array.from({ length: 12 }).map((_, i) => ( + +
+
+
+ + ))}
) : filtered.length === 0 ? (
) : (
- - {filtered.map((app) => { - const isBlacklisted = blacklistedApps.includes(app); - return ( - toggleApp(app)} - style={{ - position: "relative", - display: "flex", - alignItems: "center", - gap: spacing.md, - width: "100%", - padding: `${spacing.sm}px ${spacing.md}px`, - background: "transparent", - border: "none", - borderRadius: radii.md, - cursor: "pointer", - textAlign: "left", - color: colors.text.primary, - fontSize: fontSize.md, - }} - > - - - {/* Checkbox */} -
- {isBlacklisted && ( - - - - )} -
- - {/* App name */} -
- - {app} - -
-
- ); - })} -
+ {pinnedApps.map(renderRow)} + {openApps.map(renderRow)} + {otherApps.map(renderRow)}
)}
)} -
+ ); } diff --git a/clients/desktop/src/hooks/useAnnouncement.ts b/clients/desktop/src/hooks/useAnnouncement.ts index 06709084..23dad9b5 100644 --- a/clients/desktop/src/hooks/useAnnouncement.ts +++ b/clients/desktop/src/hooks/useAnnouncement.ts @@ -1,6 +1,9 @@ import { useEffect, useState } from "react"; -const API_BASE = "https://lookout.hackclub.com"; +import { getApiBase } from "../serverConfig.js"; + +// Read once per webview load; Settings → Server reloads the view on change. +const API_BASE = getApiBase(); // Re-check for a new/cleared announcement while the app stays open. const CHECK_INTERVAL_MS = 15 * 60_000; // 15 minutes diff --git a/clients/desktop/src/serverConfig.ts b/clients/desktop/src/serverConfig.ts new file mode 100644 index 00000000..e30dbcc8 --- /dev/null +++ b/clients/desktop/src/serverConfig.ts @@ -0,0 +1,79 @@ +/** + * Runtime-configurable Lookout server. + * + * The desktop app historically hardcoded https://lookout.hackclub.com in + * several modules. The base URL now lives here: persisted in localStorage + * (like the app blacklist), read once at module-load time by every consumer, + * and changed from Settings → Server — which reloads the webview so all + * module-scope `API_BASE` reads pick up the new value. The Rust side needs + * no storage of its own: it receives the URL per session via the + * `configure` command, which the frontend calls with this value. + * + * NOTE: the webview CSP (tauri.conf.json) allows `https:` for connect/img/ + * media sources, so custom servers must be HTTPS. Plain-http servers are + * blocked by the CSP (localhost excepted, for development). + */ + +export const DEFAULT_API_BASE = "https://lookout.hackclub.com"; + +const STORAGE_KEY = "lookout-api-base"; + +/** + * Validate and canonicalize a user-entered server URL to its origin + * (scheme + host + port). Returns null when the input isn't a usable + * server URL. HTTPS only, except localhost for development — anything + * else would be blocked by the webview CSP anyway. + */ +export function normalizeServerUrl(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + let url: URL; + try { + // Accept bare hostnames like "lookout-stage.dino.icu". + url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`); + } catch { + return null; + } + const isLocalhost = + url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalhost)) { + return null; + } + return url.origin; +} + +/** The active server base URL (no trailing slash). */ +export function getApiBase(): string { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const normalized = normalizeServerUrl(stored); + if (normalized) return normalized; + } + } catch { + // localStorage unavailable — fall through to the default + } + return DEFAULT_API_BASE; +} + +/** True when the app is pointed at the production server. */ +export function isDefaultApiBase(): boolean { + return getApiBase() === DEFAULT_API_BASE; +} + +/** + * Persist a new server base URL (pass null to reset to production). + * Callers should reload the webview afterwards — consumers read the + * value once at module load. + */ +export function setApiBase(url: string | null): void { + try { + if (url === null || url === DEFAULT_API_BASE) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, url); + } + } catch (e) { + console.error("[server-config] failed to persist server URL:", e); + } +} diff --git a/clients/react/package.json b/clients/react/package.json index 7376c055..007004b3 100644 --- a/clients/react/package.json +++ b/clients/react/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@lookout/shared": "*", + "@phosphor-icons/react": "^2.1.10", "@squircle-js/react": "^1.3.0", "@videojs/react": "^10.0.0-beta.8", "motion": "^12.38.0" diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts index b72c5478..3c576b6d 100644 --- a/clients/react/src/api/client.ts +++ b/clients/react/src/api/client.ts @@ -1,4 +1,5 @@ import type { + CaptureFormat, SessionResponse, UploadUrlResponse, ConfirmScreenshotRequest, @@ -17,10 +18,16 @@ export interface LookoutClient { getSession(): Promise; /** `capturedAt` is optional. Sending it on the first request of a new * session opts the session into credit-mode tracking; subsequent - * requests must keep sending it. Omit for legacy bucket-count behavior. */ - getUploadUrl(opts?: { capturedAt?: string }): Promise; + * requests must keep sending it. Omit for legacy bucket-count behavior. + * `format` requests a clip upload ('webm'/'mp4'); omit for a single + * JPEG. The response's `format` is the GRANTED format — the caller + * must upload exactly that. */ + getUploadUrl(opts?: { + capturedAt?: string; + format?: CaptureFormat; + }): Promise; confirmScreenshot(body: ConfirmScreenshotRequest): Promise; - uploadToR2(uploadUrl: string, blob: Blob): Promise; + uploadToR2(uploadUrl: string, blob: Blob, contentType?: string): Promise; pause(): Promise; resume(): Promise; stop(): Promise; @@ -104,6 +111,7 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient const base = await sessionUrl("/upload-url"); const params = new URLSearchParams(); if (opts?.capturedAt) params.set("capturedAt", opts.capturedAt); + if (opts?.format) params.set("format", opts.format); if (clientInfo) params.set("clientInfo", clientInfo); const qs = params.toString(); return fetchJson(qs ? `${base}?${qs}` : base); @@ -116,7 +124,7 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient }); }, - async uploadToR2(uploadUrl, blob) { + async uploadToR2(uploadUrl, blob, contentType = "image/jpeg") { if (!uploadUrl.startsWith("https://") && !uploadUrl.startsWith("/")) { throw new Error("Invalid upload URL: must be HTTPS or a relative path."); } @@ -125,7 +133,8 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient res = await fetch(uploadUrl, { method: "PUT", body: blob, - headers: { "Content-Type": "image/jpeg" }, + // Must match the content type the presigned URL was signed with. + headers: { "Content-Type": contentType }, }); } catch (err) { if (err instanceof TypeError) { diff --git a/clients/react/src/components/Gallery.tsx b/clients/react/src/components/Gallery.tsx index 0588adc1..1c66dde5 100644 --- a/clients/react/src/components/Gallery.tsx +++ b/clients/react/src/components/Gallery.tsx @@ -1,4 +1,5 @@ import React, { useRef, useState, useEffect, useCallback } from "react"; +import { GearSixIcon, PlusIcon } from "@phosphor-icons/react"; import type { SessionSummary } from "@lookout/shared"; import { SessionCard } from "./SessionCard.js"; import { Button } from "../ui/Button.js"; @@ -22,9 +23,12 @@ export interface GalleryProps { const addButtonStyle: React.CSSProperties = { borderRadius: radii.md, fontSize: fontSize.xxl, - width: 36, - height: 36, + width: 40, + height: 40, padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", }; function GalleryHeader({ onAdd, onSettings }: { onAdd?: () => void; onSettings?: () => void }) { @@ -33,15 +37,13 @@ function GalleryHeader({ onAdd, onSettings }: { onAdd?: () => void; onSettings?:

Your Timelapses

{onSettings && ( - )} {onAdd && ( - )}
diff --git a/clients/react/src/hooks/clipRecorder.ts b/clients/react/src/hooks/clipRecorder.ts new file mode 100644 index 00000000..30ce2d08 --- /dev/null +++ b/clients/react/src/hooks/clipRecorder.ts @@ -0,0 +1,284 @@ +import { + MAX_WIDTH, + MAX_HEIGHT, + JPEG_QUALITY, + CLIP_VIDEO_BITS_PER_SECOND, + type CaptureFormat, +} from "@lookout/shared"; + +/** One finalized per-minute clip, ready for the upload pipeline. */ +export interface ClipCaptureResult { + blob: Blob; + format: Exclude; + width: number; + height: number; + /** Frames drawn into the clip. Informational — the server/worker derive + * the real count by demuxing. */ + frameCount: number; + /** Client-clock ms timestamp stamped at cut time — the clip's capture + * moment for credit-mode purposes (one clip = one capture unit). */ + capturedAtMs: number; + /** JPEG snapshot of the clip's last frame, for the UI preview only. */ + previewBlob: Blob | null; +} + +interface MimeCandidate { + mime: string; + format: Exclude; +} + +/** Preference order: VP9 (best compression) → VP8 → generic WebM → + * MP4/H.264 (Safari — its MediaRecorder does not do WebM). */ +const MIME_CANDIDATES: MimeCandidate[] = [ + { mime: "video/webm;codecs=vp9", format: "webm" }, + { mime: "video/webm;codecs=vp8", format: "webm" }, + { mime: "video/webm", format: "webm" }, + { mime: "video/mp4", format: "mp4" }, +]; + +function pickMimeCandidate(): MimeCandidate | null { + if (typeof MediaRecorder === "undefined") return null; + for (const c of MIME_CANDIDATES) { + try { + if (MediaRecorder.isTypeSupported(c.mime)) return c; + } catch { + // isTypeSupported can throw on exotic UAs — treat as unsupported + } + } + return null; +} + +/** Per-recorder display knobs. Cadence and bitrate are deliberately NOT + * options: the frame interval is server-authoritative (constructor arg, + * from the session response) and the bitrate is the shared constant. */ +export interface ClipRecorderOptions { + maxWidth?: number; + maxHeight?: number; + jpegQuality?: number; +} + +/** + * Records the shared screen into per-minute video clips. + * + * Owns an offscreen canvas fed from the caller's `
- {screenshotCount} {screenshotCount === 1 ? "screenshot" : "screenshots"} + {/* One capture unit per recorded minute — a JPEG screenshot on + legacy sessions, a ~15-frame clip on clips sessions. */} + {screenshotCount} {screenshotCount === 1 ? "capture" : "captures"}
@@ -558,7 +559,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource color: colors.badge.overlayText, background: colors.badge.overlayBg, padding: "2px 6px", borderRadius: radii.sm, }}> - {windowFocused && !isCamera ? "Live preview" : "Latest capture"} + {windowFocused && !isCamera ? "Preview" : "Latest capture"}
) : ( diff --git a/clients/react/src/components/StatusBar.tsx b/clients/react/src/components/StatusBar.tsx index 5319f07f..9277a1cc 100644 --- a/clients/react/src/components/StatusBar.tsx +++ b/clients/react/src/components/StatusBar.tsx @@ -29,7 +29,9 @@ export function StatusBar({ displaySeconds, screenshotCount, uploads }: StatusBa {formatTime(displaySeconds)}
- {screenshotCount} {screenshotCount === 1 ? "screenshot" : "screenshots"} + {/* One capture unit per recorded minute — a JPEG screenshot on + legacy sessions, a ~15-frame clip on clips sessions. */} + {screenshotCount} {screenshotCount === 1 ? "capture" : "captures"} {uploads.pending > 0 && ( {uploads.pending} uploading... )} From 3bbf3ca5856e987204970399bd2ceb6049a686ea Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:05:43 +0800 Subject: [PATCH 15/65] feat: native add-menu popup, program icons, session redirect URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - desktop: Raycast-style SwiftUI popup on the gallery + button (borderless NSPanel, menu material, fling-in spring) via a new Swift FFI bridge - desktop: native SwiftUI menu-bar item with rolling-digit time (numericText) - server: programs.icon_url end-to-end — registry, admin API + page, and /api/programs; icons render in the + menu and AddSessionPage - server: sessions.redirect_url — creators can send users somewhere when their timelapse completes; desktop opens it on completion - react: Gallery onAdd passes the + button rect for popup anchoring --- clients/desktop/src-tauri/Cargo.lock | 1 + clients/desktop/src-tauri/Cargo.toml | 3 + clients/desktop/src-tauri/build.rs | 9 +- clients/desktop/src-tauri/src/lib.rs | 16 +- clients/desktop/src-tauri/src/native_menu.rs | 108 +++ clients/desktop/src-tauri/src/native_tray.rs | 60 ++ clients/desktop/src-tauri/src/tray.rs | 57 +- .../swift/lookout-tray/Package.swift | 13 + .../swift/lookout-tray/Sources/AddMenu.swift | 353 +++++++++ .../swift/lookout-tray/Sources/Tray.swift | 189 +++++ clients/desktop/src/App.tsx | 81 +- .../desktop/src/components/AddSessionPage.tsx | 14 + .../react/src/components/CameraPreview.tsx | 2 +- clients/react/src/components/Gallery.tsx | 24 +- .../react/src/components/SessionDetail.tsx | 25 + clients/react/src/index.ts | 2 +- docs/integration.md | 29 + packages/server/API.md | 8 + packages/server/drizzle/0016_redirect_url.sql | 1 + .../server/drizzle/0017_program_icon_url.sql | 1 + .../server/drizzle/meta/0016_snapshot.json | 691 +++++++++++++++++ .../server/drizzle/meta/0017_snapshot.json | 697 ++++++++++++++++++ packages/server/drizzle/meta/_journal.json | 14 + packages/server/src/db/schema.ts | 8 + packages/server/src/routes/admin.ts | 50 +- packages/server/src/routes/adminPage.ts | 28 + packages/server/src/routes/internal.ts | 18 +- packages/server/src/routes/programs.ts | 1 + packages/server/src/routes/sessions.ts | 4 + .../server/test/sessions.integration.test.ts | 75 ++ packages/shared/src/types.ts | 10 + 31 files changed, 2558 insertions(+), 34 deletions(-) create mode 100644 clients/desktop/src-tauri/src/native_menu.rs create mode 100644 clients/desktop/src-tauri/src/native_tray.rs create mode 100644 clients/desktop/src-tauri/swift/lookout-tray/Package.swift create mode 100644 clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift create mode 100644 clients/desktop/src-tauri/swift/lookout-tray/Sources/Tray.swift create mode 100644 packages/server/drizzle/0016_redirect_url.sql create mode 100644 packages/server/drizzle/0017_program_icon_url.sql create mode 100644 packages/server/drizzle/meta/0016_snapshot.json create mode 100644 packages/server/drizzle/meta/0017_snapshot.json diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock index 36366453..f1758442 100644 --- a/clients/desktop/src-tauri/Cargo.lock +++ b/clients/desktop/src-tauri/Cargo.lock @@ -3188,6 +3188,7 @@ dependencies = [ "sentry", "serde", "serde_json", + "swift-rs", "tauri", "tauri-build", "tauri-plugin-deep-link", diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml index 1ff754e6..91f545f4 100644 --- a/clients/desktop/src-tauri/Cargo.toml +++ b/clients/desktop/src-tauri/Cargo.toml @@ -21,6 +21,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +# Compiles swift/lookout-tray (SwiftUI menu-bar item with numericText digit +# animation) and links it into the macOS binary; no-op on other targets. +swift-rs = { version = "1.0.7", features = ["build"] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "devtools", "tray-icon", "image-png"] } diff --git a/clients/desktop/src-tauri/build.rs b/clients/desktop/src-tauri/build.rs index 105301eb..d18e12df 100644 --- a/clients/desktop/src-tauri/build.rs +++ b/clients/desktop/src-tauri/build.rs @@ -1,7 +1,14 @@ fn main() { // Link CoreGraphics on macOS for screen capture permission APIs #[cfg(target_os = "macos")] - println!("cargo:rustc-link-lib=framework=CoreGraphics"); + { + println!("cargo:rustc-link-lib=framework=CoreGraphics"); + // Native menu-bar item: compiles swift/lookout-tray (SwiftUI with the + // numericText digit-roll animation) and links it into the binary. + swift_rs::SwiftLinker::new("10.15") + .with_package("lookout-tray", "./swift/lookout-tray") + .link(); + } tauri_build::build() } diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 08839c4c..34b2c481 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -1,6 +1,9 @@ mod capture; mod clips; mod crop; +mod native_menu; +#[cfg(target_os = "macos")] +mod native_tray; mod pipewire; mod screencast; mod tray; @@ -2083,15 +2086,13 @@ const SLEEP_THRESHOLD_SECS: u64 = CAPTURE_INTERVAL_SECS * 2 + 30; // 150s const DEFAULT_FRAME_INTERVAL_MS: u64 = 4_000; /// Format seconds into the same tray title format as the JS side: -/// >0h: "{h}h {m}m", 0m: "< 1m", else: "{m}m" +/// >0h: "{h}h {m}m", else: "{m}m" fn format_tray_time(total_seconds: i64) -> String { let total = total_seconds.max(0) as u64; let h = total / 3600; let m = (total % 3600) / 60; if h > 0 { format!("{h}h {m}m") - } else if m == 0 { - "< 1m".to_string() } else { format!("{m}m") } @@ -2140,6 +2141,14 @@ async fn tray_timer_task( let time_text = format_tray_time(display_seconds); if was_paused || last_title.as_deref() != Some(time_text.as_str()) { + #[cfg(target_os = "macos")] + { + let _ = &app; + // None = keep the current pause state; the Swift side + // renders the tooltip and the numericText digit roll. + let _ = crate::native_tray::update(&time_text, None); + } + #[cfg(not(target_os = "macos"))] if let Some(tray) = app.tray_by_id("timelapse_tray") { let _ = tray.set_title(Some(&time_text)); // Windows doesn't render tray titles — the hover tooltip is @@ -3312,6 +3321,7 @@ pub fn run() { disable_vibrancy, is_wayland, open_external_url, + native_menu::show_add_menu, request_screencast, add_screencast, set_blacklisted_apps, diff --git a/clients/desktop/src-tauri/src/native_menu.rs b/clients/desktop/src-tauri/src/native_menu.rs new file mode 100644 index 00000000..8b134288 --- /dev/null +++ b/clients/desktop/src-tauri/src/native_menu.rs @@ -0,0 +1,108 @@ +//! Raycast-style popup menu for the gallery's "+" button, rendered by Swift +//! (swift/lookout-tray/Sources/AddMenu.swift) as a borderless NSPanel with +//! SwiftUI content. The frontend invokes `show_add_menu` with the items and +//! the button's rect (CSS px, viewport-relative — the webview spans the whole +//! window on macOS, so those are window coordinates); the command resolves to +//! the chosen item's id, or None when the menu is dismissed. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddMenuEntry { + pub id: Option, + pub label: Option, + pub symbol: Option, + /// Remote image shown instead of `symbol`, which stays the fallback. + #[serde(rename = "iconUrl")] + pub icon_url: Option, + #[serde(default)] + pub separator: bool, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct AddMenuAnchor { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{AddMenuAnchor, AddMenuEntry}; + use std::ffi::{c_char, c_void, CStr, CString}; + use std::sync::Mutex; + use tokio::sync::oneshot; + + extern "C" { + fn lookout_add_menu_show( + items_json: *const c_char, + ns_window: *mut c_void, + x: f64, + y: f64, + w: f64, + h: f64, + cb: extern "C" fn(*const c_char), + ); + } + + /// Only one menu can be open; replacing the sender cancels the previous + /// command's await (its receiver resolves to None). + static PENDING: Mutex>>> = Mutex::new(None); + + /// Selection callback from Swift; runs on the main thread. Null = dismissed. + extern "C" fn on_select(id: *const c_char) { + let value = if id.is_null() { + None + } else { + Some(unsafe { CStr::from_ptr(id) }.to_string_lossy().into_owned()) + }; + if let Some(tx) = PENDING.lock().unwrap().take() { + let _ = tx.send(value); + } + } + + pub async fn show( + window: tauri::WebviewWindow, + entries: Vec, + anchor: AddMenuAnchor, + ) -> Result, String> { + let json = serde_json::to_string(&entries).map_err(|e| e.to_string())?; + let json = CString::new(json).map_err(|e| e.to_string())?; + let (tx, rx) = oneshot::channel(); + *PENDING.lock().unwrap() = Some(tx); + // Scoped so the (!Send) NSWindow pointer isn't held across the await. + { + let ns_window = window.ns_window().map_err(|e| e.to_string())?; + unsafe { + lookout_add_menu_show( + json.as_ptr(), + ns_window, + anchor.x, + anchor.y, + anchor.width, + anchor.height, + on_select, + ); + } + } + Ok(rx.await.unwrap_or(None)) + } +} + +#[tauri::command] +pub async fn show_add_menu( + window: tauri::WebviewWindow, + entries: Vec, + anchor: AddMenuAnchor, +) -> Result, String> { + #[cfg(target_os = "macos")] + { + imp::show(window, entries, anchor).await + } + #[cfg(not(target_os = "macos"))] + { + let _ = (window, entries, anchor); + Err("native add menu is only implemented on macOS".into()) + } +} diff --git a/clients/desktop/src-tauri/src/native_tray.rs b/clients/desktop/src-tauri/src/native_tray.rs new file mode 100644 index 00000000..fe615097 --- /dev/null +++ b/clients/desktop/src-tauri/src/native_tray.rs @@ -0,0 +1,60 @@ +//! Thin FFI wrapper around the Swift-implemented menu-bar item +//! (swift/lookout-tray). The Swift side owns the NSStatusItem and renders the +//! recorded time with SwiftUI's `contentTransition(.numericText())`, so digit +//! changes roll like the system timer instead of snapping. Clicks come back +//! through a C callback carrying the item's screen rect (logical points, +//! top-left origin), which feeds the same tray-window toggle used by the +//! tauri tray on other platforms. + +use std::ffi::CString; +use std::os::raw::c_char; +use std::sync::OnceLock; + +use tauri::AppHandle; + +extern "C" { + fn lookout_tray_set_callback(cb: extern "C" fn(f64, f64, f64, f64)); + fn lookout_tray_show(text: *const c_char, icon: *const u8, icon_len: i32); + fn lookout_tray_update(text: *const c_char, paused: i32); + fn lookout_tray_hide(); +} + +static APP: OnceLock = OnceLock::new(); + +/// Click callback from Swift; runs on the main thread. +extern "C" fn on_tray_click(x: f64, y: f64, w: f64, h: f64) { + if let Some(app) = APP.get() { + let rect = tauri::Rect { + position: tauri::LogicalPosition::new(x, y).into(), + size: tauri::LogicalSize::new(w, h).into(), + }; + crate::tray::toggle_tray_window(app, rect); + } +} + +pub fn show(app: &AppHandle, time_text: &str) -> Result<(), String> { + let _ = APP.set(app.clone()); + let text = CString::new(time_text).map_err(|e| e.to_string())?; + let icon: &[u8] = include_bytes!("../icons/timelapse_template.png"); + unsafe { + lookout_tray_set_callback(on_tray_click); + lookout_tray_show(text.as_ptr(), icon.as_ptr(), icon.len() as i32); + } + Ok(()) +} + +/// `paused: None` keeps the current pause state (used by the 1s ticker). +pub fn update(time_text: &str, paused: Option) -> Result<(), String> { + let text = CString::new(time_text).map_err(|e| e.to_string())?; + let p = match paused { + None => -1, + Some(false) => 0, + Some(true) => 1, + }; + unsafe { lookout_tray_update(text.as_ptr(), p) }; + Ok(()) +} + +pub fn hide() { + unsafe { lookout_tray_hide() }; +} diff --git a/clients/desktop/src-tauri/src/tray.rs b/clients/desktop/src-tauri/src/tray.rs index 90139475..3e7f3297 100644 --- a/clients/desktop/src-tauri/src/tray.rs +++ b/clients/desktop/src-tauri/src/tray.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use std::sync::Mutex; +#[cfg(not(target_os = "macos"))] use tauri::image::Image; +#[cfg(not(target_os = "macos"))] use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder}; @@ -33,6 +35,18 @@ pub struct TrayStateMutex(pub Mutex); #[tauri::command] pub fn show_tray(time_text: String, app: AppHandle) -> Result<(), String> { + // macOS gets a native NSStatusItem rendered with SwiftUI so the time + // digits animate with contentTransition(.numericText()) — see + // swift/lookout-tray. Other platforms keep the tauri tray. + #[cfg(target_os = "macos")] + return crate::native_tray::show(&app, &time_text); + + #[cfg(not(target_os = "macos"))] + show_tauri_tray(time_text, app) +} + +#[cfg(not(target_os = "macos"))] +fn show_tauri_tray(time_text: String, app: AppHandle) -> Result<(), String> { if app.tray_by_id("timelapse_tray").is_some() { return Ok(()); } @@ -66,7 +80,7 @@ pub fn show_tray(time_text: String, app: AppHandle) -> Result<(), String> { Ok(()) } -fn toggle_tray_window(app: &AppHandle, rect: tauri::Rect) { +pub(crate) fn toggle_tray_window(app: &AppHandle, rect: tauri::Rect) { if let Some(window) = app.get_webview_window("tray") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); @@ -168,28 +182,41 @@ fn position_and_show_window( #[tauri::command] pub fn update_tray_time(time_text: String, is_paused: bool, app: AppHandle) -> Result<(), String> { + // The Swift side renders its own pause glyph and tooltip. + #[cfg(target_os = "macos")] + { + let _ = app; + return crate::native_tray::update(&time_text, Some(is_paused)); + } + // Show a pause glyph in the menu bar while paused. The Rust ticker skips // its updates while the timer is paused, so this sticks until resume — // and its first running tick force-refreshes the plain title back. - let title = if is_paused { - format!("⏸ {time_text}") - } else { - time_text.clone() - }; - let tooltip = if is_paused { - format!("Lookout — paused at {time_text}") - } else { - format!("Lookout — {time_text} recorded") - }; - if let Some(tray) = app.tray_by_id("timelapse_tray") { - let _ = tray.set_title(Some(title)); - let _ = tray.set_tooltip(Some(tooltip)); + #[cfg(not(target_os = "macos"))] + { + let title = if is_paused { + format!("⏸ {time_text}") + } else { + time_text.clone() + }; + let tooltip = if is_paused { + format!("Lookout — paused at {time_text}") + } else { + format!("Lookout — {time_text} recorded") + }; + if let Some(tray) = app.tray_by_id("timelapse_tray") { + let _ = tray.set_title(Some(title)); + let _ = tray.set_tooltip(Some(tooltip)); + } + Ok(()) } - Ok(()) } #[tauri::command] pub fn hide_tray(app: AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + crate::native_tray::hide(); + #[cfg(not(target_os = "macos"))] app.remove_tray_by_id("timelapse_tray"); if let Some(w) = app.get_webview_window("tray") { let _ = w.close(); diff --git a/clients/desktop/src-tauri/swift/lookout-tray/Package.swift b/clients/desktop/src-tauri/swift/lookout-tray/Package.swift new file mode 100644 index 00000000..bbcbd204 --- /dev/null +++ b/clients/desktop/src-tauri/swift/lookout-tray/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "lookout-tray", + platforms: [.macOS(.v10_15)], + products: [ + .library(name: "lookout-tray", type: .static, targets: ["lookout-tray"]) + ], + targets: [ + .target(name: "lookout-tray", path: "Sources") + ] +) diff --git a/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift b/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift new file mode 100644 index 00000000..6143df58 --- /dev/null +++ b/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift @@ -0,0 +1,353 @@ +// Raycast-style popup menu for the gallery's "+" button. A borderless +// NSPanel with a SwiftUI list over an NSVisualEffectView, anchored under the +// button — native chrome (blur, shadow, key handling) without the stock +// NSMenu look. Rust calls lookout_add_menu_show with the items as JSON and +// the button rect in window coordinates (logical points, top-left origin); +// the selected item id comes back through a C callback, nil on dismissal. + +import AppKit +import SwiftUI + +public typealias AddMenuCallback = @convention(c) (UnsafePointer?) -> Void + +/// Transparent padding around the menu inside its panel, so the spring's +/// overshoot (scale > 1) and the SwiftUI-drawn drop shadow have room to draw +/// instead of clipping at the window edge. The positioning math subtracts it +/// back out. +private let addMenuOvershootMargin: CGFloat = 28 + +struct AddMenuEntry: Decodable { + var id: String? + var label: String? + var symbol: String? + var iconUrl: String? + var separator: Bool? + var isSeparator: Bool { separator == true } +} + +@available(macOS 12.0, *) +final class AddMenuModel: ObservableObject { + let entries: [AddMenuEntry] + @Published var selection: Int? + /// Drives the fling-in scale. Set by the controller once the panel is on + /// screen — SwiftUI's own onAppear fires a commit later than orderFront, + /// which read as a frozen frame before the spring started. + @Published var appeared = false + let onActivate: (String?) -> Void + + init(entries: [AddMenuEntry], onActivate: @escaping (String?) -> Void) { + self.entries = entries + self.onActivate = onActivate + } + + private var selectable: [Int] { + entries.indices.filter { !entries[$0].isSeparator } + } + + func moveSelection(_ delta: Int) { + let indices = selectable + guard !indices.isEmpty else { return } + guard let current = selection, let pos = indices.firstIndex(of: current) else { + selection = delta > 0 ? indices.first : indices.last + return + } + let next = (pos + delta + indices.count) % indices.count + selection = indices[next] + } + + func activateSelection() { + guard let i = selection, !entries[i].isSeparator else { return } + onActivate(entries[i].id) + } +} + +@available(macOS 12.0, *) +private struct MenuEffectBackground: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .menu + view.blendingMode = .behindWindow + view.state = .active + return view + } + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +@available(macOS 12.0, *) +private struct AddMenuRow: View { + let entry: AddMenuEntry + let highlighted: Bool + + var body: some View { + HStack(spacing: 9) { + icon + Text(entry.label ?? "") + .font(.system(size: 13.5, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(highlighted ? Color.primary.opacity(0.09) : Color.clear) + ) + .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + } + + /// Program logo when the entry carries one; SF Symbol as the fallback + /// (and as the placeholder while the image loads or if it fails). + @ViewBuilder + private var icon: some View { + if let iconUrl = entry.iconUrl, let url = URL(string: iconUrl) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } else { + symbolIcon + } + } + .frame(width: 18, height: 18) + } else { + symbolIcon + .frame(width: 18) + } + } + + @ViewBuilder + private var symbolIcon: some View { + if let symbol = entry.symbol { + Image(systemName: symbol) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(highlighted ? .primary : .secondary) + } + } +} + +@available(macOS 12.0, *) +struct AddMenuView: View { + @ObservedObject var model: AddMenuModel + + var body: some View { + container( + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(model.entries.enumerated()), id: \.offset) { i, entry in + if entry.isSeparator { + Divider() + .padding(.vertical, 4) + .padding(.horizontal, 10) + } else { + AddMenuRow(entry: entry, highlighted: model.selection == i) + .onHover { inside in + if inside { + model.selection = i + } else if model.selection == i { + model.selection = nil + } + } + .onTapGesture { model.onActivate(entry.id) } + } + } + } + .padding(6) + .frame(minWidth: 220, maxWidth: 320, alignment: .leading) + ) + // Drawn here rather than by the window (hasShadow) — AppKit snapshots + // the window shadow once, mid-fling, leaving a stale outline at the + // wrong scale. This one tracks the animation. + .shadow(color: Color.black.opacity(0.28), radius: 16, x: 0, y: 6) + .scaleEffect(model.appeared ? 1 : 0.85, anchor: .topTrailing) + .padding(addMenuOvershootMargin) + } + + private func container(_ content: V) -> some View { + content + .background(MenuEffectBackground()) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.12), lineWidth: 1) + ) + } +} + +/// Borderless windows refuse key status by default; the menu needs it for +/// Escape/arrow keys and to learn when the user clicks away (resignKey). +@available(macOS 12.0, *) +private final class AddMenuPanel: NSPanel { + override var canBecomeKey: Bool { true } +} + +@available(macOS 12.0, *) +final class AddMenuController: NSObject, NSWindowDelegate { + static let shared = AddMenuController() + + private var panel: NSPanel? + private var callback: AddMenuCallback? + private var keyMonitor: Any? + private var finished = true + + func show(entries: [AddMenuEntry], parent: NSWindow, anchor: NSRect, cb: @escaping AddMenuCallback) { + // A stale panel here means Rust already abandoned its callback — just + // tear the old one down without firing anything. + closePanel() + finished = false + callback = cb + + let model = AddMenuModel(entries: entries) { [weak self] id in + self?.finish(id) + } + let hosting = NSHostingView(rootView: AddMenuView(model: model)) + // fittingSize includes the transparent overshoot margin on all sides; + // the menu's own width is clamped by the SwiftUI frame modifier. + let size = hosting.fittingSize + + let panel = AddMenuPanel( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false // shadow is drawn in SwiftUI; see AddMenuView + panel.level = .popUpMenu + panel.isReleasedWhenClosed = false + panel.collectionBehavior = [.transient, .ignoresCycle] + panel.contentView = hosting + panel.delegate = self + panel.setFrame(frame(for: size, parent: parent, anchor: anchor), display: false) + self.panel = panel + + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, let panel = self.panel, event.window === panel else { return event } + switch event.keyCode { + case 53: // escape + self.finish(nil) + return nil + case 125: // down + model.moveSelection(1) + return nil + case 126: // up + model.moveSelection(-1) + return nil + case 36, 76: // return, keypad enter + model.activateSelection() + return nil + default: + return event + } + } + + panel.alphaValue = 0 + panel.makeKeyAndOrderFront(nil) + // Kick fade and fling on the next tick, after the first frame (at + // 0.85 scale, alpha 0) has committed — starting them together is what + // makes the pop read as one motion. + DispatchQueue.main.async { [weak self] in + guard let self, self.panel === panel else { return } + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.15 + panel.animator().alphaValue = 1 + } + // Damping scales with √stiffness to keep the same slight overshoot. + withAnimation(.interpolatingSpring(stiffness: 1200, damping: 46, initialVelocity: 0)) { + model.appeared = true + } + } + } + + /// Screen frame for the menu: right edge aligned with the button, dropped + /// just below it, clamped to the visible screen (flips above the button + /// when there's no room underneath). The anchor rect is in window + /// coordinates with a top-left origin; the webview spans the whole window + /// (Overlay titlebar), so window-relative math is enough. + private func frame(for size: NSSize, parent: NSWindow, anchor: NSRect) -> NSRect { + let margin = addMenuOvershootMargin + // The visible menu box, excluding the transparent overshoot margin. + let menu = NSSize(width: size.width - 2 * margin, height: size.height - 2 * margin) + let wf = parent.frame + let gap: CGFloat = 6 + var x = wf.origin.x + anchor.origin.x + anchor.width - menu.width + let anchorBottomY = wf.maxY - (anchor.origin.y + anchor.height) + var y = anchorBottomY - gap - menu.height + + if let visible = (parent.screen ?? NSScreen.main)?.visibleFrame { + x = max(visible.minX + 8, min(x, visible.maxX - menu.width - 8)) + if y < visible.minY + 8 { + let anchorTopY = wf.maxY - anchor.origin.y + y = anchorTopY + gap + } + } + // Expand back out so the window carries the margin on every side. + return NSRect(x: x - margin, y: y - margin, width: size.width, height: size.height) + } + + func windowDidResignKey(_ notification: Notification) { + finish(nil) + } + + private func finish(_ id: String?) { + guard !finished else { return } + finished = true + let cb = callback + callback = nil + if let id { + id.withCString { cb?($0) } + } else { + cb?(nil) + } + closePanel() + } + + private func closePanel() { + if let monitor = keyMonitor { + NSEvent.removeMonitor(monitor) + keyMonitor = nil + } + guard let panel = panel else { return } + self.panel = nil + panel.delegate = nil + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.17 + panel.animator().alphaValue = 0 + }, completionHandler: { + panel.orderOut(nil) + }) + } +} + +@_cdecl("lookout_add_menu_show") +public func lookoutAddMenuShow( + _ itemsJson: UnsafePointer, + _ windowPtr: UnsafeMutableRawPointer, + _ x: Double, + _ y: Double, + _ w: Double, + _ h: Double, + _ cb: @escaping AddMenuCallback +) { + let json = String(cString: itemsJson) + DispatchQueue.main.async { + guard #available(macOS 12.0, *), + let data = json.data(using: .utf8), + let entries = try? JSONDecoder().decode([AddMenuEntry].self, from: data), + !entries.isEmpty + else { + cb(nil) + return + } + let window = Unmanaged.fromOpaque(windowPtr).takeUnretainedValue() + AddMenuController.shared.show( + entries: entries, + parent: window, + anchor: NSRect(x: x, y: y, width: w, height: h), + cb: cb + ) + } +} diff --git a/clients/desktop/src-tauri/swift/lookout-tray/Sources/Tray.swift b/clients/desktop/src-tauri/swift/lookout-tray/Sources/Tray.swift new file mode 100644 index 00000000..b8541b91 --- /dev/null +++ b/clients/desktop/src-tauri/swift/lookout-tray/Sources/Tray.swift @@ -0,0 +1,189 @@ +// Native macOS menu-bar item for Lookout. Owns an NSStatusItem whose content +// is a SwiftUI view, so the recorded-time digits animate with the real +// `contentTransition(.numericText())` (the system timer's rolling-digit +// effect). Rust talks to this through the @_cdecl functions at the bottom; +// clicks come back through a C callback carrying the item's screen rect +// (logical points, top-left origin) so Rust can position the tray window. + +import AppKit +import SwiftUI + +public typealias TrayClickCallback = @convention(c) (Double, Double, Double, Double) -> Void + +@available(macOS 10.15, *) +final class TrayModel: ObservableObject { + static let shared = TrayModel() + @Published var text: String = "0m" + @Published var paused: Bool = false + var icon: NSImage? +} + +/// Reports the content's natural width up to TrayController, which sets the +/// NSStatusItem length — the status-bar button doesn't size itself from +/// subview constraints, so without this the text truncates to "…". +@available(macOS 10.15, *) +struct TrayWidthKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +@available(macOS 10.15, *) +struct TrayContentView: View { + @ObservedObject var model = TrayModel.shared + + var body: some View { + HStack(spacing: 4) { + if let icon = model.icon { + Image(nsImage: icon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 17, height: 17) + } + if model.paused, #available(macOS 11.0, *) { + Image(systemName: "pause.fill") + .font(.system(size: 9)) + } + timeText + } + .padding(.horizontal, 3) + .fixedSize() + .background( + GeometryReader { geo in + Color.clear.preference(key: TrayWidthKey.self, value: geo.size.width) + } + ) + .onPreferenceChange(TrayWidthKey.self) { width in + TrayController.shared.setLength(width) + } + } + + @ViewBuilder + private var timeText: some View { + if #available(macOS 13.0, *) { + Text(model.text) + .font(.system(size: 13.5).monospacedDigit()) + .contentTransition(.numericText()) + } else { + Text(model.text) + .font(.system(size: 13.5)) + } + } +} + +/// NSHostingView swallows mouse events, which would break the status-bar +/// button's target/action (and its click highlight). Punching through hitTest +/// lets the button own the interaction while SwiftUI only draws. +@available(macOS 10.15, *) +final class PassthroughHostingView: NSHostingView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +@available(macOS 10.15, *) +final class TrayController: NSObject { + static let shared = TrayController() + var item: NSStatusItem? + var callback: TrayClickCallback? + + func show() { + guard item == nil else { return } + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + self.item = item + guard let button = item.button else { return } + + let hosting = PassthroughHostingView(rootView: TrayContentView()) + hosting.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(hosting) + NSLayoutConstraint.activate([ + hosting.topAnchor.constraint(equalTo: button.topAnchor), + hosting.bottomAnchor.constraint(equalTo: button.bottomAnchor), + hosting.leadingAnchor.constraint(equalTo: button.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: button.trailingAnchor), + ]) + + button.target = self + button.action = #selector(clicked(_:)) + } + + /// Called by the SwiftUI view whenever its natural width changes. + func setLength(_ width: CGFloat) { + guard width > 0 else { return } + item?.length = width + } + + @objc func clicked(_ sender: Any?) { + guard let button = item?.button, let window = button.window else { return } + let frame = window.frame + // AppKit rects are bottom-left origin; the Rust side wants top-left. + let screenH = NSScreen.screens.first?.frame.height ?? 0 + callback?( + frame.origin.x, + screenH - frame.origin.y - frame.height, + frame.width, + frame.height + ) + } + + func hide() { + if let item = item { + NSStatusBar.system.removeStatusItem(item) + } + item = nil + } +} + +@_cdecl("lookout_tray_set_callback") +public func lookoutTraySetCallback(_ cb: @escaping TrayClickCallback) { + DispatchQueue.main.async { + guard #available(macOS 10.15, *) else { return } + TrayController.shared.callback = cb + } +} + +@_cdecl("lookout_tray_show") +public func lookoutTrayShow(_ text: UnsafePointer, _ iconBytes: UnsafePointer?, _ iconLen: Int32) { + let s = String(cString: text) + let iconData = iconBytes.map { Data(bytes: $0, count: Int(iconLen)) } + DispatchQueue.main.async { + guard #available(macOS 10.15, *) else { return } + if TrayModel.shared.icon == nil, let data = iconData, let img = NSImage(data: data) { + img.isTemplate = true + TrayModel.shared.icon = img + } + TrayModel.shared.text = s + TrayModel.shared.paused = false + TrayController.shared.show() + TrayController.shared.item?.button?.toolTip = "Lookout — \(s) recorded" + } +} + +/// paused: -1 keeps the current pause state (the 1s ticker), 0/1 set it. +@_cdecl("lookout_tray_update") +public func lookoutTrayUpdate(_ text: UnsafePointer, _ paused: Int32) { + let s = String(cString: text) + DispatchQueue.main.async { + guard #available(macOS 10.15, *) else { return } + let p = paused < 0 ? TrayModel.shared.paused : (paused != 0) + if #available(macOS 13.0, *) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) { + TrayModel.shared.text = s + TrayModel.shared.paused = p + } + } else { + TrayModel.shared.text = s + TrayModel.shared.paused = p + } + TrayController.shared.item?.button?.toolTip = + p ? "Lookout — paused at \(s)" : "Lookout — \(s) recorded" + } +} + +@_cdecl("lookout_tray_hide") +public func lookoutTrayHide() { + DispatchQueue.main.async { + guard #available(macOS 10.15, *) else { return } + TrayController.shared.hide() + } +} diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx index 3a3bfa49..769e630b 100644 --- a/clients/desktop/src/App.tsx +++ b/clients/desktop/src/App.tsx @@ -11,6 +11,7 @@ import { useTokenStore, useGallery, useHashRouter, + type AddAnchor, } from "@lookout/react"; import { getVersion } from "@tauri-apps/api/app"; import { isValidToken, extractToken } from "./utils.js"; @@ -34,6 +35,13 @@ import { getApiBase } from "./serverConfig.js"; // Read once per webview load; Settings → Server reloads the view on change. const API_BASE = getApiBase(); +interface Program { + name: string; + displayName?: string; + newSessionUrl: string; + iconUrl?: string | null; +} + /** Pause a session by token. Fire-and-forget, logs errors. */ async function pauseSession(token: string): Promise { try { @@ -128,6 +136,70 @@ function MainWindowApp() { invoke("is_wayland").then(setIsWayland).catch(() => {}); }, []); + // Program registry cache for the + button's native popup menu. Warmed at + // launch and refreshed on every open so the menu appears instantly with + // whatever we have; the AddSessionPage stays the fallback (paste-a-link, + // empty registry, non-macOS). + const programsRef = React.useRef([]); + const fetchPrograms = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/programs`); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data.programs)) programsRef.current = data.programs; + } catch (e) { + console.warn("[programs] failed to load registry:", e); + } + }, []); + useEffect(() => { + void fetchPrograms(); + }, [fetchPrograms]); + + const handleAdd = useCallback( + async (anchor: AddAnchor) => { + const programs = programsRef.current; + void fetchPrograms(); // refresh behind the menu for next open + if (!isMacOS || programs.length === 0) { + navigate({ page: "add" }); + return; + } + const entries = [ + ...programs.map((p) => ({ + id: `program:${p.name}`, + label: p.displayName || p.name, + // The symbol stays as the fallback while the icon loads or when a + // program has none. + symbol: "arrow.up.forward.app", + iconUrl: p.iconUrl ?? undefined, + })), + { separator: true }, + { id: "create-new", label: "Create new timelapse", symbol: "plus" }, + ]; + let choice: string | null; + try { + choice = await invoke("show_add_menu", { entries, anchor }); + } catch (e) { + console.warn("[add-menu] native menu failed, falling back to page:", e); + navigate({ page: "add" }); + return; + } + if (!choice) return; // dismissed + if (choice === "create-new") { + navigate({ page: "add" }); + return; + } + const program = programs.find((p) => `program:${p.name}` === choice); + if (!program) return; + try { + await invoke("open_external_url", { url: program.newSessionUrl }); + } catch (e) { + console.error("[add-menu] failed to open program url:", e); + navigate({ page: "add" }); + } + }, + [isMacOS, fetchPrograms, navigate], + ); + // Deep link handler -- saves token and navigates appropriately. // If currently recording another session, pauses it first. // Tracks the last processed URL to deduplicate retried cold-start emits. @@ -356,7 +428,7 @@ function MainWindowApp() { gallery.refresh(); } }} - onAdd={() => navigate({ page: "add" })} + onAdd={handleAdd} // Always available: the Server subpage works everywhere; only the // Filtered Apps subpage is Wayland-restricted (it shows a notice). onSettings={() => navigate({ page: "settings" })} @@ -401,6 +473,13 @@ function MainWindowApp() { key={route.token} token={route.token} apiBaseUrl={API_BASE} + onComplete={({ redirectUrl }) => { + // Redirect hook: the session's creator asked us to send the + // user somewhere once their timelapse is ready. + if (redirectUrl) { + invoke("open_external_url", { url: redirectUrl }).catch(() => {}); + } + }} onBack={() => { gallery.refresh(); navigate({ page: "gallery" }); diff --git a/clients/desktop/src/components/AddSessionPage.tsx b/clients/desktop/src/components/AddSessionPage.tsx index 42731ef9..8d5f8546 100644 --- a/clients/desktop/src/components/AddSessionPage.tsx +++ b/clients/desktop/src/components/AddSessionPage.tsx @@ -24,6 +24,7 @@ interface Program { // unset, so this is always present, but guard anyway for older servers. displayName?: string; newSessionUrl: string; + iconUrl?: string | null; } interface AddSessionPageProps { @@ -212,6 +213,19 @@ export function AddSessionPage({ onBack, onStart }: AddSessionPageProps) { disabled={loading || (launched !== null && launched !== programLabel(p))} onClick={() => handleOpenProgram(p)} > + {p.iconUrl && ( + + )} {programLabel(p)} ))} diff --git a/clients/react/src/components/CameraPreview.tsx b/clients/react/src/components/CameraPreview.tsx index 8f26a7eb..b393b5a3 100644 --- a/clients/react/src/components/CameraPreview.tsx +++ b/clients/react/src/components/CameraPreview.tsx @@ -68,7 +68,7 @@ export function CameraPreview({ stream, fallbackImageUrl }: CameraPreviewProps) borderRadius: radii.sm, }} > - {stream ? "Live preview" : "Latest capture"} + {stream ? "Preview" : "Latest capture"}
); diff --git a/clients/react/src/components/Gallery.tsx b/clients/react/src/components/Gallery.tsx index 1c66dde5..2a78d66a 100644 --- a/clients/react/src/components/Gallery.tsx +++ b/clients/react/src/components/Gallery.tsx @@ -7,6 +7,14 @@ import { ErrorDisplay } from "../ui/ErrorDisplay.js"; import { GallerySkeleton } from "../ui/Skeleton.js"; import { colors, spacing, fontSize, fontWeight, radii } from "../ui/theme.js"; +/** Viewport-relative rect of the + button, for hosts that anchor a popup to it. */ +export interface AddAnchor { + x: number; + y: number; + width: number; + height: number; +} + export interface GalleryProps { sessions: SessionSummary[]; loading: boolean; @@ -14,7 +22,7 @@ export interface GalleryProps { onSessionClick?: (token: string) => void; onArchive?: (token: string) => void; onRefresh?: () => void; - onAdd?: () => void; + onAdd?: (anchor: AddAnchor) => void; onSettings?: () => void; /** Optional content rendered just below the header (e.g. an update banner). */ banner?: React.ReactNode; @@ -31,7 +39,7 @@ const addButtonStyle: React.CSSProperties = { justifyContent: "center", }; -function GalleryHeader({ onAdd, onSettings }: { onAdd?: () => void; onSettings?: () => void }) { +function GalleryHeader({ onAdd, onSettings }: { onAdd?: (anchor: AddAnchor) => void; onSettings?: () => void }) { return (

Your Timelapses

@@ -42,7 +50,17 @@ function GalleryHeader({ onAdd, onSettings }: { onAdd?: () => void; onSettings?: )} {onAdd && ( - )} diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx index 31be1651..5c554d22 100644 --- a/clients/react/src/components/SessionDetail.tsx +++ b/clients/react/src/components/SessionDetail.tsx @@ -15,6 +15,11 @@ export interface SessionDetailProps { apiBaseUrl: string; onBack?: () => void; onArchive?: () => void; + /** Fired once when the session is observed transitioning to "complete" + * while this view is polling (i.e. the timelapse just finished compiling). + * NOT fired when opening a session that is already complete. Carries the + * session's redirect-hook URL, if one was set at creation. */ + onComplete?: (info: { redirectUrl: string | null }) => void; } export function SessionDetail({ @@ -22,6 +27,7 @@ export function SessionDetail({ apiBaseUrl, onBack, onArchive, + onComplete, }: SessionDetailProps) { const [sessionInfo, setSessionInfo] = useState<{ name: string; createdAt: string } | null>(null); const [isRenaming, setIsRenaming] = useState(false); @@ -58,6 +64,14 @@ export function SessionDetail({ const [videoUrl, setVideoUrl] = useState(null); const [error, setError] = useState(null); + // Completion detection for the redirect hook: only a live transition from + // an in-flight state counts — a session opened when already "complete" + // must not re-fire. Refs (not state) so fetchStatus stays stable. + const prevStatusRef = useRef(null); + const completeFiredRef = useRef(false); + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + // Fetch session info (name, createdAt) once useEffect(() => { (async () => { @@ -83,6 +97,17 @@ export function SessionDetail({ const data: StatusResponse = await res.json(); setStatus(data); + const prevStatus = prevStatusRef.current; + prevStatusRef.current = data.status; + if ( + data.status === "complete" && + (prevStatus === "stopped" || prevStatus === "compiling") && + !completeFiredRef.current + ) { + completeFiredRef.current = true; + onCompleteRef.current?.({ redirectUrl: data.redirectUrl ?? null }); + } + // Fetch video URL when complete if (data.status === "complete" && !videoUrl) { try { diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts index 6dafc721..a1e6cca4 100644 --- a/clients/react/src/index.ts +++ b/clients/react/src/index.ts @@ -24,7 +24,7 @@ export { VideoPlayer } from "./components/VideoPlayer.js"; // Gallery components export { Gallery } from "./components/Gallery.js"; -export type { GalleryProps } from "./components/Gallery.js"; +export type { GalleryProps, AddAnchor } from "./components/Gallery.js"; export { SessionCard } from "./components/SessionCard.js"; export type { SessionCardProps } from "./components/SessionCard.js"; export { SessionDetail } from "./components/SessionDetail.js"; diff --git a/docs/integration.md b/docs/integration.md index 1891d59b..512844e2 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -63,6 +63,35 @@ Response: - `sessionUrl` — a convenience URL you can redirect the user to. - `metadata` — any JSON you want to associate with the session (user info, project, etc.) - `clips` — opt this session into [clips](#clips-15-frames-per-minute) (~15 frames/min video capture instead of 1 JPEG/min → 15× smoother timelapses). Default `false`; immutable after creation. +- `redirectUrl` — optional [redirect hook](#redirect-hook): an http(s) URL the recording client sends the user to once their timelapse finishes compiling. Immutable after creation. + +### Redirect hook + +Pass `redirectUrl` when creating a session to send the user somewhere when +their timelapse is done — e.g. back to your submission form: + +```bash +curl -X POST https://lookout.hackclub.com/api/internal/sessions \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ + -d '{"metadata": {"userId": "user_123"}, "redirectUrl": "https://yourprogram.example/submit?step=timelapse-done"}' +``` + +How it behaves: + +- The URL must be `http(s)` (max 2048 chars) — anything else is rejected with + a 400 at creation time. +- The desktop app opens the URL in the user's default browser the moment it + sees the session flip to `complete` while the user is watching the compile + (i.e. right after they stop recording). It fires at most once per session, + and does **not** fire when someone later re-opens an already-completed + session from their gallery. +- The URL is surfaced to clients on `GET /api/sessions/:token` and + `GET /api/sessions/:token/status` as `redirectUrl`, so custom clients can + implement the same behavior. +- Older desktop clients ignore the field — treat the redirect as a + convenience, not a guaranteed callback. For server-side certainty, poll + [session status](#get-session-info) instead. ### Clips (15 frames per minute) diff --git a/packages/server/API.md b/packages/server/API.md index d80e87a8..8df30255 100644 --- a/packages/server/API.md +++ b/packages/server/API.md @@ -191,6 +191,7 @@ Returns the current state of a session. "videoUrl": "https://...", "clipsEnabled": false, "frameIntervalMs": 4000, + "redirectUrl": null, "metadata": {} } ``` @@ -199,6 +200,8 @@ Returns the current state of a session. `clipsEnabled` / `frameIntervalMs` are the [clips](#clips) capability signal. This endpoint is the session-recovery fetch clients make before recording, so a clip-capable client knows **before its first capture** whether to record clips (and at what cadence) — the very first upload of a clips session is already a clip. +`redirectUrl` is the session's [redirect hook](#create-session) (`null` when unset): clients watching the compile open it once the status flips to `complete`. + --- ### Rename Session @@ -464,6 +467,10 @@ When complete: } ``` +Sessions created with a [redirect hook](#create-session) additionally carry +`redirectUrl` (absent otherwise) — clients watching the compile open it when +the status flips to `complete`. + --- ### Get Capture Timings @@ -668,6 +675,7 @@ Creates a new session in `pending` state. | `name` | string | no | Session name (1-255 chars) | | `metadata` | object | no | Arbitrary JSON metadata to attach to the session (max 50 properties) | | `clips` | boolean | no | Allow [clip uploads](#clips) (~15 frames/min video) for this session. Default `false` = legacy 1 JPEG/min. **Immutable after creation.** | +| `redirectUrl` | string | no | Redirect hook: http(s) URL (max 2048 chars) the recording client opens in the user's browser once the timelapse finishes compiling. Fires at most once, only for a live completion (not on re-opening a finished session). **Immutable after creation.** | **Response `201 Created`:** ```json diff --git a/packages/server/drizzle/0016_redirect_url.sql b/packages/server/drizzle/0016_redirect_url.sql new file mode 100644 index 00000000..ae5c241f --- /dev/null +++ b/packages/server/drizzle/0016_redirect_url.sql @@ -0,0 +1 @@ +ALTER TABLE "sessions" ADD COLUMN "redirect_url" text; \ No newline at end of file diff --git a/packages/server/drizzle/0017_program_icon_url.sql b/packages/server/drizzle/0017_program_icon_url.sql new file mode 100644 index 00000000..25b92078 --- /dev/null +++ b/packages/server/drizzle/0017_program_icon_url.sql @@ -0,0 +1 @@ +ALTER TABLE "programs" ADD COLUMN "icon_url" text; \ No newline at end of file diff --git a/packages/server/drizzle/meta/0016_snapshot.json b/packages/server/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..3a36b775 --- /dev/null +++ b/packages/server/drizzle/meta/0016_snapshot.json @@ -0,0 +1,691 @@ +{ + "id": "30243f6d-376e-4fc1-ab0b-e2b852dd189c", + "prevId": "b0ac680b-a96c-429c-b720-4db2c25acfb5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.announcements": { + "name": "announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "level": { + "name": "level", + "type": "announcement_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_program_id_programs_id_fk": { + "name": "api_keys_program_id_programs_id_fk", + "tableFrom": "api_keys", + "tableTo": "programs", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_name_unique": { + "name": "api_keys_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "api_keys_key_unique": { + "name": "api_keys_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.programs": { + "name": "programs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_session_url": { + "name": "new_session_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "programs_name_unique": { + "name": "programs_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.screenshots": { + "name": "screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "minute_bucket": { + "name": "minute_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "confirmed": { + "name": "confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sampled": { + "name": "sampled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'jpeg'" + }, + "frame_count": { + "name": "frame_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_info": { + "name": "client_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ja4": { + "name": "ja4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credited_seconds": { + "name": "credited_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_at": { + "name": "expected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_screenshots_session_id": { + "name": "idx_screenshots_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_screenshots_session_bucket": { + "name": "idx_screenshots_session_bucket", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "minute_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_screenshots_unconfirmed": { + "name": "idx_screenshots_unconfirmed", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "confirmed = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_screenshots_session_captured_at": { + "name": "idx_screenshots_session_captured_at", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "screenshots_session_id_sessions_id_fk": { + "name": "screenshots_session_id_sessions_id_fk", + "tableFrom": "screenshots", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "program": { + "name": "program", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "program_id": { + "name": "program_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_screenshot_at": { + "name": "last_screenshot_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resumed_at": { + "name": "resumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_active_seconds": { + "name": "total_active_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tracked_seconds": { + "name": "tracked_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tracking_mode": { + "name": "tracking_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bucket'" + }, + "streak_anchor_at": { + "name": "streak_anchor_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "streak_credited_count": { + "name": "streak_credited_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "clips_enabled": { + "name": "clips_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "screenshots_purged_at": { + "name": "screenshots_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "video_url": { + "name": "video_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "video_r2_key": { + "name": "video_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_r2_key": { + "name": "thumbnail_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compile_attempts": { + "name": "compile_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_status": { + "name": "idx_sessions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_active_last_screenshot": { + "name": "idx_sessions_active_last_screenshot", + "columns": [ + { + "expression": "last_screenshot_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status IN ('active', 'paused', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_program_id_programs_id_fk": { + "name": "sessions_program_id_programs_id_fk", + "tableFrom": "sessions", + "tableTo": "programs", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.announcement_level": { + "name": "announcement_level", + "schema": "public", + "values": [ + "info", + "success", + "warning", + "danger" + ] + }, + "public.session_status": { + "name": "session_status", + "schema": "public", + "values": [ + "pending", + "active", + "paused", + "stopped", + "compiling", + "complete", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/0017_snapshot.json b/packages/server/drizzle/meta/0017_snapshot.json new file mode 100644 index 00000000..61b98ce7 --- /dev/null +++ b/packages/server/drizzle/meta/0017_snapshot.json @@ -0,0 +1,697 @@ +{ + "id": "7d74670f-d4b8-4a9e-b1f5-284ddde5b0ed", + "prevId": "30243f6d-376e-4fc1-ab0b-e2b852dd189c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.announcements": { + "name": "announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "level": { + "name": "level", + "type": "announcement_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_program_id_programs_id_fk": { + "name": "api_keys_program_id_programs_id_fk", + "tableFrom": "api_keys", + "tableTo": "programs", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_name_unique": { + "name": "api_keys_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "api_keys_key_unique": { + "name": "api_keys_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.programs": { + "name": "programs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_session_url": { + "name": "new_session_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "programs_name_unique": { + "name": "programs_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.screenshots": { + "name": "screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "minute_bucket": { + "name": "minute_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "confirmed": { + "name": "confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sampled": { + "name": "sampled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'jpeg'" + }, + "frame_count": { + "name": "frame_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_info": { + "name": "client_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ja4": { + "name": "ja4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credited_seconds": { + "name": "credited_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_at": { + "name": "expected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_screenshots_session_id": { + "name": "idx_screenshots_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_screenshots_session_bucket": { + "name": "idx_screenshots_session_bucket", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "minute_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_screenshots_unconfirmed": { + "name": "idx_screenshots_unconfirmed", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "confirmed = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_screenshots_session_captured_at": { + "name": "idx_screenshots_session_captured_at", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "screenshots_session_id_sessions_id_fk": { + "name": "screenshots_session_id_sessions_id_fk", + "tableFrom": "screenshots", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "program": { + "name": "program", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "program_id": { + "name": "program_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_screenshot_at": { + "name": "last_screenshot_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resumed_at": { + "name": "resumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_active_seconds": { + "name": "total_active_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tracked_seconds": { + "name": "tracked_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tracking_mode": { + "name": "tracking_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bucket'" + }, + "streak_anchor_at": { + "name": "streak_anchor_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "streak_credited_count": { + "name": "streak_credited_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "clips_enabled": { + "name": "clips_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "screenshots_purged_at": { + "name": "screenshots_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "video_url": { + "name": "video_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "video_r2_key": { + "name": "video_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_r2_key": { + "name": "thumbnail_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compile_attempts": { + "name": "compile_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_status": { + "name": "idx_sessions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_active_last_screenshot": { + "name": "idx_sessions_active_last_screenshot", + "columns": [ + { + "expression": "last_screenshot_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status IN ('active', 'paused', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_program_id_programs_id_fk": { + "name": "sessions_program_id_programs_id_fk", + "tableFrom": "sessions", + "tableTo": "programs", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.announcement_level": { + "name": "announcement_level", + "schema": "public", + "values": [ + "info", + "success", + "warning", + "danger" + ] + }, + "public.session_status": { + "name": "session_status", + "schema": "public", + "values": [ + "pending", + "active", + "paused", + "stopped", + "compiling", + "complete", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index 4d0cc1de..722142bb 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -113,6 +113,20 @@ "when": 1784956044227, "tag": "0015_clips", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1785057101581, + "tag": "0016_redirect_url", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1785059903186, + "tag": "0017_program_icon_url", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 388891a5..e828d9a3 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -69,6 +69,9 @@ export const programs = pgTable("programs", { // https://fallout.hackclub.com/lookout_session/new?desktop=true). NULL means // the program isn't listed in the desktop picker. newSessionUrl: text("new_session_url"), + // URL of a small square logo shown next to the program in pickers (e.g. the + // desktop's + menu). NULL means clients fall back to a generic glyph. + iconUrl: text("icon_url"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -118,6 +121,11 @@ export const sessions = pgTable( // (disallowed formats are downgraded to jpeg), and immutable thereafter — // a session's capture character never changes mid-recording. clipsEnabled: boolean("clips_enabled").notNull().default(false), + // Redirect hook: http(s) URL the recording client sends the user to once + // the timelapse finishes compiling. Set at creation by the program's + // backend (internal API `redirectUrl`), immutable thereafter. NULL = no + // redirect. + redirectUrl: text("redirect_url"), // Set when the retention job has deleted this session's screenshot R2 // objects (after SCREENSHOT_RETENTION_DAYS). The screenshot *rows* are // kept so capture timings stay queryable; this flag stops the job from diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 5634e821..3236a19f 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -59,6 +59,20 @@ function normalizeNewSessionUrl(raw: unknown): string | null | undefined { return trimmed; } +// Same validation for a program's icon URL: empty/whitespace clears it, +// anything else must be http(s). +function normalizeIconUrl(raw: unknown): string | null | undefined { + if (raw === undefined) return undefined; // not provided → leave unchanged + if (raw === null) return null; + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + if (!trimmed) return null; + if (!/^https?:\/\//i.test(trimmed)) { + throw new Error("iconUrl must be an http(s) URL"); + } + return trimmed; +} + // Trim a display name; empty/whitespace means "unset" (NULL → falls back to // the raw program name). `undefined` means "leave unchanged" on patch. function normalizeDisplayName(raw: unknown): string | null | undefined { @@ -75,6 +89,7 @@ const createProgramBodySchema = { name: { type: "string" as const, minLength: 1, maxLength: 255 }, displayName: { type: "string" as const, maxLength: 255 }, newSessionUrl: { type: "string" as const, maxLength: 2048 }, + iconUrl: { type: "string" as const, maxLength: 2048 }, }, required: ["name"] as const, additionalProperties: false, @@ -87,6 +102,8 @@ const patchProgramBodySchema = { newSessionUrl: { type: ["string", "null"] as const, maxLength: 2048 }, // Pass "" to clear the display name (UIs fall back to the raw name). displayName: { type: ["string", "null"] as const, maxLength: 255 }, + // Pass "" to clear the icon (pickers fall back to a generic glyph). + iconUrl: { type: ["string", "null"] as const, maxLength: 2048 }, }, additionalProperties: false, }; @@ -199,6 +216,7 @@ export async function adminRoutes(app: FastifyInstance) { name: schema.programs.name, displayName: schema.programs.displayName, newSessionUrl: schema.programs.newSessionUrl, + iconUrl: schema.programs.iconUrl, createdAt: schema.programs.createdAt, }) .from(schema.programs) @@ -325,6 +343,7 @@ export async function adminRoutes(app: FastifyInstance) { name: p.name, displayName: p.displayName, newSessionUrl: p.newSessionUrl, + iconUrl: p.iconUrl, createdAt: p.createdAt, keys: (keysByProgram.get(p.id) ?? []).map((k) => ({ id: k.id, @@ -368,7 +387,9 @@ export async function adminRoutes(app: FastifyInstance) { }); // Create a program and its first API key. - app.post<{ Body: { name: string; displayName?: string; newSessionUrl?: string } }>( + app.post<{ + Body: { name: string; displayName?: string; newSessionUrl?: string; iconUrl?: string }; + }>( "/api/admin/programs", { schema: { body: createProgramBodySchema } }, async (request, reply) => { @@ -378,12 +399,14 @@ export async function adminRoutes(app: FastifyInstance) { } const displayName = normalizeDisplayName(request.body.displayName) ?? null; let newSessionUrl: string | null; + let iconUrl: string | null; try { newSessionUrl = normalizeNewSessionUrl(request.body.newSessionUrl) ?? null; + iconUrl = normalizeIconUrl(request.body.iconUrl) ?? null; } catch (e) { return reply .code(400) - .send({ error: e instanceof Error ? e.message : "invalid newSessionUrl" }); + .send({ error: e instanceof Error ? e.message : "invalid URL" }); } const existing = await db.query.programs.findFirst({ @@ -402,7 +425,7 @@ export async function adminRoutes(app: FastifyInstance) { const result = await db.transaction(async (tx) => { const [program] = await tx .insert(schema.programs) - .values({ name, displayName, newSessionUrl }) + .values({ name, displayName, newSessionUrl, iconUrl }) .returning(); const [key] = await tx .insert(schema.apiKeys) @@ -416,6 +439,7 @@ export async function adminRoutes(app: FastifyInstance) { name: result.program.name, displayName: result.program.displayName, newSessionUrl: result.program.newSessionUrl, + iconUrl: result.program.iconUrl, key: result.key.key, }); }, @@ -424,29 +448,40 @@ export async function adminRoutes(app: FastifyInstance) { // Update a program's display name and/or new-session URL (set or clear each). app.patch<{ Params: { id: string }; - Body: { newSessionUrl?: string | null; displayName?: string | null }; + Body: { + newSessionUrl?: string | null; + displayName?: string | null; + iconUrl?: string | null; + }; }>( "/api/admin/programs/:id", { schema: { params: programIdParamSchema, body: patchProgramBodySchema } }, async (request, reply) => { let newSessionUrl: string | null | undefined; + let iconUrl: string | null | undefined; try { newSessionUrl = normalizeNewSessionUrl(request.body.newSessionUrl); + iconUrl = normalizeIconUrl(request.body.iconUrl); } catch (e) { return reply .code(400) - .send({ error: e instanceof Error ? e.message : "invalid newSessionUrl" }); + .send({ error: e instanceof Error ? e.message : "invalid URL" }); } const displayName = normalizeDisplayName(request.body.displayName); // Build a partial update from only the fields the caller provided. - const set: { newSessionUrl?: string | null; displayName?: string | null } = {}; + const set: { + newSessionUrl?: string | null; + displayName?: string | null; + iconUrl?: string | null; + } = {}; if (newSessionUrl !== undefined) set.newSessionUrl = newSessionUrl; if (displayName !== undefined) set.displayName = displayName; + if (iconUrl !== undefined) set.iconUrl = iconUrl; if (Object.keys(set).length === 0) { return reply .code(400) - .send({ error: "Provide newSessionUrl and/or displayName" }); + .send({ error: "Provide newSessionUrl, displayName and/or iconUrl" }); } const [updated] = await db @@ -458,6 +493,7 @@ export async function adminRoutes(app: FastifyInstance) { name: schema.programs.name, displayName: schema.programs.displayName, newSessionUrl: schema.programs.newSessionUrl, + iconUrl: schema.programs.iconUrl, }); if (!updated) { diff --git a/packages/server/src/routes/adminPage.ts b/packages/server/src/routes/adminPage.ts index 61531ec9..caf47421 100644 --- a/packages/server/src/routes/adminPage.ts +++ b/packages/server/src/routes/adminPage.ts @@ -275,6 +275,11 @@ function rowHtml(p) { var nameCell = p.displayName ? esc(p.displayName) + '
' + esc(p.name) + "" : esc(p.name); + if (p.iconUrl) { + nameCell = '' + + nameCell; + } return "" + "" + nameCell + "" + "" + urlHtml(p.newSessionUrl) + "" + @@ -289,6 +294,8 @@ function rowHtml(p) { '" data-current="' + esc(p.displayName || "") + '">set name' + '' + + '' + '' + ""; @@ -491,6 +498,27 @@ rows.addEventListener("click", async function (ev) { return; } + var iconBtn = ev.target.closest("[data-icon]"); + if (iconBtn) { + var currentIcon = iconBtn.getAttribute("data-current"); + var nextIcon = prompt( + 'Icon URL for "' + iconBtn.getAttribute("data-name") + + '" (small square logo; leave blank to clear):', + currentIcon, + ); + if (nextIcon === null) return; // cancelled + try { + await api("PATCH", "/api/admin/programs/" + iconBtn.getAttribute("data-icon"), { + iconUrl: nextIcon.trim(), + }); + flash("Updated icon URL."); + await load(); + } catch (e) { + flash(e.message, true); + } + return; + } + var delBtn = ev.target.closest("[data-del]"); if (delBtn) { var name = delBtn.getAttribute("data-name"); diff --git a/packages/server/src/routes/internal.ts b/packages/server/src/routes/internal.ts index 687196e3..e959b938 100644 --- a/packages/server/src/routes/internal.ts +++ b/packages/server/src/routes/internal.ts @@ -19,7 +19,12 @@ export async function internalRoutes(app: FastifyInstance) { // Create a new session app.post<{ - Body: { name?: string; metadata?: Record; clips?: boolean }; + Body: { + name?: string; + metadata?: Record; + clips?: boolean; + redirectUrl?: string; + }; }>( "/api/internal/sessions", { @@ -33,13 +38,21 @@ export async function internalRoutes(app: FastifyInstance) { // frames). Default false = legacy 1 JPEG/min. Immutable after // creation — a session's capture character never changes. clips: { type: "boolean" as const }, + // Redirect hook: once the timelapse finishes compiling, the + // recording client sends the user here (desktop opens it in the + // default browser). Immutable after creation. + redirectUrl: { + type: "string" as const, + pattern: "^https?://", + maxLength: 2048, + }, }, additionalProperties: false, }, }, }, async (request, reply) => { - const { name, metadata, clips } = request.body || {}; + const { name, metadata, clips, redirectUrl } = request.body || {}; const [session] = await db .insert(schema.sessions) @@ -47,6 +60,7 @@ export async function internalRoutes(app: FastifyInstance) { ...(name ? { name } : {}), metadata: metadata ?? {}, clipsEnabled: clips ?? false, + redirectUrl: redirectUrl ?? null, // Attribution: tag with the creating program (null for global key). // `program` (name) is dual-written for backward compatibility; // `programId` is the canonical attribution. diff --git a/packages/server/src/routes/programs.ts b/packages/server/src/routes/programs.ts index 2b239983..e0020c0f 100644 --- a/packages/server/src/routes/programs.ts +++ b/packages/server/src/routes/programs.ts @@ -16,6 +16,7 @@ export async function programRoutes(app: FastifyInstance) { // older programs without one still render sensibly. displayName: sql`coalesce(${schema.programs.displayName}, ${schema.programs.name})`, newSessionUrl: schema.programs.newSessionUrl, + iconUrl: schema.programs.iconUrl, }) .from(schema.programs) .where(isNotNull(schema.programs.newSessionUrl)) diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 179f8316..a56d9562 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -202,6 +202,7 @@ export async function sessionRoutes(app: FastifyInstance) { // opening frame in the timelapse). Old clients ignore these. clipsEnabled: session.clipsEnabled, frameIntervalMs: CLIP_FRAME_INTERVAL_MS, + redirectUrl: session.redirectUrl, metadata: session.metadata ?? {}, }; }, @@ -1046,6 +1047,9 @@ export async function sessionRoutes(app: FastifyInstance) { ? `${baseUrl}/please-update.webm` : undefined, trackedSeconds, + // Redirect hook — clients watching the compile open this once the + // status flips to "complete". Absent when the session has none. + redirectUrl: session.redirectUrl ?? undefined, }; }, ); diff --git a/packages/server/test/sessions.integration.test.ts b/packages/server/test/sessions.integration.test.ts index 30aabc0a..b8f1c657 100644 --- a/packages/server/test/sessions.integration.test.ts +++ b/packages/server/test/sessions.integration.test.ts @@ -907,3 +907,78 @@ describe("latency — sustained recording under jitter", () => { expect(c4.body.trackedSeconds).toBe(120); // +60 from the new streak }); }); + +// ──────────────────────────────────────────────────────────── +// Redirect hook — per-session URL opened by the client when the +// timelapse finishes compiling +// ──────────────────────────────────────────────────────────── + +describe("redirect hook", () => { + async function makeApiKey(): Promise { + const [row] = await db + .insert(schema.apiKeys) + .values({ name: `redirect-test-${Date.now()}-${Math.random()}` }) + .returning({ key: schema.apiKeys.key }); + return row.key; + } + + it("internal create persists redirectUrl and public endpoints expose it", async () => { + const key = await makeApiKey(); + + const created = await app.inject({ + method: "POST", + url: "/api/internal/sessions", + headers: { "x-api-key": key }, + payload: { name: "redirect-on", redirectUrl: "https://example.com/done?id=42" }, + }); + expect(created.statusCode).toBe(201); + const { token, sessionId } = created.json(); + + const row = await loadSession(sessionId); + expect(row?.redirectUrl).toBe("https://example.com/done?id=42"); + + // Session-recovery fetch carries it. + const get = await app.inject({ method: "GET", url: `/api/sessions/${token}` }); + expect(get.statusCode).toBe(200); + expect(get.json().redirectUrl).toBe("https://example.com/done?id=42"); + + // Status poll (what clients watch during compile) carries it. + const status = await app.inject({ method: "GET", url: `/api/sessions/${token}/status` }); + expect(status.statusCode).toBe(200); + expect(status.json().redirectUrl).toBe("https://example.com/done?id=42"); + }); + + it("defaults to null and is absent from the status response", async () => { + const key = await makeApiKey(); + + const created = await app.inject({ + method: "POST", + url: "/api/internal/sessions", + headers: { "x-api-key": key }, + payload: { name: "no-redirect" }, + }); + expect(created.statusCode).toBe(201); + const { token, sessionId } = created.json(); + + const row = await loadSession(sessionId); + expect(row?.redirectUrl).toBeNull(); + + const status = await app.inject({ method: "GET", url: `/api/sessions/${token}/status` }); + expect(status.statusCode).toBe(200); + expect("redirectUrl" in status.json()).toBe(false); + }); + + it("rejects non-http(s) redirect URLs", async () => { + const key = await makeApiKey(); + + for (const bad of ["javascript:alert(1)", "file:///etc/passwd", "not-a-url"]) { + const r = await app.inject({ + method: "POST", + url: "/api/internal/sessions", + headers: { "x-api-key": key }, + payload: { name: "bad-redirect", redirectUrl: bad }, + }); + expect(r.statusCode).toBe(400); + } + }); +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 6d4c20e8..dc4fdd7a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -64,6 +64,10 @@ export interface CreateSessionRequest { * ~15 frames) instead of one JPEG per minute. Default false. * Immutable after creation. */ clips?: boolean; + /** Redirect hook: http(s) URL the recording client sends the user to once + * the timelapse finishes compiling (the desktop app opens it in the + * default browser). Immutable after creation. */ + redirectUrl?: string; } export interface CreateSessionResponse { @@ -97,6 +101,9 @@ export interface SessionResponse { /** Server-authoritative clip cadence (ms between frames). Absent on * pre-clips servers. */ frameIntervalMs?: number; + /** Redirect hook URL to open once the timelapse completes; `null`/absent + * when the session has none. */ + redirectUrl?: string | null; metadata: Record; } @@ -184,6 +191,9 @@ export interface StatusResponse { * points at a static "please update" message video. */ videoWebmUrl?: string; trackedSeconds: number; + /** Redirect hook URL — clients watching the compile open this when the + * status flips to "complete". Absent when the session has none. */ + redirectUrl?: string; } export interface VideoResponse { From ad4fda4a85a2018599699b1caf7bd76fdcfed775 Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:13:23 +0800 Subject: [PATCH 16/65] perf(desktop): prefetch add-menu icons into a native cache AsyncImage re-fetched program icons on every menu open, flashing the fallback SF Symbol for ~0.5s. The frontend now passes icon URLs to a new prefetch_add_menu_icons command when the registry loads; Swift warms an in-memory NSImage cache the menu rows read synchronously, so icons render on the first frame. AsyncImage remains as the cold-miss fallback. --- clients/desktop/src-tauri/src/lib.rs | 1 + clients/desktop/src-tauri/src/native_menu.rs | 23 +++++++++ .../swift/lookout-tray/Sources/AddMenu.swift | 48 ++++++++++++++++++- clients/desktop/src/App.tsx | 12 ++++- 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 34b2c481..edd1f205 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -3322,6 +3322,7 @@ pub fn run() { is_wayland, open_external_url, native_menu::show_add_menu, + native_menu::prefetch_add_menu_icons, request_screencast, add_screencast, set_blacklisted_apps, diff --git a/clients/desktop/src-tauri/src/native_menu.rs b/clients/desktop/src-tauri/src/native_menu.rs index 8b134288..0cd8df20 100644 --- a/clients/desktop/src-tauri/src/native_menu.rs +++ b/clients/desktop/src-tauri/src/native_menu.rs @@ -44,6 +44,14 @@ mod imp { h: f64, cb: extern "C" fn(*const c_char), ); + fn lookout_add_menu_prefetch_icons(urls_json: *const c_char); + } + + pub fn prefetch_icons(urls: &[String]) -> Result<(), String> { + let json = serde_json::to_string(urls).map_err(|e| e.to_string())?; + let json = CString::new(json).map_err(|e| e.to_string())?; + unsafe { lookout_add_menu_prefetch_icons(json.as_ptr()) }; + Ok(()) } /// Only one menu can be open; replacing the sender cancels the previous @@ -90,6 +98,21 @@ mod imp { } } +/// Warm the Swift-side icon cache so the menu never shows fallback symbols +/// for programs whose icons are known ahead of time. No-op off macOS. +#[tauri::command] +pub fn prefetch_add_menu_icons(urls: Vec) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + imp::prefetch_icons(&urls) + } + #[cfg(not(target_os = "macos"))] + { + let _ = urls; + Ok(()) + } +} + #[tauri::command] pub async fn show_add_menu( window: tauri::WebviewWindow, diff --git a/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift b/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift index 6143df58..59857c82 100644 --- a/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift +++ b/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift @@ -25,6 +25,33 @@ struct AddMenuEntry: Decodable { var isSeparator: Bool { separator == true } } +/// In-memory icon store, warmed via lookout_add_menu_prefetch_icons when the +/// frontend loads the program registry — AsyncImage alone re-fetches on every +/// open, which showed the fallback symbol for ~0.5s each time. Main-thread +/// access only. +final class AddMenuIconCache { + static let shared = AddMenuIconCache() + private var images: [String: NSImage] = [:] + private var inflight: Set = [] + + func image(for url: String) -> NSImage? { images[url] } + + func prefetch(_ urls: [String]) { + for u in urls where images[u] == nil && !inflight.contains(u) { + guard let url = URL(string: u) else { continue } + inflight.insert(u) + URLSession.shared.dataTask(with: url) { data, _, _ in + DispatchQueue.main.async { + self.inflight.remove(u) + if let data, let img = NSImage(data: data) { + self.images[u] = img + } + } + }.resume() + } + } +} + @available(macOS 12.0, *) final class AddMenuModel: ObservableObject { let entries: [AddMenuEntry] @@ -101,7 +128,15 @@ private struct AddMenuRow: View { /// (and as the placeholder while the image loads or if it fails). @ViewBuilder private var icon: some View { - if let iconUrl = entry.iconUrl, let url = URL(string: iconUrl) { + if let iconUrl = entry.iconUrl, let cached = AddMenuIconCache.shared.image(for: iconUrl) { + Image(nsImage: cached) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + .frame(width: 18, height: 18) + } else if let iconUrl = entry.iconUrl, let url = URL(string: iconUrl) { + // Cold miss (first open before the prefetch landed) — load in + // place, symbol as the placeholder. AsyncImage(url: url) { phase in if let image = phase.image { image @@ -322,6 +357,17 @@ final class AddMenuController: NSObject, NSWindowDelegate { } } +@_cdecl("lookout_add_menu_prefetch_icons") +public func lookoutAddMenuPrefetchIcons(_ urlsJson: UnsafePointer) { + let json = String(cString: urlsJson) + DispatchQueue.main.async { + guard let data = json.data(using: .utf8), + let urls = try? JSONDecoder().decode([String].self, from: data) + else { return } + AddMenuIconCache.shared.prefetch(urls) + } +} + @_cdecl("lookout_add_menu_show") public func lookoutAddMenuShow( _ itemsJson: UnsafePointer, diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx index 769e630b..b7243b34 100644 --- a/clients/desktop/src/App.tsx +++ b/clients/desktop/src/App.tsx @@ -146,7 +146,17 @@ function MainWindowApp() { const res = await fetch(`${API_BASE}/api/programs`); if (!res.ok) return; const data = await res.json(); - if (Array.isArray(data.programs)) programsRef.current = data.programs; + if (Array.isArray(data.programs)) { + programsRef.current = data.programs; + // Warm the native icon cache so the menu never opens with fallback + // symbols while images load. + const urls = programsRef.current + .map((p) => p.iconUrl) + .filter((u): u is string => !!u); + if (urls.length) { + invoke("prefetch_add_menu_icons", { urls }).catch(() => {}); + } + } } catch (e) { console.warn("[programs] failed to load registry:", e); } From 1cd06ad7e1348e5ce30ff3ee20e3cc60edcb6f3d Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:50:45 +0800 Subject: [PATCH 17/65] fix(desktop): anchor update pill bottom-left on Windows/Linux No overlay titlebar there, so top-right overlapped the gallery header controls; the pill now floats bottom-left and slides in from the bottom. Co-Authored-By: Claude --- clients/desktop/src/App.tsx | 6 +++--- clients/desktop/src/components/UpdatePill.tsx | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx index b7243b34..eac37abe 100644 --- a/clients/desktop/src/App.tsx +++ b/clients/desktop/src/App.tsx @@ -554,9 +554,9 @@ function MainWindowApp() {
) : ( - /* No overlay titlebar on Windows/Linux — float the pill top-right. */ -
- + /* No overlay titlebar on Windows/Linux — float the pill bottom-left. */ +
+
)}
void; + /** Which screen edge the pill is anchored to — sets the slide direction. */ + origin?: "top" | "bottom"; } /** Tiny circular progress ring for the downloading state. */ @@ -47,9 +49,11 @@ function PowerIcon() { * progress while an update streams in, then becomes a "Restart to Complete * Update" button. Renders nothing when no update is in flight. */ -export function UpdatePill({ phase, onRestart }: UpdatePillProps) { +export function UpdatePill({ phase, onRestart, origin = "top" }: UpdatePillProps) { const [hovered, setHovered] = useState(false); const clickable = phase.state === "ready"; + // Slide in from whichever edge the pill is anchored to. + const offset = origin === "bottom" ? 8 : -8; // Ready/restarting render as a solid, borderless capsule (inverted colors); // downloading stays a quiet outlined pill. const solid = phase.state === "ready" || phase.state === "restarting"; @@ -58,9 +62,9 @@ export function UpdatePill({ phase, onRestart }: UpdatePillProps) { {phase.state !== "idle" && ( Date: Sun, 26 Jul 2026 21:52:01 +0800 Subject: [PATCH 18/65] feat(desktop): DOM add-menu popup on Windows/Linux Replicates the macOS native NSPanel add menu (AddMenu.swift) where SwiftUI is not available: translucent blurred panel anchored under the gallery + button, spring fling-in from the top-right, hover/arrow-key selection, Escape or click-away to dismiss, quick fade-out exit. Program icons warm the browser HTTP cache at registry load, mirroring the Swift-side icon prefetch. Co-Authored-By: Claude --- clients/desktop/src/App.tsx | 93 ++++++-- .../desktop/src/components/AddMenuPopup.tsx | 223 ++++++++++++++++++ 2 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 clients/desktop/src/components/AddMenuPopup.tsx diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx index eac37abe..f3dc04a0 100644 --- a/clients/desktop/src/App.tsx +++ b/clients/desktop/src/App.tsx @@ -14,6 +14,7 @@ import { type AddAnchor, } from "@lookout/react"; import { getVersion } from "@tauri-apps/api/app"; +import { ArrowSquareOutIcon, PlusIcon } from "@phosphor-icons/react"; import { isValidToken, extractToken } from "./utils.js"; import { checkCameraPermission, @@ -29,6 +30,7 @@ import { useAppUpdate } from "./hooks/useAppUpdate.js"; import { useAnnouncement } from "./hooks/useAnnouncement.js"; import { ensureNotificationPermission } from "./hooks/useSessionNotifications.js"; import { UpdatePill } from "./components/UpdatePill.js"; +import { AddMenuPopup, type AddMenuPopupItem } from "./components/AddMenuPopup.js"; import { AnnouncementBanner } from "./components/AnnouncementBanner.js"; import { getApiBase } from "./serverConfig.js"; @@ -148,31 +150,82 @@ function MainWindowApp() { const data = await res.json(); if (Array.isArray(data.programs)) { programsRef.current = data.programs; - // Warm the native icon cache so the menu never opens with fallback - // symbols while images load. + // Warm the icon cache so the menu never opens with fallback symbols + // while images load — the Swift-side cache on macOS, the browser's + // HTTP cache for the DOM popup elsewhere. const urls = programsRef.current .map((p) => p.iconUrl) .filter((u): u is string => !!u); if (urls.length) { - invoke("prefetch_add_menu_icons", { urls }).catch(() => {}); + if (isMacOS) { + invoke("prefetch_add_menu_icons", { urls }).catch(() => {}); + } else { + for (const url of urls) new Image().src = url; + } } } } catch (e) { console.warn("[programs] failed to load registry:", e); } - }, []); + }, [isMacOS]); useEffect(() => { void fetchPrograms(); }, [fetchPrograms]); + // Windows/Linux add menu — a DOM replica of the macOS NSPanel popup. + const [addMenu, setAddMenu] = useState<{ items: AddMenuPopupItem[]; anchor: AddAnchor } | null>(null); + + /** Acts on an add-menu choice, from either the native panel or the DOM popup. */ + const handleMenuChoice = useCallback( + async (choice: string | null) => { + if (!choice) return; // dismissed + if (choice === "create-new") { + navigate({ page: "add" }); + return; + } + const program = programsRef.current.find((p) => `program:${p.name}` === choice); + if (!program) return; + try { + await invoke("open_external_url", { url: program.newSessionUrl }); + } catch (e) { + console.error("[add-menu] failed to open program url:", e); + navigate({ page: "add" }); + } + }, + [navigate], + ); + const handleAdd = useCallback( async (anchor: AddAnchor) => { + // Clicking the + while the DOM popup is open toggles it closed (the + // popup ignores pointerdowns on the anchor so this click reaches us). + if (addMenu) { + setAddMenu(null); + return; + } const programs = programsRef.current; void fetchPrograms(); // refresh behind the menu for next open - if (!isMacOS || programs.length === 0) { + if (programs.length === 0) { navigate({ page: "add" }); return; } + if (!isMacOS) { + setAddMenu({ + items: [ + ...programs.map((p) => ({ + id: `program:${p.name}`, + label: p.displayName || p.name, + iconUrl: p.iconUrl ?? undefined, + // Stays visible while the icon loads or when a program has none. + fallbackIcon: , + })), + { separator: true }, + { id: "create-new", label: "Create new timelapse", fallbackIcon: }, + ], + anchor, + }); + return; + } const entries = [ ...programs.map((p) => ({ id: `program:${p.name}`, @@ -193,21 +246,9 @@ function MainWindowApp() { navigate({ page: "add" }); return; } - if (!choice) return; // dismissed - if (choice === "create-new") { - navigate({ page: "add" }); - return; - } - const program = programs.find((p) => `program:${p.name}` === choice); - if (!program) return; - try { - await invoke("open_external_url", { url: program.newSessionUrl }); - } catch (e) { - console.error("[add-menu] failed to open program url:", e); - navigate({ page: "add" }); - } + await handleMenuChoice(choice); }, - [isMacOS, fetchPrograms, navigate], + [isMacOS, addMenu, fetchPrograms, navigate, handleMenuChoice], ); // Deep link handler -- saves token and navigates appropriately. @@ -559,6 +600,20 @@ function MainWindowApp() {
)} + {/* Windows/Linux + menu. Rendered here, outside the route transition's + transformed wrapper, so position:fixed anchors to the viewport. */} + + {addMenu && ( + { + setAddMenu(null); + void handleMenuChoice(choice); + }} + /> + )} +
void; +} + +const GAP = 6; // px between the button and the menu +const EDGE = 8; // min distance from the viewport edges + +function Row({ item, highlighted, onHover, onLeave, onActivate }: { + item: AddMenuPopupItem; + highlighted: boolean; + onHover: () => void; + onLeave: () => void; + onActivate: () => void; +}) { + const [failed, setFailed] = useState(false); + const showImage = !!item.iconUrl && !failed; + return ( +
+ + {showImage ? ( + setFailed(true)} + style={{ width: 18, height: 18, objectFit: "contain", borderRadius: 5, display: "block" }} + /> + ) : ( + item.fallbackIcon + )} + + + {item.label} + +
+ ); +} + +export function AddMenuPopup({ items, anchor, onSelect }: AddMenuPopupProps) { + const ref = useRef(null); + const [selection, setSelection] = useState(null); + // Drops below the anchor by default; flipped above when there's no room. + const [flipped, setFlipped] = useState(false); + + // Keep the latest onSelect without re-binding the listeners below. + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const below = anchor.y + anchor.height + GAP; + setFlipped(below + el.offsetHeight > window.innerHeight - EDGE); + }, [anchor, items]); + + // Click-away and window blur dismiss, like the NSPanel's resignKey. + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + const el = ref.current; + if (el && el.contains(e.target as Node)) return; + // The + button itself toggles the menu in its click handler; closing + // here too would make that click immediately reopen it. + if ( + e.clientX >= anchor.x && e.clientX <= anchor.x + anchor.width && + e.clientY >= anchor.y && e.clientY <= anchor.y + anchor.height + ) return; + onSelectRef.current(null); + }; + const onBlur = () => onSelectRef.current(null); + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("blur", onBlur); + return () => { + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("blur", onBlur); + }; + }, [anchor]); + + // Escape / arrows / enter, mirroring the Swift key monitor. + useEffect(() => { + const selectable = items + .map((item, i) => ({ item, i })) + .filter(({ item }) => !item.separator) + .map(({ i }) => i); + const onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "Escape": + e.preventDefault(); + onSelectRef.current(null); + break; + case "ArrowDown": + case "ArrowUp": { + e.preventDefault(); + if (selectable.length === 0) return; + const delta = e.key === "ArrowDown" ? 1 : -1; + setSelection((current) => { + const pos = current === null ? -1 : selectable.indexOf(current); + if (pos === -1) return delta > 0 ? selectable[0] : selectable[selectable.length - 1]; + return selectable[(pos + delta + selectable.length) % selectable.length]; + }); + break; + } + case "Enter": + e.preventDefault(); + setSelection((current) => { + if (current !== null && !items[current].separator) { + onSelectRef.current(items[current].id ?? null); + } + return current; + }); + break; + } + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [items]); + + const right = Math.max(EDGE, window.innerWidth - (anchor.x + anchor.width)); + + return ( + + {items.map((item, i) => + item.separator ? ( +
+ ) : ( + setSelection(i)} + onLeave={() => setSelection((s) => (s === i ? null : s))} + onActivate={() => onSelect(item.id ?? null)} + /> + ), + )} + + ); +} From 3bacb7720795e3cc617da0fb74655b6ba3dfd364 Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:22:49 +0800 Subject: [PATCH 19/65] feat: user-editable cuts for compiled timelapses (#edit) An edit is a cut list of absolute wall-clock intervals stored on the session; one shared membership rule (ts in [start, end)) drives all three outputs consistently: the published video, /timings (cut captures excluded by default, so Hackatime forwarders honor edits with no changes), and trackedSeconds (reported as raw - cutSeconds; raw kept as uncutTrackedSeconds; cuts can only shrink time). - shared: cuts.ts (CutInterval, normalize, membership, kept ranges, cut-seconds math), new API types, constants - server: GET /units, PUT /cuts, POST /compile (5-recompile budget, instant un-cut no-op), timings filtering + includeCut, post-cut tracked time on all session responses, original-video purge 7 days after the last edit (privacy backstop) - worker: compile records video_units (video second i <-> wall clock) and always writes original.mp4; cut-compiles slice the original losslessly (IDR-aligned -ss + exact -frames:v copy per kept range -- concat inpoint/outpoint leaks ~2 B-frame-dts frames per boundary, caught by the frame-exact test) with a pinned-GOP re-encode fallback; assembly fallback now GOP-pinned too - react SDK: TimelapseEditor (region drag/handles/seek/snap, playback skips cuts, scrubbing passes through with removal overlay, filmstrip, gap markers), api client methods, Edit affordances in SessionDetail, LookoutRecorder (editing prop) and hosted web Result (?edit=false) - desktop: dedicated resizable 960x720 editor window (main window is a fixed 480x640), lookout-edited event refresh, capability additions - tests: shared cut math, editor math round-trips, real-ffmpeg lossless cut verification, endpoint integration suite; docs for API.md, integration.md, react API.md --- .../src-tauri/capabilities/default.json | 19 +- clients/desktop/src/App.tsx | 33 +- .../desktop/src/components/EditorWindow.tsx | 89 ++ clients/react/API.md | 56 +- clients/react/src/api/client.ts | 31 + .../react/src/components/LookoutRecorder.tsx | 63 +- .../react/src/components/SessionDetail.tsx | 40 +- .../react/src/components/TimelapseEditor.tsx | 856 ++++++++++++++++++ clients/react/src/hooks/editorMath.test.ts | 136 +++ clients/react/src/hooks/editorMath.ts | 135 +++ clients/react/src/hooks/useHashRouter.ts | 9 + clients/react/src/index.ts | 12 + clients/web/src/components/Result.tsx | 79 +- docs/edit-feature-plan.md | 305 +++++++ docs/integration.md | 31 + packages/server/API.md | 95 +- .../server/drizzle/0018_session_edits.sql | 7 + packages/server/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema.ts | 34 + packages/server/src/lib/timeouts.ts | 54 ++ packages/server/src/routes/internal.ts | 20 +- packages/server/src/routes/sessions.ts | 405 ++++++++- packages/server/test/cuts.unit.test.ts | 144 +++ .../server/test/edits.integration.test.ts | 252 ++++++ packages/shared/src/cuts.ts | 212 +++++ packages/shared/src/index.ts | 1 + packages/shared/src/types.ts | 71 ++ packages/worker/src/compile.ts | 353 +++++++- packages/worker/src/schema.ts | 20 + packages/worker/src/segments.ts | 152 +++- packages/worker/test/cutVideo.test.ts | 157 ++++ 31 files changed, 3800 insertions(+), 78 deletions(-) create mode 100644 clients/desktop/src/components/EditorWindow.tsx create mode 100644 clients/react/src/components/TimelapseEditor.tsx create mode 100644 clients/react/src/hooks/editorMath.test.ts create mode 100644 clients/react/src/hooks/editorMath.ts create mode 100644 docs/edit-feature-plan.md create mode 100644 packages/server/drizzle/0018_session_edits.sql create mode 100644 packages/server/test/cuts.unit.test.ts create mode 100644 packages/server/test/edits.integration.test.ts create mode 100644 packages/shared/src/cuts.ts create mode 100644 packages/worker/test/cutVideo.test.ts diff --git a/clients/desktop/src-tauri/capabilities/default.json b/clients/desktop/src-tauri/capabilities/default.json index 5fa24256..e278e544 100644 --- a/clients/desktop/src-tauri/capabilities/default.json +++ b/clients/desktop/src-tauri/capabilities/default.json @@ -2,7 +2,11 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for the Lookout desktop app", - "windows": ["main", "tray"], + "windows": [ + "main", + "tray", + "editor-*" + ], "permissions": [ "core:default", "liquid-glass:default", @@ -18,12 +22,19 @@ "macos-permissions:default", "updater:default", "notification:default", + "core:webview:allow-create-webview-window", + "core:window:allow-close", + "core:window:allow-set-focus", { "identifier": "http:default", "allow": [ - { "url": "http://localhost:*" }, - { "url": "https://**" } + { + "url": "http://localhost:*" + }, + { + "url": "https://**" + } ] } ] -} +} \ No newline at end of file diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx index f3dc04a0..5c46a7b4 100644 --- a/clients/desktop/src/App.tsx +++ b/clients/desktop/src/App.tsx @@ -25,6 +25,7 @@ import { RecordPage } from "./components/RecordPage.js"; import { AddSessionPage } from "./components/AddSessionPage.js"; import { SettingsPage } from "./components/SettingsPage.js"; import { TrayApp } from "./components/TrayApp.js"; +import { EditorWindow, openEditorWindow, EDITED_EVENT } from "./components/EditorWindow.js"; import { useBlacklistedApps } from "./hooks/useBlacklistedApps.js"; import { useAppUpdate } from "./hooks/useAppUpdate.js"; import { useAnnouncement } from "./hooks/useAnnouncement.js"; @@ -72,6 +73,13 @@ export function App() { if (isTray) { return ; } + // Dedicated editor window (see EditorWindow.tsx). Branches before + // MainWindowApp so it skips the permission gates, vibrancy, deep-link + // handlers, and the rest of the main-window machinery. + const editorMatch = window.location.hash.match(/^#\/?editor\?token=([0-9a-fA-F]{64})/); + if (editorMatch) { + return ; + } return ; } @@ -114,6 +122,22 @@ function MainWindowApp() { tokens: tokenStore.getAllTokenValues(), }); + // Bumped when an editor window applies cuts — remounts the open + // SessionDetail so it re-fetches (picks up the compiling → complete flip + // and the recompiled video) and refreshes gallery thumbnails. + const [editNonce, setEditNonce] = useState(0); + const galleryRefreshRef = React.useRef(gallery.refresh); + galleryRefreshRef.current = gallery.refresh; + useEffect(() => { + let unlisten: (() => void) | undefined; + listen(EDITED_EVENT, () => { + console.log("[app] editor window applied cuts — refreshing"); + setEditNonce((n) => n + 1); + galleryRefreshRef.current(); + }).then((fn) => { unlisten = fn; }); + return () => { if (unlisten) unlisten(); }; + }, []); + // Initialize blacklisted apps sync from localStorage to Rust backend useBlacklistedApps(); @@ -368,14 +392,14 @@ function MainWindowApp() { // So we just rely on standard browser matchMedia to get the universal native standard. const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const getSystemTheme = () => mediaQuery.matches ? "dark" : "light"; - + const applyTheme = () => { const theme = getSystemTheme(); updateTheme(theme); // Force the Tauri window GTK decorations to match the media query since winit is confused getCurrentWindow().setTheme(theme).catch(() => {}); }; - + applyTheme(); const listener = () => applyTheme(); @@ -424,7 +448,7 @@ function MainWindowApp() { const prevRootBg = root?.style.background ?? ""; let effectsApplied = false; - + const isLinux = navigator.userAgent.toLowerCase().includes("linux"); if (!isLinux) { invoke("enable_vibrancy") @@ -521,9 +545,10 @@ function MainWindowApp() { case "session": return ( { void openEditorWindow(route.token); }} onComplete={({ redirectUrl }) => { // Redirect hook: the session's creator asked us to send the // user somewhere once their timelapse is ready. diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx new file mode 100644 index 00000000..f8d76de1 --- /dev/null +++ b/clients/desktop/src/components/EditorWindow.tsx @@ -0,0 +1,89 @@ +import { emit } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { TimelapseEditor, colors, fontSize, fontWeight, spacing } from "@lookout/react"; +import { getApiBase } from "../serverConfig.js"; + +/** Event the editor window emits after applying cuts, so the main window + * can refresh the session detail + gallery. Payload: { token }. */ +export const EDITED_EVENT = "lookout-edited"; + +/** + * Open (or focus) the dedicated editor window for a session. The main + * window is a fixed 480×640 — far too small to scrub a multi-hour + * timeline with any precision — so editing gets its own resizable window. + */ +export async function openEditorWindow(token: string): Promise { + const label = `editor-${token.slice(0, 8)}`; + const existing = await WebviewWindow.getByLabel(label); + if (existing) { + await existing.setFocus().catch(() => {}); + return; + } + const win = new WebviewWindow(label, { + url: `${window.location.pathname}#/editor?token=${token}`, + title: "Edit timelapse", + width: 960, + height: 720, + minWidth: 720, + minHeight: 560, + resizable: true, + center: true, + }); + win.once("tauri://error", (e) => { + console.error("[editor] failed to open editor window:", e); + }); +} + +/** The editor window's root view (route `#/editor?token=…`). */ +export function EditorWindow({ token }: { token: string }) { + return ( +
+
+ Edit timelapse + + drag the strip to remove minutes — the tracked time updates with it + +
+ { + // Tell the main window, then close. Fire-and-forget on purpose: + // even if the emit fails, closing is correct — the main window + // shows the recompiled video on its next fetch. + void emit(EDITED_EVENT, { token }) + .catch((e) => console.error("[editor] emit failed:", e)) + .finally(() => getCurrentWindow().close().catch(() => {})); + }} + onCancel={() => { + void getCurrentWindow().close().catch(() => {}); + }} + /> +
+ ); +} diff --git a/clients/react/API.md b/clients/react/API.md index 9d45cf9d..cb81f09c 100644 --- a/clients/react/API.md +++ b/clients/react/API.md @@ -468,7 +468,13 @@ Drop-in recorder widget. Handles the full lifecycle: capture, upload, pause/resu ``` -No props — reads everything from context. +**Props (`LookoutRecorderProps`):** + +| Prop | Type | Description | +|------|------|-------------| +| `editing` | `boolean?` | Offer the cut editor on completed timelapses (default `true`). Pass `false` to hide the affordance. | + +Everything else is read from context. **Renders based on status:** - `loading` — spinner @@ -700,6 +706,51 @@ Full session detail view with video player, stats, and compilation polling. Stan | `onBack` | `() => void?` | Back button handler | | `onArchive` | `() => void?` | Archive button handler | +When the session is `complete` and still editable, the header shows an **Edit** +button that swaps the view for a ``; applying edits returns to +the detail view and polls the cut-compile back to `complete`. + +--- + +### `` + +Post-compile cut editor. Previews the **uncut original** video (1 second of +video = 1 capture unit = 1 real-world minute), lets the user drag out cut +regions on a filmstrip timeline, and applies them via a fast server-side +cut-compile (usually a lossless stream copy). Standalone (no provider needed). + +```tsx + refetchStatus()} + onCancel={() => setEditing(false)} +/> +``` + +**Props (`TimelapseEditorProps`):** + +| Prop | Type | Description | +|------|------|-------------| +| `token` | `string` | Session token | +| `apiBaseUrl` | `string` | Server API base URL | +| `onApplied` | `() => void?` | Cuts were saved and the cut-compile started — return to your detail view and poll `/status` | +| `onCancel` | `() => void?` | User backed out without applying | + +**Interactions:** +- **Drag on the filmstrip** creates a cut region in one gesture (edges snap to + whole minutes); **plain click seeks**; the **ruler lane scrubs**. +- Regions are first-class objects: drag to move, edge handles to resize + (the preview follows the dragged edge, showing the boundary frame), + click to select, Delete/Backspace to remove. +- **Space** plays/pauses. Playback **skips cut regions** (previewing the + published result); scrubbing passes through them with a "will be removed" + overlay so edges can be judged. +- Footer shows server-authoritative "kept / removed" durations; recording + pauses appear as dashed gap markers on the strip. +- Shows "n edits remaining" as the per-session recompile budget runs low, + and a not-editable state once the original video has been purged. + --- ## Callbacks @@ -781,6 +832,9 @@ const session = await client.getSession(); | `rename` | `(name: string) => Promise` | Rename the timelapse | | `getStatus` | `() => Promise` | Poll compilation status | | `getVideo` | `() => Promise` | Get video URL | +| `getUnits` | `() => Promise` | Editor metadata: unit map, cuts, presigned original-video URL | +| `setCuts` | `(cuts: CutInterval[]) => Promise` | Replace the session's cut list (`[]` clears) | +| `applyCuts` | `() => Promise` | Apply the cut list to the published video (cut-compile) | --- diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts index 3c576b6d..aa78a66d 100644 --- a/clients/react/src/api/client.ts +++ b/clients/react/src/api/client.ts @@ -10,6 +10,10 @@ import type { RenameSessionResponse, StatusResponse, VideoResponse, + UnitsResponse, + SetCutsResponse, + ApplyCutsResponse, + CutInterval, } from "@lookout/shared"; import type { TokenProvider } from "../types.js"; @@ -34,6 +38,16 @@ export interface LookoutClient { rename(name: string): Promise; getStatus(): Promise; getVideo(): Promise; + /** Editor metadata: the compiled original's unit map (video second i ↔ + * wall clock), current cut list, and a token-gated presigned URL for the + * UNCUT original video. */ + getUnits(): Promise; + /** Replace the session's cut list (full replace; [] clears all edits). + * Returns the normalized list plus a server-authoritative preview. */ + setCuts(cuts: CutInterval[]): Promise; + /** Apply the current cut list to the published video (a cut-compile — + * usually a lossless stream copy, seconds not minutes). */ + applyCuts(): Promise; } export class HttpError extends Error { @@ -184,5 +198,22 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient async getVideo() { return fetchJson(await sessionUrl("/video")); }, + + async getUnits() { + return fetchJson(await sessionUrl("/units")); + }, + + async setCuts(cuts) { + return fetchJson(await sessionUrl("/cuts"), { + method: "PUT", + body: JSON.stringify({ cuts }), + }); + }, + + async applyCuts() { + return fetchJson(await sessionUrl("/compile"), { + method: "POST", + }); + }, }; } diff --git a/clients/react/src/components/LookoutRecorder.tsx b/clients/react/src/components/LookoutRecorder.tsx index 10bbde11..44a773b6 100644 --- a/clients/react/src/components/LookoutRecorder.tsx +++ b/clients/react/src/components/LookoutRecorder.tsx @@ -1,6 +1,8 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { useLookout } from "../hooks/useLookout.js"; +import { useLookoutContext } from "../LookoutProvider.js"; import { StatusBar } from "./StatusBar.js"; +import { TimelapseEditor } from "./TimelapseEditor.js"; import { ScreenPreview } from "./ScreenPreview.js"; import { CameraPreview } from "./CameraPreview.js"; import { CameraSelector } from "./CameraSelector.js"; @@ -22,8 +24,41 @@ import { colors, fontSize, fontWeight, spacing } from "../ui/theme.js"; * * Must be used within a ``. */ -export function LookoutRecorder() { +export interface LookoutRecorderProps { + /** Offer the cut editor on completed timelapses (default true). Programs + * embedding the recorder can pass false to hide the affordance. */ + editing?: boolean; +} + +export function LookoutRecorder({ editing = true }: LookoutRecorderProps = {}) { const { state, actions } = useLookout(); + const { client, config } = useLookoutContext(); + const [editorOpen, setEditorOpen] = useState(false); + const [resolvedToken, setResolvedToken] = useState(null); + const [sessionEditable, setSessionEditable] = useState(false); + + // The editor needs a concrete token string and the session's editability. + // Resolve them once the recording reaches "complete". + useEffect(() => { + if (state.status !== "complete" || !editing) return; + let cancelled = false; + (async () => { + try { + const [token, status] = await Promise.all([ + client.resolveToken(), + client.getStatus(), + ]); + if (cancelled) return; + setResolvedToken(token); + setSessionEditable(status.editable === true); + } catch { + // Editability probe is best-effort — the video still plays. + } + })(); + return () => { + cancelled = true; + }; + }, [state.status, editing, client]); if (state.status === "loading") { return ( @@ -62,12 +97,36 @@ export function LookoutRecorder() { state.status === "complete" || state.status === "failed" ) { + if (editorOpen && resolvedToken) { + return ( + + setEditorOpen(false)} + onApplied={() => { + // The session flips complete → compiling → complete on the + // server; the ResultView refetches the video on next mount. + setEditorOpen(false); + setSessionEditable(false); + }} + /> + + ); + } return ( + {state.status === "complete" && editing && sessionEditable && resolvedToken && ( +
+ +
+ )}
); } diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx index 5c554d22..2d414f3a 100644 --- a/clients/react/src/components/SessionDetail.tsx +++ b/clients/react/src/components/SessionDetail.tsx @@ -5,6 +5,7 @@ import { formatTrackedTime } from "../hooks/useSessionTimer.js"; import { Button } from "../ui/Button.js"; import { ErrorDisplay } from "../ui/ErrorDisplay.js"; import { ProcessingState } from "./ProcessingState.js"; +import { TimelapseEditor } from "./TimelapseEditor.js"; import { SessionDetailSkeleton } from "../ui/Skeleton.js"; import { Card } from "../ui/Card.js"; import { Badge } from "../ui/Badge.js"; @@ -20,6 +21,10 @@ export interface SessionDetailProps { * NOT fired when opening a session that is already complete. Carries the * session's redirect-hook URL, if one was set at creation. */ onComplete?: (info: { redirectUrl: string | null }) => void; + /** Override for the Edit button. When provided, clicking Edit calls this + * instead of opening the inline editor — e.g. the desktop app opens a + * dedicated resizable editor window (the main window is a fixed 480px). */ + onEdit?: () => void; } export function SessionDetail({ @@ -28,6 +33,7 @@ export function SessionDetail({ onBack, onArchive, onComplete, + onEdit, }: SessionDetailProps) { const [sessionInfo, setSessionInfo] = useState<{ name: string; createdAt: string } | null>(null); const [isRenaming, setIsRenaming] = useState(false); @@ -63,6 +69,7 @@ export function SessionDetail({ const [status, setStatus] = useState(null); const [videoUrl, setVideoUrl] = useState(null); const [error, setError] = useState(null); + const [editing, setEditing] = useState(false); // Completion detection for the redirect hook: only a live transition from // an in-flight state counts — a session opened when already "complete" @@ -152,6 +159,16 @@ export function SessionDetail({ )}
+ {status?.status === "complete" && status.editable && !editing && ( + + )} {onArchive && ( +
+ )} +
+ ); + } + + if (!data) { + return ( +
+ Loading editor… +
+ ); + } + + return ( +
+ {/* Preview */} +
+
+ + {/* Timeline */} +
+ {/* Ruler lane — owns scrubbing */} +
+ {units.length > 0 && + [0, 0.25, 0.5, 0.75, 1].map((f) => { + const idx = Math.min(unitCount - 1, Math.round(f * (unitCount - 1))); + return ( + + {unitClockLabel(units[idx])} + + ); + })} +
+ + {/* Filmstrip lane — drag creates a cut, click seeks */} +
+ {/* Thumbnails */} +
+ {filmstrip.length > 0 + ? filmstrip.map((url, i) => ( +
+ )) + : null} +
+ + {/* Pause-gap markers */} + {gaps.map((i) => ( +
+ ))} + + {/* Cut regions */} + {regions.map((r, i) => { + const isSelected = selected === i; + return ( +
onRegionPointerDown(e, i, "move")} + style={{ + position: "absolute", + left: pct(r.startUnit), + width: pct(r.endUnit - r.startUnit), + top: 0, + bottom: 0, + background: CUT_FILL, + border: `${isSelected ? 2 : 1}px solid ${CUT_BORDER}`, + borderRadius: radii.sm, + cursor: "grab", + boxSizing: "border-box", + }} + > + {[ + { mode: "start" as const, side: { left: -6 } }, + { mode: "end" as const, side: { right: -6 } }, + ].map(({ mode, side }) => ( +
onRegionPointerDown(e, i, mode)} + style={{ + position: "absolute", + top: 0, + bottom: 0, + width: 12, + ...side, + cursor: "ew-resize", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > +
+
+ ))} +
+ ); + })} + + {/* Playhead */} + {unitCount > 0 && ( +
+ )} +
+
+ + {/* Footer */} +
+
+ + {formatUnitsDuration(keptUnits)} + + kept + + {removedUnits > 0 && ( + + · {formatUnitsDuration(removedUnits)} removed + + )} + + + drag the strip to cut · click to seek · space to preview + +
+ +
+ {selected !== null && ( + + )} + {normalized.length > 0 && ( + + )} + {onCancel && ( + + )} + +
+
+ + {saveError && } + + {data.recompilesRemaining <= 2 && ( +
+ {data.recompilesRemaining} edit + {data.recompilesRemaining === 1 ? "" : "s"} remaining for this + timelapse. +
+ )} +
+ ); +} diff --git a/clients/react/src/hooks/editorMath.test.ts b/clients/react/src/hooks/editorMath.test.ts new file mode 100644 index 00000000..925fcc0f --- /dev/null +++ b/clients/react/src/hooks/editorMath.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import type { CutInterval, VideoUnit } from "@lookout/shared"; +import { + regionsToCuts, + cutsToRegions, + normalizeRegions, + cutUnitCount, + unitIsCut, + regionAtTime, + gapIndices, + formatUnitsDuration, + type UnitRegion, +} from "./editorMath.js"; + +const T0 = Date.parse("2026-07-01T10:00:00.000Z"); + +/** n units captured a minute apart, with optional pause gaps: `gapsAfter` + * maps unit index → extra minutes of silence before the NEXT unit. */ +function makeUnits(n: number, gapsAfter: Record = {}): VideoUnit[] { + const units: VideoUnit[] = []; + let t = T0; + for (let i = 0; i < n; i++) { + units.push({ + capturedAt: new Date(t).toISOString(), + screenshotId: `ss-${i}`, + }); + t += 60_000 + (gapsAfter[i] ?? 0) * 60_000; + } + return units; +} + +describe("regionsToCuts ⇄ cutsToRegions round-trip", () => { + it("round-trips a middle region", () => { + const units = makeUnits(10); + const regions: UnitRegion[] = [{ startUnit: 3, endUnit: 6 }]; + const cuts = regionsToCuts(regions, units); + expect(cutsToRegions(cuts, units)).toEqual(regions); + }); + + it("round-trips edge regions and multiple regions", () => { + const units = makeUnits(12); + const regions: UnitRegion[] = [ + { startUnit: 0, endUnit: 2 }, + { startUnit: 5, endUnit: 6 }, + { startUnit: 9, endUnit: 12 }, + ]; + expect(cutsToRegions(regionsToCuts(regions, units), units)).toEqual(regions); + }); + + it("round-trips across pause gaps without swallowing neighbors", () => { + // A 3-hour pause between units 4 and 5: the wall-clock interval for a + // region ending at unit 4 must not extend into unit 5's minute. + const units = makeUnits(10, { 4: 180 }); + const regions: UnitRegion[] = [{ startUnit: 3, endUnit: 5 }]; + const cuts = regionsToCuts(regions, units); + expect(cutsToRegions(cuts, units)).toEqual(regions); + expect(unitIsCut(5, cutsToRegions(cuts, units))).toBe(false); + }); + + it("serializes a region as [firstUnit, lastUnit + 60s)", () => { + const units = makeUnits(5); + const cuts = regionsToCuts([{ startUnit: 1, endUnit: 3 }], units); + expect(cuts).toEqual([ + { + start: units[1].capturedAt, + end: new Date(Date.parse(units[2].capturedAt) + 60_000).toISOString(), + }, + ]); + }); + + it("drops empty regions", () => { + const units = makeUnits(5); + expect(regionsToCuts([{ startUnit: 2, endUnit: 2 }], units)).toEqual([]); + }); +}); + +describe("normalizeRegions", () => { + it("merges overlapping and adjacent regions, sorts, drops empties", () => { + expect( + normalizeRegions([ + { startUnit: 6, endUnit: 8 }, + { startUnit: 1, endUnit: 3 }, + { startUnit: 3, endUnit: 5 }, + { startUnit: 4, endUnit: 4 }, + ]), + ).toEqual([ + { startUnit: 1, endUnit: 5 }, + { startUnit: 6, endUnit: 8 }, + ]); + }); + + it("counts cut units", () => { + expect( + cutUnitCount([ + { startUnit: 1, endUnit: 5 }, + { startUnit: 6, endUnit: 8 }, + ]), + ).toBe(6); + }); +}); + +describe("regionAtTime", () => { + const regions: UnitRegion[] = [{ startUnit: 2, endUnit: 4 }]; + it("hits inside, misses outside (end-exclusive)", () => { + expect(regionAtTime(2, regions)).toEqual(regions[0]); + expect(regionAtTime(3.99, regions)).toEqual(regions[0]); + expect(regionAtTime(4, regions)).toBeNull(); + expect(regionAtTime(1.5, regions)).toBeNull(); + }); +}); + +describe("gapIndices", () => { + it("flags pauses, ignores normal cadence and jitter", () => { + const units = makeUnits(8, { 2: 30, 5: 5 }); + expect(gapIndices(units)).toEqual([3, 6]); + }); + + it("tolerates ±30s scheduling jitter", () => { + const units = makeUnits(3); + // 80s between captures is within 1.5× the interval — not a pause. + units[2] = { + ...units[2], + capturedAt: new Date(Date.parse(units[1].capturedAt) + 80_000).toISOString(), + }; + expect(gapIndices(units)).toEqual([]); + }); +}); + +describe("formatUnitsDuration", () => { + it("formats minutes and hours", () => { + expect(formatUnitsDuration(0)).toBe("0m"); + expect(formatUnitsDuration(45)).toBe("45m"); + expect(formatUnitsDuration(60)).toBe("1h"); + expect(formatUnitsDuration(83)).toBe("1h 23m"); + }); +}); diff --git a/clients/react/src/hooks/editorMath.ts b/clients/react/src/hooks/editorMath.ts new file mode 100644 index 00000000..6a000f19 --- /dev/null +++ b/clients/react/src/hooks/editorMath.ts @@ -0,0 +1,135 @@ +// Pure math for the timelapse editor: converting between the video's time +// axis (1 second = 1 capture unit = 1 real-world minute) and the wall-clock +// cut intervals the server stores. Kept DOM-free so it's unit-testable. + +import { isCutAt, type CutInterval, type VideoUnit } from "@lookout/shared"; +import { SCREENSHOT_INTERVAL_MS } from "@lookout/shared"; + +/** A cut region in unit space: [startUnit, endUnit) video-second indices. + * This is the editor's working representation — integers, so regions are + * inherently snapped to capture-unit boundaries. */ +export interface UnitRegion { + startUnit: number; + endUnit: number; +} + +/** Clamp + floor a video-time (seconds) to a valid unit index. */ +export function unitAtTime(t: number, unitCount: number): number { + return Math.max(0, Math.min(unitCount - 1, Math.floor(t))); +} + +/** + * Serialize unit regions to the wall-clock cut intervals the server stores. + * A region [i, j) covers units i..j-1, i.e. wall-clock + * [units[i].capturedAt, units[j-1].capturedAt + 60s). Round-trips losslessly + * through the server's membership rule (ts ∈ [start, end)). + */ +export function regionsToCuts( + regions: UnitRegion[], + units: VideoUnit[], +): CutInterval[] { + return regions + .filter((r) => r.endUnit > r.startUnit) + .map((r) => ({ + start: units[r.startUnit].capturedAt, + end: new Date( + Date.parse(units[r.endUnit - 1].capturedAt) + SCREENSHOT_INTERVAL_MS, + ).toISOString(), + })); +} + +/** + * Project stored wall-clock cuts back into unit regions via the shared + * membership rule, merging adjacent cut units into contiguous regions. + * The exact inverse of regionsToCuts for any normalized list. + */ +export function cutsToRegions( + cuts: CutInterval[], + units: VideoUnit[], +): UnitRegion[] { + const regions: UnitRegion[] = []; + let open: UnitRegion | null = null; + for (let i = 0; i < units.length; i++) { + const cut = isCutAt(Date.parse(units[i].capturedAt), cuts); + if (cut) { + if (open) open.endUnit = i + 1; + else open = { startUnit: i, endUnit: i + 1 }; + } else if (open) { + regions.push(open); + open = null; + } + } + if (open) regions.push(open); + return regions; +} + +/** Merge overlapping/adjacent regions and drop empties — keeps the editor + * state canonical after drags so regions never visually stack. */ +export function normalizeRegions(regions: UnitRegion[]): UnitRegion[] { + const sorted = regions + .filter((r) => r.endUnit > r.startUnit) + .slice() + .sort((a, b) => a.startUnit - b.startUnit); + const merged: UnitRegion[] = []; + for (const r of sorted) { + const last = merged[merged.length - 1]; + if (last && r.startUnit <= last.endUnit) { + last.endUnit = Math.max(last.endUnit, r.endUnit); + } else { + merged.push({ ...r }); + } + } + return merged; +} + +/** Total units removed by a region list (assumed normalized). */ +export function cutUnitCount(regions: UnitRegion[]): number { + return regions.reduce((n, r) => n + (r.endUnit - r.startUnit), 0); +} + +/** Is unit `i` inside any region? */ +export function unitIsCut(i: number, regions: UnitRegion[]): boolean { + return regions.some((r) => i >= r.startUnit && i < r.endUnit); +} + +/** The region containing video time `t`, if any. */ +export function regionAtTime( + t: number, + regions: UnitRegion[], +): UnitRegion | null { + return regions.find((r) => t >= r.startUnit && t < r.endUnit) ?? null; +} + +/** + * Recording pauses to mark on the timeline: indices `i` where the gap + * between unit i-1 and unit i exceeds ~1.5 capture intervals (i.e. the + * recording paused/stalled between those two video seconds). + */ +export function gapIndices(units: VideoUnit[]): number[] { + const gaps: number[] = []; + for (let i = 1; i < units.length; i++) { + const delta = + Date.parse(units[i].capturedAt) - Date.parse(units[i - 1].capturedAt); + if (delta > SCREENSHOT_INTERVAL_MS * 1.5) gaps.push(i); + } + return gaps; +} + +/** "1h 23m" / "23m" / "45s" — compact duration for the editor footer. */ +export function formatUnitsDuration(unitCount: number): string { + const totalMinutes = unitCount; // one unit = one real-world minute + if (totalMinutes < 1) return "0m"; + const h = Math.floor(totalMinutes / 60); + const m = totalMinutes % 60; + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** Wall-clock label (HH:MM, local) for a unit. */ +export function unitClockLabel(unit: VideoUnit): string { + const d = new Date(unit.capturedAt); + return d.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/clients/react/src/hooks/useHashRouter.ts b/clients/react/src/hooks/useHashRouter.ts index 79af4ee8..6262abbd 100644 --- a/clients/react/src/hooks/useHashRouter.ts +++ b/clients/react/src/hooks/useHashRouter.ts @@ -6,6 +6,7 @@ export type Route = | { page: "settings" } | { page: "record"; token: string } | { page: "session"; token: string } + | { page: "editor"; token: string } | { page: "tray" }; function parseHash(hash: string): Route { @@ -21,6 +22,7 @@ function parseHash(hash: string): Route { if (path === "tray") return { page: "tray" }; if (path === "record" && token) return { page: "record", token }; if (path === "session" && token) return { page: "session", token }; + if (path === "editor" && token) return { page: "editor", token }; return { page: "gallery" }; } @@ -39,6 +41,8 @@ function routeToHash(route: Route): string { return `#/record?token=${route.token}`; case "session": return `#/session?token=${route.token}`; + case "editor": + return `#/editor?token=${route.token}`; } } @@ -63,6 +67,11 @@ export function useHashRouter() { return; } + // A view that owns these keys (e.g. the cut editor, where Backspace + // deletes a region) prevents default in a capture-phase listener — + // never navigate away underneath it. + if (e.defaultPrevented) return; + if (e.key === "Escape" || e.key === "Backspace") { const currentRoute = parseHash(window.location.hash); if (currentRoute.page !== "gallery") { diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts index a1e6cca4..ab9cfc3f 100644 --- a/clients/react/src/index.ts +++ b/clients/react/src/index.ts @@ -4,6 +4,18 @@ export type { LookoutProviderProps } from "./LookoutProvider.js"; // Drop-in widget export { LookoutRecorder } from "./components/LookoutRecorder.js"; +export type { LookoutRecorderProps } from "./components/LookoutRecorder.js"; + +// Cut editor +export { TimelapseEditor } from "./components/TimelapseEditor.js"; +export type { TimelapseEditorProps } from "./components/TimelapseEditor.js"; +export { + regionsToCuts, + cutsToRegions, + normalizeRegions, + gapIndices, +} from "./hooks/editorMath.js"; +export type { UnitRegion } from "./hooks/editorMath.js"; // Sub-components export { StatusBar } from "./components/StatusBar.js"; diff --git a/clients/web/src/components/Result.tsx b/clients/web/src/components/Result.tsx index 832206cc..781c7692 100644 --- a/clients/web/src/components/Result.tsx +++ b/clients/web/src/components/Result.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { api } from "../api/client.js"; import { formatTime } from "@lookout/react"; -import { VideoPlayer } from "@lookout/react"; +import { VideoPlayer, TimelapseEditor } from "@lookout/react"; import type { SessionStatus } from "@lookout/shared"; interface ResultProps { @@ -9,16 +9,30 @@ interface ResultProps { trackedSeconds: number; } -export function Result({ status, trackedSeconds }: ResultProps) { +/** Hosted recorder result view. Also owns the post-compile cut editor: + * the edit affordance shows on completed sessions unless the embedding + * program disabled it with `?edit=false` on the recorder URL. */ +export function Result({ status: statusProp, trackedSeconds }: ResultProps) { const [videoUrl, setVideoUrl] = useState(null); const [error, setError] = useState(null); + const [editing, setEditing] = useState(false); + const [editable, setEditable] = useState(false); + // After an edit is applied the parent's polling has already stopped, so + // this view tracks the cut-compile itself: null = mirror the prop. + const [localStatus, setLocalStatus] = useState(null); + const pollRef = useRef>(); + + const status = localStatus ?? statusProp; + + const editingAllowed = + new URLSearchParams(window.location.search).get("edit") !== "false"; useEffect(() => { if (status === "complete") { api .getVideo() .then((data) => { - if (data.videoUrl && !data.videoUrl.startsWith("https://")) { + if (data.videoUrl && !data.videoUrl.startsWith("https://") && !data.videoUrl.startsWith("/")) { throw new Error("Invalid video URL: must be HTTPS."); } setVideoUrl(data.videoUrl); @@ -26,8 +40,47 @@ export function Result({ status, trackedSeconds }: ResultProps) { .catch((err) => setError(err instanceof Error ? err.message : "Failed to load video"), ); + if (editingAllowed) { + api + .getStatus() + .then((s) => setEditable((s as { editable?: boolean }).editable === true)) + .catch(() => {}); + } } - }, [status]); + }, [status, editingAllowed]); + + useEffect(() => () => clearInterval(pollRef.current), []); + + const onApplied = () => { + setEditing(false); + setEditable(false); + setVideoUrl(null); + setLocalStatus("compiling"); + pollRef.current = setInterval(async () => { + try { + const s = await api.getStatus(); + if (s.status === "complete" || s.status === "failed") { + clearInterval(pollRef.current); + setLocalStatus(s.status); + } + } catch { + // Ignore poll errors — next tick retries. + } + }, 3000); + }; + + if (editing) { + return ( +
+ setEditing(false)} + onApplied={onApplied} + /> +
+ ); + } if (status === "stopped" || status === "compiling") { return ( @@ -68,6 +121,11 @@ export function Result({ status, trackedSeconds }: ResultProps) {
)} + {editingAllowed && editable && ( + + )}
); } @@ -99,6 +157,17 @@ const styles: Record = { background: "#000", overflow: "hidden", }, + editButton: { + marginTop: 16, + padding: "8px 16px", + fontSize: 13, + fontWeight: 600, + color: "#ccc", + background: "transparent", + border: "1px solid #444", + borderRadius: 8, + cursor: "pointer", + }, spinner: { width: 40, height: 40, diff --git a/docs/edit-feature-plan.md b/docs/edit-feature-plan.md new file mode 100644 index 00000000..1b37cdfa --- /dev/null +++ b/docs/edit-feature-plan.md @@ -0,0 +1,305 @@ +# Edit feature (cuts) — implementation plan v2 + +Status: proposal. Covers server, worker, and all three clients. + +## Model + +``` +record ── stop ──> compile (UNCHANGED — this is the "pre-compile") + └─> complete: original.mp4 published + │ + │ editor previews the compiled video + │ PUT /cuts { cuts: [{start, end}, …] } (wall-clock intervals) + │ POST /compile + v + cut-compile (fast: lossless stream-copy of kept ranges) + └─> complete: edited.mp4 published, original kept for re-edits +``` + +- The **existing compile runs exactly as today** the moment the user stops. + Nothing about stop, the compile pipeline, old clients, or program + integrations changes. Its output doubles as the editor's preview. +- **Editing is optional and happens after `complete`.** The editor scrubs + the compiled video; a second, much cheaper compile applies the cuts. +- An edit is a **cut list of absolute wall-clock intervals** stored on the + session — `[{ "start": ISO-8601, "end": ISO-8601 }]` ("start a → end a, + start b → end b"). Never "offset + duration": Lookout is heartbeat-based, + so the cut must live in the same domain as the capture timestamps that + drive `/timings` and `trackedSeconds`. + +### The invariant that makes all of this cheap + +One capture unit = one real-world minute = **exactly one second of output +video**, and every segment is encoded with a pinned closed GOP of exactly 30 +frames starting on an IDR frame (`segments.ts`: `-g 30 -keyint_min 30 +-sc_threshold 0 -x264-params open-gop=0`). Consequences: + +1. **Video-time ↔ wall-clock mapping is exact**: second *i* of the compiled + video is capture unit *i*, whose `capturedAt` is known. The editor + converts selected video-second ranges to wall-clock intervals losslessly. +2. **The final video omits cut time via lossless stream copy.** Because + every second boundary is an IDR frame in a closed GOP, each kept range is + extracted with an input seek to its IDR (`-ss`) plus an exact copied + packet count (`-frames:v`), then the ranges stream-copy concat. (NOT the + concat demuxer's `inpoint`/`outpoint`: outpoint is dts-based, and B-frame + dts offsets leak ~2 frames of the cut region past each boundary — caught + by the worker's frame-exact test.) A cut-compile is I/O-bound — seconds, + even for a 12-hour session — and adds no quality generation. +3. **Cut granularity is whole minutes**, which is also heartbeat granularity + — a sub-minute cut couldn't be expressed in the time data anyway. + Sub-minute/frame-level cutting inside clips is explicitly out of scope. + +**Membership rule (single shared definition):** a capture unit is cut iff +`coalesce(captured_at, requested_at) ∈ [start, end)` for any interval in the +list. Used identically by the cut-compile, `/timings`, and tracked-time math. +Lives once in `@lookout/shared` (mirrored in the worker's schema-local copy), +tested once. + +## Derived effects of the cut list + +| Consumer | Effect | +|---|---| +| Published video | Kept ranges of `original.mp4` stream-copy concatenated into `edited.mp4` | +| `GET /timings` | Timestamps inside cuts excluded from the default array; intervals surfaced as `cuts` | +| `trackedSeconds` | Reported as `raw − cutSeconds` everywhere; raw preserved as `uncutTrackedSeconds` | + +`trackedSeconds` shrinking with cuts is deliberate: `/timings` and +`trackedSeconds` must tell the same story ("verified time, minus what the +user removed"), and cutting can only *reduce* the number, so there's no fraud +vector. Programs forwarding `/timings` to Hackatime pick up cuts with zero +code changes. The DB keeps `sessions.tracked_seconds` raw (audit trail); +subtraction happens in the one read-side dispatcher +(`getTrackedSecondsForSession`). + +## Data model (migration) + +`sessions` gains: + +| Column | Type | Meaning | +|---|---|---| +| `cuts` | `jsonb` | Normalized cut list. `null`/`[]` = no edits | +| `cut_seconds` | `integer` | Credited seconds removed; recomputed on every cuts write and at cut-compile | +| `video_units` | `jsonb` | Ordered array of the units actually included in `original.mp4` (`[{capturedAt, screenshotId}]`), written by compile. THE video-second ↔ wall-clock map — sampled rows alone can't provide it because compile skips undecodable units | +| `original_video_r2_key` | `text` | The uncut compiled video. On first compile this equals `video_r2_key`; after an edited compile, `video_r2_key` points at `edited.mp4` while this keeps pointing at the original | +| `video_copy_aligned` | `boolean` | True when assembly used the stream-copy path (GOP grid guaranteed). False → cut-compile must use its re-encode fallback | +| `recompile_count` | `integer not null default 0` | User-initiated cut-compiles, capped | + +No `screenshots` changes — cut membership is computed from the interval +list, never denormalized onto rows. + +### Cut-list validation (server, on every `PUT /cuts`) + +- Valid ISO dates, `end > start`, per interval. +- Clamp to `[startedAt − 5 min, stoppedAt + 5 min]`. +- Sort by start; merge overlapping/adjacent intervals. +- Cap `MAX_CUT_INTERVALS = 120`. +- Reject a list that cuts **every** unit in `video_units` (a video must + remain). +- Response echoes normalized list + server-authoritative preview: + `{ cuts, unitsTotal, unitsCut, trackedSeconds, uncutTrackedSeconds }`. + +## API changes (`packages/server/src/routes/sessions.ts`) + +Token-authenticated, rate-limited like their neighbors. **No change to +`/stop`.** + +1. **`GET /api/sessions/:token/units`** — editor metadata (no presigning, no + R2 access): `{ units: , cuts, editable, originalVideoUrl }`. + `editable` is false when `original.mp4` has been purged or + `recompile_count` is exhausted. `originalVideoUrl` is a token-free + presigned GET (1 h) for `original_video_r2_key` — the editor's preview + source. It must NOT be the public `/api/media/...` URL: after an edit, + cut content exists only in the original, which stays reachable through + the secret token only. +2. **`PUT /api/sessions/:token/cuts`** — replace the whole list (idempotent; + no patch semantics). Allowed in `complete`; 409 while `compiling`. + `[]` clears edits. +3. **`POST /api/sessions/:token/compile`** — apply the current cut list: + - Guards: status `complete`, `original_video_r2_key` present, + `recompile_count < MAX_USER_RECOMPILES (5)`, per-token rate limit. + - Special case, no job needed: if `cuts` is empty and an `edited.mp4` + exists, repoint `video_r2_key` back to the original, delete + `edited.mp4`, regenerate nothing (thumbnail = original's, kept). + "Undo all edits" is instant. + - Otherwise flip status → `compiling` (worker's claim already accepts + re-entry), increment `recompile_count`, enqueue `COMPILE_JOB`. +4. **`GET /api/sessions/:token/timings`** — returns + `{ count, timestamps: [kept only], cuts, cutCount }`; cut captures are + excluded **by default** so existing Hackatime forwarders respect edits + automatically. `?includeCut=true` adds `cutTimestamps`. +5. **`GET /api/sessions/:token`**, **`/status`**, **`/batch`**, internal + session endpoint — add `cuts`, `cutSeconds`, `uncutTrackedSeconds`, + `editable`. `trackedSeconds` becomes post-cut everywhere via the + dispatcher. `/status` keeps working unchanged for old clients during a + cut-compile (`compiling` → `complete` — states they already handle). + +## Worker changes + +The compile job becomes two idempotent halves; `compileTimelapse` dispatches +on what exists: + +**A. Original build (unchanged pipeline + bookkeeping).** Runs when +`original_video_r2_key` is absent — i.e., every first compile. Identical +sampling → segment build → stream-copy assembly, plus: +- Write output to `timelapses/{id}/original.mp4`; set both + `original_video_r2_key` and `video_r2_key` to it. +- Record `video_units` (the units whose segments actually made it in, in + order) and `video_copy_aligned` (true on the copy path). +- **Fix the assembly re-encode fallback to pin the GOP** (reuse + `SEGMENT_ENCODE_ARGS`' `-g/-keyint_min/-sc_threshold/open-gop`): today the + fallback emits default x264 keyframes (~every 250 frames, scene-cut on), + which would break lossless cutting. Cheap and correct regardless of this + feature. + +**B. Cut apply.** Runs when `original_video_r2_key` exists and `cuts` is +non-empty: +- Compute kept video-second ranges: map each `video_units[i]` through the + membership rule → contiguous kept index runs → `[inpoint, outpoint)` pairs. +- `video_copy_aligned = true`: concat-demuxer file listing `original.mp4` + once per kept range with `inpoint`/`outpoint`, `-c copy`, remux with + `+faststart` → `timelapses/{id}/edited.mp4`. Lossless, seconds. +- `video_copy_aligned = false` (legacy videos assembled via the old + fallback): same ranges but re-encode with the pinned args (one CRF-18 + generation — acceptable, rare). +- Verify frame count = kept units × 30 (existing `verifyVideo`). +- Regenerate the thumbnail from `edited.mp4` (first frame may have been cut). +- Point `video_r2_key` at `edited.mp4`, persist authoritative + `cut_seconds`, status `complete`. + +Notes: +- The cut path **never downloads capture units** — it needs only + `original.mp4`. Editing therefore works even after the 7-day screenshot + purge, for as long as the original is retained. +- Keep compile step 7 (unsampled cleanup) untouched; sampled unit files + still age out via the existing retention job. They're no longer needed for + editing at all. + +### Retention & privacy for cut content + +Cut minutes vanish from the published video but live on in `original.mp4` +(token-gated). Extend the daily retention job: for sessions whose cuts are +non-empty and whose edit window has closed (`EDIT_WINDOW_DAYS = 7` after the +last cut-compile), delete `original.mp4` and null +`original_video_r2_key` — the cut content is then truly gone and `editable` +goes false. Uncut sessions keep their single video file forever, as today. +(Public media redirect caches `video.mp4` for 30 min — an edited video may +serve the stale original that long. Documented, acceptable.) + +## Clients — one editor, three surfaces + +`clients/desktop` and `clients/web` both already depend on +`@lookout/react`, so the editor is built **once** in the SDK. + +### `@lookout/react` + +- `api/client.ts`: `getUnits()`, `setCuts(cuts)`, `applyCuts()` (the compile + call). `CutInterval` type from `@lookout/shared`. +- **``**: + - Preview = `
- {saveError && } + {saveError && ( + + )} - {data.recompilesRemaining <= 2 && ( -
- {data.recompilesRemaining} edit - {data.recompilesRemaining === 1 ? "" : "s"} remaining for this - timelapse. + {/* The hold is the promise that nothing gets lost — say so plainly, + and get louder as it runs out. */} + {holdSecondsLeft !== null && ( +
+ {holdSecondsLeft < 120 + ? `Publishing automatically in ${holdSecondsLeft}s — save now to keep your cuts.` + : `Not published yet. If you close this, it publishes as recorded in ${Math.round( + holdSecondsLeft / 60, + )} min.`}
)}
diff --git a/clients/react/src/hooks/useLookout.ts b/clients/react/src/hooks/useLookout.ts index a977a2dc..ba2c5e3e 100644 --- a/clients/react/src/hooks/useLookout.ts +++ b/clients/react/src/hooks/useLookout.ts @@ -360,7 +360,7 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } { } }, [session.resume]); - const stop = useCallback(async (options?: { name?: string }) => { + const stop = useCallback(async (options?: { name?: string; edit?: boolean }) => { if (stopInFlightRef.current) return; stopInFlightRef.current = true; if (intervalRef.current) { @@ -372,7 +372,7 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } { capturingRef.current = false; capture.stopSharing(); try { - await session.stop(options?.name); + await session.stop(options?.name, { edit: options?.edit }); callbacksRef.current.onStop?.({ trackedSeconds: session.trackedSeconds, totalActiveSeconds: session.totalActiveSeconds, diff --git a/clients/react/src/hooks/useSession.ts b/clients/react/src/hooks/useSession.ts index c6230374..105ef4ea 100644 --- a/clients/react/src/hooks/useSession.ts +++ b/clients/react/src/hooks/useSession.ts @@ -142,7 +142,7 @@ export function useSession() { } }, [client, syncStatus]); - const stop = useCallback(async (name?: string) => { + const stop = useCallback(async (name?: string, opts?: { edit?: boolean }) => { // Optionally name the timelapse before stopping (non-fatal if it fails) if (name) { try { @@ -156,7 +156,7 @@ export function useSession() { const RETRY_DELAYS = [1000, 2000, 4000]; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { - const data = await client.stop(); + const data = await client.stop(opts); setState((s) => ({ ...s, status: data.status, diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts index ab9cfc3f..5d950e35 100644 --- a/clients/react/src/index.ts +++ b/clients/react/src/index.ts @@ -9,6 +9,8 @@ export type { LookoutRecorderProps } from "./components/LookoutRecorder.js"; // Cut editor export { TimelapseEditor } from "./components/TimelapseEditor.js"; export type { TimelapseEditorProps } from "./components/TimelapseEditor.js"; +export { StopChoiceModal } from "./components/StopChoiceModal.js"; +export type { StopChoiceModalProps } from "./components/StopChoiceModal.js"; export { regionsToCuts, cutsToRegions, diff --git a/clients/react/src/types.ts b/clients/react/src/types.ts index b0e910ba..e522e1a2 100644 --- a/clients/react/src/types.ts +++ b/clients/react/src/types.ts @@ -209,8 +209,12 @@ export interface LookoutActions { pause: () => Promise; /** Resume a paused session. */ resume: () => Promise; - /** Stop the session (triggers compilation). Optionally name the timelapse before stopping. */ - stop: (options?: { name?: string }) => Promise; + /** Stop the session (triggers compilation). Optionally name the timelapse + * before stopping. Pass `edit: true` to hold the timelapse unpublished + * after it compiles so the user can cut it first — programs only ever + * see `complete` with the edits already applied. The hold auto-publishes + * if the user walks away. */ + stop: (options?: { name?: string; edit?: boolean }) => Promise; /** Select a camera device by ID. Only effective when captureMode is "camera". */ selectCamera: (deviceId: string) => void; /** Start camera preview without recording. Acquires the stream so the UI can show a live video. */ diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 5f413afe..6e9965b6 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -61,7 +61,14 @@ export function App() { > ← Gallery - + {/* `?edit=false` on the recorder link lets an embedding + program keep stopping a single click. */} +
); diff --git a/clients/web/src/components/Result.tsx b/clients/web/src/components/Result.tsx index 781c7692..832206cc 100644 --- a/clients/web/src/components/Result.tsx +++ b/clients/web/src/components/Result.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { api } from "../api/client.js"; import { formatTime } from "@lookout/react"; -import { VideoPlayer, TimelapseEditor } from "@lookout/react"; +import { VideoPlayer } from "@lookout/react"; import type { SessionStatus } from "@lookout/shared"; interface ResultProps { @@ -9,30 +9,16 @@ interface ResultProps { trackedSeconds: number; } -/** Hosted recorder result view. Also owns the post-compile cut editor: - * the edit affordance shows on completed sessions unless the embedding - * program disabled it with `?edit=false` on the recorder URL. */ -export function Result({ status: statusProp, trackedSeconds }: ResultProps) { +export function Result({ status, trackedSeconds }: ResultProps) { const [videoUrl, setVideoUrl] = useState(null); const [error, setError] = useState(null); - const [editing, setEditing] = useState(false); - const [editable, setEditable] = useState(false); - // After an edit is applied the parent's polling has already stopped, so - // this view tracks the cut-compile itself: null = mirror the prop. - const [localStatus, setLocalStatus] = useState(null); - const pollRef = useRef>(); - - const status = localStatus ?? statusProp; - - const editingAllowed = - new URLSearchParams(window.location.search).get("edit") !== "false"; useEffect(() => { if (status === "complete") { api .getVideo() .then((data) => { - if (data.videoUrl && !data.videoUrl.startsWith("https://") && !data.videoUrl.startsWith("/")) { + if (data.videoUrl && !data.videoUrl.startsWith("https://")) { throw new Error("Invalid video URL: must be HTTPS."); } setVideoUrl(data.videoUrl); @@ -40,47 +26,8 @@ export function Result({ status: statusProp, trackedSeconds }: ResultProps) { .catch((err) => setError(err instanceof Error ? err.message : "Failed to load video"), ); - if (editingAllowed) { - api - .getStatus() - .then((s) => setEditable((s as { editable?: boolean }).editable === true)) - .catch(() => {}); - } } - }, [status, editingAllowed]); - - useEffect(() => () => clearInterval(pollRef.current), []); - - const onApplied = () => { - setEditing(false); - setEditable(false); - setVideoUrl(null); - setLocalStatus("compiling"); - pollRef.current = setInterval(async () => { - try { - const s = await api.getStatus(); - if (s.status === "complete" || s.status === "failed") { - clearInterval(pollRef.current); - setLocalStatus(s.status); - } - } catch { - // Ignore poll errors — next tick retries. - } - }, 3000); - }; - - if (editing) { - return ( -
- setEditing(false)} - onApplied={onApplied} - /> -
- ); - } + }, [status]); if (status === "stopped" || status === "compiling") { return ( @@ -121,11 +68,6 @@ export function Result({ status: statusProp, trackedSeconds }: ResultProps) {
)} - {editingAllowed && editable && ( - - )}
); } @@ -157,17 +99,6 @@ const styles: Record = { background: "#000", overflow: "hidden", }, - editButton: { - marginTop: 16, - padding: "8px 16px", - fontSize: 13, - fontWeight: 600, - color: "#ccc", - background: "transparent", - border: "1px solid #444", - borderRadius: 8, - cursor: "pointer", - }, spinner: { width: 40, height: 40, diff --git a/docs/edit-feature-plan.md b/docs/edit-feature-plan.md index 1b37cdfa..91a76b44 100644 --- a/docs/edit-feature-plan.md +++ b/docs/edit-feature-plan.md @@ -1,26 +1,40 @@ -# Edit feature (cuts) — implementation plan v2 +# Edit feature (cuts) — implementation plan v3 -Status: proposal. Covers server, worker, and all three clients. +Status: implemented. Covers server, worker, and all three clients. ## Model +Editing lives **inside the stop flow**, before the session is ever +published. Stopping offers three choices: + ``` -record ── stop ──> compile (UNCHANGED — this is the "pre-compile") - └─> complete: original.mp4 published - │ - │ editor previews the compiled video - │ PUT /cuts { cuts: [{start, end}, …] } (wall-clock intervals) - │ POST /compile - v - cut-compile (fast: lossless stream-copy of kept ranges) - └─> complete: edited.mp4 published, original kept for re-edits +recording ──Stop──> ┌ Keep recording ─────────────────────> (back to recording) + ├ Stop & save ───> compile ───────────> complete + └ Edit & save ──> stop {edit:true} + │ compile runs, but the session is HELD + │ (status stays "stopped", video unpublished) + │ + ├ user cuts → publish ──> compiling ──> complete + ├ "publish as recorded" ───────────────> complete + └ hold expires (30 min) ───────────────> complete ``` -- The **existing compile runs exactly as today** the moment the user stops. - Nothing about stop, the compile pipeline, old clients, or program - integrations changes. Its output doubles as the editor's preview. -- **Editing is optional and happens after `complete`.** The editor scrubs - the compiled video; a second, much cheaper compile applies the cuts. +**Why not edit after `complete`?** Because `complete` is the signal +programs act on — forwarding heartbeats to Hackatime, accepting a +submission, firing the redirect hook. Editing a published session would +mutate numbers someone already consumed. So a session reaches `complete` +exactly once, with its cuts already applied, and post-publication editing +does not exist (`editable: false`, `PUT /cuts` 409s). + +Consequences that fall out of this: + +- **Programs need no changes at all.** The observable lifecycle is still + `stopped → compiling → complete`; an edit just means longer in `stopped`. +- **The hold can delay publication, never cancel it.** A background job + publishes the timelapse as recorded when the hold expires, so an + abandoned edit still yields a video. +- **Cut footage is deleted immediately** after an edited publish, instead + of lingering for a 7-day re-edit window. - An edit is a **cut list of absolute wall-clock intervals** stored on the session — `[{ "start": ISO-8601, "end": ISO-8601 }]` ("start a → end a, start b → end b"). Never "offset + duration": Lookout is heartbeat-based, @@ -63,6 +77,9 @@ tested once. | `GET /timings` | Timestamps inside cuts excluded from the default array; intervals surfaced as `cuts` | | `trackedSeconds` | Reported as `raw − cutSeconds` everywhere; raw preserved as `uncutTrackedSeconds` | +All three are settled before the session publishes, so no consumer ever +observes them changing. + `trackedSeconds` shrinking with cuts is deliberate: `/timings` and `trackedSeconds` must tell the same story ("verified time, minus what the user removed"), and cutting can only *reduce* the number, so there's no fraud @@ -80,9 +97,10 @@ subtraction happens in the one read-side dispatcher | `cuts` | `jsonb` | Normalized cut list. `null`/`[]` = no edits | | `cut_seconds` | `integer` | Credited seconds removed; recomputed on every cuts write and at cut-compile | | `video_units` | `jsonb` | Ordered array of the units actually included in `original.mp4` (`[{capturedAt, screenshotId}]`), written by compile. THE video-second ↔ wall-clock map — sampled rows alone can't provide it because compile skips undecodable units | -| `original_video_r2_key` | `text` | The uncut compiled video. On first compile this equals `video_r2_key`; after an edited compile, `video_r2_key` points at `edited.mp4` while this keeps pointing at the original | -| `video_copy_aligned` | `boolean` | True when assembly used the stream-copy path (GOP grid guaranteed). False → cut-compile must use its re-encode fallback | -| `recompile_count` | `integer not null default 0` | User-initiated cut-compiles, capped | +| `original_video_r2_key` | `text` | The uncut compiled video — the editor's preview source. Nulled (and the object deleted) as soon as an edited publish lands | +| `video_copy_aligned` | `boolean` | True when assembly used the stream-copy path (GOP grid guaranteed). False → the cut must use its re-encode fallback | +| `recompile_count` | `integer not null default 0` | User-initiated publishes-with-cuts, capped | +| `edit_hold_until` | `timestamptz` | While set and in the future, the compiled video stays unpublished (`status` `stopped`, `video_r2_key` null) so the owner can cut it. Cleared by the publish call or the expiry job | No `screenshots` changes — cut membership is computed from the interval list, never denormalized onto rows. @@ -100,38 +118,42 @@ list, never denormalized onto rows. ## API changes (`packages/server/src/routes/sessions.ts`) -Token-authenticated, rate-limited like their neighbors. **No change to -`/stop`.** - -1. **`GET /api/sessions/:token/units`** — editor metadata (no presigning, no - R2 access): `{ units: , cuts, editable, originalVideoUrl }`. - `editable` is false when `original.mp4` has been purged or - `recompile_count` is exhausted. `originalVideoUrl` is a token-free - presigned GET (1 h) for `original_video_r2_key` — the editor's preview - source. It must NOT be the public `/api/media/...` URL: after an edit, - cut content exists only in the original, which stays reachable through - the secret token only. -2. **`PUT /api/sessions/:token/cuts`** — replace the whole list (idempotent; - no patch semantics). Allowed in `complete`; 409 while `compiling`. - `[]` clears edits. -3. **`POST /api/sessions/:token/compile`** — apply the current cut list: - - Guards: status `complete`, `original_video_r2_key` present, - `recompile_count < MAX_USER_RECOMPILES (5)`, per-token rate limit. - - Special case, no job needed: if `cuts` is empty and an `edited.mp4` - exists, repoint `video_r2_key` back to the original, delete - `edited.mp4`, regenerate nothing (thumbnail = original's, kept). - "Undo all edits" is instant. - - Otherwise flip status → `compiling` (worker's claim already accepts - re-entry), increment `recompile_count`, enqueue `COMPILE_JOB`. -4. **`GET /api/sessions/:token/timings`** — returns +Token-authenticated, rate-limited like their neighbors. + +1. **`POST /api/sessions/:token/stop`** — accepts an optional body + `{ edit: true }`. Absent (or no body at all) → today's behavior + byte-for-byte, so shipped clients are untouched. Present, and the + session has captures → sets `edit_hold_until = now + 30 min` and + returns it. The compile is enqueued either way. +2. **`GET /api/sessions/:token/units`** — editor metadata: + `{ units: , cuts, editable, editableReason, editHoldUntil, + originalVideoUrl, recompilesRemaining }`. `originalVideoUrl` is a + presigned GET (1 h) for the unpublished original. It must NOT be the + public `/api/media/...` URL — that is null until the session publishes, + and afterwards serves the cut version only. +3. **`PUT /api/sessions/:token/cuts`** — replace the whole list (idempotent; + `[]` clears). **Only during an active hold**; the write is guarded on + `status = 'stopped' AND edit_hold_until > now()` so a session that + published mid-edit can never be mutated. +4. **`POST /api/sessions/:token/compile`** — publish, baking in the cuts: + - No cuts → publish the built original directly, no worker round-trip + (`instant: true`). This is "Save as recorded". + - With cuts → claim `stopped → compiling`, clear the hold (so the expiry + job can't race), increment `recompile_count`, enqueue `COMPILE_JOB`. + - Already `complete` → `200 instant` (the expiry job won the race; the + timelapse is out either way). Already `compiling` → `202`. +5. **`GET /api/sessions/:token/timings`** — returns `{ count, timestamps: [kept only], cuts, cutCount }`; cut captures are excluded **by default** so existing Hackatime forwarders respect edits automatically. `?includeCut=true` adds `cutTimestamps`. -5. **`GET /api/sessions/:token`**, **`/status`**, **`/batch`**, internal +6. **`GET /api/sessions/:token`**, **`/status`**, **`/batch`**, internal session endpoint — add `cuts`, `cutSeconds`, `uncutTrackedSeconds`, - `editable`. `trackedSeconds` becomes post-cut everywhere via the - dispatcher. `/status` keeps working unchanged for old clients during a - cut-compile (`compiling` → `complete` — states they already handle). + `editable`, `editHoldUntil`. `trackedSeconds` becomes post-cut + everywhere via the dispatcher. +7. **Hold expiry** (`lib/timeouts.ts`, on the existing every-minute cron): + publish any `stopped` session whose `edit_hold_until` has passed, via + the shared `publishHeldSession` helper. This is what makes offering the + edit step safe — the hold delays publication, never cancels it. ## Worker changes @@ -141,49 +163,55 @@ on what exists: **A. Original build (unchanged pipeline + bookkeeping).** Runs when `original_video_r2_key` is absent — i.e., every first compile. Identical sampling → segment build → stream-copy assembly, plus: -- Write output to `timelapses/{id}/original.mp4`; set both - `original_video_r2_key` and `video_r2_key` to it. +- Write output to `timelapses/{id}/original.mp4`; set + `original_video_r2_key`. - Record `video_units` (the units whose segments actually made it in, in order) and `video_copy_aligned` (true on the copy path). +- **Publish, or hold.** Re-read `edit_hold_until` at the end of the build + (it may have lapsed during the minutes it ran). Hold active → leave the + session `stopped` with `video_r2_key` null: everything is built, nothing + is published. No hold → publish exactly as before. - **Fix the assembly re-encode fallback to pin the GOP** (reuse `SEGMENT_ENCODE_ARGS`' `-g/-keyint_min/-sc_threshold/open-gop`): today the fallback emits default x264 keyframes (~every 250 frames, scene-cut on), which would break lossless cutting. Cheap and correct regardless of this feature. -**B. Cut apply.** Runs when `original_video_r2_key` exists and `cuts` is -non-empty: +**B. Cut apply + publish.** Runs when `original_video_r2_key` and +`video_units` exist — i.e. the user published a held session with cuts: - Compute kept video-second ranges: map each `video_units[i]` through the - membership rule → contiguous kept index runs → `[inpoint, outpoint)` pairs. -- `video_copy_aligned = true`: concat-demuxer file listing `original.mp4` - once per kept range with `inpoint`/`outpoint`, `-c copy`, remux with - `+faststart` → `timelapses/{id}/edited.mp4`. Lossless, seconds. -- `video_copy_aligned = false` (legacy videos assembled via the old - fallback): same ranges but re-encode with the pinned args (one CRF-18 - generation — acceptable, rare). -- Verify frame count = kept units × 30 (existing `verifyVideo`). -- Regenerate the thumbnail from `edited.mp4` (first frame may have been cut). + membership rule → contiguous kept index runs. +- `video_copy_aligned = true`: per kept range, an input seek to its IDR + (`-ss`) plus an exact copied packet count (`-frames:v n×30`) into a TS + intermediate, then stream-copy concat → `timelapses/{id}/edited.mp4`. + Lossless, seconds. (NOT concat `inpoint`/`outpoint`: outpoint is + dts-based and B-frame dts offsets leak ~2 frames of the cut region past + each boundary — caught by the worker's frame-exact test.) +- `video_copy_aligned = false`: one frame-exact re-encode of the original + through a `select` filter with the pinned args (rare; CRF 18). +- Verify frame count = kept units × 30, exactly on the copy path. +- Regenerate the thumbnail from `edited.mp4` (the first minute may be cut). - Point `video_r2_key` at `edited.mp4`, persist authoritative - `cut_seconds`, status `complete`. + `cut_seconds`, clear the hold, status `complete` — **then** delete the + uncut original and null its key. Ordering matters: deleting first would + leave a crash pointing the session at bytes that no longer exist. Notes: - The cut path **never downloads capture units** — it needs only - `original.mp4`. Editing therefore works even after the 7-day screenshot - purge, for as long as the original is retained. -- Keep compile step 7 (unsampled cleanup) untouched; sampled unit files - still age out via the existing retention job. They're no longer needed for - editing at all. + `original.mp4`, so it is unaffected by screenshot retention. +- Publishing *without* cuts never reaches the worker at all: the server + repoints `video_r2_key` at the already-built original + (`lib/publish.ts#publishHeldSession`), which is also what the hold-expiry + job calls. One helper, one atomic guard, so a user's publish and the + expiry job racing each other publish exactly once. ### Retention & privacy for cut content -Cut minutes vanish from the published video but live on in `original.mp4` -(token-gated). Extend the daily retention job: for sessions whose cuts are -non-empty and whose edit window has closed (`EDIT_WINDOW_DAYS = 7` after the -last cut-compile), delete `original.mp4` and null -`original_video_r2_key` — the cut content is then truly gone and `editable` -goes false. Uncut sessions keep their single video file forever, as today. -(Public media redirect caches `video.mp4` for 30 min — an edited video may -serve the stale original that long. Documented, acceptable.) +Cut minutes vanish from the published video, so the uncut original is +deleted **immediately** after an edited publish — not kept for a re-edit +window. `EDIT_WINDOW_DAYS = 7` remains only as a retention backstop in the +daily job, for originals orphaned by a crashed publish. Uncut sessions keep +their single video file forever, as today. ## Clients — one editor, three surfaces @@ -214,33 +242,51 @@ serve the stale original that long. Documented, acceptable.) - Region ↔ interval serialization: selected units `[i..j]` → `start = video_units[i].capturedAt`, `end = video_units[j].capturedAt + 60 s`; server normalizes. - - Footer: "`` after cuts" (from the PUT response), Cancel, Save & - apply (`PUT /cuts` → `POST /compile` → existing `ProcessingState` until - `complete`). -- Wiring: `SessionDetail` and `ResultView` gain an "Edit" button when - `complete && editable`. `LookoutRecorder` post-stop flow flows into the - same button once compile finishes — no recording-path changes at all. + - Footer: kept/removed durations (server-authoritative), optional "Not + now", and a primary button that reads **"Save & publish"** with cuts or + **"Publish as recorded"** without — publishing is the way out, not an + optional extra step. + - Polls `/units` while the preview is still compiling, and counts down to + the hold's auto-publish (louder in the last two minutes) so the user + always knows the timelapse is safe but not yet out. +- **``**: the stop confirmation — keep recording / stop & + save / edit & save. This is where editing is offered; there is no + post-publication entry point. +- Wiring: `LookoutRecorder` routes every Stop button through the modal and + renders the editor inline after an `edit` stop. `SessionDetail` shows a + **review panel** for any session with a live `editHoldUntil` (Edit & save + / Publish as recorded / countdown), and suppresses the compile spinner + there — "processing" under "ready to review" would contradict itself. - Pure helpers with tests (style of `computeBestTracked.ts`): unit↔interval mapping, kept-range computation, gap detection. ### Desktop (`clients/desktop`) +- `NamingModal` (already the stop confirmation) gains **Edit & Save** + alongside Save & Stop and Resume — the three choices the user asked for, + in the place they already exist. - The main window is a fixed 480×640 — too small for precise timeline - scrubbing — so the Edit button (via `SessionDetail`'s `onEdit` override) - opens a **dedicated resizable 960×720 editor window** (`EditorWindow.tsx`, - route `#/editor?token=…`, Tauri `WebviewWindow` labeled `editor-*`). - Applying cuts emits a `lookout-edited` event; the main window remounts the - open `SessionDetail` and refreshes the gallery. -- The post-stop RecordPage routes to `SessionDetail` on completion, which - carries the Edit affordance; the `redirectUrl` hook keeps firing on first - `complete` exactly as documented. + scrubbing — so editing opens a **dedicated resizable 960×720 window** + (`EditorWindow.tsx`, route `#/editor?token=…`, Tauri `WebviewWindow` + labeled `editor-*`). Publishing emits `lookout-edited`; the main window + remounts the open `SessionDetail` and refreshes the gallery. +- **While that window is open the main window steps aside**, showing only + an icon and "Edit your timelapse in the edit window." (click to bring it + to the front). Two live views of one session would just compete for + attention. The main window learns the editor opened from an event and + then *polls* for the window's existence — the poll is what guarantees it + can never get stuck behind the placeholder if the editor is force-quit. +- Both stop paths (`RecordPage` and `DesktopRecorder`) send + `{ edit: true }` and open the editor window; `SessionDetail`'s `onEdit` + override reopens it from the review panel. - No Rust changes — capture/tray/upload untouched (window creation + close permissions added to the default capability). ### Web (`clients/web`) -- `Result.tsx`: Edit button → ``. `?edit=false` recorder - URL param lets an embedding program hide it. +- The hosted recorder renders the SDK's ``, so it inherits + the stop modal and editor. `?edit=false` on the recorder URL maps to + `editing={false}` for programs that want one-click stops. ## Docs @@ -254,36 +300,47 @@ serve the stale original that long. Documented, acceptable.) ## Tests -- **Shared**: membership + normalization/merge pure-function suite. -- **Server integration**: PUT cuts validation matrix (merge, clamp, all-cut - rejection, cap, 409 while compiling); compile endpoint transitions, - instant un-cut repoint, recompile cap; timings filtering (+`includeCut`); - trackedSeconds subtraction in `GET`/`status`/`batch`; units endpoint auth - + purged behavior; original purge job. -- **Worker** (extend the `clips.integration.test.ts` harness): original - build writes `video_units`/aligned flag; cut-compile produces exact - kept-units × 30 frames via stream copy; misaligned original takes the - re-encode path; thumbnail regenerated; fallback assembly now pins GOP. -- **React**: mapping/gap/kept-range unit tests; editor interaction tests. +- **Shared** (`packages/server/test/cuts.unit.test.ts`): membership, + normalization/merge/clamp, kept ranges, cut-seconds in both tracking + modes. +- **Server integration** (`packages/server/test/edits.integration.test.ts`): + stop with/without `{edit}` (including the no-captures case); `/units` + across every editability state; PUT cuts validation matrix and the + "published sessions are immutable" guarantee; publish semantics (instant + without cuts, worker handoff with cuts, idempotent against the expiry + job, 202 while publishing, 409 once lapsed); timings filtering + (+`includeCut`); trackedSeconds subtraction in `GET`/`status`/`batch`. +- **Worker** (`packages/worker/test/cutVideo.test.ts`, real ffmpeg): cuts + built from a production-shaped original are frame-exact via stream copy + (zero tolerance), head/tail ranges, the re-encode fallback, and + `computeKeptRanges` output feeding the cutter directly. +- **React** (`editorMath.test.ts`): region↔interval round-trips including + across pause gaps, normalization, gap detection. - **Desktop legacy** (`legacy_client.rs`): stop path byte-identical. ## Rollout order +Server and worker must both be deployed before any client sends +`{ edit: true }` — a hold set by the server but ignored by an old worker +would leave a session `stopped` until the expiry job publishes it (safe, +but a 30-minute wait). Deploy order: + 1. `@lookout/shared`: `CutInterval`, membership/normalize helpers, - constants (`MAX_CUT_INTERVALS`, `MAX_USER_RECOMPILES`, `EDIT_WINDOW_DAYS`). -2. Worker: GOP-pin the assembly fallback; write - `video_units`/`original_video_r2_key`/`video_copy_aligned` on compile. - (Deployable alone; migration ships here.) -3. Server: units/cuts/compile endpoints + timings & response fields + - original-purge job. (Deployable alone — inert until a client writes cuts.) -4. Worker: cut-apply path. -5. `@lookout/react`: api client + `` + wiring. + constants (`MAX_CUT_INTERVALS`, `MAX_USER_RECOMPILES`, + `EDIT_HOLD_MINUTES`, `EDIT_WINDOW_DAYS`). +2. Migrations `0018_session_edits` + `0019_edit_hold`. +3. Worker: GOP-pinned assembly fallback, `video_units` bookkeeping, + hold-aware publish, cut-apply path. (Inert — nothing sets a hold yet.) +4. Server: stop `{edit}`, units/cuts/publish endpoints, timings + response + fields, hold-expiry job. (Inert until a client opts in.) +5. `@lookout/react`: api client, ``, ``, + review panel. 6. Web + desktop surfaces; desktop release. 7. Docs; announce to program authors. -Videos compiled before step 2 lack `video_units`/alignment info — -`editable: false` for them (backfill is possible from sampled rows but not -worth it; sessions age out of relevance in days). +Sessions compiled before step 3 have no `video_units` — they simply never +offer editing, and old clients never request a hold, so both keep working +unchanged. ## Edge cases ledger @@ -294,12 +351,16 @@ worth it; sessions age out of relevance in days). - **Build-failure holes**: `video_units` records what's actually in the video, so the mapping stays exact even when compile skipped undecodable units. -- **Concurrent PUT cuts vs compile**: PUT checks status in its transaction, - 409 while `compiling`; compile claim is already atomic. -- **Re-edit loops**: each cut-compile starts from `original.mp4`, so edits - never compound quality loss and un-cutting any region always works within - the window. -- **Failed cut-compile**: pg-boss retries; final failure marks `failed` as - today — recovery via internal recompile re-enters half A/B dispatch - idempotently. +- **Hold expires mid-build**: the build re-reads the hold at the end and + publishes normally if it lapsed; the expiry job skips sessions with no + original yet (and clears their hold so the next build publishes). +- **User publishes while the expiry job fires**: both go through + `publishHeldSession`'s atomic guard, so exactly one wins; the loser's + endpoint returns `200 instant` because the timelapse is out either way. +- **PUT cuts racing publication**: the write is guarded on + `status='stopped' AND edit_hold_until > now()`, so a published session's + numbers can never move. +- **Failed cut publish**: pg-boss retries; final failure marks `failed` as + today. Admin recompile re-enters half A (the original was deleted), which + rebuilds from capture units and re-applies the same cut list. - **`videoWebmUrl` legacy field**: unchanged (static please-update video). diff --git a/docs/integration.md b/docs/integration.md index 0767f34e..e8d39281 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -447,34 +447,39 @@ Notes: ## Edits and cuts -Users can **edit** a completed timelapse in the official clients: they select -wall-clock stretches of the recording to remove ("cuts"), and Lookout removes -those minutes from the published video, the `/timings` heartbeats, and -`trackedSeconds` — consistently, from one stored cut list of -`{start, end}` intervals. +When a user stops a recording, the official clients offer three choices: +keep recording, save as recorded, or **review and cut first**. If they +choose to edit, they mark wall-clock stretches to remove and Lookout drops +those minutes from the video, the `/timings` heartbeats, and +`trackedSeconds` — all from one stored list of `{start, end}` intervals. What this means for your program: -- **Nothing in your integration changes.** `trackedSeconds` and `/timings` - already reflect the user's edits. If you forward `/timings` to Hackatime as - described above, cut minutes are simply absent from the heartbeat array. -- **Cuts only ever shrink the numbers.** A user cannot gain time by editing — - removing footage removes its credit. The pre-edit value is available as - `uncutTrackedSeconds`, and the intervals themselves as `cuts`, on - `GET /api/sessions/:token` (and `?includeCut=true` on `/timings` returns - the removed timestamps) if you want to audit or display them. -- **Re-editing is time-boxed.** Users can revise or clear their cuts for up - to 7 days after their last edit; then the uncut original video is deleted - (the cut content is meant to be gone — think "I accidentally recorded my - bank tab") and the edit becomes final. -- **Opting out of the UI:** the hosted web recorder hides the edit button - when the session URL carries `?edit=false`; `` - does the same in the React SDK. The API itself stays available to the - token holder either way. -- Forward heartbeats **after** the user is done editing when possible (e.g. - when you accept a submission) — if you forwarded earlier and the user then - cuts, re-fetch `/timings` and reconcile, since Hackatime won't know about - the removal. +- **Nothing in your integration changes, and nothing you read ever changes + underneath you.** Editing happens *before* the session reaches + `complete`: a session being edited stays `stopped`, and only flips to + `complete` once the user's cuts are baked in. So the first time you see a + finished session, its video, `trackedSeconds`, and `/timings` are final. + There is no post-publication editing. +- **The lifecycle you observe is unchanged.** `stopped → compiling → + complete` (or `stopped → complete`), exactly as before — an edit just + means the session sits in `stopped` a little longer. The redirect hook + still fires when the session completes, which is now also the moment the + edits are in. +- **An abandoned edit can't strand a timelapse.** The hold expires after 30 + minutes and the session publishes as recorded. It can delay publication, + never cancel it. If you poll, treat a long `stopped` exactly as you + always have. +- **Cuts only ever shrink the numbers.** A user cannot gain time by + editing — removing footage removes its credit. The pre-edit value is + available as `uncutTrackedSeconds` and the intervals as `cuts` on + `GET /api/sessions/:token`; `?includeCut=true` on `/timings` returns the + removed timestamps, if you want to audit or display them. +- **Cut footage is deleted immediately** once the edited timelapse + publishes — the point of a cut is usually "I didn't mean to record that." +- **Opting out of the review step:** add `?edit=false` to the hosted + recorder URL, or pass `` in the React + SDK. Stopping is then a single click, as before. ## Client telemetry diff --git a/packages/server/API.md b/packages/server/API.md index e7a44b18..055b939a 100644 --- a/packages/server/API.md +++ b/packages/server/API.md @@ -417,22 +417,35 @@ Stops a session and enqueues video compilation if screenshots exist. |------|------|-------------| | `token` | string | 64-char hex session token | +**Body (optional):** +```json +{ "edit": true } +``` + +| Field | Type | Description | +|-------|------|-------------| +| `edit` | boolean | Hold the timelapse unpublished after it compiles so the owner can cut it first. See [Edits (Cuts)](#edits-cuts). Omit for today's behavior. | + **Response `200 OK`:** ```json { "status": "stopped", "trackedSeconds": 123, - "totalActiveSeconds": 300 + "totalActiveSeconds": 300, + "editHoldUntil": "2026-07-26T14:35:00.000Z" } ``` +`editHoldUntil` is present only when the stop requested `edit: true` and the session actually has captures to edit. + **Errors:** - `404` — Session not found - `409` — Session already in terminal state **Notes:** -- Marks session `complete` immediately if no screenshots exist (skips compilation) +- Marks session `failed` immediately if no screenshots exist (skips compilation), regardless of `edit` - Accumulates any remaining active time +- Only send `edit: true` from a client that can render the editor. The hold auto-publishes when it expires, so an abandoned edit still yields a timelapse — but a client that requests a hold and then never shows an editor makes the user wait for no reason. --- @@ -539,7 +552,7 @@ Returns the ISO-8601 capture timestamps of **every confirmed screenshot** in the ### Edits (Cuts) -A completed timelapse can be **edited** by its owner: an edit is a **cut list** of absolute wall-clock intervals of the session that are removed from every output — +When a user stops a recording they can review it before it goes out. An edit is a **cut list** of absolute wall-clock intervals of the session that are removed from every output — ```json [ @@ -558,9 +571,26 @@ Cuts are intervals (not video offsets) because Lookout is heartbeat-based — th **Membership rule:** a capture is cut iff its timestamp ∈ `[start, end)` of any interval. Granularity is effectively whole minutes (one capture unit ≈ one minute ≈ one second of video). -**Mechanics:** the first compile always produces the **uncut original** video and records its unit map. Applying cuts is a *cut-compile*: a lossless stream-copy of the kept ranges of the original (seconds, even for 12-hour sessions; no quality loss; works after the 7-day screenshot purge). The original stays reachable only through the session token; **7 days after the last cut-compile** the retention job deletes the original of edited sessions, so the cut content is truly gone and editing freezes. Cutting can only *reduce* tracked time — there is no fraud surface. +#### Editing happens before publication, never after + +`complete` is the status programs act on — forwarding heartbeats to Hackatime, accepting a submission, firing the redirect hook. So a session reaches `complete` **exactly once, with its cuts already applied**. There is no post-publication editing: the data a program reads is final the first time it sees it. -Session responses (`GET /:token`, `/status`, internal) carry `cuts`, `cutSeconds`, `uncutTrackedSeconds`, and `editable`. +That is what the **edit hold** is for. `POST /stop` with `{"edit": true}` marks the session; it compiles as usual, but the worker leaves it `stopped` with `videoUrl` still null instead of publishing. During the hold the owner previews the built video, sets a cut list, and publishes. The lifecycle programs observe is unchanged — `stopped → compiling → complete`, or `stopped → complete` when there was nothing to cut. + +``` +stop {edit:true} ─> stopped (hold, compiling internally) + ├─ PUT /cuts … then POST /compile ─> compiling ─> complete + ├─ POST /compile with no cuts ────────────────────> complete + └─ hold expires (30 min) ────────────────────────> complete +``` + +The hold can only **delay** publication, never cancel it: if the user closes the app mid-edit, a background job publishes the timelapse as recorded when the hold expires. A stop without `{"edit": true}` behaves exactly as it always has, so existing clients are unaffected. + +**Mechanics:** the compile always produces the **uncut original** and records its unit map. Publishing with cuts is a lossless stream-copy of the kept ranges (seconds, even for 12-hour sessions, no quality loss), after which the uncut original is **deleted immediately** — cut content does not outlive the publish. Publishing without cuts just points the session at the original, with no worker round-trip. + +Cutting can only *reduce* tracked time — there is no fraud surface. + +Session responses (`GET /:token`, `/status`, internal) carry `cuts`, `cutSeconds`, `uncutTrackedSeconds`, `editable`, and `editHoldUntil`. #### Get Editor Units @@ -576,14 +606,16 @@ Editor metadata. Rate limit: 10 req/min per token. "units": [ { "capturedAt": "2026-07-26T14:00:12.000Z", "screenshotId": "…" } ], "cuts": [], "editable": true, + "editHoldUntil": "2026-07-26T14:35:00.000Z", "originalVideoUrl": "https://…presigned, ~1h…", "recompilesRemaining": 5 } ``` - `units` — the capture units of the compiled **original** video, in output order. Array index = video second = real-world minute: the exact video-time ↔ wall-clock map. -- `originalVideoUrl` — presigned GET for the UNCUT original (the editor's preview source). Deliberately not the public media URL: after an edit, cut content exists only here, gated by the secret token. `null` when not editable. -- `editable` / `editableReason` — `false` with `"not_complete"`, `"no_original"` (compiled before edit support, or original purged), or `"recompiles_exhausted"`. +- `originalVideoUrl` — presigned GET for the unpublished original (the editor's preview source). Deliberately not the public media URL, which is null until the session publishes. `null` when not editable. +- `editable` / `editableReason` — `false` with `"no_original"` (the preview is still compiling — poll, since `editHoldUntil` is set), `"not_ready"` (no hold, or it lapsed), `"published"` (already `complete`, so editing is over), or `"recompiles_exhausted"`. +- `editHoldUntil` — when the session auto-publishes; `null` when no hold is active. #### Set Cut List @@ -592,7 +624,7 @@ PUT /api/sessions/:token/cuts Body: { "cuts": [{ "start": ISO-8601, "end": ISO-8601 }, …] } ``` -Replaces the whole cut list (idempotent; `[]` clears all edits). Only valid while `complete` and editable. The server normalizes (sorts, merges overlaps, clamps to the session envelope, caps at 120 intervals) and rejects a list that would remove **every** unit. Takes effect in `/timings` and `trackedSeconds` immediately; the published video updates on the next compile call. Rate limit: 20 req/min per token. +Replaces the whole cut list (idempotent; `[]` clears all edits). **Only valid during an active edit hold** — a published session is immutable. The server normalizes (sorts, merges overlaps, clamps to the session envelope, caps at 120 intervals) and rejects a list that would remove **every** unit. The cuts are baked into the video by the publish call below. Rate limit: 20 req/min per token. **Response `200 OK`:** ```json @@ -607,16 +639,21 @@ Replaces the whole cut list (idempotent; `[]` clears all edits). Only valid whil **Errors:** `400` invalid/entire-timelapse cut list · `409` compiling or not editable · `429` rate limit. -#### Apply Cuts (Cut-Compile) +#### Publish (End the Hold) ``` POST /api/sessions/:token/compile ``` -Applies the current cut list to the published video. Flips the session `complete → compiling → complete` (poll [`/status`](#poll-compilation-status)); usually completes in seconds. Clearing all cuts when the original is already published returns `{ "instant": true }` with no compile. Each non-instant call burns one of **5** recompiles per session. Rate limit: 5 req/min per token. +Ends the edit hold and publishes the timelapse with the current cut list baked in. + +- **With cuts:** `stopped → compiling → complete` (poll [`/status`](#poll-compilation-status)); the worker stream-copies the kept ranges, usually in seconds, then deletes the uncut original. Burns one of **5** publishes per session. +- **Without cuts:** returns `{ "instant": true, "status": "complete" }` immediately — the built original is simply published, no worker involved. + +Rate limit: 5 req/min per token. **Response `200 OK`:** `{ "status": "compiling" | "complete", "instant": boolean, "recompilesRemaining": number }` -**Errors:** `202` already compiling (safe to retry/poll) · `409` not editable or budget exhausted · `429` rate limit. +**Errors:** `202` publish already running (safe to retry/poll) · `409` hold lapsed or not editable · `429` rate limit. Calling it on an already-published session is a no-op `200` with `instant: true`, so a client racing the expiry job never sees a spurious failure. --- diff --git a/packages/server/drizzle/0019_edit_hold.sql b/packages/server/drizzle/0019_edit_hold.sql new file mode 100644 index 00000000..19b0ec6e --- /dev/null +++ b/packages/server/drizzle/0019_edit_hold.sql @@ -0,0 +1 @@ +ALTER TABLE "sessions" ADD COLUMN "edit_hold_until" timestamp with time zone; diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index 534f9f2f..1e68f5d9 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1785077227872, "tag": "0018_session_edits", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1785080224937, + "tag": "0019_edit_hold", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 550a842e..425beeee 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -168,10 +168,17 @@ export const sessions = pgTable( // User-initiated cut-compiles, capped at MAX_USER_RECOMPILES. recompileCount: integer("recompile_count").notNull().default(0), // When the last cut-compile finished; anchors the EDIT_WINDOW_DAYS - // original-video retention for edited sessions. + // original-video retention backstop for edited sessions. lastEditCompileAt: timestamp("last_edit_compile_at", { withTimezone: true, }), + // Edit hold: while set and in the future, a stopped session's compiled + // video stays UNPUBLISHED (status remains "stopped", video_r2_key null) + // so the owner can cut it before programs ever see `complete`. Set by + // POST /stop {edit: true}; cleared by the finalize call or the expiry + // job (which auto-publishes uncut). Editing is only possible during + // this hold — never after complete, because programs act on complete. + editHoldUntil: timestamp("edit_hold_until", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/packages/server/src/lib/publish.ts b/packages/server/src/lib/publish.ts new file mode 100644 index 00000000..cc493b51 --- /dev/null +++ b/packages/server/src/lib/publish.ts @@ -0,0 +1,48 @@ +import { and, eq, isNotNull } from "drizzle-orm"; +import { db, schema } from "../db/index.js"; + +/** + * Publish a held session by pointing it at its already-built UNCUT + * original — the "no edits" outcome of an edit hold. + * + * A held session finished compiling but deliberately stayed `stopped` with + * `video_r2_key` null so that programs never observe `complete` before the + * user's cuts are baked in. This flips it to `complete` with no worker + * round-trip (the bytes already exist in R2), which is why "Save without + * edits" and hold expiry are both instant. + * + * Atomic and idempotent: the guard means a racing caller (the user's + * finalize vs. the expiry job) publishes exactly once; the loser gets + * `false` and should treat the session as already published. + */ +export async function publishHeldSession(sessionId: string): Promise { + const publicDomain = process.env.R2_PUBLIC_DOMAIN || ""; + + const [row] = await db + .select({ originalVideoR2Key: schema.sessions.originalVideoR2Key }) + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)); + if (!row?.originalVideoR2Key) return false; + + const [updated] = await db + .update(schema.sessions) + .set({ + status: "complete", + videoR2Key: row.originalVideoR2Key, + videoUrl: publicDomain + ? `https://${publicDomain}/${row.originalVideoR2Key}` + : row.originalVideoR2Key, + editHoldUntil: null, + updatedAt: new Date(), + }) + .where( + and( + eq(schema.sessions.id, sessionId), + eq(schema.sessions.status, "stopped"), + isNotNull(schema.sessions.originalVideoR2Key), + ), + ) + .returning({ id: schema.sessions.id }); + + return Boolean(updated); +} diff --git a/packages/server/src/lib/timeouts.ts b/packages/server/src/lib/timeouts.ts index 59cf1190..b424427e 100644 --- a/packages/server/src/lib/timeouts.ts +++ b/packages/server/src/lib/timeouts.ts @@ -10,6 +10,7 @@ import { CLEANUP_SCREENSHOTS_JOB, } from "./queue.js"; import { cleanupRateLimits } from "./timing.js"; +import { publishHeldSession } from "./publish.js"; import { AUTO_PAUSE_AFTER_MINUTES, AUTO_STOP_AFTER_MINUTES, @@ -50,9 +51,54 @@ export async function registerTimeoutJobs() { }); } +/** + * Publish sessions whose edit hold ran out. This is the promise that makes + * the "Edit & Save" flow safe to offer: a user who closes the app mid-edit + * still gets their timelapse, uncut, within EDIT_HOLD_MINUTES — the hold + * can delay publication, never cancel it. + */ +async function publishExpiredHolds() { + const expired = await db + .select({ id: schema.sessions.id }) + .from(schema.sessions) + .where( + and( + eq(schema.sessions.status, "stopped"), + isNotNull(schema.sessions.editHoldUntil), + lt(schema.sessions.editHoldUntil, new Date()), + ), + ); + + for (const session of expired) { + // The original exists once the build lands; if the compile is still + // running (or failed), leave the row alone — the build path publishes + // directly when it finds no live hold, and the stuck-compiling timeout + // covers genuine failures. + const published = await publishHeldSession(session.id); + if (published) { + console.log(`[edit-hold] auto-published ${session.id} (hold expired)`); + } else { + // No original yet: drop the hold so the next compile publishes + // normally instead of the session sitting in limbo. + await db + .update(schema.sessions) + .set({ editHoldUntil: null, updatedAt: new Date() }) + .where( + and( + eq(schema.sessions.id, session.id), + eq(schema.sessions.status, "stopped"), + isNull(schema.sessions.originalVideoR2Key), + ), + ); + } + } +} + async function checkTimeouts() { const now = new Date(); + await publishExpiredHolds(); + // Auto-pause: active sessions with no screenshots for AUTO_PAUSE_AFTER_MINUTES const autoPauseThreshold = new Date( now.getTime() - AUTO_PAUSE_AFTER_MINUTES * 60_000, diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index e5ddb1ad..5d1f57e5 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto"; import { db, schema } from "../db/index.js"; import { r2Client, R2_BUCKET } from "../config/r2.js"; import { boss, COMPILE_JOB } from "../lib/queue.js"; +import { publishHeldSession } from "../lib/publish.js"; import { computeMinuteBucket, checkRateLimit, @@ -31,6 +32,7 @@ import { CAPTURE_FORMATS, CAPTURE_FORMAT_CONTENT_TYPES, MAX_USER_RECOMPILES, + EDIT_HOLD_MINUTES, normalizeCuts, isCutAt, countCutUnits, @@ -87,20 +89,39 @@ function sessionCuts(session: { cuts: unknown }): CutInterval[] { return Array.isArray(session.cuts) ? (session.cuts as CutInterval[]) : []; } -/** Whether the compiled timelapse can (still) be edited, and why not. */ +/** Is the session's edit hold currently active? */ +function holdActive(session: { editHoldUntil: Date | null }): boolean { + return ( + session.editHoldUntil !== null && session.editHoldUntil.getTime() > Date.now() + ); +} + +/** + * Whether the session is CURRENTLY editable, and why not. Editing exists + * only inside the stop-time edit hold — never after `complete`. `complete` + * is the signal programs act on (forwarding heartbeats to Hackatime, + * accepting submissions, firing the redirect hook), so the data they read + * must already be final; a post-publish edit would mutate numbers someone + * already consumed. + */ function sessionEditability(session: { status: string; videoUnits: unknown; originalVideoR2Key: string | null; recompileCount: number; -}): { editable: boolean; reason?: "no_original" | "recompiles_exhausted" | "not_complete" } { - if (session.status !== "complete") { - return { editable: false, reason: "not_complete" }; + editHoldUntil: Date | null; +}): { + editable: boolean; + reason?: "no_original" | "recompiles_exhausted" | "not_ready" | "published"; +} { + if (session.status === "complete") { + return { editable: false, reason: "published" }; } - // videoUnits is written by every post-edit-feature compile; its absence - // means the video predates edit support (no unit map to cut against). - // A null originalVideoR2Key means the retention job reclaimed the uncut - // original of an edited session — the source material is gone. + if (session.status !== "stopped" || !holdActive(session)) { + return { editable: false, reason: "not_ready" }; + } + // Hold is active but the preview build hasn't landed yet (the compile + // job writes videoUnits + the original when it finishes). if ( !Array.isArray(session.videoUnits) || session.videoUnits.length === 0 || @@ -268,6 +289,9 @@ export async function sessionRoutes(app: FastifyInstance) { cutSeconds: session.cutSeconds ?? 0, uncutTrackedSeconds: rawTrackedSeconds, editable: sessionEditability(session).editable, + editHoldUntil: holdActive(session) + ? session.editHoldUntil!.toISOString() + : undefined, screenshotCount, clientInfo, ja4, @@ -1012,11 +1036,24 @@ export async function sessionRoutes(app: FastifyInstance) { }, ); - // Stop session - app.post<{ Params: { token: string } }>( + // Stop session. + // Optional body { edit: true } holds the session UNPUBLISHED after its + // compile so the owner can cut it before programs ever observe + // `complete`. The hold auto-publishes after EDIT_HOLD_MINUTES. Old + // clients send no body and get today's behavior byte-for-byte. + app.post<{ Params: { token: string }; Body: { edit?: boolean } | null }>( "/api/sessions/:token/stop", { - schema: { params: tokenParamSchema }, + schema: { + params: tokenParamSchema, + body: { + type: ["object", "null"] as const, + properties: { + edit: { type: "boolean" as const }, + }, + additionalProperties: false, + }, + }, }, async (request, reply) => { // Rate limit: 10 req/min per token (actions) @@ -1057,6 +1094,13 @@ export async function sessionRoutes(app: FastifyInstance) { // Compute tracked seconds before stopping (screenshots may be cleaned up later) const trackedSeconds = await getTrackedSecondsForSession(session); + // Edit hold: only meaningful when there will be a video to edit. + const screenshotCount = await getScreenshotCount(session.id); + const wantsEdit = request.body?.edit === true && screenshotCount > 0; + const editHoldUntil = wantsEdit + ? new Date(stopNow.getTime() + EDIT_HOLD_MINUTES * 60_000) + : null; + const [updated] = await db .update(schema.sessions) .set({ @@ -1064,6 +1108,7 @@ export async function sessionRoutes(app: FastifyInstance) { stoppedAt: stopNow, totalActiveSeconds, trackedSeconds, + editHoldUntil, updatedAt: stopNow, }) .where(and( @@ -1077,7 +1122,6 @@ export async function sessionRoutes(app: FastifyInstance) { } // Enqueue compilation - const screenshotCount = await getScreenshotCount(session.id); if (screenshotCount > 0) { await boss.send(COMPILE_JOB, { sessionId: session.id }); } else { @@ -1092,6 +1136,9 @@ export async function sessionRoutes(app: FastifyInstance) { status: "stopped" as const, trackedSeconds, totalActiveSeconds, + ...(editHoldUntil + ? { editHoldUntil: editHoldUntil.toISOString() } + : {}), }; }, ); @@ -1137,7 +1184,12 @@ export async function sessionRoutes(app: FastifyInstance) { // Redirect hook — clients watching the compile open this once the // status flips to "complete". Absent when the session has none. redirectUrl: session.redirectUrl ?? undefined, + // Edit hold. `editable` flips true when the preview build lands; + // until then a set `editHoldUntil` means "still preparing". editable: sessionEditability(session).editable, + editHoldUntil: holdActive(session) + ? session.editHoldUntil!.toISOString() + : undefined, }; }, ); @@ -1280,6 +1332,9 @@ export async function sessionRoutes(app: FastifyInstance) { cuts: sessionCuts(session), editable, ...(editable ? {} : { editableReason: reason }), + editHoldUntil: holdActive(session) + ? session.editHoldUntil!.toISOString() + : null, originalVideoUrl, recompilesRemaining: Math.max( 0, @@ -1290,9 +1345,9 @@ export async function sessionRoutes(app: FastifyInstance) { ); // Replace the session's cut list. Idempotent full replace — no patch - // semantics (the list is small). `[]` clears all edits. The cuts take - // effect in /timings and trackedSeconds immediately; the published video - // updates on the next POST /compile. + // semantics (the list is small). `[]` clears all edits. Only valid during + // an active edit hold; the cuts are baked in by POST /compile, which also + // publishes the session. app.put<{ Params: { token: string }; Body: { cuts: Array<{ start: string; end: string }> }; @@ -1383,6 +1438,9 @@ export async function sessionRoutes(app: FastifyInstance) { normalized.cuts, ); + // Guard on `stopped` + a live hold: the expiry job could have + // published this session between our read and this write, and a + // published session's numbers must never move. const [updated] = await db .update(schema.sessions) .set({ @@ -1393,14 +1451,15 @@ export async function sessionRoutes(app: FastifyInstance) { .where( and( eq(schema.sessions.id, session.id), - eq(schema.sessions.status, "complete"), + eq(schema.sessions.status, "stopped"), + sql`${schema.sessions.editHoldUntil} > now()`, ), ) .returning({ id: schema.sessions.id }); if (!updated) { return reply .code(409) - .send({ error: "Session state changed concurrently, please retry" }); + .send({ error: "Edit window closed — the timelapse was already published" }); } return { @@ -1413,10 +1472,13 @@ export async function sessionRoutes(app: FastifyInstance) { }, ); - // Apply the current cut list to the published video (a cut-compile). - // Usually a lossless stream-copy of the kept ranges of the original — - // seconds, not minutes. Clearing all cuts when the original is already - // published is a pure no-op ("instant"). + // Publish a held session, baking in its current cut list. + // + // This ENDS the edit hold: the session goes `complete` and programs read + // its final numbers. With cuts, the worker slices the kept ranges out of + // the built original (usually a lossless stream copy — seconds) and then + // deletes the uncut original. With no cuts, the already-built original is + // published as-is with no compile job at all ("instant"). app.post<{ Params: { token: string } }>( "/api/sessions/:token/compile", { @@ -1435,17 +1497,27 @@ export async function sessionRoutes(app: FastifyInstance) { const session = await findSession(request.params.token); if (!session) return reply.code(404).send({ error: "Session not found" }); + const recompilesRemaining = Math.max( + 0, + MAX_USER_RECOMPILES - session.recompileCount, + ); + if (session.status === "compiling") { - // Already applying — treat as success so client retries are safe. - return reply.code(202).send({ - status: "compiling" as const, - instant: false, - recompilesRemaining: Math.max( - 0, - MAX_USER_RECOMPILES - session.recompileCount, - ), - }); + // Already publishing — treat as success so client retries are safe. + return reply + .code(202) + .send({ status: "compiling" as const, instant: false, recompilesRemaining }); } + if (session.status === "complete") { + // Someone (usually the hold-expiry job) published first. Idempotent + // from the client's point of view: the timelapse is out. + return { + status: "complete" as const, + instant: true, + recompilesRemaining, + }; + } + const { editable, reason } = sessionEditability(session); if (!editable) { return reply @@ -1454,33 +1526,39 @@ export async function sessionRoutes(app: FastifyInstance) { } const cuts = sessionCuts(session); - const publishedIsOriginal = - session.videoR2Key === session.originalVideoR2Key; - if (cuts.length === 0 && publishedIsOriginal) { - // Nothing to change: no cuts, original already published. + + if (cuts.length === 0) { + // No cuts: publish the already-built original directly. No worker + // round-trip, so "Save without edits" is instant. + const published = await publishHeldSession(session.id); + if (!published) { + return reply + .code(409) + .send({ error: "Session state changed concurrently, please retry" }); + } return { status: "complete" as const, instant: true, - recompilesRemaining: Math.max( - 0, - MAX_USER_RECOMPILES - session.recompileCount, - ), + recompilesRemaining, }; } - // Atomically claim: complete → compiling, burn one recompile. The - // worker's own claim accepts re-entry from 'compiling'. + // Cuts to bake in: claim stopped → compiling and hand off to the + // worker (whose claim accepts re-entry from 'compiling' on retry). const [updated] = await db .update(schema.sessions) .set({ status: "compiling", recompileCount: session.recompileCount + 1, + // Clear the hold: this session is being published now, so the + // expiry job must not race in behind us. + editHoldUntil: null, updatedAt: new Date(), }) .where( and( eq(schema.sessions.id, session.id), - eq(schema.sessions.status, "complete"), + eq(schema.sessions.status, "stopped"), ), ) .returning({ id: schema.sessions.id }); diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts index c1785eb8..c95b5435 100644 --- a/packages/server/test/edits.integration.test.ts +++ b/packages/server/test/edits.integration.test.ts @@ -1,10 +1,12 @@ /** - * Integration tests for the edit (cuts) endpoints against a real Postgres: - * GET /units, PUT /cuts, POST /compile, the timings filtering, and the - * post-cut trackedSeconds reporting. + * Integration tests for the stop-time edit flow against a real Postgres. * - * Requires the test docker postgres running on port 5434 (see - * test/setup.ts for the connection string). + * The invariant under test: a session reaches `complete` exactly once, with + * the user's cuts already applied. Editing happens during the stop-time + * hold and is impossible afterwards, because `complete` is what programs + * act on (heartbeat forwarding, submissions, the redirect hook). + * + * Requires the test docker postgres on port 5434 (see test/setup.ts). */ import { afterAll, beforeEach, describe, expect, it } from "vitest"; import type { FastifyInstance } from "fastify"; @@ -17,6 +19,7 @@ let app: FastifyInstance; const T0 = new Date("2026-07-01T10:00:00.000Z"); const minute = (i: number) => new Date(T0.getTime() + i * 60_000); const iso = (i: number) => minute(i).toISOString(); +const UNITS = 10; beforeEach(async () => { await db.execute(sql`TRUNCATE screenshots, sessions RESTART IDENTITY CASCADE`); @@ -31,24 +34,28 @@ afterAll(async () => { }); /** - * Seed a completed, editable session: 10 capture units a minute apart, - * credit-mode with 540 raw tracked seconds (seed capture credits 0), a - * compiled original video, and the unit map the compile would have written. + * Seed a session in its edit hold: stopped, compiled (original + unit map + * written) but NOT published — `video_r2_key` is still null, exactly the + * state the worker leaves a held session in. */ -async function seedCompleteSession(overrides: Partial = {}) { +async function seedHeldSession( + overrides: Partial = {}, +) { const [s] = await db .insert(schema.sessions) .values({ name: "edit-test", - status: "complete", + status: "stopped", trackingMode: "credit", trackedSeconds: 540, startedAt: minute(0), - stoppedAt: minute(10), - videoR2Key: "timelapses/x/original.mp4", + stoppedAt: minute(UNITS), + editHoldUntil: new Date(Date.now() + 30 * 60_000), + videoR2Key: null, originalVideoR2Key: "timelapses/x/original.mp4", + thumbnailR2Key: "timelapses/x/thumbnail.jpg", videoCopyAligned: true, - videoUnits: Array.from({ length: 10 }, (_, i) => ({ + videoUnits: Array.from({ length: UNITS }, (_, i) => ({ capturedAt: iso(i), screenshotId: `00000000-0000-0000-0000-0000000000${String(i).padStart(2, "0")}`, })), @@ -57,7 +64,7 @@ async function seedCompleteSession(overrides: Partial ({ + Array.from({ length: UNITS }, (_, i) => ({ sessionId: s.id, r2Key: `screenshots/${s.id}/${i}.jpg`, requestedAt: minute(i), @@ -72,6 +79,33 @@ async function seedCompleteSession(overrides: Partial + db.query.sessions.findFirst({ where: eq(schema.sessions.id, id) }); + async function putCuts(token: string, cuts: unknown) { const r = await app.inject({ method: "PUT", @@ -81,84 +115,133 @@ async function putCuts(token: string, cuts: unknown) { return { status: r.statusCode, body: r.json() }; } -describe("GET /api/sessions/:token/units", () => { - it("returns the unit map and a presigned original URL when editable", async () => { - const s = await seedCompleteSession(); - const r = await app.inject({ method: "GET", url: `/api/sessions/${s.token}/units` }); +const getJson = async (url: string) => (await app.inject({ method: "GET", url })).json(); + +describe("POST /stop with { edit }", () => { + it("sets an edit hold and still enqueues the compile", async () => { + const s = await seedActiveSession(); + const r = await app.inject({ + method: "POST", + url: `/api/sessions/${s.token}/stop`, + payload: { edit: true }, + }); + expect(r.statusCode).toBe(200); + expect(r.json().editHoldUntil).toBeTruthy(); + + const row = await load(s.id); + expect(row!.status).toBe("stopped"); + expect(row!.editHoldUntil).not.toBeNull(); + }); + + it("leaves old clients untouched — no body means no hold", async () => { + const s = await seedActiveSession(); + const r = await app.inject({ + method: "POST", + url: `/api/sessions/${s.token}/stop`, + }); + expect(r.statusCode).toBe(200); + expect(r.json().editHoldUntil).toBeUndefined(); + expect((await load(s.id))!.editHoldUntil).toBeNull(); + }); + + it("does not hold a session with nothing recorded", async () => { + const [s] = await db + .insert(schema.sessions) + .values({ name: "empty", status: "active", startedAt: minute(0) }) + .returning({ id: schema.sessions.id, token: schema.sessions.token }); + const r = await app.inject({ + method: "POST", + url: `/api/sessions/${s.token}/stop`, + payload: { edit: true }, + }); expect(r.statusCode).toBe(200); - const body = r.json(); + expect(r.json().editHoldUntil).toBeUndefined(); + // No screenshots → failed, as before. + expect((await load(s.id))!.status).toBe("failed"); + }); +}); + +describe("GET /units", () => { + it("is editable during the hold and exposes the unit map", async () => { + const s = await seedHeldSession(); + const body = await getJson(`/api/sessions/${s.token}/units`); expect(body.editable).toBe(true); - expect(body.units).toHaveLength(10); + expect(body.units).toHaveLength(UNITS); expect(body.units[3].capturedAt).toBe(iso(3)); expect(body.originalVideoUrl).toContain("https://"); - expect(body.cuts).toEqual([]); + expect(body.editHoldUntil).toBeTruthy(); }); - it("reports pre-edit-feature sessions as not editable", async () => { - const s = await seedCompleteSession({ videoUnits: null, originalVideoR2Key: null }); - const r = await app.inject({ method: "GET", url: `/api/sessions/${s.token}/units` }); - expect(r.statusCode).toBe(200); - expect(r.json().editable).toBe(false); - expect(r.json().editableReason).toBe("no_original"); - expect(r.json().originalVideoUrl).toBeNull(); + it("reports 'still building' while the compile is in flight", async () => { + const s = await seedHeldSession({ videoUnits: null, originalVideoR2Key: null }); + const body = await getJson(`/api/sessions/${s.token}/units`); + expect(body.editable).toBe(false); + expect(body.editableReason).toBe("no_original"); + // The hold is still surfaced so clients wait rather than give up. + expect(body.editHoldUntil).toBeTruthy(); }); - it("reports in-flight sessions as not editable", async () => { - const s = await seedCompleteSession({ status: "active" }); - const r = await app.inject({ method: "GET", url: `/api/sessions/${s.token}/units` }); - expect(r.json().editable).toBe(false); - expect(r.json().editableReason).toBe("not_complete"); + it("refuses editing once the session is published", async () => { + const s = await seedHeldSession({ + status: "complete", + editHoldUntil: null, + videoR2Key: "timelapses/x/original.mp4", + }); + const body = await getJson(`/api/sessions/${s.token}/units`); + expect(body.editable).toBe(false); + expect(body.editableReason).toBe("published"); + expect(body.originalVideoUrl).toBeNull(); + }); + + it("refuses editing after the hold lapses", async () => { + const s = await seedHeldSession({ + editHoldUntil: new Date(Date.now() - 1000), + }); + const body = await getJson(`/api/sessions/${s.token}/units`); + expect(body.editable).toBe(false); + expect(body.editableReason).toBe("not_ready"); }); }); -describe("PUT /api/sessions/:token/cuts", () => { - it("persists a normalized cut list and reports the tracked-time preview", async () => { - const s = await seedCompleteSession(); - // Two overlapping intervals covering minutes 3..5 → merged, 2 units cut - // (units 3 and 4), each credited 60s. +describe("PUT /cuts", () => { + it("normalizes the list and previews the post-cut tracked time", async () => { + const s = await seedHeldSession(); + // Two adjacent intervals covering minutes 3..5 → merged; units 3 and 4 + // are cut, each worth 60 credited seconds. const { status, body } = await putCuts(s.token, [ { start: iso(3), end: iso(4) }, { start: iso(4), end: iso(5) }, ]); expect(status).toBe(200); expect(body.cuts).toEqual([{ start: iso(3), end: iso(5) }]); - expect(body.unitsTotal).toBe(10); + expect(body.unitsTotal).toBe(UNITS); expect(body.unitsCut).toBe(2); expect(body.uncutTrackedSeconds).toBe(540); expect(body.trackedSeconds).toBe(420); - const row = await db.query.sessions.findFirst({ - where: eq(schema.sessions.id, s.id), - }); + const row = await load(s.id); expect(row!.cuts).toEqual([{ start: iso(3), end: iso(5) }]); expect(row!.cutSeconds).toBe(120); }); - it("reflects cuts in GET /:token, /status, and /timings", async () => { - const s = await seedCompleteSession(); + it("flows into /timings, /:token and /batch", async () => { + const s = await seedHeldSession(); await putCuts(s.token, [{ start: iso(3), end: iso(5) }]); - const session = (await app.inject({ method: "GET", url: `/api/sessions/${s.token}` })).json(); + const session = await getJson(`/api/sessions/${s.token}`); expect(session.trackedSeconds).toBe(420); expect(session.uncutTrackedSeconds).toBe(540); expect(session.cutSeconds).toBe(120); - expect(session.cuts).toEqual([{ start: iso(3), end: iso(5) }]); - - const status = (await app.inject({ method: "GET", url: `/api/sessions/${s.token}/status` })).json(); - expect(status.trackedSeconds).toBe(420); - const timings = (await app.inject({ method: "GET", url: `/api/sessions/${s.token}/timings` })).json(); + const timings = await getJson(`/api/sessions/${s.token}/timings`); expect(timings.count).toBe(8); expect(timings.timestamps).not.toContain(iso(3)); expect(timings.timestamps).not.toContain(iso(4)); expect(timings.timestamps).toContain(iso(5)); expect(timings.cutCount).toBe(2); - expect(timings.cuts).toEqual([{ start: iso(3), end: iso(5) }]); expect(timings.cutTimestamps).toBeUndefined(); - const withCut = ( - await app.inject({ method: "GET", url: `/api/sessions/${s.token}/timings?includeCut=true` }) - ).json(); + const withCut = await getJson(`/api/sessions/${s.token}/timings?includeCut=true`); expect(withCut.cutTimestamps).toEqual([iso(3), iso(4)]); const batch = ( @@ -172,81 +255,110 @@ describe("PUT /api/sessions/:token/cuts", () => { }); it("clears edits with an empty list", async () => { - const s = await seedCompleteSession(); + const s = await seedHeldSession(); await putCuts(s.token, [{ start: iso(3), end: iso(5) }]); const { status, body } = await putCuts(s.token, []); expect(status).toBe(200); expect(body.trackedSeconds).toBe(540); - const row = await db.query.sessions.findFirst({ where: eq(schema.sessions.id, s.id) }); + const row = await load(s.id); expect(row!.cuts).toEqual([]); expect(row!.cutSeconds).toBe(0); }); it("rejects a list that removes the entire timelapse", async () => { - const s = await seedCompleteSession(); + const s = await seedHeldSession(); const { status, body } = await putCuts(s.token, [{ start: iso(0), end: iso(60) }]); expect(status).toBe(400); expect(body.error).toMatch(/entire timelapse/); }); it("rejects malformed intervals", async () => { - const s = await seedCompleteSession(); + const s = await seedHeldSession(); expect((await putCuts(s.token, [{ start: iso(5), end: iso(3) }])).status).toBe(400); expect((await putCuts(s.token, [{ start: "garbage", end: iso(3) }])).status).toBe(400); }); - it("409s while compiling and on non-editable sessions", async () => { - const compiling = await seedCompleteSession({ status: "compiling" }); - expect((await putCuts(compiling.token, [])).status).toBe(409); + it("cannot touch a published session", async () => { + const s = await seedHeldSession({ + status: "complete", + editHoldUntil: null, + videoR2Key: "timelapses/x/original.mp4", + }); + const { status, body } = await putCuts(s.token, [{ start: iso(3), end: iso(5) }]); + expect(status).toBe(409); + expect(body.error).toMatch(/published/); + expect((await load(s.id))!.cuts).toBeNull(); + }); - const legacy = await seedCompleteSession({ videoUnits: null, originalVideoR2Key: null }); - expect((await putCuts(legacy.token, [])).status).toBe(409); + it("cannot touch a session mid-publish", async () => { + const s = await seedHeldSession({ status: "compiling" }); + expect((await putCuts(s.token, [])).status).toBe(409); }); }); -describe("POST /api/sessions/:token/compile", () => { - it("is an instant no-op when there are no cuts and the original is published", async () => { - const s = await seedCompleteSession(); +describe("POST /compile (publish)", () => { + it("publishes the original instantly when there are no cuts", async () => { + const s = await seedHeldSession(); const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` }); expect(r.statusCode).toBe(200); expect(r.json()).toMatchObject({ status: "complete", instant: true }); - const row = await db.query.sessions.findFirst({ where: eq(schema.sessions.id, s.id) }); + + const row = await load(s.id); expect(row!.status).toBe("complete"); + expect(row!.videoR2Key).toBe("timelapses/x/original.mp4"); + expect(row!.editHoldUntil).toBeNull(); + // No worker round-trip, so no recompile is consumed. expect(row!.recompileCount).toBe(0); }); - it("claims complete → compiling and burns one recompile when cuts exist", async () => { - const s = await seedCompleteSession(); + it("hands off to the worker when cuts must be baked in", async () => { + const s = await seedHeldSession(); await putCuts(s.token, [{ start: iso(3), end: iso(5) }]); const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` }); expect(r.statusCode).toBe(200); expect(r.json()).toMatchObject({ status: "compiling", instant: false }); - const row = await db.query.sessions.findFirst({ where: eq(schema.sessions.id, s.id) }); + + const row = await load(s.id); expect(row!.status).toBe("compiling"); expect(row!.recompileCount).toBe(1); + // Hold cleared so the expiry job can't publish the uncut original out + // from under the pending cut-compile. + expect(row!.editHoldUntil).toBeNull(); + // Still unpublished until the worker finishes. + expect(row!.videoR2Key).toBeNull(); }); - it("enqueues an un-cut recompile when cuts were cleared but an edited video is published", async () => { - const s = await seedCompleteSession({ - videoR2Key: "timelapses/x/edited.mp4", - cuts: [], - cutSeconds: 0, + it("is idempotent against the expiry job winning the race", async () => { + const s = await seedHeldSession({ + status: "complete", + editHoldUntil: null, + videoR2Key: "timelapses/x/original.mp4", }); const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` }); expect(r.statusCode).toBe(200); - expect(r.json()).toMatchObject({ status: "compiling", instant: false }); + expect(r.json()).toMatchObject({ status: "complete", instant: true }); }); - it("202s while already compiling (idempotent client retries)", async () => { - const s = await seedCompleteSession({ status: "compiling" }); + it("202s while a publish is already running", async () => { + const s = await seedHeldSession({ status: "compiling" }); const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` }); expect(r.statusCode).toBe(202); }); - it("409s once the recompile budget is exhausted", async () => { - const s = await seedCompleteSession({ recompileCount: 5 }); + it("409s once the hold has lapsed", async () => { + const s = await seedHeldSession({ editHoldUntil: new Date(Date.now() - 1000) }); const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` }); expect(r.statusCode).toBe(409); - expect(r.json().error).toMatch(/recompiles_exhausted/); + }); +}); + +describe("held sessions in status reads", () => { + it("look unpublished, with the hold deadline attached", async () => { + const s = await seedHeldSession(); + const status = await getJson(`/api/sessions/${s.token}/status`); + expect(status.status).toBe("stopped"); + expect(status.videoUrl).toBeUndefined(); + expect(status.editable).toBe(true); + expect(status.editHoldUntil).toBeTruthy(); }); }); diff --git a/packages/shared/src/cuts.ts b/packages/shared/src/cuts.ts index 78bf8486..6393adde 100644 --- a/packages/shared/src/cuts.ts +++ b/packages/shared/src/cuts.ts @@ -27,10 +27,19 @@ export const MAX_CUT_INTERVALS = 120; * but enqueues worker jobs — bound the loop. */ export const MAX_USER_RECOMPILES = 5; -/** How long after the last cut-compile the uncut original video is retained - * for further re-edits. After this the retention job deletes the original - * of EDITED sessions (the cut content must eventually be truly gone) and - * editing freezes. Uncut sessions keep their single video forever. */ +/** + * How long a session stopped with `{edit: true}` waits, unpublished, for + * the user to edit before it auto-publishes uncut. The clock starts at + * stop. Editing happens ONLY inside this hold — never after `complete`, + * because `complete` is the signal programs act on (forwarding heartbeats, + * accepting submissions, firing the redirect hook); data must be final the + * first time they see it. + */ +export const EDIT_HOLD_MINUTES = 30; + +/** Backstop retention for uncut originals of EDITED sessions (the worker + * deletes them immediately after an edited publish; this catches crashed + * flows). Uncut sessions keep their single video forever. */ export const EDIT_WINDOW_DAYS = 7; /** How far outside [startedAt, stoppedAt] a cut interval may reach before diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 3d661044..8eb9b7e9 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -150,11 +150,18 @@ export interface UnitsResponse { units: VideoUnit[]; /** Current cut list ([] = no edits). */ cuts: CutInterval[]; - /** Whether the session can (still) be edited: original video present, - * recompile budget remaining. */ + /** Whether the session is currently editable: an edit hold is active, + * the original video is built, and recompile budget remains. Editing is + * only possible during the hold — never after `complete`. */ editable: boolean; /** Why `editable` is false (for UX copy); absent when editable. */ - editableReason?: "no_original" | "recompiles_exhausted" | "not_complete"; + editableReason?: + | "no_original" + | "recompiles_exhausted" + | "not_ready" + | "published"; + /** When the edit hold auto-publishes; null when no hold is active. */ + editHoldUntil?: string | null; /** Presigned GET URL (~1h) of the UNCUT original video — the editor's * preview source. Token-gated by this endpoint; deliberately NOT the * public media URL, which after an edit serves the cut version only. @@ -246,10 +253,21 @@ export interface ResumeResponse { serverTime?: string; } +export interface StopRequest { + /** Hold the session unpublished after compiling so the user can edit + * (cut) it before programs see `complete`. The hold auto-publishes + * after EDIT_HOLD_MINUTES if the user walks away. Only send this when + * the stopping client can render the editor. */ + edit?: boolean; +} + export interface StopResponse { status: "stopped"; trackedSeconds: number; totalActiveSeconds: number; + /** When the edit hold auto-publishes; present only when the stop + * requested `edit: true`. */ + editHoldUntil?: string; } export interface StatusResponse { @@ -263,8 +281,15 @@ export interface StatusResponse { /** Redirect hook URL — clients watching the compile open this when the * status flips to "complete". Absent when the session has none. */ redirectUrl?: string; - /** Whether the compiled timelapse can (still) be edited. */ + /** Whether the session is editable RIGHT NOW: an edit hold is active and + * its preview video has finished building. Only ever true while + * `stopped` — never after `complete`, which is the point at which + * programs consume the session's data. */ editable?: boolean; + /** When the edit hold auto-publishes the session (uncut). Absent when no + * hold is active. While this is set and `editable` is false, the + * preview is still compiling — show "preparing", not "done". */ + editHoldUntil?: string; } export interface VideoResponse { diff --git a/packages/worker/src/compile.ts b/packages/worker/src/compile.ts index c45b1617..857d1b4b 100644 --- a/packages/worker/src/compile.ts +++ b/packages/worker/src/compile.ts @@ -465,10 +465,10 @@ export async function compileTimelapse(sessionId: string): Promise<{ // Both assembly paths land on the pinned 1s closed-GOP grid. const videoCopyAligned = true; - // Step 4.25: apply cuts, if any. First compiles never have cuts (they're - // only settable on complete sessions) — this branch covers recompiles of - // sessions that lost their original (e.g. internal recompile of a failed - // edited compile). + // Step 4.25: apply cuts, if any. A first compile normally has none — + // cuts are authored during the edit hold, after this build hands the + // user a preview. This branch covers a re-run that lost its original + // (e.g. an internal recompile of a failed edited compile). const unitTimesMs = videoUnits.map((u) => Date.parse(u.capturedAt)); const keptRanges = computeKeptRanges(unitTimesMs, cuts); const hasEffectiveCuts = @@ -534,7 +534,15 @@ export async function compileTimelapse(sessionId: string): Promise<{ ) : 0; - // Step 6: Mark complete + // Step 6: Publish — or hold. + // + // A session stopped with `{edit: true}` must NOT reach `complete` yet: + // that status is what programs act on (forwarding heartbeats, accepting + // submissions, firing the redirect hook), so it may only appear once + // the user's cuts are baked in. Such a session goes back to `stopped` + // with everything built but `video_r2_key` still null — the editor + // opens on the original, and either the user's publish call or the + // hold-expiry job flips it to `complete`. const thumbnailUrl = R2_PUBLIC_DOMAIN ? `https://${R2_PUBLIC_DOMAIN}/${thumbnailR2Key}` : thumbnailR2Key; @@ -543,12 +551,22 @@ export async function compileTimelapse(sessionId: string): Promise<{ ? `https://${R2_PUBLIC_DOMAIN}/${publishR2Key}` : publishR2Key; + // Re-read the hold: the user may have let it lapse (or the expiry job + // may have cleared it) during the minutes this build was running. + const [current] = await db + .select({ editHoldUntil: schema.sessions.editHoldUntil }) + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)); + const holdActive = + current?.editHoldUntil != null && + current.editHoldUntil.getTime() > Date.now(); + await db .update(schema.sessions) .set({ - status: "complete", - videoUrl, - videoR2Key: publishR2Key, + status: holdActive ? "stopped" : "complete", + videoUrl: holdActive ? null : videoUrl, + videoR2Key: holdActive ? null : publishR2Key, originalVideoR2Key: originalR2Key, videoUnits, videoCopyAligned, @@ -560,6 +578,12 @@ export async function compileTimelapse(sessionId: string): Promise<{ }) .where(eq(schema.sessions.id, sessionId)); + if (holdActive) { + console.log( + `Session ${sessionId} built and held for editing until ${current!.editHoldUntil!.toISOString()}`, + ); + } + // Step 7: Cleanup unsampled screenshots from R2 const unsampled = await db .select({ r2Key: schema.screenshots.r2Key, id: schema.screenshots.id }) @@ -599,10 +623,16 @@ export async function compileTimelapse(sessionId: string): Promise<{ } /** - * Half B of the compile job: the original video and its unit map already - * exist, so apply (or clear) the session's cut list against it. Downloads a - * single MP4, cuts it (usually a lossless stream copy), regenerates the - * thumbnail, and republishes — no capture units involved. + * Half B of the compile job: bake the user's cuts into the already-built + * original and PUBLISH the session. Downloads a single MP4, cuts it + * (usually a lossless stream copy), regenerates the thumbnail, and flips + * the session `complete` — no capture units involved. + * + * This is the end of the edit hold, so the uncut original is deleted right + * after: the cut minutes are gone the moment the timelapse goes out, not + * seven days later. Recovering an edited session afterwards (admin + * recompile) falls back to Half A, which rebuilds from capture units and + * applies the same cut list. */ async function applyCutCompile( session: typeof schema.sessions.$inferSelect, @@ -701,9 +731,12 @@ async function applyCutCompile( videoUrl, videoR2Key: publishR2Key, cutSeconds, - // Cleared cuts stay [] (the user's choice); the window anchor still - // advances so the retention job eventually reclaims the original of a - // session that was ever edited. + // The hold ends here — the session is published and its numbers are + // final for every program reading them. + editHoldUntil: null, + // Cut content must not outlive the publish: once the edited video is + // out, the uncut original is deleted below and its key cleared. + ...(hasEffectiveCuts ? { originalVideoR2Key: null } : {}), lastEditCompileAt: new Date(), thumbnailUrl, thumbnailR2Key, @@ -711,5 +744,12 @@ async function applyCutCompile( }) .where(eq(schema.sessions.id, sessionId)); + // Delete the uncut original only AFTER the edited video is published and + // the row committed — if this ordering flipped, a crash in between would + // leave a session pointing at bytes that no longer exist. + if (hasEffectiveCuts) { + await deleteObjectQuiet(originalR2Key); + } + return { videoUrl, videoR2Key: publishR2Key, thumbnailUrl, thumbnailR2Key }; } diff --git a/packages/worker/src/schema.ts b/packages/worker/src/schema.ts index ab7f470e..8ff7c738 100644 --- a/packages/worker/src/schema.ts +++ b/packages/worker/src/schema.ts @@ -59,6 +59,7 @@ export const sessions = pgTable( lastEditCompileAt: timestamp("last_edit_compile_at", { withTimezone: true, }), + editHoldUntil: timestamp("edit_hold_until", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), From d7dfdc7d8694a6b42b4b5b3954eecbb4d086fa5d Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:50:54 +0800 Subject: [PATCH 22/65] fix: editor opened into a dead end while the preview was still compiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking 'Edit & save' failed immediately with 'this timelapse isn't available for editing'. The worker claims the compile within a second of the stop, flipping the session to 'compiling' — and sessionEditability only tolerated 'stopped', so the state the editor almost always opens into was reported as terminal. - server: 'compiling' with a live hold is now 'preparing' (a wait, not a failure), a failed compile reports 'failed' rather than a generic not-ready, and /units returns expectedUnits so clients can size a progress estimate - server: POST /compile during 'preparing' drops the hold and returns 200 instead of 409 — 'publish as recorded' shouldn't make the user wait for a preview they just declined; the in-flight build publishes when it lands - react SDK: new ProgressRing primitive; the editor and the SessionDetail review panel poll through 'preparing' behind a live ring sized from the session's capture count. It's a time estimate, so it eases toward but never reaches 100% — only the video actually landing completes it - SessionDetail no longer treats a failed compile as a hold to wait on - regression tests cover the compiling-with-hold state, the failed state, and publishing mid-compile --- clients/desktop/src-tauri/src/lib.rs | 219 ++++++++++++++---- clients/desktop/src-tauri/src/tray.rs | 18 +- .../src/components/DesktopRecorder.tsx | 88 +++++-- clients/desktop/src/components/TrayApp.tsx | 54 +++-- clients/desktop/src/hooks/useNativeCapture.ts | 11 +- clients/react/API.md | 33 ++- .../react/src/components/SessionDetail.tsx | 76 ++++-- .../react/src/components/TimelapseEditor.tsx | 92 ++++++-- .../react/src/hooks/useSessionTimer.test.ts | 105 ++++++++- clients/react/src/hooks/useSessionTimer.ts | 90 ++++++- clients/react/src/index.ts | 12 +- clients/react/src/ui/ProgressRing.tsx | 80 +++++++ clients/react/src/ui/index.ts | 2 + docs/edit-feature-plan.md | 14 +- packages/server/API.md | 17 +- packages/server/src/routes/sessions.ts | 44 +++- .../server/test/edits.integration.test.ts | 45 +++- packages/shared/src/types.ts | 9 +- 18 files changed, 858 insertions(+), 151 deletions(-) create mode 100644 clients/react/src/ui/ProgressRing.tsx diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index edd1f205..48191ba4 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -107,10 +107,20 @@ struct CaptureLoopHandle { /// Shared state for the Rust-side tray title timer. /// Uses atomics so the capture loop can update tracked_seconds /// without acquiring a mutex on every tick. +/// +/// This mirrors `useSessionTimerState` in @lookout/react. The menu bar, +/// the tray popup and the main window each tick their own clock (so a +/// throttled WebView can't stall the menu bar), which only works if all +/// three apply the *same* rules to the same anchor: ratchet the base +/// forward, cap interpolation at one capture interval, and drop the +/// interpolated remainder while paused. Diverge on any of those and the +/// menu bar visibly disagrees with the main window. struct TrayTimerState { /// Authoritative tracked seconds from the last server response. + /// Ratchets forward only — see `sync_tray_timer`. tracked_seconds: AtomicI64, - /// Wall-clock instant when tracking started (or last synced). + /// Wall-clock instant `tracked_seconds` last advanced (the + /// interpolation anchor). started_at: Mutex, /// Whether the timer is actively ticking (false = paused). is_running: AtomicBool, @@ -2085,6 +2095,25 @@ const SLEEP_THRESHOLD_SECS: u64 = CAPTURE_INTERVAL_SECS * 2 + 30; // 150s /// and the JPEG preview side is only produced while the window is focused. const DEFAULT_FRAME_INTERVAL_MS: u64 = 4_000; +/// Max seconds the menu-bar time may run ahead of the last server-credited +/// `tracked_seconds`. Must equal `MAX_INTERPOLATION_S` in +/// @lookout/react's useSessionTimer — one capture interval. Without the cap +/// the menu bar kept counting through a capture stall while the main window +/// froze at base + 60, and the two never reconverged. +const MAX_TRAY_INTERPOLATION_SECS: i64 = CAPTURE_INTERVAL_SECS as i64; + +/// The Rust mirror of `deriveDisplaySeconds` in @lookout/react. Keep the two +/// in step: the menu bar and the main window each tick their own clock, so any +/// difference here is directly visible as the two showing different times. +fn tray_display_seconds(base_seconds: i64, elapsed_secs: i64, running: bool) -> i64 { + if !running { + // Paused drops the interpolated remainder rather than freezing it, + // matching the main window's snap-down. + return base_seconds; + } + base_seconds + elapsed_secs.clamp(0, MAX_TRAY_INTERPOLATION_SECS) +} + /// Format seconds into the same tray title format as the JS side: /// >0h: "{h}h {m}m", else: "{m}m" fn format_tray_time(total_seconds: i64) -> String { @@ -2127,40 +2156,54 @@ async fn tray_timer_task( } } - if !timer_state.is_running.load(Ordering::Relaxed) { - was_paused = true; - continue; - } - let base_seconds = timer_state.tracked_seconds.load(Ordering::Relaxed); + let running = timer_state.is_running.load(Ordering::Relaxed); + let elapsed = { let started = timer_state.started_at.lock().unwrap(); started.elapsed().as_secs() as i64 }; - let display_seconds = base_seconds + elapsed; + let display_seconds = tray_display_seconds(base_seconds, elapsed, running); let time_text = format_tray_time(display_seconds); - if was_paused || last_title.as_deref() != Some(time_text.as_str()) { - #[cfg(target_os = "macos")] - { - let _ = &app; - // None = keep the current pause state; the Swift side - // renders the tooltip and the numericText digit roll. - let _ = crate::native_tray::update(&time_text, None); - } - #[cfg(not(target_os = "macos"))] - if let Some(tray) = app.tray_by_id("timelapse_tray") { - let _ = tray.set_title(Some(&time_text)); - // Windows doesn't render tray titles — the hover tooltip is - // the only way to see the recorded time there. - let _ = tray.set_tooltip(Some(format!("Lookout — {time_text} recorded"))); + + if !running { + // Write the frozen value once (a pause snaps the title down by + // the dropped remainder), then idle until resume. + if last_title.as_deref() != Some(time_text.as_str()) { + set_tray_title(&app, &time_text); + last_title = Some(time_text); } + was_paused = true; + continue; + } + + if was_paused || last_title.as_deref() != Some(time_text.as_str()) { + set_tray_title(&app, &time_text); last_title = Some(time_text); } was_paused = false; } } +/// Write the menu-bar time text (and, off macOS, the hover tooltip). +fn set_tray_title(app: &AppHandle, time_text: &str) { + #[cfg(target_os = "macos")] + { + let _ = app; + // None = keep the current pause state; the Swift side renders the + // tooltip and the numericText digit roll. + let _ = crate::native_tray::update(time_text, None); + } + #[cfg(not(target_os = "macos"))] + if let Some(tray) = app.tray_by_id("timelapse_tray") { + let _ = tray.set_title(Some(time_text)); + // Windows doesn't render tray titles — the hover tooltip is the + // only way to see the recorded time there. + let _ = tray.set_tooltip(Some(format!("Lookout — {time_text} recorded"))); + } +} + /// Start the tray timer (if not already running). Returns the shared state /// so the capture loop can sync `tracked_seconds` into it. fn start_tray_timer(app: &AppHandle, state: &AppState) -> Arc { @@ -2217,21 +2260,37 @@ fn stop_tray_timer(state: &AppState) { } } +/// Ratchet `tracked_seconds` to a new authoritative value, re-anchoring the +/// elapsed counter **only if the value actually advanced**. +/// +/// Both halves matter for staying in step with the main window: +/// - Ratchet: an idempotent retry can confirm against a stale read and +/// return a *lower* `trackedSeconds`. JS keeps the higher value, so +/// storing the lower one here made the menu bar jump backwards and sit +/// a minute behind until the next credit. +/// - Anchor only on advance: a repeated reading must not restart the +/// interpolation window, or the menu bar loses time the main window keeps. +fn ratchet_tray_tracked_seconds(timer_state: &TrayTimerState, tracked_seconds: i64) { + let prev = timer_state + .tracked_seconds + .fetch_max(tracked_seconds, Ordering::Relaxed); + if tracked_seconds > prev { + let mut started = timer_state.started_at.lock().unwrap(); + *started = StdInstant::now(); + } +} + /// Sync the tray timer to a new authoritative tracked_seconds value -/// (typically from a capture result). Resets the elapsed counter. +/// (typically from a capture result). fn sync_tray_timer(state: &AppState, tracked_seconds: i64) { let guard = state.tray_timer.lock().unwrap(); if let Some(ref handle) = *guard { - handle - .state - .tracked_seconds - .store(tracked_seconds, Ordering::Relaxed); - let mut started = handle.state.started_at.lock().unwrap(); - *started = StdInstant::now(); + ratchet_tray_tracked_seconds(&handle.state, tracked_seconds); } } -/// Pause the tray timer (freeze the displayed time). +/// Pause the tray timer. The next tick drops the interpolated remainder and +/// shows the bare `tracked_seconds`, matching the main window's snap-down. fn pause_tray_timer(state: &AppState) { let guard = state.tray_timer.lock().unwrap(); if let Some(ref handle) = *guard { @@ -2239,7 +2298,7 @@ fn pause_tray_timer(state: &AppState) { } } -/// Resume the tray timer. Resets the elapsed counter so it continues +/// Resume the tray timer. Re-anchors the elapsed counter so it continues /// from the current tracked_seconds. fn resume_tray_timer(state: &AppState) { let guard = state.tray_timer.lock().unwrap(); @@ -3087,9 +3146,14 @@ async fn start_tray_ticker( app: AppHandle, ) -> Result<(), String> { let timer_state = start_tray_timer(&app, &state); - timer_state.tracked_seconds.store(tracked_seconds, Ordering::Relaxed); - let mut started = timer_state.started_at.lock().unwrap(); - *started = StdInstant::now(); + // Ratchet, don't store: `start_tray_timer` returns the *existing* state + // if a session is already being tracked, and a re-entrant call with a + // stale (or zero) baseline would knock the menu bar backwards. + ratchet_tray_tracked_seconds(&timer_state, tracked_seconds); + { + let mut started = timer_state.started_at.lock().unwrap(); + *started = StdInstant::now(); + } timer_state.is_running.store(true, Ordering::Relaxed); Ok(()) } @@ -3107,12 +3171,7 @@ fn resume_tray_ticker( tracked_seconds: i64, state: State<'_, AppState>, ) -> Result<(), String> { - { - let guard = state.tray_timer.lock().unwrap(); - if let Some(ref handle) = *guard { - handle.state.tracked_seconds.store(tracked_seconds, Ordering::Relaxed); - } - } + sync_tray_timer(&state, tracked_seconds); resume_tray_timer(&state); Ok(()) } @@ -3568,6 +3627,88 @@ pub fn run() { // compat guarantee: an unupgraded user's binary in the wild keeps working. // ────────────────────────────────────────────────────────────────── +/// The menu-bar clock must agree with the main window's clock. Both tick +/// independently, so they only stay together if these rules match +/// `deriveDisplaySeconds` / `useSessionTimerState` in @lookout/react. +#[cfg(test)] +mod tray_timer_tests { + use super::{ + format_tray_time, ratchet_tray_tracked_seconds, tray_display_seconds, TrayTimerState, + MAX_TRAY_INTERPOLATION_SECS, + }; + use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; + use std::sync::Mutex; + use std::time::Instant; + + fn state(tracked: i64) -> TrayTimerState { + TrayTimerState { + tracked_seconds: AtomicI64::new(tracked), + started_at: Mutex::new(Instant::now()), + is_running: AtomicBool::new(true), + } + } + + #[test] + fn cap_matches_the_js_side() { + // MAX_INTERPOLATION_S in useSessionTimer.ts is + // SCREENSHOT_INTERVAL_MS / 1000 = 60. + assert_eq!(MAX_TRAY_INTERPOLATION_SECS, 60); + } + + #[test] + fn interpolates_at_wall_clock_rate() { + assert_eq!(tray_display_seconds(120, 0, true), 120); + assert_eq!(tray_display_seconds(120, 30, true), 150); + } + + #[test] + fn interpolation_is_capped_at_one_interval() { + // Without the cap the menu bar kept counting through a capture stall + // while the main window froze at base + 60, and the two never + // reconverged — the reported "menu bar shows a different time". + assert_eq!(tray_display_seconds(120, 90, true), 180); + assert_eq!(tray_display_seconds(120, 600, true), 180); + } + + #[test] + fn pause_drops_the_interpolated_remainder() { + // The main window snaps down to the base on pause. Freezing at the + // interpolated value here left the menu bar up to a minute ahead for + // the whole pause. + assert_eq!(tray_display_seconds(120, 45, false), 120); + assert_eq!(format_tray_time(tray_display_seconds(299, 59, false)), "4m"); + } + + #[test] + fn ratchet_ignores_a_stale_lower_reading() { + // An idempotent retry can confirm against a stale read and return a + // lower trackedSeconds. JS keeps the higher value; storing the lower + // one here made the menu bar jump backwards and sit behind. + let s = state(120); + ratchet_tray_tracked_seconds(&s, 60); + assert_eq!(s.tracked_seconds.load(Ordering::Relaxed), 120); + ratchet_tray_tracked_seconds(&s, 180); + assert_eq!(s.tracked_seconds.load(Ordering::Relaxed), 180); + } + + #[test] + fn ratchet_re_anchors_only_on_advance() { + let s = state(120); + let before = *s.started_at.lock().unwrap(); + + // A repeated reading must not restart the interpolation window, or + // the menu bar loses time the main window is still counting. + ratchet_tray_tracked_seconds(&s, 120); + assert_eq!(*s.started_at.lock().unwrap(), before); + ratchet_tray_tracked_seconds(&s, 60); + assert_eq!(*s.started_at.lock().unwrap(), before); + + // A real advance re-anchors. + ratchet_tray_tracked_seconds(&s, 180); + assert!(*s.started_at.lock().unwrap() > before); + } +} + #[cfg(test)] mod compat_tests { use super::{ diff --git a/clients/desktop/src-tauri/src/tray.rs b/clients/desktop/src-tauri/src/tray.rs index 3e7f3297..54877c31 100644 --- a/clients/desktop/src-tauri/src/tray.rs +++ b/clients/desktop/src-tauri/src/tray.rs @@ -6,13 +6,23 @@ use tauri::image::Image; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder}; +/// State handed to the tray popup window, which ticks its own clock so it +/// stays live while the main WebView is throttled. +/// +/// It carries the interpolation *anchor*, not a display value: the popup +/// re-derives the ticking time with the same rules as the main window +/// (see `useSessionTimerState` in @lookout/react). Passing the main +/// window's already-interpolated `displaySeconds` here meant the popup +/// extrapolated on top of an extrapolation and drifted ahead of it. #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrayState { - pub display_seconds: u32, + /// Ratcheted server-authoritative tracked seconds. + pub base_seconds: u32, pub screenshot_count: u32, pub control_mode: String, - pub updated_at: u64, + /// `Date.now()` (ms) when `base_seconds` last advanced. + pub anchor_at: u64, } impl Default for TrayState { @@ -23,10 +33,10 @@ impl Default for TrayState { .unwrap() .as_millis() as u64; Self { - display_seconds: 0, + base_seconds: 0, screenshot_count: 0, control_mode: "recording".to_string(), - updated_at: now, + anchor_at: now, } } } diff --git a/clients/desktop/src/components/DesktopRecorder.tsx b/clients/desktop/src/components/DesktopRecorder.tsx index fb5f5fc8..6f3d8739 100644 --- a/clients/desktop/src/components/DesktopRecorder.tsx +++ b/clients/desktop/src/components/DesktopRecorder.tsx @@ -3,7 +3,8 @@ import { invoke } from "../logger.js"; import { listen, emit } from "@tauri-apps/api/event"; import { useSession, - useSessionTimer, + useSessionTimerState, + computeBestTrackedSeconds, formatTime, Button, ErrorDisplay, @@ -129,6 +130,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource source, isCamera ? cameraFrameCapture : undefined, handleSessionTerminated, + session.trackedSeconds, ); // Native OS notifications: alert the user when a session pauses, errors, @@ -164,10 +166,23 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource }; }, [capture.isCapturing, capture.lastCaptureAt]); - const displaySeconds = useSessionTimer( - capture.trackedSeconds || session.trackedSeconds, - capture.isCapturing, - ); + // The single authoritative baseline for every surface that shows the time + // (main window, menu-bar title, tray popup). Both inputs are server-derived; + // `max` rather than `||` so a capture-local value that hasn't caught up with + // the session poll yet can't drag the baseline down. + const bestTrackedSeconds = computeBestTrackedSeconds({ + sessionTrackedSeconds: session.trackedSeconds, + uploaderTrackedSeconds: capture.trackedSeconds, + }); + + const timer = useSessionTimerState(bestTrackedSeconds, capture.isCapturing); + const displaySeconds = timer.displaySeconds; + + // Read the baseline from a ref inside async handlers: `session.resume()` + // awaits a round trip that itself refreshes `session.trackedSeconds`, so the + // value captured at render time is stale by the time we seed the ticker. + const bestTrackedRef = useRef(bestTrackedSeconds); + bestTrackedRef.current = bestTrackedSeconds; const [pauseLoading, setPauseLoading] = useState(false); const [resumeLoading, setResumeLoading] = useState(false); @@ -218,7 +233,11 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource await startCameraAndWait(); // Camera sources use JS-side capture loop, so we need to // explicitly start the Rust tray ticker for the menu bar time. - invoke("start_tray_ticker", { trackedSeconds: 0 }).catch(console.error); + // Seed it with the session's existing time — hardcoding 0 made the + // menu bar restart from "0m" when recording an already-started session. + invoke("start_tray_ticker", { + trackedSeconds: session.trackedSeconds, + }).catch(console.error); } capture.startCapturing(); })(); @@ -308,7 +327,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource console.log(`[session] restarting camera stream for device ${cameraDeviceId}`); await startCameraAndWait(); } - invoke("resume_tray_ticker", { trackedSeconds: capture.trackedSeconds }).catch(console.error); + invoke("resume_tray_ticker", { trackedSeconds: bestTrackedRef.current }).catch(console.error); await capture.startCapturing(); console.log("[session] resumed"); setResumeLoading(false); @@ -333,7 +352,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource if (isCamera) { await startCameraAndWait(); } - invoke("resume_tray_ticker", { trackedSeconds: capture.trackedSeconds }).catch(console.error); + invoke("resume_tray_ticker", { trackedSeconds: bestTrackedRef.current }).catch(console.error); await capture.startCapturing(); setResumeLoading(false); }, [capture, session, isCamera, cameraDeviceId, camera, startCameraAndWait]); @@ -357,11 +376,27 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource const screenshotCount = session.screenshotCount + capture.screenshotCount; - // Keep a ref of the latest state. `updatedAt` matters: the tray window - // extrapolates the ticking clock from it, so omitting it (as the tray-ready - // fallback used to) made the tray compute NaN until the next sync. - const trayStateRef = useRef({ displaySeconds, screenshotCount, controlMode, updatedAt: Date.now() }); - trayStateRef.current = { displaySeconds, screenshotCount, controlMode, updatedAt: Date.now() }; + // Keep a ref of the latest state for the tray popup. + // + // This carries the interpolation ANCHOR (`baseSeconds` + `anchorAt`), never + // `displaySeconds`. The popup ticks its own clock, so handing it an + // already-interpolated value made it extrapolate on top of an + // extrapolation — it drifted ahead of the main window and never came back. + // `anchorAt` must likewise be when the base last advanced, not `Date.now()` + // at push time: re-stamping it on every push restarted the popup's + // interpolation window and lost the seconds the main window kept. + const trayStateRef = useRef({ + baseSeconds: timer.baseSeconds, + screenshotCount, + controlMode, + anchorAt: timer.anchorAt, + }); + trayStateRef.current = { + baseSeconds: timer.baseSeconds, + screenshotCount, + controlMode, + anchorAt: timer.anchorAt, + }; // Listen for tray requesting initial state (fallback) useEffect(() => { @@ -402,25 +437,32 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource // Tray-window state sync — event-driven, NOT per-second. // - // The tray window ticks its own clock by extrapolating from `updatedAt`, - // and the Rust ticker owns the menu-bar title. So the only things worth - // pushing over IPC are the ones the tray can't derive locally: screenshot - // count changes, pause/resume, and server time corrections. Syncing every + // The tray window ticks its own clock from the anchor we push, and the Rust + // ticker owns the menu-bar title. So the only things worth pushing over IPC + // are the ones the tray can't derive locally: screenshot count changes, + // pause/resume, and a new anchor when the server credits time. Syncing every // second (3 IPC calls + a broadcast event) was pure overhead that also // drowned the debug log in [ipc] noise. useEffect(() => { const state = trayStateRef.current; invoke("set_tray_state", { state }).catch(console.error); emit("tray-state", state).catch(console.error); - }, [screenshotCount, controlMode, capture.trackedSeconds]); + }, [screenshotCount, controlMode, timer.baseSeconds, timer.anchorAt]); - // Sync tracked seconds to the Rust tray timer when the server corrects it - // (arrives with each confirmed capture, ~once a minute). + // Sync the baseline to the Rust tray timer whenever it advances. Rust + // ratchets and re-anchors on its own, so pushing the same value twice is a + // no-op there. + // + // Uses `bestTrackedSeconds`, not `capture.trackedSeconds`: the latter is + // hook-local state that starts at 0, so resuming a session that already had + // recorded time (app reopened on a paused session) seeded the menu bar with + // 0 and it showed "0m" next to a main window reading 25:00 until the first + // confirm landed. useEffect(() => { - if (capture.trackedSeconds > 0) { - invoke("sync_tray_tracked_seconds", { trackedSeconds: capture.trackedSeconds }).catch(console.error); + if (bestTrackedSeconds > 0) { + invoke("sync_tray_tracked_seconds", { trackedSeconds: bestTrackedSeconds }).catch(console.error); } - }, [capture.trackedSeconds]); + }, [bestTrackedSeconds]); // Hide tray on unmount or session end useEffect(() => { diff --git a/clients/desktop/src/components/TrayApp.tsx b/clients/desktop/src/components/TrayApp.tsx index 766cfe11..5c670ff4 100644 --- a/clients/desktop/src/components/TrayApp.tsx +++ b/clients/desktop/src/components/TrayApp.tsx @@ -2,9 +2,34 @@ import { useState, useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; import { invoke } from "../logger.js"; import { isGlassSupported, setLiquidGlassEffect, GlassMaterialVariant } from "tauri-plugin-liquid-glass-api"; -import { Button, colors, spacing, fontSize, fontWeight } from "@lookout/react"; +import { Button, colors, spacing, fontSize, fontWeight, deriveDisplaySeconds } from "@lookout/react"; import NumberFlow from "@number-flow/react"; +interface TrayState { + /** Ratcheted server-authoritative tracked seconds (the anchor value). */ + baseSeconds: number; + screenshotCount: number; + controlMode: "recording" | "paused"; + /** `Date.now()` when `baseSeconds` last advanced. */ + anchorAt: number; +} + +/** + * Re-derive the ticking clock from the anchor the main window pushed, using + * the *same* shared function the main window's own timer uses. Rolling a + * local version of this is how the popup ended up showing a different time + * from the main app: it extrapolated without a cap, from an already + * interpolated value. + */ +function deriveLiveSeconds(state: TrayState, now: number): number { + return deriveDisplaySeconds( + state.baseSeconds, + state.anchorAt, + state.controlMode === "recording", + now, + ); +} + function TrayTimer({ totalSeconds }: { totalSeconds: number }) { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); @@ -31,11 +56,11 @@ function TrayTimer({ totalSeconds }: { totalSeconds: number }) { } export function TrayApp() { - const [state, setState] = useState({ - displaySeconds: 0, + const [state, setState] = useState({ + baseSeconds: 0, screenshotCount: 0, - controlMode: "recording" as "recording" | "paused", - updatedAt: Date.now(), + controlMode: "recording", + anchorAt: Date.now(), }); const [liveSeconds, setLiveSeconds] = useState(0); @@ -91,20 +116,13 @@ export function TrayApp() { // Derive the live real-time seconds ticking up useEffect(() => { - // Immediate sync - let current = state.displaySeconds; - if (state.controlMode === "recording") { - const elapsed = Math.floor((Date.now() - state.updatedAt) / 1000); - current += Math.max(0, elapsed); - } - setLiveSeconds(current); + setLiveSeconds(deriveLiveSeconds(state, Date.now())); if (state.controlMode !== "recording") return; // Tick locally so we don't depend on the asleep main window const interval = setInterval(() => { - const elapsed = Math.floor((Date.now() - state.updatedAt) / 1000); - setLiveSeconds(state.displaySeconds + Math.max(0, elapsed)); + setLiveSeconds(deriveLiveSeconds(state, Date.now())); }, 1000); return () => clearInterval(interval); @@ -116,7 +134,7 @@ export function TrayApp() { const syncState = async () => { try { - const backendState = await invoke("get_tray_state"); + const backendState = await invoke("get_tray_state"); setState(backendState); } catch (e) { console.error("Failed to sync tray state from backend", e); @@ -125,12 +143,12 @@ export function TrayApp() { const setup = async () => { // Listen for regular state updates - unlistenState = await listen("tray-state", (event) => { + unlistenState = await listen("tray-state", (event) => { setState(event.payload); }); - + // When the tray window is opened, explicitly request the latest state - unlistenOpened = await listen("tray-opened", syncState); + unlistenOpened = await listen("tray-opened", syncState); // Request initial state on first mount syncState(); diff --git a/clients/desktop/src/hooks/useNativeCapture.ts b/clients/desktop/src/hooks/useNativeCapture.ts index 2e1b9664..e237e38b 100644 --- a/clients/desktop/src/hooks/useNativeCapture.ts +++ b/clients/desktop/src/hooks/useNativeCapture.ts @@ -55,6 +55,11 @@ export function useNativeCapture( cameraCapture?: CameraFrameCapture, /** Called when the capture loop discovers the server moved the session to a terminal state. */ onSessionTerminated?: (status: string) => void, + /** The session's server-known tracked seconds, used to seed a freshly + * started Rust tray timer. Needed because `trackedSeconds` below is + * hook-local state that starts at 0 — on a session that already has + * recorded time it can't seed anything until the first confirm lands. */ + sessionTrackedSeconds = 0, ) { const [isCapturing, setIsCapturing] = useState(false); const [trackedSeconds, setTrackedSeconds] = useState(0); @@ -71,9 +76,11 @@ export function useNativeCapture( // check if the user intentionally stopped (avoids auto-resume race). const capturingRef = useRef(false); - // Latest tracked seconds, for seeding a freshly started Rust tray timer. + // Best known tracked seconds, for seeding a freshly started Rust tray timer. + // Both inputs are server-derived; take the higher one so neither a + // not-yet-confirmed capture nor a lagging session poll seeds a low baseline. const trackedSecondsRef = useRef(0); - trackedSecondsRef.current = trackedSeconds; + trackedSecondsRef.current = Math.max(trackedSeconds, sessionTrackedSeconds); // Track blob URL for cleanup const blobUrlRef = useRef(null); diff --git a/clients/react/API.md b/clients/react/API.md index cfe31c53..bbcc3333 100644 --- a/clients/react/API.md +++ b/clients/react/API.md @@ -367,6 +367,33 @@ const displaySeconds = useSessionTimer(trackedSeconds, isActive); --- +### `useSessionTimerState(serverTrackedSeconds, isActive)` + +Same timer, but returns the interpolation **anchor** alongside the display value. Use this when another surface has to tick its own copy of the clock — the desktop app's menu-bar title (Rust) and tray popup window both do, so they stay live while the main WebView is throttled. + +**Returns:** `SessionTimerState` + +| Field | Type | Description | +|-------|------|-------------| +| `displaySeconds` | `number` | What to render | +| `baseSeconds` | `number` | The ratcheted server-authoritative value the display is anchored to | +| `anchorAt` | `number` | `Date.now()` when `baseSeconds` last advanced | + +### `deriveDisplaySeconds(baseSeconds, anchorAt, isActive, now)` + +The pure function behind the hook, exported so independently-ticking surfaces derive the clock identically instead of reimplementing it: + +```ts +const seconds = deriveDisplaySeconds(baseSeconds, anchorAt, isRecording, Date.now()); +``` + +Any surface ticking its own clock must go through this (or mirror it exactly — see `tray_display_seconds` in the desktop crate). Two rules are easy to get wrong and both produce a visibly wrong clock: + +- **Interpolate from `baseSeconds`, never from `displaySeconds`.** The latter already contains the interpolated remainder, so extrapolating from it double-counts and the surface drifts ahead of the main window. +- **Pass through `anchorAt` unchanged.** It marks when the base last advanced; re-stamping it to "now" on each push restarts the interpolation window and loses time the main window is still counting. + +--- + ### `useTokenStore()` Manages session tokens in `localStorage` with cross-tab sync. No provider required. @@ -764,7 +791,10 @@ baked in (a lossless server-side stream copy) or without them. Standalone | `onApplied` | `() => void?` | The timelapse was published — return to your detail view and poll `/status` | | `onCancel` | `() => void?` | Optional "not now". The session stays held and publishes itself when the hold expires, so nothing is lost. Omit where publishing should be an explicit choice | -It polls while the preview is still compiling, and shows a countdown to the +The editor normally opens **before** the preview video exists — the compile +starts at stop and takes tens of seconds — so it polls through that state +and shows a `` sized from the session's capture count, then +swaps to the timeline when the video lands. It also counts down to the hold's auto-publish (getting louder in the last two minutes). **Interactions:** @@ -876,6 +906,7 @@ The SDK exports styled UI primitives used by its components. All use inline styl |--------|-------------| | `Button` | Styled button with variants: `primary`, `secondary`, `success`, `warning`, `danger`, `ghost` and sizes: `sm`, `md`, `lg` | | `Spinner` | Loading spinner with sizes: `sm`, `md`, `lg` | +| `ProgressRing` | Determinate circular progress (`progress` 0–1, optional centre `label`) — for waits long enough that a spinner under-informs | | `Badge` | Status badge with variants: `default`, `overlay` | | `Card` | Styled card container | | `ErrorDisplay` | Error message display with variants: `inline`, `banner`, `page` | diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx index ee538be5..7cbec1a8 100644 --- a/clients/react/src/components/SessionDetail.tsx +++ b/clients/react/src/components/SessionDetail.tsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "motion/react"; import type { StatusResponse, VideoResponse, SessionResponse } from "@lookout/shared"; import { formatTrackedTime } from "../hooks/useSessionTimer.js"; import { Button } from "../ui/Button.js"; +import { ProgressRing } from "../ui/ProgressRing.js"; import { ErrorDisplay } from "../ui/ErrorDisplay.js"; import { ProcessingState } from "./ProcessingState.js"; import { TimelapseEditor } from "./TimelapseEditor.js"; @@ -43,34 +44,54 @@ function HoldPanel({ return () => clearInterval(id); }, [holdUntil]); + // Same asymptotic estimate the editor uses (the worker reports no real + // progress); it eases toward 100% and the `editable` flip is what + // actually ends it. + const [buildProgress, setBuildProgress] = useState(0); + useEffect(() => { + if (editable) return; + const startedAt = Date.now(); + const tick = () => + setBuildProgress(1 - Math.exp(-2.2 * ((Date.now() - startedAt) / 30_000))); + tick(); + const id = setInterval(tick, 250); + return () => clearInterval(id); + }, [editable]); + const minutesLeft = Math.max(1, Math.round(secondsLeft / 60)); return ( -
- {editable ? "Ready to review" : "Preparing your timelapse…"} -
-
- {editable - ? "Cut out anything you don't want to share, then save. Nothing is published until you do." - : "Your recording is compiling. You'll be able to trim it in a moment."} - {" "} - {secondsLeft > 0 && ( - - {secondsLeft < 120 - ? `Publishing automatically in ${secondsLeft}s.` - : `Publishes automatically in ${minutesLeft} min.`} - +
+ {!editable && ( + )} +
+
+ {editable ? "Ready to review" : "Preparing your timelapse…"} +
+
+ {editable + ? "Cut out anything you don't want to share, then save. Nothing is published until you do." + : "Your recording is compiling. You'll be able to trim it in a moment."} + {" "} + {secondsLeft > 0 && ( + + {secondsLeft < 120 + ? `Publishing automatically in ${secondsLeft}s.` + : `Publishes automatically in ${minutesLeft} min.`} + + )} +
+
-
+
@@ -226,6 +247,11 @@ export function SessionDetail({ return () => clearInterval(interval); }, [status?.status, fetchStatus]); + // A live edit hold owns the view: the review panel replaces the compile + // spinner. A failed compile is not a hold worth waiting on, even if the + // deadline hasn't passed yet — show the normal failure state instead. + const inHold = Boolean(status?.editHoldUntil) && status?.status !== "failed"; + const cardButtonStyle: React.CSSProperties = { background: colors.bg.surface, border: `1px solid ${colors.border.default}`, @@ -277,10 +303,10 @@ export function SessionDetail({ {/* Edit hold: the recording is compiled but deliberately not published yet, so this is the user's one chance to cut it. */} - {status && !editing && status.editHoldUntil && ( + {status && !editing && inHold && ( (onEdit ? onEdit() : setEditing(true))} onPublish={async () => { try { @@ -300,7 +326,7 @@ export function SessionDetail({ {/* Video area. Suppressed during an edit hold: the session reads as "stopped", but showing a compile spinner under a panel that says "ready to review" would contradict it. */} - {!status.editHoldUntil && ( + {!inHold && (
(null); const [loadError, setLoadError] = useState(null); + /** Non-null while the preview video is still compiling. */ + const [preparing, setPreparing] = useState<{ expectedUnits: number } | null>(null); + const [buildProgress, setBuildProgress] = useState(0); const [regions, setRegions] = useState([]); const [selected, setSelected] = useState(null); const [time, setTime] = useState(0); @@ -99,9 +108,10 @@ export function TimelapseEditor({ const unitCount = units.length; // ── Load ──────────────────────────────────────────────────── - // The preview video is built by the compile that ran at stop; while it's - // still building, /units reports the hold with editable=false. Poll until - // it's ready rather than sending the user away. + // The preview video is built by the compile that ran at stop, so the + // editor almost always opens BEFORE it exists: `/units` reports + // `preparing` for the whole build. Poll through that instead of + // showing an error — this is the normal path, not a failure. useEffect(() => { let cancelled = false; let timer: ReturnType | undefined; @@ -111,21 +121,24 @@ export function TimelapseEditor({ const res = await client.getUnits(); if (cancelled) return; if (res.editable && res.originalVideoUrl) { + setPreparing(null); setData(res); setRegions(cutsToRegions(res.cuts, res.units)); return; } - if (res.editableReason === "no_original" && res.editHoldUntil) { - // Still compiling inside the hold — check back shortly. - timer = setTimeout(load, 2000); + if (res.editableReason === "preparing" || res.editableReason === "no_original") { + setPreparing({ expectedUnits: res.expectedUnits ?? 0 }); + timer = setTimeout(load, 1500); return; } setLoadError( res.editableReason === "published" ? "This timelapse has already been published, so it can't be edited." - : res.editableReason === "recompiles_exhausted" - ? "This timelapse has reached its edit limit." - : "This timelapse isn't available for editing.", + : res.editableReason === "failed" + ? "This timelapse couldn't be compiled, so there's nothing to edit." + : res.editableReason === "recompiles_exhausted" + ? "This timelapse has reached its edit limit." + : "This timelapse isn't available for editing.", ); } catch (err) { if (!cancelled) @@ -140,6 +153,25 @@ export function TimelapseEditor({ }; }, [client]); + // ── Build progress ────────────────────────────────────────── + // The worker doesn't report progress, so this is a time estimate scaled + // by how much footage there is to compile. It eases toward — and stops + // short of — 100%, and only completes when the real thing does; a ring + // that sat at 100% while the user waited would be worse than none. + useEffect(() => { + if (!preparing) return; + const startedAt = Date.now(); + const estimateMs = COMPILE_BASE_MS + preparing.expectedUnits * COMPILE_MS_PER_UNIT; + const tick = () => { + const elapsed = Date.now() - startedAt; + // Asymptotic: fast at first, never quite arriving. + setBuildProgress(1 - Math.exp(-2.2 * (elapsed / estimateMs))); + }; + tick(); + const id = setInterval(tick, 200); + return () => clearInterval(id); + }, [preparing]); + // ── Hold countdown ────────────────────────────────────────── // The session publishes itself when the hold expires. Surface the // deadline (and bail out gracefully once it passes) instead of letting @@ -497,15 +529,47 @@ export function TimelapseEditor({
- Preparing your timelapse… + {preparing ? ( + + ) : ( + + )} +
+
+ Preparing your timelapse… +
+
+ {preparing && preparing.expectedUnits > 0 + ? `Stitching ${preparing.expectedUnits} minute${ + preparing.expectedUnits === 1 ? "" : "s" + } of footage. Nothing is published until you save.` + : "Nothing is published until you save."} +
+
); } diff --git a/clients/react/src/hooks/useSessionTimer.test.ts b/clients/react/src/hooks/useSessionTimer.test.ts index 8d7c4b8b..76ccb676 100644 --- a/clients/react/src/hooks/useSessionTimer.test.ts +++ b/clients/react/src/hooks/useSessionTimer.test.ts @@ -16,7 +16,11 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; -import { useSessionTimer } from "./useSessionTimer.js"; +import { + useSessionTimer, + useSessionTimerState, + deriveDisplaySeconds, +} from "./useSessionTimer.js"; // The hook uses `Date.now()` for elapsed-time math and // `requestAnimationFrame` for the per-second tick. We control both via @@ -272,3 +276,102 @@ describe("useSessionTimer — latency / delay scenarios", () => { expect(result.current).toBe(60); // capped at one interval, not 90 }); }); + +/** + * The desktop app renders this clock on three surfaces that each tick + * independently: the main window, the menu-bar title (Rust), and the tray + * popup window. The two that don't run this hook are handed `baseSeconds` + + * `anchorAt` and re-derive from them, so these tests pin the contract those + * surfaces are written against. Break one of these and the menu bar starts + * showing a different time from the main app. + */ +describe("deriveDisplaySeconds — shared cross-surface derivation", () => { + const ANCHOR = 1_000_000; + + it("interpolates at wall-clock rate from the anchor", () => { + expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR)).toBe(120); + expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR + 30_000)).toBe(150); + }); + + it("caps interpolation at one capture interval", () => { + // The menu bar used to keep counting here while the main window froze + // at +60, and the two never reconverged. + expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR + 90_000)).toBe(180); + expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR + 600_000)).toBe(180); + }); + + it("drops the interpolated remainder when not active", () => { + // Pausing mid-interval snaps DOWN to the base on every surface. The + // menu bar used to freeze at the interpolated value instead, leaving it + // up to a minute ahead for the whole pause. + expect(deriveDisplaySeconds(120, ANCHOR, false, ANCHOR + 30_000)).toBe(120); + }); + + it("never goes backward if the anchor is in the future", () => { + expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR - 5_000)).toBe(120); + }); + + it("tracks the hook's own display value across a whole interval", () => { + // The surfaces tick on independent 1s timers, so at any given instant + // they may be up to one tick apart from the main window — invisible at + // the menu bar's minute granularity. What must never happen is the + // unbounded, non-converging drift this test would catch as `step` grows. + const { result } = renderHook(({ s, a }) => useSessionTimerState(s, a), { + initialProps: { s: 120, a: true }, + }); + for (const step of [0, 1_000, 30_000, 59_000, 90_000, 600_000]) { + tickClock(step); + const surface = deriveDisplaySeconds( + result.current.baseSeconds, + result.current.anchorAt, + true, + Date.now(), + ); + expect(Math.abs(surface - result.current.displaySeconds)).toBeLessThanOrEqual(1); + } + }); +}); + +describe("useSessionTimerState — anchor exposed to other surfaces", () => { + it("exposes the ratcheted base, not the interpolated display", () => { + const { result } = renderHook(({ s, a }) => useSessionTimerState(s, a), { + initialProps: { s: 120, a: true }, + }); + tickClock(30_000); + expect(result.current.displaySeconds).toBe(150); + // Surfaces must interpolate from 120, never from 150 — extrapolating + // from an already-interpolated value double-counts the remainder. + expect(result.current.baseSeconds).toBe(120); + }); + + it("holds the base on a stale lower reading, so surfaces don't jump back", () => { + const { result, rerender } = renderHook( + ({ s, a }) => useSessionTimerState(s, a), + { initialProps: { s: 120, a: true } }, + ); + rerender({ s: 60, a: true }); + expect(result.current.baseSeconds).toBe(120); + }); + + it("re-anchors only when the base actually advances", () => { + const { result, rerender } = renderHook( + ({ s, a }) => useSessionTimerState(s, a), + { initialProps: { s: 120, a: true } }, + ); + const firstAnchor = result.current.anchorAt; + + // A repeated (or lower) reading must not restart the interpolation + // window — that silently discarded up to a minute the other surfaces + // were still counting. + tickClock(20_000); + rerender({ s: 120, a: true }); + expect(result.current.anchorAt).toBe(firstAnchor); + expect(result.current.displaySeconds).toBe(140); + + // A real advance re-anchors and restarts interpolation from there. + rerender({ s: 180, a: true }); + expect(result.current.anchorAt).toBeGreaterThan(firstAnchor); + expect(result.current.baseSeconds).toBe(180); + expect(result.current.displaySeconds).toBe(180); + }); +}); diff --git a/clients/react/src/hooks/useSessionTimer.ts b/clients/react/src/hooks/useSessionTimer.ts index 84d4ba1d..48825843 100644 --- a/clients/react/src/hooks/useSessionTimer.ts +++ b/clients/react/src/hooks/useSessionTimer.ts @@ -6,7 +6,52 @@ import { SCREENSHOT_INTERVAL_MS } from "@lookout/shared"; * the display jumps to the new server value (== frozen value) and * unfreezes smoothly. If captures stall, the freeze stays put so the * user sees something is wrong instead of an inflated count. */ -const MAX_INTERPOLATION_S = Math.floor(SCREENSHOT_INTERVAL_MS / 1000); +export const MAX_INTERPOLATION_S = Math.floor(SCREENSHOT_INTERVAL_MS / 1000); + +/** + * The timer's anchor state, for surfaces that tick their own clock + * instead of consuming `displaySeconds` (the desktop menu-bar ticker in + * Rust, and the tray popup window). + * + * Those surfaces MUST reproduce the same three rules or they drift out + * of sync with the main window — which is exactly the "menu bar shows a + * different time" bug: + * + * 1. display = `baseSeconds` + min(MAX_INTERPOLATION_S, now - `anchorAt`) + * 2. while not active (paused/stopped), display = `baseSeconds` — the + * interpolated remainder is dropped, not frozen + * 3. `baseSeconds` ratchets forward only, and `anchorAt` resets only + * when it actually advances + * + * Never re-interpolate from `displaySeconds`: it already contains the + * interpolated remainder, so extrapolating from it double-counts. + */ +export interface SessionTimerState { + /** What to render. */ + displaySeconds: number; + /** Ratcheted server-authoritative value the display is anchored to. */ + baseSeconds: number; + /** `Date.now()` when `baseSeconds` last advanced. */ + anchorAt: number; +} + +/** + * The one implementation of rules 1 and 2 above. Every JS surface that + * renders the recording clock goes through this — the main window via + * `useSessionTimerState`, the desktop tray popup from the anchor it + * receives over IPC. (The Rust menu-bar ticker mirrors it in + * `tray_timer_task`; keep the two in step.) + */ +export function deriveDisplaySeconds( + baseSeconds: number, + anchorAt: number, + isActive: boolean, + now: number, +): number { + if (!isActive) return baseSeconds; + const elapsed = Math.floor((now - anchorAt) / 1000); + return baseSeconds + Math.min(MAX_INTERPOLATION_S, Math.max(0, elapsed)); +} /** * Display timer for the recording session. @@ -26,10 +71,10 @@ const MAX_INTERPOLATION_S = Math.floor(SCREENSHOT_INTERVAL_MS / 1000); * ratchets up and `lastSyncRef` resets — display jumps to the new * value and the next interpolation cycle starts from there. */ -export function useSessionTimer( +export function useSessionTimerState( serverTrackedSeconds: number, isActive: boolean, -): number { +): SessionTimerState { const [displaySeconds, setDisplaySeconds] = useState(serverTrackedSeconds); const lastSyncRef = useRef(Date.now()); const baseRef = useRef(serverTrackedSeconds); @@ -57,15 +102,17 @@ export function useSessionTimer( lastSyncRef.current = Date.now(); let raf: number; - let lastRenderedSecond = -1; + let lastRendered = -1; const tick = () => { - const elapsed = Math.min( - MAX_INTERPOLATION_S, - Math.floor((Date.now() - lastSyncRef.current) / 1000), + const next = deriveDisplaySeconds( + baseRef.current, + lastSyncRef.current, + true, + Date.now(), ); - if (elapsed !== lastRenderedSecond) { - lastRenderedSecond = elapsed; - setDisplaySeconds(baseRef.current + elapsed); + if (next !== lastRendered) { + lastRendered = next; + setDisplaySeconds(next); } raf = requestAnimationFrame(tick); }; @@ -78,9 +125,28 @@ export function useSessionTimer( // keep the inflated baseRef). Server credits after resume re-anchor // baseRef via the sync effect above. }; - }, [isActive, serverTrackedSeconds]); + // `serverTrackedSeconds` is deliberately NOT a dep. The tick reads + // baseRef/lastSyncRef live, and the sync effect above already + // re-anchors on advance. Including it here re-ran this effect on + // every server response and reset `lastSyncRef` even when the value + // did NOT advance (a repeated or lower reading), silently discarding + // up to a minute of interpolation that the other surfaces kept. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive]); + + return { + displaySeconds, + baseSeconds: baseRef.current, + anchorAt: lastSyncRef.current, + }; +} - return displaySeconds; +/** Convenience wrapper: just the number to render. */ +export function useSessionTimer( + serverTrackedSeconds: number, + isActive: boolean, +): number { + return useSessionTimerState(serverTrackedSeconds, isActive).displaySeconds; } /** Format seconds as H:MM:SS or M:SS (for live timer display). */ diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts index 5d950e35..156bbde2 100644 --- a/clients/react/src/index.ts +++ b/clients/react/src/index.ts @@ -53,7 +53,17 @@ export type { UploadPayload } from "./hooks/useUploader.js"; export { ClipRecorder } from "./hooks/clipRecorder.js"; export type { ClipCaptureResult, ClipRecorderOptions } from "./hooks/clipRecorder.js"; export { useSession } from "./hooks/useSession.js"; -export { useSessionTimer, formatTime, formatTrackedTime } from "./hooks/useSessionTimer.js"; +export { + useSessionTimer, + useSessionTimerState, + deriveDisplaySeconds, + MAX_INTERPOLATION_S, + formatTime, + formatTrackedTime, +} from "./hooks/useSessionTimer.js"; +export type { SessionTimerState } from "./hooks/useSessionTimer.js"; +export { computeBestTrackedSeconds } from "./hooks/computeBestTracked.js"; +export type { BestTrackedInputs } from "./hooks/computeBestTracked.js"; // Gallery hooks export { useTokenStore } from "./hooks/useTokenStore.js"; diff --git a/clients/react/src/ui/ProgressRing.tsx b/clients/react/src/ui/ProgressRing.tsx new file mode 100644 index 00000000..36ab4aa2 --- /dev/null +++ b/clients/react/src/ui/ProgressRing.tsx @@ -0,0 +1,80 @@ +import { colors, fontSize, fontWeight } from "./theme.js"; + +export interface ProgressRingProps { + /** 0–1. Values outside are clamped. */ + progress: number; + size?: number; + strokeWidth?: number; + /** Centre label. Omit for a bare ring. */ + label?: string; + color?: string; +} + +/** + * Determinate circular progress. Used where a spinner would under-inform — + * a long wait the user is standing in front of, like a timelapse compile. + */ +export function ProgressRing({ + progress, + size = 72, + strokeWidth = 5, + label, + color, +}: ProgressRingProps) { + const clamped = Math.max(0, Math.min(1, progress)); + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - clamped); + + return ( +
+ + + + + {label && ( +
+ {label} +
+ )} +
+ ); +} diff --git a/clients/react/src/ui/index.ts b/clients/react/src/ui/index.ts index 6a8c2e8b..a006ae2d 100644 --- a/clients/react/src/ui/index.ts +++ b/clients/react/src/ui/index.ts @@ -2,6 +2,8 @@ export { Button } from "./Button.js"; export type { ButtonProps } from "./Button.js"; export { Spinner } from "./Spinner.js"; export type { SpinnerProps } from "./Spinner.js"; +export { ProgressRing } from "./ProgressRing.js"; +export type { ProgressRingProps } from "./ProgressRing.js"; export { Badge } from "./Badge.js"; export type { BadgeProps } from "./Badge.js"; export { ErrorDisplay } from "./ErrorDisplay.js"; diff --git a/docs/edit-feature-plan.md b/docs/edit-feature-plan.md index 91a76b44..8e89f2c3 100644 --- a/docs/edit-feature-plan.md +++ b/docs/edit-feature-plan.md @@ -246,9 +246,17 @@ their single video file forever, as today. now", and a primary button that reads **"Save & publish"** with cuts or **"Publish as recorded"** without — publishing is the way out, not an optional extra step. - - Polls `/units` while the preview is still compiling, and counts down to - the hold's auto-publish (louder in the last two minutes) so the user - always knows the timelapse is safe but not yet out. + - **The editor opens before the preview exists** — the compile starts at + stop and runs for tens of seconds — so `preparing` is the normal + opening state, not an error. It polls through it behind a + `` sized from `expectedUnits` (compile time scales with + unit count), then swaps to the timeline. It also counts down to the + hold's auto-publish (louder in the last two minutes) so the user always + knows the timelapse is safe but not yet out. + - The ring is a time estimate, not worker-reported progress: it eases + asymptotically toward 100% and only completes when `/units` actually + reports the video ready, so it can never sit at 100% while the user + waits. - **``**: the stop confirmation — keep recording / stop & save / edit & save. This is where editing is offered; there is no post-publication entry point. diff --git a/packages/server/API.md b/packages/server/API.md index 055b939a..00acd139 100644 --- a/packages/server/API.md +++ b/packages/server/API.md @@ -607,15 +607,27 @@ Editor metadata. Rate limit: 10 req/min per token. "cuts": [], "editable": true, "editHoldUntil": "2026-07-26T14:35:00.000Z", + "expectedUnits": 47, "originalVideoUrl": "https://…presigned, ~1h…", "recompilesRemaining": 5 } ``` -- `units` — the capture units of the compiled **original** video, in output order. Array index = video second = real-world minute: the exact video-time ↔ wall-clock map. +- `units` — the capture units of the compiled **original** video, in output order. Array index = video second = real-world minute: the exact video-time ↔ wall-clock map. Empty until the preview finishes building. - `originalVideoUrl` — presigned GET for the unpublished original (the editor's preview source). Deliberately not the public media URL, which is null until the session publishes. `null` when not editable. -- `editable` / `editableReason` — `false` with `"no_original"` (the preview is still compiling — poll, since `editHoldUntil` is set), `"not_ready"` (no hold, or it lapsed), `"published"` (already `complete`, so editing is over), or `"recompiles_exhausted"`. +- `editable` / `editableReason` — `false` with one of: + + | Reason | Meaning | What a client should do | + |--------|---------|-------------------------| + | `preparing` | Hold is active; the preview video is still compiling (the session reads `stopped` or `compiling`) | **Poll** — this is the normal state right after a stop, not an error. Show progress | + | `no_original` | Hold active but no original recorded | Poll; same as above | + | `not_ready` | No hold, or it lapsed | Editing isn't on offer | + | `published` | Already `complete` | Editing is over — by design | + | `failed` | The compile failed | Show the failure; there's nothing to edit | + | `recompiles_exhausted` | Publish budget spent | Editing is over | + - `editHoldUntil` — when the session auto-publishes; `null` when no hold is active. +- `expectedUnits` — confirmed captures ≈ units the finished video will hold. Lets a client waiting on the build size a progress estimate (compile time scales with unit count). #### Set Cut List @@ -649,6 +661,7 @@ Ends the edit hold and publishes the timelapse with the current cut list baked i - **With cuts:** `stopped → compiling → complete` (poll [`/status`](#poll-compilation-status)); the worker stream-copies the kept ranges, usually in seconds, then deletes the uncut original. Burns one of **5** publishes per session. - **Without cuts:** returns `{ "instant": true, "status": "complete" }` immediately — the built original is simply published, no worker involved. +- **Before the preview finishes building** (`editableReason: "preparing"`): drops the hold and returns `200` with `instant: false`. The in-flight compile publishes normally when it lands, so "publish as recorded" works without waiting for a preview the user just declined. Rate limit: 5 req/min per token. diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 5d1f57e5..05127725 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -112,22 +112,39 @@ function sessionEditability(session: { editHoldUntil: Date | null; }): { editable: boolean; - reason?: "no_original" | "recompiles_exhausted" | "not_ready" | "published"; + reason?: + | "preparing" + | "no_original" + | "recompiles_exhausted" + | "not_ready" + | "failed" + | "published"; } { if (session.status === "complete") { return { editable: false, reason: "published" }; } - if (session.status !== "stopped" || !holdActive(session)) { + if (session.status === "failed") { + return { editable: false, reason: "failed" }; + } + if (!holdActive(session)) { + return { editable: false, reason: "not_ready" }; + } + // A held session is "compiling" for most of the wait — the worker claims + // the job within a second of the stop and only returns the session to + // "stopped" once the preview is built. Both states are legitimate + // waiting room; anything else means the recording isn't finished. + if (session.status !== "stopped" && session.status !== "compiling") { return { editable: false, reason: "not_ready" }; } // Hold is active but the preview build hasn't landed yet (the compile // job writes videoUnits + the original when it finishes). if ( + session.status === "compiling" || !Array.isArray(session.videoUnits) || session.videoUnits.length === 0 || !session.originalVideoR2Key ) { - return { editable: false, reason: "no_original" }; + return { editable: false, reason: "preparing" }; } if (session.recompileCount >= MAX_USER_RECOMPILES) { return { editable: false, reason: "recompiles_exhausted" }; @@ -1335,6 +1352,10 @@ export async function sessionRoutes(app: FastifyInstance) { editHoldUntil: holdActive(session) ? session.editHoldUntil!.toISOString() : null, + // Roughly how many units the finished video will hold. Lets a + // client waiting on the build size its progress estimate — compile + // time scales with unit count. + expectedUnits: await getScreenshotCount(session.id), originalVideoUrl, recompilesRemaining: Math.max( 0, @@ -1519,6 +1540,23 @@ export async function sessionRoutes(app: FastifyInstance) { } const { editable, reason } = sessionEditability(session); + + // "Publish as recorded" while the preview is still building: just + // drop the hold. The build re-reads it when it finishes and + // publishes normally, so the user never has to wait for a preview + // they said they don't want. + if (!editable && reason === "preparing") { + await db + .update(schema.sessions) + .set({ editHoldUntil: null, updatedAt: new Date() }) + .where(eq(schema.sessions.id, session.id)); + return { + status: session.status as "stopped" | "compiling", + instant: false, + recompilesRemaining, + }; + } + if (!editable) { return reply .code(409) diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts index c95b5435..0e7d6588 100644 --- a/packages/server/test/edits.integration.test.ts +++ b/packages/server/test/edits.integration.test.ts @@ -172,15 +172,40 @@ describe("GET /units", () => { expect(body.editHoldUntil).toBeTruthy(); }); - it("reports 'still building' while the compile is in flight", async () => { + it("reports 'preparing' while the compile is in flight", async () => { const s = await seedHeldSession({ videoUnits: null, originalVideoR2Key: null }); const body = await getJson(`/api/sessions/${s.token}/units`); expect(body.editable).toBe(false); - expect(body.editableReason).toBe("no_original"); + expect(body.editableReason).toBe("preparing"); // The hold is still surfaced so clients wait rather than give up. expect(body.editHoldUntil).toBeTruthy(); }); + it("reports 'preparing' — not a hard failure — once the worker claims the job", async () => { + // Regression: the worker flips a held session to `compiling` within a + // second of the stop, which is the state the editor almost always + // opens into. Reporting it as un-editable made "Edit & save" fail + // immediately for every user. + const s = await seedHeldSession({ + status: "compiling", + videoUnits: null, + originalVideoR2Key: null, + }); + const body = await getJson(`/api/sessions/${s.token}/units`); + expect(body.editable).toBe(false); + expect(body.editableReason).toBe("preparing"); + expect(body.editHoldUntil).toBeTruthy(); + // The client needs this to size its progress estimate. + expect(body.expectedUnits).toBe(UNITS); + }); + + it("reports a failed compile as failed, not as something to wait for", async () => { + const s = await seedHeldSession({ status: "failed" }); + const body = await getJson(`/api/sessions/${s.token}/units`); + expect(body.editable).toBe(false); + expect(body.editableReason).toBe("failed"); + }); + it("refuses editing once the session is published", async () => { const s = await seedHeldSession({ status: "complete", @@ -350,6 +375,22 @@ describe("POST /compile (publish)", () => { const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` }); expect(r.statusCode).toBe(409); }); + + it("lets the user publish mid-compile by dropping the hold", async () => { + // "I don't want to edit after all" must work even while the preview is + // still building: clearing the hold makes the in-flight build publish + // when it finishes, instead of making the user wait for a preview they + // just declined. + const s = await seedHeldSession({ + status: "compiling", + videoUnits: null, + originalVideoR2Key: null, + }); + const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` }); + expect(r.statusCode).toBe(200); + expect(r.json().instant).toBe(false); + expect((await load(s.id))!.editHoldUntil).toBeNull(); + }); }); describe("held sessions in status reads", () => { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8eb9b7e9..58b7c30c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -154,14 +154,21 @@ export interface UnitsResponse { * the original video is built, and recompile budget remains. Editing is * only possible during the hold — never after `complete`. */ editable: boolean; - /** Why `editable` is false (for UX copy); absent when editable. */ + /** Why `editable` is false (for UX copy); absent when editable. + * `"preparing"` means the hold is active and the preview video is still + * compiling — keep polling, it will become editable. */ editableReason?: + | "preparing" | "no_original" | "recompiles_exhausted" | "not_ready" + | "failed" | "published"; /** When the edit hold auto-publishes; null when no hold is active. */ editHoldUntil?: string | null; + /** Confirmed captures in the session ≈ units the finished video will + * hold. Lets a client waiting on the build size a progress estimate. */ + expectedUnits?: number; /** Presigned GET URL (~1h) of the UNCUT original video — the editor's * preview source. Token-gated by this endpoint; deliberately NOT the * public media URL, which after an edit serves the cut version only. From 8d2114a453e25b3f82e5050cb1d034f99f496212 Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:08:11 +0800 Subject: [PATCH 23/65] fix(editor): thumbnails silently failed when the preview video lacked CORS headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filmstrip and hover scrubber read pixels through a canvas, so the offscreen