diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4183778e..22a2ac3a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -76,7 +76,7 @@ jobs:
zstd
- name: Setup Node.js
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: lts/*
cache: npm
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 85e9df74..f807921a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -59,7 +59,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: lts/*
cache: npm
@@ -82,6 +82,47 @@ 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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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
+
+ # Shared package: pure units — cut math, clock-offset estimation.
+ - name: Vitest (shared)
+ run: npm test -w packages/shared
+
# ──────────────────────────────────────────────────────────────────
# Desktop client: Rust unit + serde compat tests.
# The 4-way matrix (legacy/new struct × legacy/new JSON) here is the
@@ -111,6 +152,25 @@ jobs:
libgstreamer-plugins-base1.0-dev \
libgbm-dev
+ # RUNTIME GStreamer plugins, not just the dev headers above. The Linux
+ # clip encoder builds `appsrc ! videoconvert ! x264enc ! h264parse !
+ # mp4mux ! filesink`, and those elements live in four different packages:
+ # videoconvert in -base, mp4mux in -good, h264parse in -bad, x264enc in
+ # -ugly. Without them ClipRecorder::new fails with "no H.264 encoder
+ # element available" and every clip test that touches a real encoder dies
+ # — which is exactly how this job has been failing.
+ #
+ # This is also the only place the Linux encoder path is executed at all
+ # (macOS runs it locally, Windows is check-only), so installing them is
+ # what makes that path tested rather than merely compiled.
+ - name: Install GStreamer runtime plugins
+ run: |
+ sudo apt-get install -y \
+ gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good \
+ gstreamer1.0-plugins-bad \
+ gstreamer1.0-plugins-ugly
+
- name: Install Rust stable
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
@@ -126,9 +186,42 @@ jobs:
workspaces: "./clients/desktop/src-tauri -> target"
shared-key: rust-ubuntu-24.04-native
- - name: cargo test --lib
+ # Release, not debug. The clip encoders are unsafe FFI against
+ # VideoToolbox / Media Foundation / GStreamer, and that is exactly the
+ # code whose behaviour differs under optimisation — the kind of bug this
+ # project has already been bitten by once (a datatype-misalignment crash
+ # that only showed up in a release build). Testing debug binaries proves
+ # the wrong artifact.
+ - name: cargo test --lib --release
+ working-directory: clients/desktop/src-tauri
+ run: cargo test --lib --release
+
+ # ──────────────────────────────────────────────────────────────────
+ # Windows type-check: the Media Foundation clip encoder is Windows-only
+ # cfg code that no other job compiles. A check (not full test run)
+ # catches API drift in the `windows` crate bindings.
+ # ──────────────────────────────────────────────────────────────────
+ desktop-rust-windows:
+ name: Desktop Rust check (Windows)
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
+
+ - name: Rust cache
+ uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+ with:
+ workspaces: "./clients/desktop/src-tauri -> target"
+ shared-key: rust-windows-check
+
+ # Release profile, matching what actually ships (see the Linux job).
+ # This is the only job that compiles the Windows-only Media Foundation
+ # encoder at all, so it should compile it the way users get it.
+ - name: cargo check --release
working-directory: clients/desktop/src-tauri
- run: cargo test --lib
+ run: cargo check --release
# ──────────────────────────────────────────────────────────────────
# Type-check the rest of the workspace (React/web clients). Catches
@@ -141,7 +234,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: lts/*
cache: npm
diff --git a/.gitignore b/.gitignore
index 70353d22..dc7e5eb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,15 @@ dist/
# Rust / Tauri
clients/desktop/src-tauri/target/
clients/desktop/src-tauri/gen/
+# SwiftPM build output for the native tray. Its index database is a 64MB file
+# that was committed once already; keep it out for good.
+clients/desktop/src-tauri/swift/*/.build/
+
+# Env files, including the editor/tooling backups that are easy to commit by
+# accident (.env itself is covered above).
+.env.bak
+.env.*.bak
+*.env.bak
# Drizzle generated migrations are committed, but the meta folder is not needed
# (optional — remove this line if you want to commit migration metadata)
diff --git a/clients/desktop/package.json b/clients/desktop/package.json
index 14c54ac4..01230855 100644
--- a/clients/desktop/package.json
+++ b/clients/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@lookout/desktop",
- "version": "0.3.5",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
@@ -12,7 +12,8 @@
"dependencies": {
"@lookout/react": "*",
"@lookout/shared": "*",
- "@number-flow/react": "^0.6.0",
+ "@number-flow/react": "^0.6.2",
+ "@phosphor-icons/react": "^2.1.10",
"@sentry/react": "^10.45.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-deep-link": "^2.4.7",
diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock
index c5ca9fe0..5f405532 100644
--- a/clients/desktop/src-tauri/Cargo.lock
+++ b/clients/desktop/src-tauri/Cargo.lock
@@ -1529,6 +1529,18 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "fast_image_resize"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbc7fe45cf92b43817ff62a3723e862b85bd1d06288f63007f7645d1d2f7a060"
+dependencies = [
+ "cfg-if",
+ "document-features",
+ "num-traits",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -2546,7 +2558,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
@@ -3152,22 +3164,31 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lookout-desktop"
-version = "0.3.5"
+version = "0.3.7"
dependencies = [
"ashpd",
"base64 0.22.1",
+ "block2",
+ "bytes",
+ "fast_image_resize",
"gstreamer",
"gstreamer-app",
"gstreamer-video",
"image",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-av-foundation",
"objc2-core-foundation",
"objc2-core-graphics",
+ "objc2-core-media",
+ "objc2-core-video",
"objc2-foundation",
"os_info",
"reqwest 0.12.28",
"sentry",
"serde",
"serde_json",
+ "swift-rs",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
@@ -3185,6 +3206,7 @@ dependencies = [
"url",
"webview2-com",
"window-vibrancy 0.7.1",
+ "windows 0.62.2",
"xcap",
]
@@ -3608,6 +3630,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
+ "objc2-core-media",
"objc2-core-video",
"objc2-foundation",
"objc2-image-io",
@@ -4588,7 +4611,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4625,7 +4648,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml
index 3eb3e5d1..d40df3a8 100644
--- a/clients/desktop/src-tauri/Cargo.toml
+++ b/clients/desktop/src-tauri/Cargo.toml
@@ -12,7 +12,7 @@ debug = true
[package]
name = "lookout-desktop"
-version = "0.3.5"
+version = "0.3.7"
edition = "2021"
[lib]
@@ -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"] }
@@ -29,10 +32,17 @@ 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"] }
+# SIMD resizing (SSE4/AVX2/NEON) for the capture downscale — ~4x faster than
+# the `image` crate's scalar filters at screen sizes (29ms → 7ms, 5K→1080p).
+fast_image_resize = "5"
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,12 +63,30 @@ 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"] }
+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"] }
+# Clip encoding: hardware H.264 via AVAssetWriter (VideoToolbox under the
+# hood) writing MP4 directly. All already in the tree transitively via xcap.
+objc2-av-foundation = { version = "0.3.2", features = ["AVAssetWriter", "AVAssetWriterInput", "AVMediaFormat", "AVVideoSettings", "objc2-core-media", "objc2-core-video"] }
+objc2-core-media = { version = "0.3.2", features = ["CMTime"] }
+objc2-core-video = { version = "0.3.2", features = ["CVPixelBuffer", "CVBuffer", "CVReturn", "CVImageBuffer"] }
+block2 = "0.6"
[target.'cfg(target_os = "windows")'.dependencies]
webview2-com = "0.38"
+windows = { version = "0.62", features = [
+ "Win32_Foundation",
+ "Win32_Graphics_Gdi",
+ # Clip encoding: hardware H.264 via the Media Foundation sink writer.
+ "Win32_Media_MediaFoundation",
+ "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/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/capabilities/default.json b/clients/desktop/src-tauri/capabilities/default.json
index 55114185..569a7c8e 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,13 +22,22 @@
"macos-permissions:default",
"updater:default",
"notification:default",
+ "core:webview:allow-create-webview-window",
+ "core:window:allow-close",
+ "core:window:allow-set-focus",
+ "core:window:allow-get-all-windows",
+ "core:webview:allow-get-all-webviews",
+ "core:window:allow-destroy",
{
"identifier": "http:default",
"allow": [
- { "url": "http://localhost:*" },
- { "url": "https://lookout.hackclub.com/*" },
- { "url": "https://*.r2.cloudflarestorage.com/*" }
+ {
+ "url": "http://localhost:*"
+ },
+ {
+ "url": "https://**"
+ }
]
}
]
-}
+}
\ No newline at end of file
diff --git a/clients/desktop/src-tauri/src/capture.rs b/clients/desktop/src-tauri/src/capture.rs
index d52bce0f..142faf07 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,31 +237,78 @@ pub struct RawCaptureResult {
pub height: u32,
}
-pub fn take_screenshot_raw(
- source: CaptureSource,
- max_width: u32,
- max_height: u32,
+/// SIMD resize (fast_image_resize: SSE4/AVX2/NEON) — ~4x faster than the
+/// `image` crate's scalar filters at screen sizes (measured 29ms → 7ms for
+/// a 5K→1080p downscale). Box for downscales (area average: the correct
+/// filter for heavy downscaling AND the cheapest), bilinear when upscaling
+/// (box upscales look blocky). Falls back to the scalar Triangle path if
+/// the SIMD buffers can't be built — unreachable in practice, but capture
+/// must never die on a resize.
+pub fn fast_resize_rgba(dynamic: DynamicImage, new_w: u32, new_h: u32) -> DynamicImage {
+ // Captures are always RGBA8 — into_rgba8 is a move, not a copy.
+ let rgba = dynamic.into_rgba8();
+ match fast_resize_buffer(&rgba, new_w, new_h) {
+ Some(out) => DynamicImage::ImageRgba8(out),
+ // Unreachable in practice; the scalar path keeps capture alive.
+ None => DynamicImage::ImageRgba8(rgba).resize_exact(
+ new_w,
+ new_h,
+ image::imageops::FilterType::Triangle,
+ ),
+ }
+}
+
+/// Borrowing core of {@link fast_resize_rgba} — resizes without consuming
+/// the source, for callers that still need the original frame (e.g. the
+/// preview downscale next to the clip encoder).
+pub fn fast_resize_buffer(
+ rgba: &image::RgbaImage,
+ new_w: u32,
+ new_h: u32,
+) -> Option {
+ use fast_image_resize as fir;
+
+ let (w, h) = (rgba.width(), rgba.height());
+ let alg = if new_w <= w && new_h <= h {
+ fir::ResizeAlg::Convolution(fir::FilterType::Box)
+ } else {
+ fir::ResizeAlg::Convolution(fir::FilterType::Bilinear)
+ };
+ let src = fir::images::ImageRef::new(w, h, rgba.as_raw(), fir::PixelType::U8x4).ok()?;
+ let mut dst = fir::images::Image::new(new_w, new_h, fir::PixelType::U8x4);
+ let opts = fir::ResizeOptions::new().resize_alg(alg);
+ fir::Resizer::new().resize(&src, &mut dst, &opts).ok()?;
+ image::RgbaImage::from_raw(new_w, new_h, dst.into_vec())
+}
+
+/// Encode a captured (already redacted + scaled) frame as JPEG.
+pub fn encode_frame_jpeg(
+ dynamic: &DynamicImage,
jpeg_quality: u8,
- pipewire_fds: &std::collections::HashMap,
) -> Result {
- take_screenshot_raw_with_blacklist(
- source,
- max_width,
- max_height,
- jpeg_quality,
- pipewire_fds,
- &[],
- )
+ let rgb = dynamic.to_rgb8();
+ let mut jpeg_buf = Cursor::new(Vec::new());
+ let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
+ encoder
+ .encode_image(&rgb)
+ .map_err(|e| format!("JPEG encoding failed: {e}"))?;
+ Ok(RawCaptureResult {
+ data: jpeg_buf.into_inner(),
+ width: dynamic.width(),
+ height: dynamic.height(),
+ })
}
-pub fn take_screenshot_raw_with_blacklist(
+/// Capture a single source, redact, and scale to fit — returning the raw
+/// RGBA frame (no JPEG encode). Base primitive shared by the JPEG upload
+/// path, the live preview, and the clip encoder.
+pub fn take_screenshot_image_with_blacklist(
source: CaptureSource,
max_width: u32,
max_height: u32,
- jpeg_quality: u8,
pipewire_fds: &std::collections::HashMap,
blacklisted_apps: &[String],
-) -> Result {
+) -> Result {
let mut dynamic =
capture_to_dynamic_image_with_blacklist(&source, pipewire_fds, blacklisted_apps)?;
@@ -273,51 +322,37 @@ pub fn take_screenshot_raw_with_blacklist(
let scale = f64::min(max_width as f64 / w as f64, max_height as f64 / h as f64);
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::Triangle);
+ dynamic = fast_resize_rgba(dynamic, new_w, new_h);
}
- let (final_w, final_h) = (dynamic.width(), dynamic.height());
-
- // Encode as JPEG
- let rgb = dynamic.to_rgb8();
- let mut jpeg_buf = Cursor::new(Vec::new());
- let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
- encoder
- .encode_image(&rgb)
- .map_err(|e| format!("JPEG encoding failed: {e}"))?;
-
- Ok(RawCaptureResult {
- data: jpeg_buf.into_inner(),
- width: final_w,
- height: final_h,
- })
+ Ok(dynamic)
}
-/// Capture a specific source (monitor or window), scale to fit, encode as JPEG.
-pub fn take_screenshot(
+pub fn take_screenshot_raw_with_blacklist(
source: CaptureSource,
max_width: u32,
max_height: u32,
jpeg_quality: u8,
pipewire_fds: &std::collections::HashMap,
-) -> Result {
- take_screenshot_with_blacklist(
+ blacklisted_apps: &[String],
+) -> Result {
+ let dynamic = take_screenshot_image_with_blacklist(
source,
max_width,
max_height,
- jpeg_quality,
pipewire_fds,
- &[],
- )
+ blacklisted_apps,
+ )?;
+ encode_frame_jpeg(&dynamic, jpeg_quality)
}
-pub fn take_screenshot_with_blacklist(
+/// Capture a specific source (monitor or window), scale to fit, encode as JPEG.
+pub fn take_screenshot(
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 +360,7 @@ pub fn take_screenshot_with_blacklist(
max_height,
jpeg_quality,
pipewire_fds,
- blacklisted_apps,
+ &[],
)?;
let size_bytes = raw.data.len();
@@ -340,41 +375,25 @@ pub fn take_screenshot_with_blacklist(
})
}
-pub fn take_stitched_screenshots(
+/// Capture one or more sources side-by-side, redact, and scale to fit —
+/// returning the composed raw RGBA frame (no JPEG encode). Base primitive
+/// shared by the JPEG upload path, the live preview, and the clip encoder.
+pub fn take_stitched_screenshots_image_with_blacklist(
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(
- 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_image_with_blacklist(
sources[0].clone(),
max_width,
max_height,
- jpeg_quality,
pipewire_fds,
blacklisted_apps,
);
@@ -409,7 +428,7 @@ 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);
+ let scaled = fast_resize_rgba(img, new_w, target_h);
total_w += scaled.width();
scaled_images.push(scaled);
} else {
@@ -419,11 +438,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,31 +462,31 @@ 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 = fast_resize_rgba(dynamic, new_w, new_h);
}
- let (final_w, final_h) = (dynamic.width(), dynamic.height());
-
- // Encode as JPEG
- let rgb = dynamic.to_rgb8();
- let mut jpeg_buf = Cursor::new(Vec::new());
- let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
- encoder
- .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(dynamic)
+}
- Ok(CaptureResult {
- base64,
- width: final_w,
- height: final_h,
- size_bytes,
- })
+/// 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 {
+ let dynamic = take_stitched_screenshots_image_with_blacklist(
+ sources,
+ max_width,
+ max_height,
+ pipewire_fds,
+ blacklisted_apps,
+ )?;
+ encode_frame_jpeg(&dynamic, jpeg_quality)
}
#[cfg(target_os = "macos")]
diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs
new file mode 100644
index 00000000..032d5c04
--- /dev/null
+++ b/clients/desktop/src-tauri/src/clips.rs
@@ -0,0 +1,1239 @@
+//! Per-minute clip recording: hardware H.264 encoding of capture-loop
+//! frames into MP4 clips — the `format=mp4` upload payload for sessions
+//! with clips enabled.
+//!
+//! One `ClipRecorder` lives per upload interval: the capture loop pushes a
+//! frame every `frameIntervalMs` (server-authoritative, 10s = 6/min), and
+//! at the upload tick `finish()` produces the MP4 bytes. Encoding is done
+//! by the OS hardware encoder on every platform — no bundled codecs:
+//!
+//! - macOS: AVAssetWriter (VideoToolbox underneath), muxes MP4 itself
+//! - Windows: Media Foundation sink writer (hardware MFT when available)
+//! - Linux: GStreamer (already a dependency for PipeWire capture)
+//!
+//! Every error is recoverable by design: the capture loop falls back to
+//! the legacy one-JPEG-per-minute upload for that interval, so a broken
+//! encoder degrades smoothness, never the recording.
+
+use image::DynamicImage;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+/// Per-frame byte budget — the quality dial. Mirrors the shared
+/// CLIP_FRAME_BYTE_BUDGET; keep the two in step.
+///
+/// ~400 KB buys a JPEG-q85-class keyframe at 1080p, the bar the legacy
+/// single-screenshot pipeline set. 133k/400k-per-second equivalents were
+/// tried first and produced visibly soft H.264.
+pub const CLIP_FRAME_BYTE_BUDGET: u64 = 400_000;
+
+/// Bitrate (bits/second of MEDIA time) that lands CLIP_FRAME_BYTE_BUDGET per
+/// frame at the given cadence. Mirrors `nativeClipBitsPerSecond` in
+/// @lookout/shared.
+///
+/// This must scale with the cadence. These encoders get each frame's real
+/// presentation timestamp, so their bitrate is denominated per second of
+/// media time — the same number buys 2.5x the bytes per frame when frames
+/// sit 10s apart instead of 4s. Left fixed, a slower cadence would silently
+/// inflate every clip toward the server's 8 MB limit while a faster one
+/// would starve it. (Browsers work differently and need a much larger
+/// figure for the same quality — see CLIP_WEB_VIDEO_BITS_PER_SECOND. The
+/// two are not comparable.) VBR ceiling, not a floor: static screens
+/// undershoot heavily.
+pub fn clip_bits_per_second(frame_interval_ms: u64) -> u32 {
+ let interval_ms = frame_interval_ms.max(1);
+ ((CLIP_FRAME_BYTE_BUDGET * 8 * 1000) / interval_ms).min(u32::MAX as u64) as u32
+}
+
+/// A finished clip ready for upload.
+pub struct FinishedClip {
+ pub mp4: Vec,
+ pub frame_count: u32,
+ pub width: u32,
+ pub height: u32,
+}
+
+/// Unique-enough temp path for an in-progress clip container.
+fn clip_temp_path() -> PathBuf {
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
+ let n = COUNTER.fetch_add(1, Ordering::Relaxed);
+ std::env::temp_dir().join(format!(
+ "lookout-clip-{}-{}.mp4",
+ std::process::id(),
+ n
+ ))
+}
+
+/// Records one clip: accepts RGBA frames, hands back MP4 bytes.
+pub struct ClipRecorder {
+ encoder: platform::Encoder,
+ path: PathBuf,
+ width: u32,
+ height: u32,
+ frame_count: u32,
+ frame_interval_ms: u64,
+}
+
+impl ClipRecorder {
+ /// Start a new clip sized to the given frame dimensions (rounded down
+ /// to even — H.264 4:2:0 needs even dims). Later frames that arrive at
+ /// a different size (display change mid-clip) are scaled to fit.
+ pub fn new(width: u32, height: u32, frame_interval_ms: u64) -> Result {
+ let width = (width & !1).max(2);
+ let height = (height & !1).max(2);
+ let path = clip_temp_path();
+ // A failed init can still have created the container — the GStreamer
+ // path opens `filesink` as soon as the pipeline goes Playing, and there
+ // is no ClipRecorder yet whose finish()/discard() would remove it. That
+ // matters more now the loop retries init: a machine with a broken
+ // encoder would drip an orphan into the temp dir per attempt.
+ let encoder = match platform::Encoder::new(
+ &path,
+ width,
+ height,
+ clip_bits_per_second(frame_interval_ms),
+ frame_interval_ms,
+ ) {
+ Ok(e) => e,
+ Err(e) => {
+ let _ = std::fs::remove_file(&path);
+ return Err(e);
+ }
+ };
+ Ok(Self {
+ encoder,
+ path,
+ width,
+ height,
+ frame_count: 0,
+ frame_interval_ms,
+ })
+ }
+
+ pub fn frame_count(&self) -> u32 {
+ self.frame_count
+ }
+
+ /// The in-progress container's path, so tests can assert it was cleaned up
+ /// without scanning the shared OS temp directory.
+ #[cfg(test)]
+ fn temp_path(&self) -> std::path::PathBuf {
+ self.path.clone()
+ }
+
+ /// Append one captured frame. Presentation time advances by the clip
+ /// frame interval per frame, so the clip plays back in real time.
+ pub fn push_frame(&mut self, frame: &DynamicImage) -> Result<(), String> {
+ // Normalize to the encoder's fixed dimensions. Cheap no-op clone
+ // path when dimensions already match (the common case).
+ let bgra = frame_to_bgra(frame, self.width, self.height);
+ let pts_ms = self.frame_count as u64 * self.frame_interval_ms;
+ self.encoder
+ .append_bgra_frame(&bgra, self.width, self.height, pts_ms)?;
+ self.frame_count += 1;
+ Ok(())
+ }
+
+ /// Finalize the container and return its bytes. Consumes the recorder;
+ /// the temp file is always removed.
+ pub fn finish(self) -> Result {
+ // Hold the final duration so the last frame isn't zero-length.
+ let duration_ms = self.frame_count as u64 * self.frame_interval_ms;
+ let result = self.encoder.finish(duration_ms);
+ let bytes = result.and_then(|()| {
+ std::fs::read(&self.path).map_err(|e| format!("failed to read clip file: {e}"))
+ });
+ let _ = std::fs::remove_file(&self.path);
+ let mp4 = bytes?;
+ if self.frame_count == 0 || mp4.is_empty() {
+ return Err("clip has no frames".into());
+ }
+ Ok(FinishedClip {
+ mp4,
+ frame_count: self.frame_count,
+ width: self.width,
+ height: self.height,
+ })
+ }
+
+ /// Abort and clean up without producing a clip (pause/stop mid-minute).
+ pub fn discard(self) {
+ let _ = self.encoder.finish(0);
+ let _ = std::fs::remove_file(&self.path);
+ }
+}
+
+/// Convert a frame to tightly-packed BGRA at exactly (width, height),
+/// scaling (aspect-preserving pillarbox on black) when dimensions differ.
+fn frame_to_bgra(frame: &DynamicImage, width: u32, height: u32) -> Vec {
+ // Common case: dimensions match and the frame is already RGBA8
+ // (captures always are) — swizzle straight from the borrowed buffer
+ // into the output, one copy total.
+ if frame.width() == width && frame.height() == height {
+ if let Some(rgba) = frame.as_rgba8() {
+ let src = rgba.as_raw();
+ let mut bgra = Vec::with_capacity(src.len());
+ for px in src.chunks_exact(4) {
+ bgra.extend_from_slice(&[px[2], px[1], px[0], px[3]]);
+ }
+ return bgra;
+ }
+ }
+
+ // Rare path: mid-clip display change — normalize to the clip's fixed
+ // dimensions with a pillarboxed canvas.
+ let rgba = if frame.width() == width && frame.height() == height {
+ frame.to_rgba8()
+ } else {
+ let scaled = frame.resize(width, height, image::imageops::FilterType::Triangle);
+ let mut canvas = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 255]));
+ let x = (width - scaled.width()) / 2;
+ let y = (height - scaled.height()) / 2;
+ image::imageops::overlay(&mut canvas, &scaled.to_rgba8(), x as i64, y as i64);
+ canvas
+ };
+ let mut bgra = rgba.into_raw();
+ for px in bgra.chunks_exact_mut(4) {
+ px.swap(0, 2);
+ }
+ bgra
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+
+ /// Full round-trip through the real OS encoder: synthetic frames in,
+ /// container bytes out, then ffprobe (when installed) verifies the
+ /// frame count and that the stream decodes.
+ #[test]
+ fn encodes_frames_into_playable_mp4() {
+ let mut recorder = ClipRecorder::new(640, 360, 3000).expect("encoder init");
+ for i in 0u32..5 {
+ let mut img =
+ image::RgbaImage::from_pixel(640, 360, image::Rgba([20, 20, 40, 255]));
+ // Moving block so inter frames aren't empty.
+ for x in 0..80 {
+ for y in 0..80 {
+ img.put_pixel(x + i * 60, y + 40, image::Rgba([220, 90, 40, 255]));
+ }
+ }
+ recorder
+ .push_frame(&DynamicImage::ImageRgba8(img))
+ .expect("push frame");
+ }
+ let clip = recorder.finish().expect("finish clip");
+
+ assert_eq!(clip.frame_count, 5);
+ assert_eq!(clip.width, 640);
+ assert_eq!(clip.height, 360);
+ assert!(clip.mp4.len() > 500, "suspiciously small mp4: {}B", clip.mp4.len());
+ assert_eq!(&clip.mp4[4..8], b"ftyp", "not an MP4 container");
+
+ // Deep verification when ffprobe is on the machine (dev boxes, CI).
+ let probe = std::process::Command::new("ffprobe").arg("-version").output();
+ if probe.is_ok() {
+ let path = clip_temp_path();
+ std::fs::write(&path, &clip.mp4).unwrap();
+ let out = std::process::Command::new("ffprobe")
+ .args([
+ "-v", "error",
+ "-count_packets",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=nb_read_packets,codec_name",
+ "-of", "csv=p=0",
+ ])
+ .arg(&path)
+ .output()
+ .expect("ffprobe run");
+ let _ = std::fs::remove_file(&path);
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert!(
+ stdout.contains("h264"),
+ "expected h264 stream, got: {stdout}"
+ );
+ assert!(
+ stdout.trim().ends_with(",5"),
+ "expected 5 packets, got: {stdout}"
+ );
+
+ // GOP shape: exactly ONE keyframe. Frames are seconds apart in
+ // media time, so a default max-keyframe-interval-duration makes
+ // the encoder emit ALL-keyframe clips — which rations the
+ // bitrate budget across 20 I-frames and produces uniformly soft
+ // output (~20KB/frame). One IDR + cheap P-frames is the shape
+ // that lets the keyframe stay crisp.
+ let path2 = clip_temp_path();
+ std::fs::write(&path2, &clip.mp4).unwrap();
+ let frames_out = std::process::Command::new("ffprobe")
+ .args([
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "frame=key_frame",
+ "-of", "csv=p=0",
+ ])
+ .arg(&path2)
+ .output()
+ .expect("ffprobe frames run");
+ let _ = std::fs::remove_file(&path2);
+ let keyframes = String::from_utf8_lossy(&frames_out.stdout)
+ .lines()
+ .filter(|l| l.trim_end_matches(',') == "1")
+ .count();
+ assert_eq!(
+ keyframes, 1,
+ "expected exactly 1 keyframe in the clip, got {keyframes}"
+ );
+ } else {
+ eprintln!("ffprobe not found — container-level checks only");
+ }
+ }
+
+ /// The bitrate must buy the same bytes per FRAME at any cadence — that
+ /// invariant is the whole reason it's derived instead of hardcoded.
+ #[test]
+ fn bitrate_holds_per_frame_quality_across_cadences() {
+ for interval_ms in [2_000u64, 4_000, 12_000, 30_000] {
+ let bytes_per_frame =
+ (clip_bits_per_second(interval_ms) as u64 * interval_ms) / (8 * 1000);
+ let drift = bytes_per_frame.abs_diff(CLIP_FRAME_BYTE_BUDGET);
+ assert!(
+ drift <= CLIP_FRAME_BYTE_BUDGET / 100,
+ "at {interval_ms}ms a frame gets {bytes_per_frame}B, want ~{CLIP_FRAME_BYTE_BUDGET}B"
+ );
+ }
+
+ // The 4s cadence is the one that was measured and tuned by hand at
+ // 800 kbps. Reproducing it exactly is what makes the formula
+ // trustworthy at every other cadence.
+ assert_eq!(clip_bits_per_second(4_000), 800_000);
+
+ // And a whole clip has to stay clear of the server's 8 MB limit at
+ // the cadence actually shipping.
+ let frames_per_clip = 60_000 / 10_000;
+ assert!(
+ frames_per_clip * CLIP_FRAME_BYTE_BUDGET < 8 * 1024 * 1024,
+ "nominal clip exceeds MAX_CLIP_BYTES"
+ );
+ }
+
+ /// Resident-set size of this process, in KB, via `ps`. Crude on purpose —
+ /// good enough to tell a leak from steady state, and needs no dependency.
+ #[cfg(test)]
+ fn rss_kb() -> u64 {
+ let out = std::process::Command::new("ps")
+ .args(["-o", "rss=", "-p"])
+ .arg(std::process::id().to_string())
+ .output()
+ .expect("ps");
+ String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0)
+ }
+
+ /// Leak check for the encode cycle: many recorders, many frames each, all
+ /// finished properly. The capture loop runs one of these per minute for as
+ /// long as a session lasts (up to 12 hours = 720 cycles), so a per-cycle
+ /// leak in the CVPixelBuffer / AVAssetWriter handling would accumulate into
+ /// something a user notices.
+ ///
+ /// Ignored by default: it's a few seconds of real encoding and it shells
+ /// out to `ps`. Run with `cargo test --release -- --ignored leak`.
+ #[test]
+ #[ignore = "stress test — run explicitly"]
+ fn encode_cycle_does_not_leak() {
+ let frame = |i: u32| {
+ let mut img = image::RgbaImage::from_pixel(1280, 720, image::Rgba([30, 30, 40, 255]));
+ for x in 0..120u32 {
+ for y in 0..120u32 {
+ img.put_pixel((x + i * 37) % 1280, (y + i * 11) % 720,
+ image::Rgba([200, 80, 40, 255]));
+ }
+ }
+ DynamicImage::ImageRgba8(img)
+ };
+
+ // Warm up so one-time allocations (framework init, codec tables) don't
+ // read as growth.
+ for _ in 0..3 {
+ let mut r = ClipRecorder::new(1280, 720, 10_000).expect("init");
+ for i in 0..7 { r.push_frame(&frame(i)).expect("push"); }
+ r.finish().expect("finish");
+ }
+
+ let before = rss_kb();
+ let cycles: u32 = std::env::var("LEAK_CYCLES")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(40);
+ for c in 0..cycles {
+ let mut r = ClipRecorder::new(1280, 720, 10_000).expect("init");
+ for i in 0..7 { r.push_frame(&frame(c * 7 + i)).expect("push"); }
+ let clip = r.finish().expect("finish");
+ assert!(!clip.mp4.is_empty());
+ }
+ let after = rss_kb();
+
+ let growth = after.saturating_sub(before);
+ eprintln!(
+ "RSS {before} -> {after} KB over {cycles} encode cycles ({} KB/cycle)",
+ growth / u64::from(cycles)
+ );
+ // A genuine per-cycle leak of a 1280x720 BGRA buffer would be ~3.6MB
+ // each, i.e. ~144MB over this run. Allow generous headroom for
+ // allocator behaviour and VideoToolbox's own caches while still
+ // catching anything of that order.
+ // Scale the budget with the run so a deeper LEAK_CYCLES run stays a
+ // real assertion rather than a formality.
+ let budget = 20_000 + 500 * u64::from(cycles);
+ assert!(
+ growth < budget,
+ "RSS grew {growth} KB over {cycles} cycles (budget {budget}) — suspected leak"
+ );
+ }
+
+ /// Discarding a recorder mid-clip must release just as cleanly as
+ /// finishing one. This is the pause/stop path, and on Windows it is also
+ /// the path that has to balance MFStartup.
+ #[test]
+ #[ignore = "stress test — run explicitly"]
+ fn discard_path_does_not_leak() {
+ let img = || {
+ DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+ 1280, 720, image::Rgba([10, 20, 30, 255]),
+ ))
+ };
+ for _ in 0..3 {
+ let mut r = ClipRecorder::new(1280, 720, 10_000).expect("init");
+ r.push_frame(&img()).expect("push");
+ r.discard();
+ }
+ let before = rss_kb();
+ let cycles: u32 = std::env::var("LEAK_CYCLES")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(40);
+ for _ in 0..cycles {
+ let mut r = ClipRecorder::new(1280, 720, 10_000).expect("init");
+ for _ in 0..4 { r.push_frame(&img()).expect("push"); }
+ r.discard();
+ }
+ let growth = rss_kb().saturating_sub(before);
+ eprintln!("RSS growth over {cycles} discard cycles: {growth} KB");
+ let budget = 20_000 + 500 * u64::from(cycles);
+ assert!(
+ growth < budget,
+ "RSS grew {growth} KB over {cycles} cycles (budget {budget}) — suspected leak on discard"
+ );
+ }
+
+ /// Encode cost at real capture resolution. The capture loop does this on a
+ /// tokio worker while the user works, so the number that matters is CPU per
+ /// captured frame — at 6 frames/min a millisecond here is nothing, but a
+ /// regression into hundreds would be felt on an old laptop.
+ ///
+ /// Run with `cargo test --release -- --ignored perf --nocapture`.
+ #[test]
+ #[ignore = "benchmark — run explicitly"]
+ fn encode_cost_at_1080p() {
+ let frame = |i: u32| {
+ // Dense detail so the encoder can't cheat: this is the worst case,
+ // a screen full of text and edges.
+ let mut img = image::RgbaImage::new(1920, 1080);
+ for (x, y, px) in img.enumerate_pixels_mut() {
+ let v = ((x * 7 + y * 13 + i * 29) % 256) as u8;
+ *px = image::Rgba([v, v.wrapping_mul(3), v.wrapping_add(90), 255]);
+ }
+ DynamicImage::ImageRgba8(img)
+ };
+ let frames: Vec<_> = (0..7).map(frame).collect();
+
+ // Warm up the codec.
+ {
+ let mut r = ClipRecorder::new(1920, 1080, 10_000).expect("init");
+ for f in &frames { r.push_frame(f).expect("push"); }
+ r.finish().expect("finish");
+ }
+
+ const CLIPS: u32 = 10;
+ let t0 = std::time::Instant::now();
+ let mut bytes = 0usize;
+ for _ in 0..CLIPS {
+ let mut r = ClipRecorder::new(1920, 1080, 10_000).expect("init");
+ for f in &frames { r.push_frame(f).expect("push"); }
+ bytes += r.finish().expect("finish").mp4.len();
+ }
+ let per_clip = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(CLIPS);
+ let per_frame = per_clip / frames.len() as f64;
+ eprintln!(
+ "1080p worst-case: {per_clip:.1} ms/clip, {per_frame:.1} ms/frame, \
+{} KB/clip avg",
+ bytes / CLIPS as usize / 1024
+ );
+
+ // One clip a minute: even 2s/clip would be 3% of a core. This ceiling
+ // is loose on purpose — it exists to catch an order-of-magnitude
+ // regression, not to police jitter on a shared CI box.
+ assert!(per_clip < 2_000.0, "encode cost regressed: {per_clip:.0} ms/clip");
+ }
+
+ /// Temp files must not accumulate. Each clip writes a container to the OS
+ /// temp dir and must remove it on every exit path; a session leaking one a
+ /// minute would fill a small disk.
+ ///
+ /// Asserts on each recorder's OWN path rather than scanning the temp
+ /// directory: that directory is shared by every test in the process, so a
+ /// count-based check reports another test's in-flight file as a leak. (It
+ /// did exactly that in CI.)
+ #[test]
+ fn clip_temp_files_are_always_removed() {
+ let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+ 320, 240, image::Rgba([1, 2, 3, 255]),
+ ));
+
+ // finish(): the happy path.
+ let mut r = ClipRecorder::new(320, 240, 10_000).expect("init");
+ let finished = r.temp_path();
+ r.push_frame(&img).expect("push");
+ r.finish().expect("finish");
+ assert!(!finished.exists(), "finish() left {finished:?}");
+
+ // discard(): pause/stop mid-clip.
+ let mut r = ClipRecorder::new(320, 240, 10_000).expect("init");
+ let discarded = r.temp_path();
+ r.push_frame(&img).expect("push");
+ r.discard();
+ assert!(!discarded.exists(), "discard() left {discarded:?}");
+
+ // finish() on a clip with no frames: returns Err, and must STILL clean
+ // up. This is the path a paused-immediately session takes.
+ let r = ClipRecorder::new(320, 240, 10_000).expect("init");
+ let errored = r.temp_path();
+ assert!(r.finish().is_err());
+ assert!(!errored.exists(), "failed finish() left {errored:?}");
+ }
+
+ /// A recorder with zero frames must fail, not produce an empty clip.
+ #[test]
+ fn empty_clip_errors() {
+ let recorder = ClipRecorder::new(640, 360, 3000).expect("encoder init");
+ assert!(recorder.finish().is_err());
+ }
+
+ /// Mid-clip resolution changes are normalized to the clip's dimensions.
+ #[test]
+ fn resized_frames_are_normalized() {
+ let mut recorder = ClipRecorder::new(640, 360, 3000).expect("encoder init");
+ let small = image::RgbaImage::from_pixel(320, 200, image::Rgba([255, 0, 0, 255]));
+ recorder
+ .push_frame(&DynamicImage::ImageRgba8(small))
+ .expect("push mismatched frame");
+ let big = image::RgbaImage::from_pixel(1920, 1080, image::Rgba([0, 255, 0, 255]));
+ recorder
+ .push_frame(&DynamicImage::ImageRgba8(big))
+ .expect("push mismatched frame");
+ let clip = recorder.finish().expect("finish clip");
+ assert_eq!(clip.frame_count, 2);
+ assert_eq!((clip.width, clip.height), (640, 360));
+ }
+}
+
+// ── macOS: AVAssetWriter (VideoToolbox) ─────────────────────────────
+
+#[cfg(target_os = "macos")]
+mod platform {
+ use std::path::Path;
+ use std::ptr::NonNull;
+
+ use block2::RcBlock;
+ use objc2::rc::{autoreleasepool, Retained};
+ use objc2::runtime::{AnyObject, ProtocolObject};
+ use objc2_av_foundation::{
+ AVAssetWriter, AVAssetWriterInput, AVAssetWriterInputPixelBufferAdaptor,
+ AVAssetWriterStatus, AVFileTypeMPEG4, AVMediaTypeVideo,
+ AVVideoAllowFrameReorderingKey, AVVideoAverageBitRateKey, AVVideoCodecKey,
+ AVVideoCodecTypeH264, AVVideoCompressionPropertiesKey,
+ AVVideoExpectedSourceFrameRateKey, AVVideoHeightKey,
+ AVVideoMaxKeyFrameIntervalKey, AVVideoWidthKey,
+ };
+ use objc2_core_media::CMTime;
+ use objc2_core_video::{
+ kCVPixelFormatType_32BGRA, CVPixelBuffer, CVPixelBufferCreate,
+ CVPixelBufferGetBaseAddress, CVPixelBufferGetBytesPerRow, CVPixelBufferLockBaseAddress,
+ CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress,
+ };
+ use objc2_foundation::{NSMutableDictionary, NSNumber, NSString, NSURL};
+
+ fn ms_time(ms: u64) -> CMTime {
+ unsafe { CMTime::new(ms as i64, 1000) }
+ }
+
+ pub struct Encoder {
+ writer: Retained,
+ input: Retained,
+ adaptor: Retained,
+ started: bool,
+ }
+
+ // AVAssetWriter and friends are documented thread-safe for this usage
+ // pattern (single writer thread); the recorder is driven from one loop.
+ unsafe impl Send for Encoder {}
+
+ impl Encoder {
+ pub fn new(
+ path: &Path,
+ width: u32,
+ height: u32,
+ bitrate: u32,
+ frame_interval_ms: u64,
+ ) -> Result {
+ // Every Cocoa object built below is autoreleased, and the capture
+ // loop calls this from a tokio worker thread — which, unlike the
+ // main run loop, never drains a pool. Without an explicit one the
+ // settings dictionaries and the writer itself accumulate for the
+ // life of the process: measured ~4.9 MB per clip, i.e. GBs over a
+ // long session.
+ autoreleasepool(|_| unsafe {
+ let url = NSURL::fileURLWithPath(&NSString::from_str(
+ path.to_str().ok_or("non-utf8 temp path")?,
+ ));
+
+ // Weak-linked framework constants come through as Options.
+ let file_type = AVFileTypeMPEG4.ok_or("AVFileTypeMPEG4 unavailable")?;
+ let media_video = AVMediaTypeVideo.ok_or("AVMediaTypeVideo unavailable")?;
+ let codec_h264 = AVVideoCodecTypeH264.ok_or("AVVideoCodecTypeH264 unavailable")?;
+ let key_codec = AVVideoCodecKey.ok_or("AVVideoCodecKey unavailable")?;
+ let key_width = AVVideoWidthKey.ok_or("AVVideoWidthKey unavailable")?;
+ let key_height = AVVideoHeightKey.ok_or("AVVideoHeightKey unavailable")?;
+ let key_compression =
+ AVVideoCompressionPropertiesKey.ok_or("AVVideoCompressionPropertiesKey unavailable")?;
+ let key_bitrate =
+ AVVideoAverageBitRateKey.ok_or("AVVideoAverageBitRateKey unavailable")?;
+ let key_max_kf =
+ AVVideoMaxKeyFrameIntervalKey.ok_or("AVVideoMaxKeyFrameIntervalKey unavailable")?;
+ let key_reorder = AVVideoAllowFrameReorderingKey
+ .ok_or("AVVideoAllowFrameReorderingKey unavailable")?;
+ let key_expected_fps = AVVideoExpectedSourceFrameRateKey
+ .ok_or("AVVideoExpectedSourceFrameRateKey unavailable")?;
+
+ let writer = AVAssetWriter::assetWriterWithURL_fileType_error(&url, file_type)
+ .map_err(|e| format!("AVAssetWriter init failed: {e}"))?;
+
+ // {AVVideoCodecKey: h264, AVVideoWidthKey, AVVideoHeightKey,
+ // AVVideoCompressionPropertiesKey: {AVVideoAverageBitRateKey}}
+ let compression: Retained> =
+ NSMutableDictionary::new();
+ compression.setObject_forKey(
+ NSNumber::new_u32(bitrate).as_ref(),
+ ProtocolObject::from_ref(key_bitrate),
+ );
+ // One IDR per clip: frames sit seconds apart in media time,
+ // so any keyframe-interval default expressed in seconds
+ // would turn the whole clip into rationed I-frames —
+ // uniformly soft. One crisp keyframe + cheap P-frames is
+ // the intended shape.
+ compression.setObject_forKey(
+ NSNumber::new_u32(1200).as_ref(),
+ ProtocolObject::from_ref(key_max_kf),
+ );
+ // No B-frames: pointless at this cadence and they add
+ // reorder latency/complexity.
+ compression.setObject_forKey(
+ NSNumber::new_bool(false).as_ref(),
+ ProtocolObject::from_ref(key_reorder),
+ );
+ // Rate-control hint: the source is ~1 frame/interval, not
+ // 30fps — lets the encoder budget bits per frame correctly.
+ // The key is integer fps, so any interval at or above one
+ // second floors to 1; that's the honest answer and matches
+ // the measured behaviour (VideoToolbox budgets against the
+ // real presentation timestamps we hand it, which is why
+ // `bitrate` is derived from the cadence rather than fixed).
+ let expected_fps = (1000 / frame_interval_ms.max(1)).max(1) as u32;
+ compression.setObject_forKey(
+ NSNumber::new_u32(expected_fps).as_ref(),
+ ProtocolObject::from_ref(key_expected_fps),
+ );
+
+ let settings: Retained> =
+ NSMutableDictionary::new();
+ settings.setObject_forKey(
+ codec_h264.as_ref(),
+ ProtocolObject::from_ref(key_codec),
+ );
+ settings.setObject_forKey(
+ NSNumber::new_u32(width).as_ref(),
+ ProtocolObject::from_ref(key_width),
+ );
+ settings.setObject_forKey(
+ NSNumber::new_u32(height).as_ref(),
+ ProtocolObject::from_ref(key_height),
+ );
+ settings.setObject_forKey(
+ compression.as_ref(),
+ ProtocolObject::from_ref(key_compression),
+ );
+
+ let input = AVAssetWriterInput::assetWriterInputWithMediaType_outputSettings(
+ media_video,
+ Some(&*settings),
+ );
+ // Live source: encode as frames arrive instead of buffering.
+ input.setExpectsMediaDataInRealTime(true);
+
+ if !writer.canAddInput(&input) {
+ return Err("AVAssetWriter rejected video input".into());
+ }
+ writer.addInput(&input);
+
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor::assetWriterInputPixelBufferAdaptorWithAssetWriterInput_sourcePixelBufferAttributes(&input, None);
+
+ Ok(Self {
+ writer,
+ input,
+ adaptor,
+ started: false,
+ })
+ })
+ }
+
+ pub fn append_bgra_frame(
+ &mut self,
+ bgra: &[u8],
+ width: u32,
+ height: u32,
+ pts_ms: u64,
+ ) -> Result<(), String> {
+ // Per-frame pool: this is the hottest of the three entry points,
+ // and CVPixelBufferCreate's buffer is only one of several objects
+ // the frameworks autorelease on the way through.
+ autoreleasepool(|_| unsafe {
+ if !self.started {
+ if !self.writer.startWriting() {
+ return Err(format!(
+ "AVAssetWriter startWriting failed: {:?}",
+ self.writer.error()
+ ));
+ }
+ self.writer.startSessionAtSourceTime(ms_time(0));
+ self.started = true;
+ }
+
+ // Wait (bounded) for the encoder to drain. With realtime
+ // input and 3s between frames this is virtually always
+ // immediate.
+ let mut waited_ms = 0u64;
+ while !self.input.isReadyForMoreMediaData() {
+ if waited_ms > 2_000 {
+ return Err("encoder not ready after 2s".into());
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ waited_ms += 10;
+ }
+
+ // BGRA CVPixelBuffer, row-by-row copy (CV row stride may
+ // exceed width*4).
+ let mut pb_out: *mut CVPixelBuffer = std::ptr::null_mut();
+ let ret = CVPixelBufferCreate(
+ None,
+ width as usize,
+ height as usize,
+ kCVPixelFormatType_32BGRA,
+ None,
+ NonNull::from(&mut pb_out),
+ );
+ if ret != 0 || pb_out.is_null() {
+ return Err(format!("CVPixelBufferCreate failed: {ret}"));
+ }
+ // Take ownership so the buffer is released on all paths.
+ let pb = Retained::from_raw(pb_out).ok_or("null pixel buffer")?;
+
+ CVPixelBufferLockBaseAddress(&pb, CVPixelBufferLockFlags::empty());
+ let base = CVPixelBufferGetBaseAddress(&pb) as *mut u8;
+ let dst_stride = CVPixelBufferGetBytesPerRow(&pb);
+ let src_stride = (width * 4) as usize;
+ for row in 0..height as usize {
+ std::ptr::copy_nonoverlapping(
+ bgra.as_ptr().add(row * src_stride),
+ base.add(row * dst_stride),
+ src_stride,
+ );
+ }
+ CVPixelBufferUnlockBaseAddress(&pb, CVPixelBufferLockFlags::empty());
+
+ if !self
+ .adaptor
+ .appendPixelBuffer_withPresentationTime(&pb, ms_time(pts_ms))
+ {
+ return Err(format!(
+ "appendPixelBuffer failed: {:?}",
+ self.writer.error()
+ ));
+ }
+ Ok(())
+ })
+ }
+
+ pub fn finish(self, duration_ms: u64) -> Result<(), String> {
+ autoreleasepool(|_| unsafe {
+ if !self.started {
+ // Nothing was written; cancel to avoid a zero-byte file
+ // error from finishWriting.
+ self.writer.cancelWriting();
+ return Err("no frames written".into());
+ }
+ self.input.markAsFinished();
+ self.writer.endSessionAtSourceTime(ms_time(duration_ms));
+
+ let (tx, rx) = std::sync::mpsc::channel::<()>();
+ let block = RcBlock::new(move || {
+ let _ = tx.send(());
+ });
+ self.writer.finishWritingWithCompletionHandler(&block);
+ rx.recv_timeout(std::time::Duration::from_secs(15))
+ .map_err(|_| "finishWriting timed out".to_string())?;
+
+ if self.writer.status() != AVAssetWriterStatus::Completed {
+ return Err(format!(
+ "AVAssetWriter finished with status {:?}: {:?}",
+ self.writer.status(),
+ self.writer.error()
+ ));
+ }
+ Ok(())
+ })
+ }
+ }
+}
+
+// ── Windows: Media Foundation sink writer ───────────────────────────
+
+#[cfg(target_os = "windows")]
+mod platform {
+ use std::path::Path;
+
+ use windows::core::HSTRING;
+ use windows::Win32::Media::MediaFoundation::{
+ IMFMediaType, IMFSample, IMFSinkWriter, MFCreateMediaType, MFCreateMemoryBuffer,
+ MFCreateSample, MFCreateSinkWriterFromURL, MFShutdown, MFStartup, MFSTARTUP_FULL,
+ MFVideoFormat_H264, MFVideoFormat_RGB32, MFVideoInterlace_Progressive,
+ MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE,
+ MF_MT_MAJOR_TYPE, MF_MT_MAX_KEYFRAME_SPACING, MF_MT_SUBTYPE, MF_VERSION,
+ MFMediaType_Video,
+ };
+ use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
+
+ /// Media Foundation needs COM initialized on the calling thread, and the
+ /// capture loop's calls land on tokio worker threads that never did so.
+ /// Refcounted and idempotent per thread; S_FALSE (already initialized)
+ /// and RPC_E_CHANGED_MODE (thread is STA) are both fine for the sink
+ /// writer, so the result is deliberately ignored. Called at the top of
+ /// every encoder entry point because consecutive async calls may run on
+ /// different pool threads.
+ fn ensure_com() {
+ unsafe {
+ let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
+ }
+ }
+
+ /// Pack two u32s into the u64 layout MF uses for SIZE/RATIO attributes.
+ fn pack_u64(hi: u32, lo: u32) -> u64 {
+ ((hi as u64) << 32) | lo as u64
+ }
+
+ /// Balances one `MFStartup` on drop.
+ ///
+ /// MFStartup/MFShutdown are refcounted, and encoder construction has a
+ /// dozen fallible steps after the startup call. Every one of those early
+ /// returns used to leak a refcount — invisible on a healthy machine
+ /// (init succeeds, finish() balances it), unbounded on one whose encoder
+ /// always fails, because the capture loop retries init on every frame
+ /// for the length of the session. `std::mem::forget` on the success path
+ /// hands the refcount to the encoder instead.
+ struct MfStartupGuard;
+
+ impl Drop for MfStartupGuard {
+ fn drop(&mut self) {
+ unsafe {
+ let _ = MFShutdown();
+ }
+ }
+ }
+
+ pub struct Encoder {
+ writer: IMFSinkWriter,
+ stream_index: u32,
+ width: u32,
+ height: u32,
+ frame_interval_ms: u64,
+ }
+
+ // Single-threaded use from the capture loop.
+ unsafe impl Send for Encoder {}
+
+ impl Encoder {
+ /// Two attempts, in order of correctness:
+ ///
+ /// 1. Declare the REAL sub-1fps cadence as a ratio (1000 :
+ /// interval_ms) and hand over the per-media-second bitrate. Both
+ /// readings of `MF_MT_AVG_BITRATE` — bits per second of media
+ /// time, or bits per declared frame — then agree on the same
+ /// ~CLIP_FRAME_BYTE_BUDGET per frame.
+ /// 2. If the MFT refuses that media type (some hardware encoders
+ /// reject fractional frame rates outright), fall back to the
+ /// 1 fps hint this code shipped with, and scale the bitrate to
+ /// match so the per-frame budget is preserved rather than
+ /// silently divided by the interval.
+ ///
+ /// Only if BOTH fail does the caller fall back to a JPEG for the
+ /// interval. Windows per-frame output has not been measured on real
+ /// hardware the way the macOS path has; if clips come back soft or
+ /// oversize, this pair of attempts is where to look first.
+ pub fn new(
+ path: &Path,
+ width: u32,
+ height: u32,
+ bitrate: u32,
+ frame_interval_ms: u64,
+ ) -> Result {
+ let interval_ms = frame_interval_ms.max(1);
+ match Self::try_new(path, width, height, bitrate, interval_ms, 1000, interval_ms as u32)
+ {
+ Ok(enc) => Ok(enc),
+ Err(real_cadence_err) => {
+ let per_frame_bitrate = ((bitrate as u64 * interval_ms) / 1000)
+ .min(u32::MAX as u64) as u32;
+ eprintln!(
+ "[clips] Media Foundation rejected the {interval_ms}ms cadence \
+ ({real_cadence_err}) — retrying at a 1fps hint"
+ );
+ Self::try_new(path, width, height, per_frame_bitrate, interval_ms, 1, 1)
+ }
+ }
+ }
+
+ fn try_new(
+ path: &Path,
+ width: u32,
+ height: u32,
+ bitrate: u32,
+ frame_interval_ms: u64,
+ frame_rate_num: u32,
+ frame_rate_den: u32,
+ ) -> Result {
+ ensure_com();
+ unsafe {
+ // Idempotent per-process init (returns S_OK on repeat calls).
+ MFStartup(MF_VERSION, MFSTARTUP_FULL)
+ .map_err(|e| format!("MFStartup failed: {e}"))?;
+
+ // From here on every early return must balance that startup.
+ // Without this the refcount leaked once per failed init —
+ // and a machine whose encoder always fails attempts one per
+ // frame, for the length of the session.
+ let guard = MfStartupGuard;
+
+ let writer: IMFSinkWriter = MFCreateSinkWriterFromURL(
+ &HSTRING::from(path.to_string_lossy().as_ref()),
+ None,
+ None,
+ )
+ .map_err(|e| format!("MFCreateSinkWriterFromURL failed: {e}"))?;
+
+ // Output: H.264 at the clip bitrate. Frame timing is also
+ // carried per-sample; the rate attribute seeds the encoder's
+ // rate control, so it and `bitrate` have to agree about what
+ // a "second" means (see `new`).
+ let out_type: IMFMediaType =
+ MFCreateMediaType().map_err(|e| format!("MFCreateMediaType: {e}"))?;
+ out_type
+ .SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)
+ .map_err(|e| e.to_string())?;
+ out_type
+ .SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)
+ .map_err(|e| e.to_string())?;
+ out_type
+ .SetUINT32(&MF_MT_AVG_BITRATE, bitrate)
+ .map_err(|e| e.to_string())?;
+ out_type
+ .SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)
+ .map_err(|e| e.to_string())?;
+ out_type
+ .SetUINT64(&MF_MT_FRAME_SIZE, pack_u64(width, height))
+ .map_err(|e| e.to_string())?;
+ out_type
+ .SetUINT64(
+ &MF_MT_FRAME_RATE,
+ pack_u64(frame_rate_num, frame_rate_den),
+ )
+ .map_err(|e| e.to_string())?;
+ // One IDR per clip (see the macOS encoder for rationale).
+ out_type
+ .SetUINT32(&MF_MT_MAX_KEYFRAME_SPACING, 10_000)
+ .map_err(|e| e.to_string())?;
+ let stream_index = writer
+ .AddStream(&out_type)
+ .map_err(|e| format!("AddStream failed: {e}"))?;
+
+ // Input: BGRA (MF calls it RGB32); the sink writer inserts
+ // the color converter to the encoder's NV12 automatically.
+ let in_type: IMFMediaType =
+ MFCreateMediaType().map_err(|e| format!("MFCreateMediaType: {e}"))?;
+ in_type
+ .SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)
+ .map_err(|e| e.to_string())?;
+ in_type
+ .SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_RGB32)
+ .map_err(|e| e.to_string())?;
+ in_type
+ .SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)
+ .map_err(|e| e.to_string())?;
+ in_type
+ .SetUINT64(&MF_MT_FRAME_SIZE, pack_u64(width, height))
+ .map_err(|e| e.to_string())?;
+ in_type
+ .SetUINT64(
+ &MF_MT_FRAME_RATE,
+ pack_u64(frame_rate_num, frame_rate_den),
+ )
+ .map_err(|e| e.to_string())?;
+ writer
+ .SetInputMediaType(stream_index, &in_type, None)
+ .map_err(|e| format!("SetInputMediaType failed: {e}"))?;
+
+ writer
+ .BeginWriting()
+ .map_err(|e| format!("BeginWriting failed: {e}"))?;
+
+ // Success: ownership of the MFStartup refcount passes to the
+ // encoder, which balances it in finish().
+ std::mem::forget(guard);
+ Ok(Self {
+ writer,
+ stream_index,
+ width,
+ height,
+ frame_interval_ms,
+ })
+ }
+ }
+
+ pub fn append_bgra_frame(
+ &mut self,
+ bgra: &[u8],
+ width: u32,
+ height: u32,
+ pts_ms: u64,
+ ) -> Result<(), String> {
+ debug_assert_eq!((width, height), (self.width, self.height));
+ ensure_com();
+ unsafe {
+ let byte_len = (width * height * 4) as u32;
+ let buffer = MFCreateMemoryBuffer(byte_len)
+ .map_err(|e| format!("MFCreateMemoryBuffer: {e}"))?;
+
+ let mut data_ptr: *mut u8 = std::ptr::null_mut();
+ buffer
+ .Lock(&mut data_ptr, None, None)
+ .map_err(|e| e.to_string())?;
+ // MF RGB32 with positive stride is bottom-up; flip rows so
+ // the frame is right side up.
+ let stride = (width * 4) as usize;
+ for row in 0..height as usize {
+ let src = bgra.as_ptr().add(row * stride);
+ let dst = data_ptr.add((height as usize - 1 - row) * stride);
+ std::ptr::copy_nonoverlapping(src, dst, stride);
+ }
+ buffer.Unlock().map_err(|e| e.to_string())?;
+ buffer
+ .SetCurrentLength(byte_len)
+ .map_err(|e| e.to_string())?;
+
+ let sample: IMFSample =
+ MFCreateSample().map_err(|e| format!("MFCreateSample: {e}"))?;
+ sample.AddBuffer(&buffer).map_err(|e| e.to_string())?;
+ // MF time units: 100ns.
+ sample
+ .SetSampleTime((pts_ms * 10_000) as i64)
+ .map_err(|e| e.to_string())?;
+ // Duration must be the REAL frame interval. This was pinned
+ // at 3000ms — correct only for a cadence the app no longer
+ // uses — which left every sample claiming a span that
+ // disagreed with its own presentation timestamps.
+ sample
+ .SetSampleDuration((self.frame_interval_ms * 10_000) as i64)
+ .map_err(|e| e.to_string())?;
+
+ self.writer
+ .WriteSample(self.stream_index, &sample)
+ .map_err(|e| format!("WriteSample failed: {e}"))
+ }
+ }
+
+ pub fn finish(self, _duration_ms: u64) -> Result<(), String> {
+ ensure_com();
+ unsafe {
+ let result = self
+ .writer
+ .Finalize()
+ .map_err(|e| format!("sink writer Finalize failed: {e}"));
+ // Balance MFStartup from new() on BOTH paths — an early
+ // return here would leak a startup refcount per failed clip.
+ let _ = MFShutdown();
+ result
+ }
+ }
+ }
+}
+
+// ── Linux: GStreamer (already a dependency for PipeWire capture) ────
+
+#[cfg(target_os = "linux")]
+mod platform {
+ use std::path::Path;
+
+ use gstreamer as gst;
+ use gstreamer::prelude::*;
+ use gstreamer_app::AppSrc;
+
+ /// Encoders in preference order: VA-API hardware first, then software
+ /// fallbacks. Availability differs per distro/GPU; first that exists
+ /// wins.
+ ///
+ /// PACKAGING: none of these are guaranteed present, and they live in
+ /// different packages from the ones the pipeline's other elements need.
+ /// The full Linux runtime set, declared in tauri.conf.json's
+ /// `bundle.linux`:
+ ///
+ /// pipewiresrc gstreamer1.0-pipewire (capture — see pipewire.rs)
+ /// videoconvert gstreamer1.0-plugins-base (capture + clips)
+ /// mp4mux gstreamer1.0-plugins-good
+ /// h264parse gstreamer1.0-plugins-bad
+ /// x264enc gstreamer1.0-plugins-ugly (Recommends, not Depends)
+ ///
+ /// The encoder is deliberately a soft dependency: -ugly carries GPL x264,
+ /// and gstreamer1-plugins-ugly isn't in Fedora proper at all, so a hard
+ /// dependency would either impose that licence or make the package
+ /// uninstallable. Missing it is survivable — `ClipRecorder::new` fails,
+ /// the interval falls back to a JPEG, and after MAX_CLIP_ENCODER_FAILURES
+ /// the loop stops trying. A user with no encoder gets the legacy
+ /// one-frame-per-minute recording rather than a broken app.
+ const ENCODER_CANDIDATES: &[&str] = &["vah264enc", "vaapih264enc", "x264enc", "openh264enc"];
+
+ pub struct Encoder {
+ pipeline: gst::Pipeline,
+ appsrc: AppSrc,
+ width: u32,
+ height: u32,
+ }
+
+ unsafe impl Send for Encoder {}
+
+ impl Encoder {
+ /// `frame_interval_ms` is unused here: the pipeline declares
+ /// `framerate=0/1` (variable) and carries timing per-buffer, and the
+ /// bitrate the caller passes is already scaled for the cadence.
+ pub fn new(
+ path: &Path,
+ width: u32,
+ height: u32,
+ bitrate: u32,
+ _frame_interval_ms: u64,
+ ) -> Result {
+ gst::init().map_err(|e| format!("gst init failed: {e}"))?;
+
+ let encoder_name = ENCODER_CANDIDATES
+ .iter()
+ .find(|name| gst::ElementFactory::find(name).is_some())
+ .ok_or("no H.264 encoder element available")?;
+
+ // x264enc wants kbit/s; the VA encoders take kbps too;
+ // openh264enc uses bps. x264enc's default `medium` preset burns
+ // 5-10x the CPU this job needs — superfast + zerolatency keeps
+ // the software fallback cheap at screen-recording quality.
+ let encoder_props = match *encoder_name {
+ "openh264enc" => format!("bitrate={bitrate}"),
+ "x264enc" => format!(
+ "bitrate={} speed-preset=superfast tune=zerolatency",
+ (bitrate / 1000).max(1)
+ ),
+ _ => format!("bitrate={}", (bitrate / 1000).max(1)),
+ };
+
+ let desc = format!(
+ "appsrc name=src is-live=false format=time \
+ caps=video/x-raw,format=BGRA,width={width},height={height},framerate=0/1 \
+ ! videoconvert ! {encoder_name} {encoder_props} \
+ ! h264parse ! mp4mux ! filesink location=\"{}\"",
+ path.to_string_lossy()
+ );
+ let pipeline = gst::parse::launch(&desc)
+ .map_err(|e| format!("gst pipeline parse failed: {e}"))?
+ .downcast::()
+ .map_err(|_| "not a pipeline".to_string())?;
+
+ let appsrc = pipeline
+ .by_name("src")
+ .ok_or("appsrc missing")?
+ .downcast::()
+ .map_err(|_| "appsrc cast failed".to_string())?;
+
+ pipeline
+ .set_state(gst::State::Playing)
+ .map_err(|e| format!("gst set_state failed: {e}"))?;
+
+ Ok(Self {
+ pipeline,
+ appsrc,
+ width,
+ height,
+ })
+ }
+
+ pub fn append_bgra_frame(
+ &mut self,
+ bgra: &[u8],
+ width: u32,
+ height: u32,
+ pts_ms: u64,
+ ) -> Result<(), String> {
+ debug_assert_eq!((width, height), (self.width, self.height));
+ let mut buffer = gst::Buffer::with_size(bgra.len())
+ .map_err(|e| format!("gst buffer alloc failed: {e}"))?;
+ {
+ let buffer_mut = buffer.get_mut().ok_or("buffer not writable")?;
+ buffer_mut.set_pts(gst::ClockTime::from_mseconds(pts_ms));
+ let mut map = buffer_mut
+ .map_writable()
+ .map_err(|e| format!("gst buffer map failed: {e}"))?;
+ map.copy_from_slice(bgra);
+ }
+ self.appsrc
+ .push_buffer(buffer)
+ .map_err(|e| format!("gst push_buffer failed: {e}"))?;
+ Ok(())
+ }
+
+ pub fn finish(self, _duration_ms: u64) -> Result<(), String> {
+ self.appsrc
+ .end_of_stream()
+ .map_err(|e| format!("gst EOS failed: {e}"))?;
+ // Wait for the muxer to flush the moov atom.
+ let bus = self.pipeline.bus().ok_or("no gst bus")?;
+ let result = (|| {
+ for msg in bus.iter_timed(gst::ClockTime::from_seconds(15)) {
+ use gst::MessageView;
+ match msg.view() {
+ MessageView::Eos(_) => return Ok(()),
+ MessageView::Error(e) => {
+ return Err(format!("gst error: {}", e.error()));
+ }
+ _ => {}
+ }
+ }
+ Err("gst EOS timed out".to_string())
+ })();
+ let _ = self.pipeline.set_state(gst::State::Null);
+ result
+ }
+ }
+}
diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs
index 39d1986e..97e6909d 100644
--- a/clients/desktop/src-tauri/src/lib.rs
+++ b/clients/desktop/src-tauri/src/lib.rs
@@ -1,11 +1,65 @@
mod capture;
+mod clips;
mod crop;
+mod native_menu;
+#[cfg(target_os = "macos")]
+mod native_tray;
mod pipewire;
mod screencast;
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")]
@@ -53,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,
@@ -508,6 +572,11 @@ pub struct UploadUrlResponse {
/// Sticky tracking mode for the session. Absent on pre-credit-mode servers.
#[serde(rename = "trackingMode", default)]
pub tracking_mode: Option,
+ /// GRANTED payload format — may differ from the requested one (the
+ /// server downgrades clip formats to "jpeg" on sessions without clips).
+ /// Absent on pre-clips servers.
+ #[serde(rename = "format", default)]
+ pub format: Option,
}
#[derive(Serialize, Deserialize)]
@@ -560,57 +629,571 @@ 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() {
+ // `entry.file_type()` reads the attributes the directory
+ // enumeration already returned; `entry.path().is_dir()` would stat
+ // each entry again. On Windows that is a real syscall per shortcut,
+ // through whatever filter drivers and AV hooks are installed, and
+ // the Start Menu tree has hundreds of entries.
+ // ...but file_type() does NOT follow symlinks where is_dir() did,
+ // so a directory junction in the Start Menu would stop being
+ // traversed. Fall back to the stat only for that rare case.
+ let path = entry.path();
+ let is_dir = match entry.file_type() {
+ Ok(t) if t.is_symlink() => path.is_dir(),
+ Ok(t) => t.is_dir(),
+ Err(_) => path.is_dir(),
+ };
+ if 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.insert(app_name);
+ apps.push(AppEntry {
+ name,
+ path: Some(path.to_string_lossy().into_owned()),
+ running: false,
+ });
}
- apps.into_iter().collect()
+ }
+ 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;
+ }
+ 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
+}
+
+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.
+///
+/// The work is BLOCKING — a Start Menu tree walk on the first call, and a
+/// window enumeration on every call — so it runs on the blocking pool rather
+/// than on the async runtime. `async fn` alone was not enough: the body never
+/// yields, so it occupied a tokio worker for its whole duration, and the
+/// capture loop lives on those same workers. A slow enumeration could
+/// therefore delay a capture tick, not just the Settings page.
+#[tauri::command]
+async fn list_installed_apps() -> Vec {
+ tauri::async_runtime::spawn_blocking(list_installed_apps_blocking)
+ .await
+ .unwrap_or_default()
+}
+
+/// Pre-warm the installed-app cache so the first visit to Filtered Apps doesn't
+/// pay for the app scan while the user waits.
+///
+/// DEFERRED on purpose. The scan is disk-bound — a Start Menu tree walk on
+/// Windows, /Applications on macOS, .desktop files on Linux — and launch is
+/// already the most I/O-contended moment in the process's life: the webview is
+/// loading its own assets at the same time. Starting the scan immediately would
+/// trade a faster Settings page for a slower app open, which is the wrong way
+/// round. A few seconds' delay is still far earlier than anyone navigates to
+/// Filtered Apps, and by then the launch I/O has settled.
+fn prewarm_installed_apps() {
+ std::thread::Builder::new()
+ .name("app-scan-prewarm".into())
+ .spawn(|| {
+ std::thread::sleep(std::time::Duration::from_secs(5));
+ let _ = installed_apps_cached();
+ })
+ // A failed prewarm is not worth failing startup over: the cache just
+ // fills lazily on first use, exactly as it did before.
+ .ok();
+}
+
+fn list_installed_apps_blocking() -> 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).
#[tauri::command]
fn list_capture_sources() -> Result {
@@ -818,6 +1401,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 +1635,65 @@ 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.
-async fn upload_and_confirm(
- jpeg_base64: &str,
+///
+/// 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.
+/// One capture unit ready for upload: the legacy single JPEG or an H.264
+/// MP4 clip. The content type must match the granted format — the
+/// presigned URL is signed with it.
+struct UploadPayload {
+ bytes: bytes::Bytes,
+ content_type: &'static str,
+ /// `format` query value for upload-url. None = legacy JPEG request.
+ format: Option<&'static str>,
+ /// Frames inside a clip (confirm-body telemetry). None for JPEG.
+ frame_count: Option,
width: u32,
height: u32,
+ /// JPEG preview (base64) of the unit's last frame, for the UI event.
+ preview_base64: String,
+}
+
+impl UploadPayload {
+ fn jpeg(bytes: bytes::Bytes, base64: String, width: u32, height: u32) -> Self {
+ Self {
+ bytes,
+ content_type: "image/jpeg",
+ format: None,
+ frame_count: None,
+ width,
+ height,
+ preview_base64: base64,
+ }
+ }
+
+ fn mp4(clip: clips::FinishedClip, preview_base64: String) -> Self {
+ Self {
+ bytes: bytes::Bytes::from(clip.mp4),
+ content_type: "video/mp4",
+ format: Some("mp4"),
+ frame_count: Some(clip.frame_count),
+ width: clip.width,
+ height: clip.height,
+ preview_base64,
+ }
+ }
+}
+
+async fn upload_and_confirm(
+ payload: UploadPayload,
captured_at: Option<&str>,
config: &SessionConfig,
app: &AppHandle,
) -> Result {
- let jpeg_bytes = base64_decode(jpeg_base64)?;
- let size_bytes = jpeg_bytes.len();
+ let size_bytes = payload.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
@@ -1064,11 +1704,15 @@ async fn upload_and_confirm(
if let Some(c) = captured_at {
query.push(("capturedAt", c));
}
+ if let Some(f) = payload.format {
+ query.push(("format", f));
+ }
// Each attempt re-requests a FRESH presigned URL (it has a 120s expiry).
let upload_url_resp: UploadUrlResponse = retry_upload_step!("upload-url", {
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)))?;
@@ -1091,6 +1735,20 @@ async fn upload_and_confirm(
),
);
+ // The presigned URL is signed for the GRANTED format's content type —
+ // uploading a clip against a jpeg grant would fail the signature. A
+ // downgrade here (clips disabled server-side, pre-clips server) is a
+ // terminal error for this payload; the capture loop retries the tick
+ // with its JPEG fallback.
+ if let Some(requested) = payload.format {
+ let granted = upload_url_resp.format.as_deref().unwrap_or("jpeg");
+ if granted != requested {
+ return Err(format!(
+ "server granted \"{granted}\" for a \"{requested}\" clip upload"
+ ));
+ }
+ }
+
// Step 2: Upload JPEG to R2
let _ = app.emit(
"capture-progress",
@@ -1107,8 +1765,10 @@ async fn upload_and_confirm(
retry_upload_step!(r2_label, {
client
.put(upload_url_resp.upload_url.as_str())
- .header("Content-Type", "image/jpeg")
- .body(jpeg_bytes.clone())
+ .header("Content-Type", payload.content_type)
+ // Bytes::clone is a refcount bump, not a buffer copy.
+ .body(payload.bytes.clone())
+ .timeout(STEP_TIMEOUT)
.send()
.await
.map_err(|e| StepError::Retryable(describe_reqwest_error(&e)))?
@@ -1120,18 +1780,23 @@ async fn upload_and_confirm(
// Step 3: Confirm upload with server
let _ = app.emit("capture-progress", "confirming upload with server...");
+ let mut confirm_body = serde_json::json!({
+ "screenshotId": upload_url_resp.screenshot_id,
+ "width": payload.width,
+ "height": payload.height,
+ "fileSize": size_bytes,
+ });
+ if let Some(fc) = payload.frame_count {
+ confirm_body["frameCount"] = fc.into();
+ }
let confirm_resp: ConfirmResponse = retry_upload_step!("confirm", {
let confirm_response = client
.post(format!(
"{}/api/sessions/{}/screenshots",
config.api_base_url, config.token
))
- .json(&serde_json::json!({
- "screenshotId": upload_url_resp.screenshot_id,
- "width": width,
- "height": height,
- "fileSize": size_bytes,
- }))
+ .json(&confirm_body)
+ .timeout(STEP_TIMEOUT)
.send()
.await
.map_err(|e| StepError::Retryable(describe_reqwest_error(&e)))?;
@@ -1158,9 +1823,9 @@ 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_width: width,
- preview_height: height,
+ preview_base64: payload.preview_base64,
+ preview_width: payload.width,
+ preview_height: payload.height,
})
}
@@ -1383,21 +2048,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,10 +2077,14 @@ async fn capture_and_upload(
} else {
None
};
+ let jpeg_base64 = base64_encode(&screenshot.data);
upload_and_confirm(
- &screenshot.base64,
- screenshot.width,
- screenshot.height,
+ UploadPayload::jpeg(
+ bytes::Bytes::from(screenshot.data),
+ jpeg_base64,
+ screenshot.width,
+ screenshot.height,
+ ),
captured_at.as_deref(),
&config,
&app,
@@ -1444,7 +2119,14 @@ 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(
+ UploadPayload::jpeg(jpeg_bytes, base64, width, height),
+ captured_at.as_deref(),
+ &config,
+ &app,
+ )
+ .await
}
// ── Capture-loop interval (seconds) ─────────────────────────────
@@ -1452,19 +2134,68 @@ const CAPTURE_INTERVAL_SECS: u64 = 60;
/// If the wall-clock gap between ticks exceeds this, the machine
/// probably slept (or the WebView was throttled hard).
const SLEEP_THRESHOLD_SECS: u64 = CAPTURE_INTERVAL_SECS * 2 + 30; // 150s
-
-/// Format seconds into the same tray title format as the JS side:
-/// >0h: "{h}h {m}m", 0m: "< 1m", else: "{m}m"
+/// Fallback frame cadence when the server doesn't advertise one (pre-clips
+/// servers): every 10s = 6 frames/min. Mirrors CLIP_FRAME_INTERVAL_MS in
+/// @lookout/shared. When the server sends `frameIntervalMs` on the session
+/// GET, that value wins — the cadence is server-authoritative. Frames go
+/// through the identical redaction-aware capture path as uploads; in clips
+/// mode they're recorded into the clip, and the JPEG preview side is only
+/// produced while the window is focused.
+const DEFAULT_FRAME_INTERVAL_MS: u64 = 10_000;
+
+/// Delay from capture start to the FIRST upload tick. Mirrors
+/// CLIP_FIRST_CUT_DELAY_MS in @lookout/shared.
+///
+/// Deliberately not a multiple of the frame cadence: the opening clip is the
+/// session's seed capture, which credits 0 seconds and which the compiler
+/// drops from the video outright, so its frame density doesn't matter. What
+/// this delay does control is how long the user stares at an unstarted
+/// session — and tying it to the cadence turned every slower cadence into a
+/// 20-second-plus wait.
+const CLIP_FIRST_CUT_DELAY_MS: u64 = 8_000;
+
+/// Consecutive clip-encoder failures tolerated before this capture run gives
+/// up on clips and records plain JPEGs for the rest of the session.
+///
+/// A broken encoder is already survivable one interval at a time (each
+/// failure falls back to a JPEG), but "survivable" was not the same as
+/// "quiet": on a machine where the encoder can never initialize, the loop
+/// retried it on every single frame — for hours — each attempt paying the
+/// full cost of constructing and tearing down an OS encoder, and writing a
+/// line to stderr. Latching off after a few consecutive failures keeps the
+/// recording intact and stops the thrash.
+const MAX_CLIP_ENCODER_FAILURES: u32 = 3;
+
+/// 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 a clock-style tray title:
+/// >0h: "{h}:{mm:02}:{ss:02}", else: "{mm:02}:{ss:02}"
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;
+ let s = total % 60;
if h > 0 {
- format!("{h}h {m}m")
- } else if m == 0 {
- "< 1m".to_string()
+ format!("{h}:{m:02}:{s:02}")
} else {
- format!("{m}m")
+ format!("{m:02}:{s:02}")
}
}
@@ -1480,6 +2211,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() => {}
@@ -1489,24 +2228,51 @@ async fn tray_timer_task(
}
}
- if !timer_state.is_running.load(Ordering::Relaxed) {
- 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 let Some(tray) = app.tray_by_id("timelapse_tray") {
- let _ = tray.set_title(Some(time_text));
+
+ 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;
}
- // Also emit to the tray popup window so it stays in sync
- let _ = app.emit("tray-timer-tick", display_seconds);
+ 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")));
}
}
@@ -1520,6 +2286,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,24 +2325,44 @@ 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();
+ }
+}
+
+/// 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 {
@@ -1578,7 +2370,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();
@@ -1620,6 +2412,16 @@ struct CaptureTickError {
message: String,
}
+/// Event payload for an in-between live-preview frame from the capture
+/// loop (one per frame interval while the window is focused).
+#[derive(Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct CapturePreviewFrame {
+ preview_base64: String,
+ preview_width: u32,
+ preview_height: u32,
+}
+
/// Event payload emitted when the capture loop detects a terminal session state.
#[derive(Clone, Serialize)]
struct CaptureSessionTerminated {
@@ -1635,6 +2437,165 @@ struct SessionStatusResponse {
tracked_seconds: Option,
}
+/// What JPEG (if any) a frame grab should produce alongside the raw image.
+#[derive(Clone, Copy, PartialEq)]
+enum GrabJpeg {
+ /// No JPEG — clip frame while the window is unfocused.
+ None,
+ /// Preview-sized (≤854x480, q65) — matches the resolution the live
+ /// preview always used, and keeps the per-frame IPC payload ~5x
+ /// smaller than a full-res frame would be.
+ Preview,
+ /// Full capture resolution at upload quality — the tick frame, which
+ /// doubles as the JPEG upload/fallback payload.
+ Full,
+}
+
+/// Downscale bounds + quality for preview JPEGs (mirrors the values the
+/// dedicated preview protocol always served).
+const PREVIEW_MAX_W: u32 = 854;
+const PREVIEW_MAX_H: u32 = 480;
+const PREVIEW_JPEG_QUALITY: u8 = 65;
+
+/// One frame off the capture pipeline: the raw (redacted, scaled) image
+/// plus, when requested, its JPEG encoding.
+struct FrameGrab {
+ image: image::DynamicImage,
+ jpeg: Option,
+}
+
+/// Read the current blacklist (+ Linux PipeWire fds) and capture one
+/// redaction-aware stitched frame on the blocking pool. Shared by the
+/// upload tick, the clip frames, and the live preview, so everything goes
+/// through the exact same capture path — Filtered Apps redaction included.
+async fn grab_frame(
+ app: &AppHandle,
+ sources: &[CaptureSource],
+ max_width: u32,
+ max_height: u32,
+ jpeg_quality: u8,
+ jpeg: GrabJpeg,
+) -> Result {
+ let blacklisted = {
+ let state = app.state::();
+ state
+ .blacklisted_apps
+ .lock()
+ .map(|g| g.clone())
+ .unwrap_or_default()
+ };
+
+ #[allow(unused_mut, unused_assignments)]
+ let mut pipewire_fds = std::collections::HashMap::new();
+ #[cfg(target_os = "linux")]
+ {
+ let state = app.state::();
+ if let Ok(guard) = state.pipewire_fds.lock() {
+ pipewire_fds = guard.clone();
+ };
+ }
+
+ let sources_clone = sources.to_vec();
+ tokio::task::spawn_blocking(move || {
+ let image = capture::take_stitched_screenshots_image_with_blacklist(
+ &sources_clone,
+ max_width,
+ max_height,
+ &pipewire_fds,
+ &blacklisted,
+ )?;
+ let encoded = match jpeg {
+ GrabJpeg::None => None,
+ GrabJpeg::Full => Some(capture::encode_frame_jpeg(&image, jpeg_quality)?),
+ GrabJpeg::Preview => {
+ let (w, h) = (image.width(), image.height());
+ if w > PREVIEW_MAX_W || h > PREVIEW_MAX_H {
+ let scale =
+ f64::min(PREVIEW_MAX_W as f64 / w as f64, PREVIEW_MAX_H as f64 / h as f64);
+ let pw = ((w as f64 * scale).round() as u32).max(2);
+ let ph = ((h as f64 * scale).round() as u32).max(2);
+ // Borrowing resize: the full-res frame stays untouched
+ // for the clip encoder.
+ image
+ .as_rgba8()
+ .and_then(|rgba| capture::fast_resize_buffer(rgba, pw, ph))
+ .map(|small| {
+ capture::encode_frame_jpeg(
+ &image::DynamicImage::ImageRgba8(small),
+ PREVIEW_JPEG_QUALITY,
+ )
+ })
+ .transpose()?
+ } else {
+ Some(capture::encode_frame_jpeg(&image, PREVIEW_JPEG_QUALITY)?)
+ }
+ }
+ };
+ Ok(FrameGrab {
+ image,
+ jpeg: encoded,
+ })
+ })
+ .await
+ .map_err(|e| format!("spawn_blocking panicked: {e}"))
+ .and_then(|r| r)
+}
+
+/// Record one clip-encoder failure, and latch clips off for the rest of the
+/// run once they stop looking transient.
+///
+/// The recording itself is never at risk either way — every clip failure
+/// already falls back to a JPEG for that interval. This is about not
+/// re-attempting a hopeless encoder several times a minute for hours. Any
+/// clip that finalizes successfully resets the counter, so a one-off
+/// hiccup (a display mode change, a busy GPU) never disables clips.
+fn note_clip_failure(failures: &mut u32, clips_mode: &mut bool) {
+ *failures += 1;
+ if *failures >= MAX_CLIP_ENCODER_FAILURES && *clips_mode {
+ *clips_mode = false;
+ eprintln!(
+ "[capture-loop] {} consecutive clip-encoder failures — disabling clips \
+ for this session, continuing with one JPEG per minute",
+ *failures
+ );
+ }
+}
+
+/// Clip capability the server advertises for a session (on the session
+/// GET). Fetched once at capture-loop start; any failure means clips off,
+/// i.e. legacy one-JPEG-per-minute behavior.
+#[derive(Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+struct SessionClipCapabilities {
+ #[serde(default)]
+ clips_enabled: bool,
+ #[serde(default)]
+ frame_interval_ms: Option,
+}
+
+async fn fetch_clip_capabilities(config: &SessionConfig) -> SessionClipCapabilities {
+ let url = format!("{}/api/sessions/{}", config.api_base_url, config.token);
+ match http_client()
+ .get(&url)
+ .timeout(std::time::Duration::from_secs(15))
+ .send()
+ .await
+ {
+ Ok(res) if res.status().is_success() => res.json().await.unwrap_or_default(),
+ Ok(res) => {
+ eprintln!(
+ "[capture-loop] capability fetch returned HTTP {} — clips off",
+ res.status()
+ );
+ SessionClipCapabilities::default()
+ }
+ Err(e) => {
+ eprintln!("[capture-loop] capability fetch failed ({e}) — clips off");
+ SessionClipCapabilities::default()
+ }
+ }
+}
+
/// The core capture loop, runs on a tokio task. Captures screenshots at
/// a fixed interval, uploads them, and emits events back to JS.
///
@@ -1668,12 +2629,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 +2644,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!(
@@ -1712,12 +2671,239 @@ async fn capture_loop_task(
Ok(true)
}
- loop {
- // ── First capture fires immediately; subsequent ones wait for the interval tick ──
- // (We already consumed the first tick above, so this select waits for either
- // the next 60s tick or cancellation.)
+ /// Apply a finished upload's outcome: sync the tray timer, refine the
+ /// next tick target from the server's `nextExpectedAt`, emit the UI
+ /// events, and run pause/termination recovery on failure. Returns false
+ /// when the capture loop should stop (terminal session state).
+ async fn apply_upload_result(
+ app: &AppHandle,
+ config: &SessionConfig,
+ result: Result,
+ next_fire: &mut tokio::time::Instant,
+ interval_dur: tokio::time::Duration,
+ ) -> bool {
+ match result {
+ Ok(result) => {
+ // Sync tray timer to authoritative server time
+ {
+ let state = app.state::();
+ sync_tray_timer(&state, result.tracked_seconds);
+ }
+ // Compute next fire from the server-provided nextExpectedAt.
+ // If parsing fails or the target is in the past, default to
+ // "fire now" (catch-up). Upper-bounded at 2x interval as a
+ // guard against malformed responses.
+ let parsed_target_ms = parse_iso_to_unix_ms(&result.next_expected_at);
+ let now_ms = current_unix_ms();
+ let delay_ms = match parsed_target_ms {
+ Some(target) => (target - now_ms).max(0) as u64,
+ None => CAPTURE_INTERVAL_SECS * 1000,
+ };
+ let delay_ms = delay_ms.min(CAPTURE_INTERVAL_SECS * 2 * 1000);
+ *next_fire =
+ tokio::time::Instant::now() + tokio::time::Duration::from_millis(delay_ms);
+ let _ = app.emit("capture-tick-result", CaptureTickResult::from(result));
+ true
+ }
+ Err(e) => {
+ eprintln!("[capture-loop] upload failed: {e}");
+ let _ = app.emit(
+ "capture-tick-error",
+ CaptureTickError { message: e.clone() },
+ );
+ // No server target available — fall back to a full interval.
+ *next_fire = tokio::time::Instant::now() + interval_dur;
+ // Check if the server paused/stopped the session
+ match handle_sleep_recovery(app, config).await {
+ Ok(true) => true,
+ Ok(false) => false,
+ Err(_) => true,
+ }
+ }
+ }
+ }
+
+ // Clip capability comes from the server, once per loop run. Any fetch
+ // failure (or clips off) means legacy JPEG mode, bit-for-bit.
+ let initial_config = {
+ let state = app.state::();
+ let guard = state.config.lock().unwrap();
+ guard.clone()
+ };
+ let caps = match &initial_config {
+ Some(c) => fetch_clip_capabilities(c).await,
+ None => SessionClipCapabilities::default(),
+ };
+ // Mutable: latches off after MAX_CLIP_ENCODER_FAILURES consecutive
+ // encoder failures, so a machine with a broken encoder settles into
+ // plain JPEG mode instead of retrying forever.
+ let mut clips_mode = caps.clips_enabled;
+ let mut clip_encoder_failures: u32 = 0;
+ // Server-authoritative cadence, clamped defensively against a
+ // misbehaving server so the loop can't spin or stall.
+ let frame_interval_ms = caps
+ .frame_interval_ms
+ .unwrap_or(DEFAULT_FRAME_INTERVAL_MS)
+ .clamp(500, 30_000);
+ let frame_dur = Duration::from_millis(frame_interval_ms);
+ let mut recorder: Option = None;
+ if clips_mode {
+ eprintln!("[capture-loop] clips enabled (frame every {frame_interval_ms}ms)");
+ }
+
+ // Clips: hold the first upload back so the opening clip has a few frames
+ // and the session activates promptly. Fixed delay, NOT a multiple of the
+ // cadence — see CLIP_FIRST_CUT_DELAY_MS. JPEG mode keeps the legacy
+ // immediate first tick.
+ next_fire = TokioInstant::now()
+ + if clips_mode {
+ Duration::from_millis(CLIP_FIRST_CUT_DELAY_MS)
+ } else {
+ Duration::ZERO
+ };
- // Run one capture tick
+ // The opening window is shorter than one frame interval, so at the normal
+ // cadence the first clip would hold a single frame. Capture it densely
+ // enough to carry a handful; after the first upload the cadence returns
+ // to the server's value.
+ let opening_frame_dur = Duration::from_millis((CLIP_FIRST_CUT_DELAY_MS / 4).max(500));
+ let mut first_upload_done = false;
+
+ // The in-flight upload, if any. Uploads run CONCURRENTLY with frame
+ // capture: a multi-second clip finalize+upload must not punch a hole in
+ // the recording every minute — serially that compounds to minutes of
+ // missing screen time per hour. Strictly one upload at a time: the next
+ // tick settles the previous one before cutting, which preserves
+ // capturedAt monotonicity and the per-session rate-limit assumptions.
+ let mut upload_handle: Option>> =
+ None;
+ let mut upload_cfg: Option = None;
+
+ 'outer: loop {
+ // ── Wait until next_fire, collecting frames along the way ──
+ // Frames run at the clip cadence (server-set, 6/min) through the
+ // SAME redaction-aware capture path as uploads. In clips mode every
+ // frame is recorded into the current clip; the JPEG preview side
+ // is focus-gated either way (nobody can see it unfocused).
+ // sleep_until returns immediately when next_fire is already past
+ // (catch-up), which also skips frame collection.
+ let cadence = if first_upload_done {
+ frame_dur
+ } else {
+ opening_frame_dur
+ };
+ loop {
+ let now = TokioInstant::now();
+ if now >= next_fire {
+ break;
+ }
+ let wake = std::cmp::min(now + cadence, next_fire);
+ // Third arm: the in-flight upload finishing mid-wait. Its body
+ // only records the outcome — applying it (which needs mutable
+ // access to upload_handle/next_fire) happens after the select.
+ let mut upload_outcome: Option> = None;
+ tokio::select! {
+ _ = sleep_until(wake) => {}
+ _ = cancel_rx.changed() => {
+ eprintln!("[capture-loop] cancelled");
+ break 'outer;
+ }
+ res = async {
+ match upload_handle.as_mut() {
+ Some(h) => match h.await {
+ Ok(r) => r,
+ Err(e) => Err(format!("upload task panicked: {e}")),
+ },
+ None => unreachable!("guarded by select condition"),
+ }
+ }, if upload_handle.is_some() => {
+ upload_outcome = Some(res);
+ }
+ }
+ if let Some(res) = upload_outcome {
+ upload_handle = None;
+ let cfg = upload_cfg.take().expect("cfg tracks upload_handle");
+ if !apply_upload_result(&app, &cfg, res, &mut next_fire, interval_dur).await {
+ break 'outer;
+ }
+ // next_fire was just refined by the confirm — recompute the
+ // wake target instead of falling through with a stale one.
+ continue;
+ }
+ // Woke for the upload tick, not a frame.
+ if TokioInstant::now() >= next_fire {
+ break;
+ }
+
+ let focused = app
+ .get_webview_window("main")
+ .map(|w| w.is_focused().unwrap_or(false))
+ .unwrap_or(false);
+ if !clips_mode && !focused {
+ continue;
+ }
+
+ let jpeg_mode = if focused {
+ GrabJpeg::Preview
+ } else {
+ GrabJpeg::None
+ };
+ match grab_frame(&app, &sources, max_width, max_height, jpeg_quality, jpeg_mode).await
+ {
+ Ok(grab) => {
+ if clips_mode {
+ if recorder.is_none() {
+ match clips::ClipRecorder::new(
+ grab.image.width(),
+ grab.image.height(),
+ frame_interval_ms,
+ ) {
+ Ok(r) => recorder = Some(r),
+ Err(e) => {
+ eprintln!(
+ "[capture-loop] clip encoder init failed: {e} — JPEG fallback this interval"
+ );
+ note_clip_failure(
+ &mut clip_encoder_failures,
+ &mut clips_mode,
+ );
+ }
+ }
+ }
+ if let Some(r) = recorder.as_mut() {
+ if let Err(e) = r.push_frame(&grab.image) {
+ eprintln!(
+ "[capture-loop] clip frame append failed: {e} — dropping clip, JPEG fallback"
+ );
+ if let Some(r) = recorder.take() {
+ r.discard();
+ }
+ note_clip_failure(&mut clip_encoder_failures, &mut clips_mode);
+ }
+ }
+ }
+ if focused {
+ if let Some(jpeg) = grab.jpeg {
+ let _ = app.emit(
+ "capture-preview-frame",
+ CapturePreviewFrame {
+ preview_base64: base64_encode(&jpeg.data),
+ preview_width: jpeg.width,
+ preview_height: jpeg.height,
+ },
+ );
+ }
+ }
+ }
+ Err(e) => {
+ // Frame-level failure: log and keep going — the upload
+ // tick has its own error handling and retry cadence.
+ eprintln!("[capture-loop] frame capture failed: {e}");
+ }
+ }
+ }
+
+ // ── Upload tick ──
let now = StdInstant::now();
let elapsed_secs = now.duration_since(last_tick).as_secs();
last_tick = now;
@@ -1746,6 +2932,11 @@ async fn capture_loop_task(
"[capture-loop] detected sleep (gap: {}s), checking session status...",
elapsed_secs
);
+ // A clip spanning a sleep gap would carry an hours-long hole —
+ // drop it and start fresh after recovery.
+ if let Some(r) = recorder.take() {
+ r.discard();
+ }
match handle_sleep_recovery(&app, &config).await {
Ok(true) => { /* continue capturing */ }
Ok(false) => break,
@@ -1753,42 +2944,26 @@ async fn capture_loop_task(
}
}
- // Take screenshot (blocking I/O via xcap — run on blocking threadpool)
- let blacklisted = {
- let state = app.state::();
- state
- .blacklisted_apps
- .lock()
- .map(|g| g.clone())
- .unwrap_or_default()
- };
-
- #[allow(unused_mut, unused_assignments)]
- let mut pipewire_fds = std::collections::HashMap::new();
- #[cfg(target_os = "linux")]
- {
- let state = app.state::();
- if let Ok(guard) = state.pipewire_fds.lock() {
- pipewire_fds = guard.clone();
+ // A previous upload still in flight (very slow network): settle it
+ // before cutting the next clip so uploads stay strictly ordered —
+ // capturedAt monotonicity and the per-session rate limits both
+ // assume order.
+ if let Some(handle) = upload_handle.take() {
+ let cfg = upload_cfg.take().expect("cfg tracks upload_handle");
+ let res = match handle.await {
+ Ok(r) => r,
+ Err(e) => Err(format!("upload task panicked: {e}")),
};
+ if !apply_upload_result(&app, &cfg, res, &mut next_fire, interval_dur).await {
+ break;
+ }
}
- let sources_clone = sources.clone();
- let bl = blacklisted;
- let pw_fds = pipewire_fds;
- let screenshot_result = tokio::task::spawn_blocking(move || {
- capture::take_stitched_screenshots_with_blacklist(
- &sources_clone,
- max_width,
- max_height,
- jpeg_quality,
- &pw_fds,
- &bl,
- )
- })
- .await
- .map_err(|e| format!("spawn_blocking panicked: {e}"))
- .and_then(|r| r);
+ // Grab the tick frame — the clip's final frame, the UI preview,
+ // and the JPEG fallback, all from one capture. Full-size JPEG:
+ // this one may be uploaded.
+ let grab_result =
+ grab_frame(&app, &sources, max_width, max_height, jpeg_quality, GrabJpeg::Full).await;
// Capture the wall-clock moment NOW — that's the value we'll send
// as `capturedAt`, not when the upload eventually reaches the server.
@@ -1798,57 +2973,113 @@ async fn capture_loop_task(
None
};
- match screenshot_result {
- Ok(screenshot) => {
- match upload_and_confirm(
- &screenshot.base64,
- screenshot.width,
- screenshot.height,
- captured_at.as_deref(),
- &config,
- &app,
- )
- .await
- {
- Ok(result) => {
- // Sync tray timer to authoritative server time
- {
- let state = app.state::();
- sync_tray_timer(&state, result.tracked_seconds);
+ match grab_result {
+ Ok(grab) => {
+ let capture::RawCaptureResult {
+ data: jpeg_data,
+ width: jpeg_w,
+ height: jpeg_h,
+ } = grab.jpeg.expect("tick grab always requests jpeg");
+ let jpeg_base64 = base64_encode(&jpeg_data);
+ let jpeg_bytes = bytes::Bytes::from(jpeg_data);
+
+ // Clips: append the final frame and cut this interval's clip.
+ let clip = if clips_mode {
+ if recorder.is_none() {
+ recorder = clips::ClipRecorder::new(
+ grab.image.width(),
+ grab.image.height(),
+ frame_interval_ms,
+ )
+ .map_err(|e| {
+ eprintln!("[capture-loop] clip encoder init failed: {e}");
+ note_clip_failure(&mut clip_encoder_failures, &mut clips_mode);
+ })
+ .ok();
+ }
+ if let Some(r) = recorder.as_mut() {
+ if let Err(e) = r.push_frame(&grab.image) {
+ eprintln!("[capture-loop] clip frame append failed: {e}");
+ if let Some(r) = recorder.take() {
+ r.discard();
+ }
+ note_clip_failure(&mut clip_encoder_failures, &mut clips_mode);
}
- // Compute next fire from the server-provided
- // nextExpectedAt. If parsing fails or the target is
- // in the past, default to "fire now" (catch-up).
- let parsed_target_ms = parse_iso_to_unix_ms(&result.next_expected_at);
- let now_ms = current_unix_ms();
- let delay_ms = match parsed_target_ms {
- Some(target) => (target - now_ms).max(0) as u64,
- None => CAPTURE_INTERVAL_SECS * 1000,
- };
- // Safety upper-bound: never sleep longer than 2x interval,
- // protects against malformed responses.
- let delay_ms = delay_ms.min(CAPTURE_INTERVAL_SECS * 2 * 1000);
- next_fire = TokioInstant::now() + Duration::from_millis(delay_ms);
- let _ = app.emit("capture-tick-result", CaptureTickResult::from(result));
}
- Err(e) => {
- eprintln!("[capture-loop] upload failed: {e}");
- let _ = app.emit(
- "capture-tick-error",
- CaptureTickError {
- message: e.clone(),
- },
- );
- // No server target available — fall back to interval.
- next_fire = TokioInstant::now() + interval_dur;
- // Check if server paused the session
- match handle_sleep_recovery(&app, &config).await {
- Ok(true) => { /* continue */ }
- Ok(false) => break,
- Err(_) => {}
+ match recorder.take().map(|r| r.finish()) {
+ Some(Ok(c)) => {
+ // A clip made it out whole — the encoder works,
+ // so earlier failures were transient.
+ clip_encoder_failures = 0;
+ Some(c)
}
+ Some(Err(e)) => {
+ eprintln!(
+ "[capture-loop] clip finalize failed: {e} — uploading JPEG instead"
+ );
+ note_clip_failure(&mut clip_encoder_failures, &mut clips_mode);
+ None
+ }
+ None => None,
}
- }
+ } else {
+ None
+ };
+
+ // Spawn the upload as a background task — frame capture for
+ // the NEXT clip resumes immediately instead of stalling for
+ // the finalize+upload round trip (which would put a hole in
+ // the recording every minute). Clip first; ANY clip-upload
+ // failure (size cap, server downgrade, transient) retries
+ // the tick as a JPEG so the credit streak never skips a
+ // beat.
+ let task_app = app.clone();
+ let task_config = config.clone();
+ let task_captured_at = captured_at.clone();
+ upload_handle = Some(tokio::spawn(async move {
+ let jpeg_fallback =
+ UploadPayload::jpeg(jpeg_bytes, jpeg_base64.clone(), jpeg_w, jpeg_h);
+ match clip {
+ Some(c) => {
+ match upload_and_confirm(
+ UploadPayload::mp4(c, jpeg_base64),
+ task_captured_at.as_deref(),
+ &task_config,
+ &task_app,
+ )
+ .await
+ {
+ Ok(r) => Ok(r),
+ Err(e) => {
+ eprintln!(
+ "[capture-loop] clip upload failed ({e}) — retrying tick as JPEG"
+ );
+ upload_and_confirm(
+ jpeg_fallback,
+ task_captured_at.as_deref(),
+ &task_config,
+ &task_app,
+ )
+ .await
+ }
+ }
+ }
+ None => {
+ upload_and_confirm(
+ jpeg_fallback,
+ task_captured_at.as_deref(),
+ &task_config,
+ &task_app,
+ )
+ .await
+ }
+ }
+ }));
+ upload_cfg = Some(config.clone());
+ // Provisional next tick one interval out; refined to the
+ // server's nextExpectedAt when the confirm lands mid-wait
+ // (see the wait-loop's third select arm).
+ next_fire = TokioInstant::now() + interval_dur;
}
Err(e) => {
eprintln!("[capture-loop] screenshot failed: {e}");
@@ -1863,15 +3094,16 @@ async fn capture_loop_task(
}
}
- // Wait until next_fire or cancellation. sleep_until returns
- // immediately if next_fire is already in the past (catch-up).
- tokio::select! {
- _ = sleep_until(next_fire) => {}
- _ = cancel_rx.changed() => {
- eprintln!("[capture-loop] cancelled");
- break;
- }
- }
+ // Whatever happened, the opening window is over — later intervals
+ // are full-length, so the normal cadence applies (a failed first
+ // upload must not run the fast cadence across a 60s retry window).
+ first_upload_done = true;
+ }
+
+ // Never leave a half-recorded clip (or its temp file) behind on
+ // pause/stop/cancel.
+ if let Some(r) = recorder.take() {
+ r.discard();
}
eprintln!("[capture-loop] stopped");
@@ -2025,9 +3257,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(())
}
@@ -2045,12 +3282,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(())
}
@@ -2079,6 +3311,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;
@@ -2254,11 +3491,14 @@ pub fn run() {
disable_vibrancy,
is_wayland,
open_external_url,
+ native_menu::show_add_menu,
+ native_menu::prefetch_add_menu_icons,
request_screencast,
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,
@@ -2268,25 +3508,15 @@ pub fn run() {
])
.manage(tray::TrayStateMutex(std::sync::Mutex::new(tray::TrayState::default())))
.setup(|app| {
+ // Warm the installed-app cache off-thread so the first visit to
+ // Filtered Apps is instant rather than paying for the scan.
+ prewarm_installed_apps();
+
#[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 +3709,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}"),
}
@@ -2510,6 +3742,93 @@ 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);
+ // Clock-style title: the paused value is the base, formatted exactly —
+ // 299s is 04:59, not the 4m the minute-granularity title used to show.
+ assert_eq!(
+ format_tray_time(tray_display_seconds(299, 59, false)),
+ "04:59"
+ );
+ }
+
+ #[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/native_menu.rs b/clients/desktop/src-tauri/src/native_menu.rs
new file mode 100644
index 00000000..0cd8df20
--- /dev/null
+++ b/clients/desktop/src-tauri/src/native_menu.rs
@@ -0,0 +1,131 @@
+//! 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),
+ );
+ 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
+ /// 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))
+ }
+}
+
+/// 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,
+ 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 7bdc6e94..54877c31 100644
--- a/clients/desktop/src-tauri/src/tray.rs
+++ b/clients/desktop/src-tauri/src/tray.rs
@@ -1,16 +1,28 @@
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};
+/// 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 {
@@ -21,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,
}
}
}
@@ -33,6 +45,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(());
}
@@ -42,6 +66,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| {
@@ -63,7 +90,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();
@@ -164,15 +191,42 @@ fn position_and_show_window(
}
#[tauri::command]
-pub fn update_tray_time(time_text: String, _is_paused: bool, app: AppHandle) -> Result<(), String> {
- if let Some(tray) = app.tray_by_id("timelapse_tray") {
- let _ = tray.set_title(Some(time_text));
+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.
+ #[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..59857c82
--- /dev/null
+++ b/clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift
@@ -0,0 +1,399 @@
+// 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 }
+}
+
+/// 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]
+ @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 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
+ .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_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,
+ _ 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-tauri/tauri.conf.json b/clients/desktop/src-tauri/tauri.conf.json
index f54e394f..6f0d80f4 100644
--- a/clients/desktop/src-tauri/tauri.conf.json
+++ b/clients/desktop/src-tauri/tauri.conf.json
@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/nicehash/tauri/refs/tags/tauri-v2.3.1/crates/tauri-utils/schema.json",
"productName": "Lookout",
"identifier": "com.hackclub.lookout",
- "version": "0.3.5",
+ "version": "0.3.7",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
@@ -31,14 +31,21 @@
}
],
"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
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
- "targets": ["app", "dmg", "nsis", "deb", "appimage", "rpm"],
+ "targets": [
+ "app",
+ "dmg",
+ "nsis",
+ "deb",
+ "appimage",
+ "rpm"
+ ],
"macOS": {
"entitlements": "./Entitlements.plist"
},
@@ -48,12 +55,38 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
- ]
+ ],
+ "linux": {
+ "deb": {
+ "depends": [
+ "gstreamer1.0-pipewire",
+ "gstreamer1.0-plugins-base",
+ "gstreamer1.0-plugins-good",
+ "gstreamer1.0-plugins-bad"
+ ],
+ "recommends": [
+ "gstreamer1.0-plugins-ugly"
+ ]
+ },
+ "rpm": {
+ "depends": [
+ "pipewire-gstreamer",
+ "gstreamer1-plugins-base",
+ "gstreamer1-plugins-good",
+ "gstreamer1-plugins-bad-free"
+ ],
+ "recommends": [
+ "gstreamer1-plugin-openh264"
+ ]
+ }
+ }
},
"plugins": {
"deep-link": {
"desktop": {
- "schemes": ["lookout"]
+ "schemes": [
+ "lookout"
+ ]
}
},
"updater": {
diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx
index 864f937f..c7527520 100644
--- a/clients/desktop/src/App.tsx
+++ b/clients/desktop/src/App.tsx
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
import { confirm } from "@tauri-apps/plugin-dialog";
import { invoke } from "./logger.js";
import { getCurrentWindow } from "@tauri-apps/api/window";
+import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { AnimatePresence, motion } from "motion/react";
import {
Gallery,
@@ -11,24 +12,51 @@ import {
useTokenStore,
useGallery,
useHashRouter,
- spacing,
+ 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 { PermissionScreen } from "./components/PermissionScreen.js";
+import {
+ checkCameraPermission,
+ checkScreenRecordingPermission,
+} from "tauri-plugin-macos-permissions-api";
+import { PermissionScreen, permCacheKey } from "./components/PermissionScreen.js";
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,
+ EditorOpenPlaceholder,
+ useEditorWindowOpen,
+ openEditorWindow,
+ EDITED_EVENT,
+} from "./components/EditorWindow.js";
import { useBlacklistedApps } from "./hooks/useBlacklistedApps.js";
-import { useUpdateCheck } from "./hooks/useUpdateCheck.js";
-import { useBackgroundUpdate } from "./hooks/useBackgroundUpdate.js";
+import { useAppUpdate } from "./hooks/useAppUpdate.js";
import { useAnnouncement } from "./hooks/useAnnouncement.js";
import { ensureNotificationPermission } from "./hooks/useSessionNotifications.js";
-import { UpdateBanner } from "./components/UpdateBanner.js";
+import { UpdatePill } from "./components/UpdatePill.js";
+import { AddMenuPopup, type AddMenuPopupItem } from "./components/AddMenuPopup.js";
import { AnnouncementBanner } from "./components/AnnouncementBanner.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();
+
+// How long to keep watching a post-edit cut-compile for `complete` before
+// giving up on firing the redirect hook. The worker's assemble step alone
+// can run up to 30 min (ASSEMBLE_TIMEOUT_MS); add slack for queue wait and
+// the final upload so a legitimately slow compile is never abandoned.
+const REDIRECT_POLL_MAX_MS = 35 * 60_000;
+
+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 {
@@ -58,35 +86,156 @@ 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 ;
}
function MainWindowApp() {
const isMacOS = navigator.userAgent.includes("Mac");
- const [screenPermGranted, setScreenPermGranted] = useState(!isMacOS);
- const [cameraPermGranted, setCameraPermGranted] = useState(!isMacOS);
+ // A cached grant skips the permission gate (no boot flicker); a background
+ // re-check below yanks it back if the permission was revoked since.
+ const [screenPermGranted, setScreenPermGranted] = useState(
+ () => !isMacOS || localStorage.getItem(permCacheKey("screen")) === "1",
+ );
+ const [cameraPermGranted, setCameraPermGranted] = useState(
+ () => !isMacOS || localStorage.getItem(permCacheKey("camera")) === "1",
+ );
+
+ useEffect(() => {
+ if (!isMacOS) return;
+ (async () => {
+ try {
+ if (localStorage.getItem(permCacheKey("screen")) === "1" && !(await checkScreenRecordingPermission())) {
+ console.warn("[permissions] screen recording revoked — regating");
+ localStorage.removeItem(permCacheKey("screen"));
+ setScreenPermGranted(false);
+ }
+ if (localStorage.getItem(permCacheKey("camera")) === "1" && !(await checkCameraPermission())) {
+ console.warn("[permissions] camera revoked — regating");
+ localStorage.removeItem(permCacheKey("camera"));
+ setCameraPermGranted(false);
+ }
+ } catch {
+ // Plugin unavailable — leave the cached grants alone
+ }
+ })();
+ }, [isMacOS]);
const [isWayland, setIsWayland] = useState(false);
const { route, navigate } = useHashRouter();
const tokenStore = useTokenStore();
- const updateStatus = useUpdateCheck();
+ const appUpdate = useAppUpdate();
const gallery = useGallery({
apiBaseUrl: API_BASE,
tokens: tokenStore.getAllTokenValues(),
});
+ // While the editor window is up, the main window steps aside entirely —
+ // two views of the same session competing for attention is worse than
+ // one clear pointer to where the work is happening.
+ const editorWindowToken = useEditorWindowOpen();
+
+ // 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;
+
+ // The redirect hook must fire exactly once per session, from whichever
+ // path observes the timelapse finish. Both paths funnel through here.
+ const redirectFiredRef = React.useRef>(new Set());
+ const fireRedirect = useCallback((token: string, url: string | null) => {
+ if (!url || redirectFiredRef.current.has(token)) return;
+ redirectFiredRef.current.add(token);
+ console.log("[app] firing redirect hook");
+ invoke("open_external_url", { url }).catch((e) =>
+ console.error("[app] redirect hook failed:", e),
+ );
+ }, []);
+
+ useEffect(() => {
+ let unlisten: (() => void) | undefined;
+ let cancelled = false;
+
+ listen<{ token: string; status?: string | null; redirectUrl?: string | null }>(
+ EDITED_EVENT,
+ (event) => {
+ console.log("[app] editor window published — refreshing");
+ setEditNonce((n) => n + 1);
+ galleryRefreshRef.current();
+
+ // Publishing from the editor can land instantly (no cuts) or after a
+ // cut-compile. Either way SessionDetail may mount on an
+ // already-complete session, and its onComplete deliberately doesn't
+ // fire for that — so the redirect hook would be silently skipped in
+ // the whole edit flow. Fire it from here instead.
+ const token = event.payload?.token;
+ if (!token) return;
+
+ // Instant publish (no cuts): the /compile response already told us
+ // it's `complete` and carried the redirect URL. Fire now — no poll.
+ if (event.payload?.status === "complete") {
+ fireRedirect(token, event.payload.redirectUrl ?? null);
+ return;
+ }
+
+ // A compile is running server-side. Poll until it's terminal.
+ // The worker's assemble step alone can run up to ASSEMBLE_TIMEOUT_MS
+ // (30 min); a fixed few-minute deadline abandoned long compiles
+ // before they finished. Cap at that budget plus queue/upload slack,
+ // and back off so a busy worker isn't hammered.
+ const deadline = Date.now() + REDIRECT_POLL_MAX_MS;
+ let delay = 2500;
+ const poll = async () => {
+ if (cancelled || Date.now() > deadline) return;
+ try {
+ const res = await fetch(`${API_BASE}/api/sessions/${token}/status`);
+ if (res.ok) {
+ const data = await res.json();
+ if (data.status === "complete") {
+ galleryRefreshRef.current();
+ fireRedirect(token, data.redirectUrl ?? null);
+ return;
+ }
+ if (data.status === "failed") return;
+ }
+ } catch {
+ // Transient — the retry below covers it.
+ }
+ delay = Math.min(delay * 1.5, 15_000);
+ setTimeout(poll, delay);
+ };
+ void poll();
+ },
+ ).then((fn) => { unlisten = fn; });
+
+ return () => {
+ cancelled = true;
+ if (unlisten) unlisten();
+ };
+ }, [fireRedirect]);
+
// Initialize blacklisted apps sync from localStorage to Rust backend
useBlacklistedApps();
- // Request notification permission as soon as the app is past the update
- // gate — so the OS prompt appears at launch, not deferred to recording start.
- const updateSettled = updateStatus.state === "idle";
+ // Boot timing: first React commit and the frame after it (≈ first paint).
useEffect(() => {
- if (updateSettled) void ensureNotificationPermission();
- }, [updateSettled]);
+ console.log(`[boot] app mounted at ${Math.round(performance.now())}ms`);
+ requestAnimationFrame(() =>
+ console.log(`[boot] first frame at ${Math.round(performance.now())}ms`),
+ );
+ }, []);
- // Poll the update server in the background once past the launch update gate;
- // surfaces a "restart to update" banner on the gallery when a build ships.
- const backgroundUpdate = useBackgroundUpdate(updateSettled);
+ // Request notification permission at launch, not deferred to recording start.
+ useEffect(() => {
+ void ensureNotificationPermission();
+ }, []);
// Admin-authored announcement banner; checked on open and every 15 min.
const announcement = useAnnouncement();
@@ -96,6 +245,173 @@ 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;
+ // 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) {
+ 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 (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}`,
+ 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: "Start from link", 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;
+ }
+ await handleMenuChoice(choice);
+ },
+ [isMacOS, addMenu, fetchPrograms, navigate, handleMenuChoice],
+ );
+
+ // Opens a session the way clicking its card would: recordable sessions go to
+ // the record page, finished ones to their detail view.
+ const openSession = useCallback(
+ (token: string) => {
+ const session = gallery.sessions.find((s) => s.token === token);
+ if (session && ["pending", "active", "paused"].includes(session.status)) {
+ navigate({ page: "record", token });
+ } else {
+ navigate({ page: "session", token });
+ }
+ },
+ [gallery.sessions, navigate],
+ );
+
+ const archiveSession = useCallback(
+ async (token: string) => {
+ const yes = await confirm("Are you sure you want to archive this session?", {
+ title: "Archive Session",
+ kind: "warning",
+ });
+ if (yes) {
+ tokenStore.archiveToken(token);
+ gallery.refresh();
+ }
+ },
+ [tokenStore, gallery],
+ );
+
+ // Native right-click menu for a gallery card. Uses Tauri's menu plugin so the
+ // popup is a real OS context menu rather than a DOM overlay.
+ const handleSessionContextMenu = useCallback(
+ async (token: string) => {
+ const session = gallery.sessions.find((s) => s.token === token);
+ const items: (MenuItem | PredefinedMenuItem)[] = [
+ await MenuItem.new({ text: "Open", action: () => openSession(token) }),
+ ];
+ if (session && session.status === "complete") {
+ items.push(
+ await MenuItem.new({
+ text: "Open in Editor",
+ action: () => { void openEditorWindow(token); },
+ }),
+ );
+ }
+ items.push(await PredefinedMenuItem.new({ item: "Separator" }));
+ items.push(
+ await MenuItem.new({ text: "Archive", action: () => { void archiveSession(token); } }),
+ );
+ const menu = await Menu.new({ items });
+ await menu.popup();
+ },
+ [gallery.sessions, openSession, archiveSession],
+ );
+
// 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.
@@ -213,14 +529,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();
@@ -269,7 +585,7 @@ function MainWindowApp() {
const prevRootBg = root?.style.background ?? "";
let effectsApplied = false;
-
+
const isLinux = navigator.userAgent.toLowerCase().includes("linux");
if (!isLinux) {
invoke("enable_vibrancy")
@@ -302,6 +618,10 @@ function MainWindowApp() {
// Step 2: Route
const content = (() => {
+ // The editor owns the session while its window is open.
+ if (editorWindowToken) {
+ return ;
+ }
switch (route.page) {
case "gallery":
return (
@@ -309,38 +629,14 @@ function MainWindowApp() {
sessions={gallery.sessions}
loading={gallery.loading}
error={gallery.error}
- onSessionClick={(token) => {
- const session = gallery.sessions.find((s) => s.token === token);
- if (session && ["pending", "active", "paused"].includes(session.status)) {
- navigate({ page: "record", token });
- } else {
- navigate({ page: "session", token });
- }
- }}
- onArchive={async (token) => {
- const yes = await confirm("Are you sure you want to archive this session?", { title: "Archive Session", kind: "warning" });
- if (yes) {
- tokenStore.archiveToken(token);
- gallery.refresh();
- }
- }}
- onAdd={() => navigate({ page: "add" })}
- onSettings={isWayland ? undefined : () => navigate({ page: "settings" })}
- banner={
- announcement || backgroundUpdate.availableVersion ? (
-
- {/* Announcement sits above the update banner. */}
- {announcement &&
}
- {backgroundUpdate.availableVersion && (
-
- )}
-
- ) : undefined
- }
+ onSessionClick={openSession}
+ onArchive={archiveSession}
+ onSessionContextMenu={handleSessionContextMenu}
+ 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" })}
+ banner={announcement ? : undefined}
/>
);
case "settings":
@@ -378,9 +674,17 @@ 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. Shared
+ // de-dupe with the post-edit watcher above, so a session
+ // seen finishing by both paths only redirects once.
+ fireRedirect(route.token, redirectUrl);
+ }}
onBack={() => {
gallery.refresh();
navigate({ page: "gallery" });
@@ -429,46 +733,45 @@ function MainWindowApp() {
Sentry.setTag("session_token", token ?? null);
}, [route]);
- const routeKey = `${route.page}:${(route as { token?: string }).token ?? ""}`;
-
- if (updateStatus.state !== "idle") {
- return (
-
- {isMacOS && (
-
- )}
-
- {updateStatus.state === "checking" && "Checking for updates…"}
- {updateStatus.state === "no-update" && updateStatus.message}
- {updateStatus.state === "downloading" && `Updating… ${updateStatus.progress}%`}
- {updateStatus.state === "installing" && "Installing update…"}
- {updateStatus.state === "done" && "Restarting…"}
-
-
- );
- }
+ // The editor placeholder participates in the route transition, so the
+ // main window slides to it and back instead of hard-cutting.
+ const routeKey = editorWindowToken
+ ? `editor-open:${editorWindowToken}`
+ : `${route.page}:${(route as { token?: string }).token ?? ""}`;
return (
-
- {/* Draggable Titlebar Area that dodges the traffic lights (macOS only) */}
- {isMacOS && (
+
+ {/* Draggable Titlebar Area that dodges the traffic lights (macOS only).
+ The update pill lives here, Ghostty-style — the titlebar is a
+ transparent webview overlay, so it renders inside the real titlebar. */}
+ {isMacOS ? (
+ style={{ height: 32, flexShrink: 0, width: "100%", zIndex: 9999, background: "transparent", cursor: "default", display: "flex", alignItems: "center", justifyContent: "flex-end", paddingRight: 6, boxSizing: "border-box" }}
+ >
+
+
+ ) : (
+ /* No overlay titlebar on Windows/Linux — float the pill bottom-left. */
+
+
+
)}
+ {/* 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)}
+ />
+ ),
+ )}
+
+ );
+}
diff --git a/clients/desktop/src/components/AddSessionPage.tsx b/clients/desktop/src/components/AddSessionPage.tsx
index e6622694..d183afe4 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;
@@ -21,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 {
@@ -36,7 +40,6 @@ export function AddSessionPage({ onBack, onStart }: AddSessionPageProps) {
const [link, setLink] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
- const [showLink, setShowLink] = useState(false);
// Fetch the program registry. Failures and empty lists are non-fatal — the
// paste-a-link backup always remains available.
@@ -62,6 +65,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));
@@ -108,64 +121,43 @@ export function AddSessionPage({ onBack, onStart }: AddSessionPageProps) {
{error}
)}
{/* Backup: paste a lookout:// link, for when the deep link doesn't fire. */}
- {showLink ? (
- <>
- {
- setLink(e.target.value);
- setError(null);
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter" && !loading) handleStart();
- }}
- placeholder="Paste a lookout:// link here"
- disabled={loading}
- style={{
- width: "100%",
- padding: `${spacing.md}px ${spacing.lg}px`,
- fontSize: fontSize.md,
- fontWeight: fontWeight.medium,
- color: colors.text.primary,
- background: colors.bg.sunken,
- border: `1px solid ${error ? colors.status.danger : colors.border.default}`,
- borderRadius: radii.lg,
- outline: "none",
- boxSizing: "border-box",
- height: 48,
- opacity: loading ? 0.5 : 1,
- }}
- />
-
- Start from link
-
- >
- ) : (
- setShowLink(true)}
- style={{
- background: "none",
- border: "none",
- color: colors.text.tertiary,
- fontSize: fontSize.sm,
- cursor: "pointer",
- padding: spacing.xs,
- textDecoration: "underline",
- alignSelf: "center",
- }}
- >
- Deep link not working? Paste a link instead
-
- )}
+ {
+ setLink(e.target.value);
+ setError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && !loading) handleStart();
+ }}
+ placeholder="Paste a lookout:// link here"
+ disabled={loading}
+ style={{
+ width: "100%",
+ padding: `${spacing.md}px ${spacing.lg}px`,
+ fontSize: fontSize.md,
+ fontWeight: fontWeight.medium,
+ color: colors.text.primary,
+ background: colors.bg.sunken,
+ border: `1px solid ${error ? colors.status.danger : colors.border.default}`,
+ borderRadius: radii.lg,
+ outline: "none",
+ boxSizing: "border-box",
+ height: 48,
+ opacity: loading ? 0.5 : 1,
+ }}
+ />
+
+ Start from link
+
>
}
>
@@ -199,6 +191,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/desktop/src/components/AnnouncementBanner.tsx b/clients/desktop/src/components/AnnouncementBanner.tsx
index b1c133da..44c9e02b 100644
--- a/clients/desktop/src/components/AnnouncementBanner.tsx
+++ b/clients/desktop/src/components/AnnouncementBanner.tsx
@@ -10,9 +10,8 @@ const LEVEL_COLOR: Record = {
};
/**
- * Gallery banner for an admin-authored announcement. Same shape as
- * UpdateBanner, tinted by level; if a URL is set, an "Open" button launches it
- * in the OS browser.
+ * Gallery banner for an admin-authored announcement, tinted by level; if a
+ * URL is set, an "Open" button launches it in the OS browser.
*/
export function AnnouncementBanner({ announcement }: { announcement: Announcement }) {
const color = LEVEL_COLOR[announcement.level] ?? colors.status.info;
diff --git a/clients/desktop/src/components/DesktopRecorder.tsx b/clients/desktop/src/components/DesktopRecorder.tsx
index 2d6816e7..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,
@@ -18,9 +19,11 @@ import {
} from "@lookout/react";
import { getReport } from "../logger.js";
import { NamingModal } from "./NamingModal.js";
+import { openEditorWindow } from "./EditorWindow.js";
import { useNativeCapture } from "../hooks/useNativeCapture.js";
import type { CaptureSource } from "../hooks/useNativeCapture.js";
import { useScreenPreview } from "../hooks/useScreenPreview.js";
+import { useWindowFocus } from "../hooks/useWindowFocus.js";
import { useCameraCapture, waitForVideoReady } from "../hooks/useCameraCapture.js";
import { useSessionNotifications } from "../hooks/useSessionNotifications.js";
@@ -32,24 +35,29 @@ interface DesktopRecorderProps {
onViewSession: (token: string) => void;
}
-const API_BASE = "https://lookout.hackclub.com";
-
-function RecorderPreviewItem({
- src,
- isMain,
- captureUrl,
- isMulti
-}: {
- src: CaptureSource;
- isMain: boolean;
- captureUrl: string | null;
+import { getApiBase } from "../serverConfig.js";
+
+// Read once per webview load; Settings → Server reloads the view on change.
+const API_BASE = getApiBase();
+
+function RecorderPreviewItem({
+ src,
+ captureUrl,
+ isMulti,
+ live,
+}: {
+ src: CaptureSource;
+ /** Latest uploaded capture, shown when the live loop is parked. */
+ captureUrl: string | null;
isMulti: boolean;
+ /** Poll the native live preview. When false, useScreenPreview fetches at
+ * most one frame and parks — the tile freezes (or shows captureUrl)
+ * instead of burning a native capture per second off-focus. */
+ live: boolean;
}) {
- const { previewUrl: livePreviewUrl } = useScreenPreview(
- isMain && captureUrl ? null : src,
- 1
- );
- const previewUrl = (isMain ? captureUrl : null) || livePreviewUrl;
+ const { previewUrl: livePreviewUrl } = useScreenPreview(src, 1, live);
+ const showingLive = live && !!livePreviewUrl;
+ const previewUrl = showingLive ? livePreviewUrl : captureUrl ?? livePreviewUrl;
if (!previewUrl) {
return (
@@ -75,13 +83,13 @@ function RecorderPreviewItem({
alt="Screen preview"
style={{ width: "100%", height: "100%", objectFit: isMulti ? "cover" : "contain", display: "block" }}
/>
- {isMain && (
+ {!isMulti && (
- {captureUrl ? "Latest capture" : "Live preview"}
+ {showingLive ? "Preview" : captureUrl ? "Latest capture" : "Preview"}
)}
@@ -91,15 +99,15 @@ function RecorderPreviewItem({
function formatTimeTray(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
-
+
if (h > 0) return `${h}h ${m}m`;
- if (m === 0) return `< 1m`;
return `${m}m`;
}
export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource, onBack, onViewSession }: DesktopRecorderProps) {
const isMacOS = navigator.userAgent.includes("Mac");
const isCamera = source.length === 1 && source[0].type === "camera";
+ const windowFocused = useWindowFocus();
const session = useSession();
const camera = useCameraCapture();
@@ -122,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,
@@ -157,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);
@@ -211,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();
})();
@@ -237,12 +263,14 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
};
}, []);
- // Finalize stop: optionally name, then stop the session.
- const handleConfirmStop = useCallback(async (name: string | null) => {
+ // Finalize stop: optionally name, then stop the session. With `edit`,
+ // the timelapse is held unpublished after compiling so the user can cut
+ // it first — programs only ever observe it once, finished.
+ const finalizeStop = useCallback(async (name: string | null, edit: boolean) => {
if (stopActionHandled.current) return;
stopActionHandled.current = true;
setStopLoading(true);
- console.log(`[session] stopping, name: ${name?.trim() || "(none)"}`);
+ console.log(`[session] stopping, name: ${name?.trim() || "(none)"}, edit: ${edit}`);
if (name && name.trim()) {
try {
await fetch(`${API_BASE}/api/sessions/${token}/name`, {
@@ -256,9 +284,11 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
}
try {
- await session.stop();
+ // Name was already applied above via the rename endpoint.
+ await session.stop(undefined, { edit });
console.log("[session] stopped, navigating to session detail");
if (isCamera) camera.stopStream();
+ if (edit) void openEditorWindow(token);
setIsPrompting(false);
setStopLoading(false);
} catch (e) {
@@ -268,6 +298,16 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
}
}, [token, session, isCamera, camera]);
+ const handleConfirmStop = useCallback(
+ (name: string | null) => finalizeStop(name, false),
+ [finalizeStop],
+ );
+
+ const handleEditAndSave = useCallback(
+ (name: string | null) => finalizeStop(name, true),
+ [finalizeStop],
+ );
+
const handlePause = useCallback(async () => {
console.log("[session] pausing...");
setPauseLoading(true);
@@ -287,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);
@@ -312,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]);
@@ -336,9 +376,27 @@ 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 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(() => {
@@ -355,7 +413,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
// The Rust tray ticker is the authoritative source for the menu-bar title
// and runs at 1s cadence; calling update_tray_time from JS every second
// creates two writers fighting over the title, which produces the visible
- // flicker between "<1m"/"1m" and "1m"/"2m" at the minute boundaries
+ // flicker between "0m"/"1m" and "1m"/"2m" at the minute boundaries
// (JS interpolates to one second, Rust to another).
//
// We still split the writes:
@@ -377,23 +435,34 @@ 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 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 = {
- 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, timer.baseSeconds, timer.anchorAt]);
- // Sync tracked seconds to Rust tray timer so it stays accurate
- // after server corrections or session timer updates.
- if (capture.trackedSeconds > 0) {
- invoke("sync_tray_tracked_seconds", { trackedSeconds: capture.trackedSeconds }).catch(console.error);
+ // 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 (bestTrackedSeconds > 0) {
+ invoke("sync_tray_tracked_seconds", { trackedSeconds: bestTrackedSeconds }).catch(console.error);
}
- }, [displaySeconds, screenshotCount, controlMode]);
+ }, [bestTrackedSeconds]);
// Hide tray on unmount or session end
useEffect(() => {
@@ -502,11 +571,17 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
- {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"}
- {/* Preview — fills available space */}
+ {/* Preview — fills available space. Once the Rust capture loop is
+ running it feeds this image directly: in-between frames arrive at
+ the clip cadence (20/min) while the window is focused, and stop
+ when it isn't — so the same element is a live preview when watched
+ and the latest capture when not. No separate preview loop. */}
- Latest capture
+ {windowFocused && !isCamera ? "Preview" : "Latest capture"}
) : (
@@ -549,9 +624,9 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
1}
+ live={windowFocused && !isCamera}
/>
))
)}
@@ -641,6 +716,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
)}
diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx
new file mode 100644
index 00000000..bed89c4d
--- /dev/null
+++ b/clients/desktop/src/components/EditorWindow.tsx
@@ -0,0 +1,400 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { emit } from "@tauri-apps/api/event";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
+import { confirm } from "@tauri-apps/plugin-dialog";
+import { createLookoutClient, type CutInterval } from "@lookout/react";
+import { TimelapseEditor, colors, fontSize, fontWeight, spacing } from "@lookout/react";
+import { invoke } from "../logger.js";
+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";
+
+/** Emitted when an editor window is opened, so the main window can step
+ * out of the way. Payload: { token }. */
+export const EDITOR_OPENED_EVENT = "lookout-editor-opened";
+
+/** Tauri window label for a session's editor. */
+export function editorWindowLabel(token: string): string {
+ return `editor-${token.slice(0, 8)}`;
+}
+
+/** Is the editor window for this session currently open? */
+export async function isEditorWindowOpen(token: string): Promise {
+ try {
+ return (await WebviewWindow.getByLabel(editorWindowLabel(token))) !== null;
+ } catch {
+ return false;
+ }
+}
+
+/** Bring an already-open editor window to the front. */
+export async function focusEditorWindow(token: string): Promise {
+ const win = await WebviewWindow.getByLabel(editorWindowLabel(token));
+ await win?.setFocus().catch(() => {});
+}
+
+/**
+ * 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 = editorWindowLabel(token);
+ const existing = await WebviewWindow.getByLabel(label);
+ if (existing) {
+ await existing.setFocus().catch(() => {});
+ await emit(EDITOR_OPENED_EVENT, { token }).catch(() => {});
+ return;
+ }
+ const isMacOS = navigator.userAgent.includes("Mac");
+ const win = new WebviewWindow(label, {
+ url: `${window.location.pathname}#/editor?token=${token}`,
+ title: "Edit timelapse",
+ width: 940,
+ height: 660,
+ // The floor is what the shell actually needs: chrome + a legible
+ // stage + the dock. Below this the layout would be cramped rather
+ // than broken — it still reflows — but there's no reason to allow it.
+ minWidth: 620,
+ minHeight: 480,
+ resizable: true,
+ center: true,
+ // Transparent + overlay titlebar is what lets the vibrancy material
+ // show through, matching the main window's chrome exactly.
+ transparent: true,
+ // Overlay titlebar with the title VISIBLE: macOS draws and centers it
+ // for us, so it can't drift out of alignment with the traffic lights
+ // the way a hand-placed label does.
+ ...(isMacOS ? { titleBarStyle: "overlay" as const } : {}),
+ });
+ win.once("tauri://error", (e) => {
+ console.error("[editor] failed to open editor window:", e);
+ });
+ await emit(EDITOR_OPENED_EVENT, { token }).catch(() => {});
+}
+
+/**
+ * Close this editor window and hand focus back to the app.
+ *
+ * Never swallow the failure: if the close is refused (a missing
+ * capability, say) a silent catch leaves the user staring at a window
+ * that says it saved and won't go away. Log it, then fall back to
+ * destroy(), which skips the close-requested round trip entirely.
+ */
+async function closeEditorWindow(): Promise {
+ // Bring the main window forward first — closing the frontmost window
+ // otherwise drops the user behind whatever app is underneath.
+ try {
+ const main = await WebviewWindow.getByLabel("main");
+ await main?.setFocus();
+ } catch (e) {
+ console.warn("[editor] could not focus main window:", e);
+ }
+
+ try {
+ await getCurrentWindow().close();
+ } catch (e) {
+ console.error("[editor] close() failed, destroying instead:", e);
+ try {
+ await getCurrentWindow().destroy();
+ } catch (e2) {
+ console.error("[editor] destroy() failed too:", e2);
+ }
+ }
+}
+
+/**
+ * What the main window shows while the editor window is up. The editing
+ * happens over there, so anything rendered here would just be a second,
+ * stale copy of the same session competing for attention.
+ */
+export function EditorOpenPlaceholder({ token }: { token: string }) {
+ const [focusing, setFocusing] = useState(false);
+
+ return (
+ {
+ setFocusing(true);
+ void focusEditorWindow(token).finally(() => setFocusing(false));
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") void focusEditorWindow(token);
+ }}
+ style={{
+ height: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: spacing.md,
+ padding: spacing.xxl,
+ textAlign: "center",
+ cursor: "pointer",
+ userSelect: "none",
+ opacity: focusing ? 0.6 : 1,
+ transition: "opacity 0.15s ease",
+ }}
+ >
+
+
+
+
+
+
+ Edit your timelapse in the edit window.
+
+
+ Click here to bring it to the front.
+
+
+ );
+}
+
+/**
+ * Tracks whether an editor window is open, for the main window.
+ *
+ * Listens for the open event, then polls for the window's existence — the
+ * poll is what guarantees the main window can never get stuck behind the
+ * placeholder if the editor window is force-quit or crashes.
+ */
+export function useEditorWindowOpen(): string | null {
+ const [token, setToken] = useState(null);
+
+ useEffect(() => {
+ const unlisteners: Array<() => void> = [];
+ void import("@tauri-apps/api/event").then(async ({ listen }) => {
+ unlisteners.push(
+ await listen<{ token: string }>(EDITOR_OPENED_EVENT, (e) => {
+ if (e.payload?.token) setToken(e.payload.token);
+ }),
+ // Publishing closes the editor window; clear immediately rather
+ // than waiting for the poll, so the session view is back the
+ // instant the user saves.
+ await listen(EDITED_EVENT, () => setToken(null)),
+ );
+ });
+ return () => {
+ for (const un of unlisteners) un();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!token) return;
+ let cancelled = false;
+ const id = setInterval(async () => {
+ if (cancelled) return;
+ if (!(await isEditorWindowOpen(token))) setToken(null);
+ }, 1000);
+ return () => {
+ cancelled = true;
+ clearInterval(id);
+ };
+ }, [token]);
+
+ return token;
+}
+
+/**
+ * The editor window's root view (route `#/editor?token=…`).
+ *
+ * An app shell, not a page: a draggable strip clearing the window
+ * controls, a body that owns all remaining height, and nothing that can
+ * push content past the window edge. The chrome is the system vibrancy
+ * material, so this window reads as the same app as the main one in both
+ * light and dark.
+ *
+ * The title is the WINDOW's title, drawn by the OS — centered and aligned
+ * to the traffic lights for free. A hand-placed label next to them has to
+ * be pixel-matched against a position that varies by OS version, and it
+ * was visibly off.
+ */
+export function EditorWindow({ token }: { token: string }) {
+ const isMacOS = navigator.userAgent.includes("Mac");
+
+ // Vibrancy: the main window does this too. The webview must be
+ // transparent for the material to show, so only go transparent once the
+ // native side confirms it applied — otherwise (Linux, or a failure) the
+ // window would render see-through with nothing behind it.
+ useEffect(() => {
+ const html = document.documentElement;
+ const body = document.body;
+ const root = document.getElementById("root");
+ const isLinux = navigator.userAgent.toLowerCase().includes("linux");
+
+ if (isLinux) {
+ html.style.background = "var(--color-bg-body)";
+ body.style.background = "var(--color-bg-body)";
+ if (root) root.style.background = "var(--color-bg-body)";
+ return;
+ }
+
+ let applied = false;
+ invoke("enable_vibrancy")
+ .then(() => {
+ applied = true;
+ html.style.background = "transparent";
+ body.style.background = "transparent";
+ if (root) root.style.background = "transparent";
+ })
+ .catch((err) => {
+ console.warn("[editor] vibrancy unavailable, falling back:", err);
+ html.style.background = "var(--color-bg-body)";
+ body.style.background = "var(--color-bg-body)";
+ if (root) root.style.background = "var(--color-bg-body)";
+ });
+
+ return () => {
+ if (applied) invoke("disable_vibrancy").catch(() => {});
+ };
+ }, []);
+
+ // The window title carries the session name on the native title bar;
+ // the in-window strip shows it too, since the title is hidden on macOS.
+ useEffect(() => {
+ fetch(`${getApiBase()}/api/sessions/${token}`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((d) => {
+ if (d?.name) void getCurrentWindow().setTitle(`Edit — ${d.name}`);
+ })
+ .catch(() => {
+ // Name is decoration — the editor works without it.
+ });
+ }, [token]);
+
+ // Closing the window IS the decision to finish: the timelapse publishes
+ // with whatever cuts are on screen. There's no "leave it hanging" exit —
+ // the session is unpublished until someone decides, so an editor that
+ // could be dismissed without deciding would just strand it until the
+ // lease lapsed. Hence: confirm, publish, then close.
+ const cutsRef = useRef([]);
+ const dirtyRef = useRef(false);
+ const finishedRef = useRef(false);
+ const client = useRef(
+ createLookoutClient({ baseUrl: getApiBase(), token }),
+ ).current;
+
+ const finishAndClose = useCallback(async () => {
+ finishedRef.current = true;
+ let published: Awaited> | null = null;
+ try {
+ await client.setCuts(cutsRef.current);
+ published = await client.applyCuts();
+ } catch (e) {
+ console.error("[editor] publish on close failed:", e);
+ // Don't trap the user in a window they asked to close: the hold
+ // lapses on its own and publishes as recorded shortly after.
+ }
+ // Fire-and-forget: the close must not wait on the notification. Carry
+ // the publish result so the main window can fire the redirect the
+ // instant it's done (`complete`) or watch the compile to completion.
+ emit(EDITED_EVENT, {
+ token,
+ status: published?.status ?? null,
+ redirectUrl: published?.redirectUrl ?? null,
+ }).catch((e) => console.error("[editor] emit failed:", e));
+ await closeEditorWindow();
+ }, [client, token]);
+
+ useEffect(() => {
+ let unlisten: (() => void) | undefined;
+ void getCurrentWindow()
+ .onCloseRequested(async (event) => {
+ if (finishedRef.current) return;
+ event.preventDefault();
+ const removed = cutsRef.current.length;
+ const ok = await confirm(
+ dirtyRef.current && removed > 0
+ ? `Closing publishes this timelapse with ${removed} cut${
+ removed === 1 ? "" : "s"
+ } applied. This can't be undone.`
+ : "Closing publishes this timelapse as recorded. This can't be undone.",
+ { title: "Finish timelapse?", kind: "warning" },
+ );
+ if (ok) void finishAndClose();
+ })
+ .then((fn) => {
+ unlisten = fn;
+ });
+ return () => {
+ if (unlisten) unlisten();
+ };
+ }, [finishAndClose]);
+
+ return (
+
+ {/* Drag strip clearing the window controls and the OS-drawn title.
+ macOS only: elsewhere the window has real decorations above the
+ webview, so the content can start at the very top. */}
+ {isMacOS && (
+
+ )}
+
+ {/* Body owns the rest. min-height:0 is what lets the stage inside
+ letterboxe down instead of clipping the dock off the bottom. */}
+
+ {
+ cutsRef.current = cuts;
+ dirtyRef.current = dirty;
+ }}
+ onApplied={(result) => {
+ // Saved from inside the editor. Flag it first so the close
+ // handler doesn't prompt to publish what's already published.
+ finishedRef.current = true;
+ emit(EDITED_EVENT, {
+ token,
+ status: result.status,
+ redirectUrl: result.redirectUrl,
+ }).catch((e) => console.error("[editor] emit failed:", e));
+ void closeEditorWindow();
+ }}
+ />
+
+
+ );
+}
diff --git a/clients/desktop/src/components/NamingModal.tsx b/clients/desktop/src/components/NamingModal.tsx
index f24949bd..eb8a05a1 100644
--- a/clients/desktop/src/components/NamingModal.tsx
+++ b/clients/desktop/src/components/NamingModal.tsx
@@ -5,11 +5,17 @@ import { Button, Card, colors, spacing, radii, fontSize, fontWeight } from "@loo
interface NamingModalProps {
loading: boolean;
onConfirm: (name: string | null) => void;
+ /** Stop, then open the editor before anything is published. Editing is
+ * offered HERE, not after the timelapse goes out: `complete` is when
+ * programs consume a session (heartbeats, tracked time, video), so the
+ * data has to be final the first time they see it. */
+ onEditAndSave: (name: string | null) => void;
onResume: () => void;
}
-export function NamingModal({ loading, onConfirm, onResume }: NamingModalProps) {
+export function NamingModal({ loading, onConfirm, onEditAndSave, onResume }: NamingModalProps) {
const [name, setName] = useState("");
+ const [choice, setChoice] = useState<"stop" | "edit" | null>(null);
const inputRef = useRef(null);
useEffect(() => {
@@ -66,26 +72,44 @@ export function NamingModal({ loading, onConfirm, onResume }: NamingModalProps)
opacity: loading ? 0.5 : 1,
}}
/>
-
+
onConfirm(name)}
+ loading={loading && choice === "edit"}
+ disabled={loading && choice !== "edit"}
+ onClick={() => {
+ setChoice("edit");
+ onEditAndSave(name);
+ }}
>
- Save & Stop
-
-
- Resume
+ Edit & Save
+
+ {
+ setChoice("stop");
+ onConfirm(name);
+ }}
+ >
+ Save & Stop
+
+
+ Resume
+
+
diff --git a/clients/desktop/src/components/PermissionScreen.tsx b/clients/desktop/src/components/PermissionScreen.tsx
index e35ef225..ed65cfe6 100644
--- a/clients/desktop/src/components/PermissionScreen.tsx
+++ b/clients/desktop/src/components/PermissionScreen.tsx
@@ -12,6 +12,13 @@ type PermissionStatus = "checking" | "granted" | "denied";
type PermissionType = "screen" | "camera";
+/**
+ * localStorage key remembering that a permission check passed once, so later
+ * boots skip the gate (and its flicker) and re-verify in the background.
+ * Only a real grant is cached — "Skip" never writes it.
+ */
+export const permCacheKey = (type: PermissionType) => `lookout-perm-${type}`;
+
const PERMISSION_CONFIG: Record
{
+ const stopSession = useCallback(async (name: string | null, edit: boolean) => {
setStopping(true);
- console.log(`[record] stopping session, name: ${name?.trim() || "(none)"}`);
+ console.log(
+ `[record] stopping session, name: ${name?.trim() || "(none)"}, edit: ${edit}`,
+ );
if (name && name.trim()) {
try {
await fetch(`${API_BASE}/api/sessions/${token}/name`, {
@@ -82,14 +88,39 @@ export function RecordPage({ token, onBack, onViewSession }: RecordPageProps) {
}
}
try {
- await fetch(`${API_BASE}/api/sessions/${token}/stop`, { method: "POST" });
+ // `edit: true` holds the timelapse unpublished after it compiles so
+ // the user can cut it first — programs only ever see it finished.
+ await fetch(`${API_BASE}/api/sessions/${token}/stop`, {
+ method: "POST",
+ ...(edit
+ ? {
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ edit: true }),
+ }
+ : {}),
+ });
console.log("[record] session stopped");
} catch (e) {
console.error("[record] stop failed:", e);
}
+ // Either way the user lands on the session view; when held, it shows
+ // the review panel (and the editor window opens from there).
+ if (edit) {
+ void openEditorWindow(token);
+ }
onViewSession(token);
}, [token, onViewSession]);
+ const handleConfirmStop = useCallback(
+ (name: string | null) => stopSession(name, false),
+ [stopSession],
+ );
+
+ const handleEditAndSave = useCallback(
+ (name: string | null) => stopSession(name, true),
+ [stopSession],
+ );
+
const handleResumeFromModal = useCallback(() => {
setIsPrompting(false);
}, []);
@@ -226,6 +257,7 @@ export function RecordPage({ token, onBack, onViewSession }: RecordPageProps) {
)}
diff --git a/clients/desktop/src/components/SettingsPage.tsx b/clients/desktop/src/components/SettingsPage.tsx
index b76fe452..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,52 +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
- useEffect(() => {
- fetchRunningApps();
- refreshTimerRef.current = setInterval(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
) : (
-
-
-
+
Back
)}
@@ -96,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 (
+
+
+
+ {icon}
+
+
+
{title}
+
+ {description}
+
+
+
+
+ );
+}
+
+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 */}
+
+
+ }
+ title="Filtered Apps"
+ description={
+ isWayland
+ ? "Not supported on Wayland"
+ : blacklistedApps.length > 0
+ ? `${blacklistedApps.length} app${blacklistedApps.length !== 1 ? "s" : ""} filtered`
+ : "Black out selected apps in captures"
+ }
+ onClick={() => setSubpage("filtered-apps")}
+ />
+ }
+ title="Advanced"
+ description={
+ isDefaultApiBase()
+ ? "Developer options"
+ : // Make an active server override impossible to miss from
+ // the menu — it changes where every recording goes.
+ `Custom server: ${getApiBase().replace(/^https?:\/\//, "")}`
+ }
+ onClick={() => setSubpage("advanced")}
+ />
+
+
+ );
+ }
+ })();
+
+ // 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}
+
+ )}
+
+
+ normalized && save(normalized)}
+ >
+ {saving ? "Checking…" : "Save"}
+
+ {current !== DEFAULT_API_BASE && (
+ save(null)}
+ >
+ Reset to default
+
+ )}
+
+
+
+ {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 (
+ toggleApp(app.name)}
+ style={{
+ position: "relative",
+ display: "flex",
+ alignItems: "center",
+ // 6px icon-to-name gap; the checkbox adds 2px margin for an 8px
+ // checkbox-to-icon gap.
+ gap: 6,
+ width: "100%",
+ padding: "6px 8px",
+ background: "transparent",
+ border: "none",
+ borderRadius: radii.md,
+ cursor: "pointer",
+ textAlign: "left",
+ color: colors.text.primary,
+ fontSize: fontSize.md,
+ }}
+ >
+ {/* Hover/press highlight (pure CSS so search stays instant) */}
+
+
+ {/* Checkbox */}
+
+ {isBlacklisted && (
+
+ )}
+
+
+ {/* App icon (observed so its data loads only when scrolled into view) */}
+ observeIcon(el, app)}
+ style={{ position: "relative", zIndex: 1, width: 20, height: 20, flexShrink: 0 }}
+ >
+
+
+
+ {/* App name */}
+
+
+ {app.name}
+
+
+
+ );
+ };
+
+ 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/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 && (
-
onSelect(selected)} style={{ marginTop: spacing.lg }}>
+ {
+ saveLastMonitorSelection(selected);
+ onSelect(selected);
+ }}
+ style={{ marginTop: spacing.lg }}
+ >
{submitLabel}
)}
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/components/UpdateBanner.tsx b/clients/desktop/src/components/UpdateBanner.tsx
deleted file mode 100644
index a6b94713..00000000
--- a/clients/desktop/src/components/UpdateBanner.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Button, colors, spacing, fontSize, fontWeight, radii } from "@lookout/react";
-
-interface UpdateBannerProps {
- version: string;
- restarting?: boolean;
- onRestart: () => void;
-}
-
-/** Gallery banner shown when a newer app version is available. */
-export function UpdateBanner({ version, restarting, onRestart }: UpdateBannerProps) {
- return (
-
-
-
- Update available
-
-
- Version {version} is ready — restart to update.
-
-
-
- {restarting ? "Updating…" : "Restart"}
-
-
- );
-}
diff --git a/clients/desktop/src/components/UpdatePill.tsx b/clients/desktop/src/components/UpdatePill.tsx
new file mode 100644
index 00000000..b7e70198
--- /dev/null
+++ b/clients/desktop/src/components/UpdatePill.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import { AnimatePresence, motion } from "motion/react";
+import NumberFlow from "@number-flow/react";
+import { colors, fontSize, fontWeight } from "@lookout/react";
+import type { UpdatePhase } from "../hooks/useAppUpdate.js";
+
+interface UpdatePillProps {
+ phase: UpdatePhase;
+ onRestart: () => void;
+ /** Which screen edge the pill is anchored to — sets the slide direction. */
+ origin?: "top" | "bottom";
+}
+
+/** Tiny circular progress ring for the downloading state. */
+function ProgressRing({ progress }: { progress: number }) {
+ const r = 5;
+ const c = 2 * Math.PI * r;
+ return (
+
+
+
+
+ );
+}
+
+function PowerIcon() {
+ return (
+
+
+
+
+ );
+}
+
+/**
+ * Ghostty-style update pill that lives in the titlebar. Shows download
+ * 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, 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";
+
+ return (
+
+ {phase.state !== "idle" && (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ title={
+ phase.state === "downloading"
+ ? `Downloading v${phase.version}`
+ : `Restart to update to v${phase.version}`
+ }
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 6,
+ height: 22,
+ padding: "0 10px",
+ borderRadius: 999,
+ border: `1px solid ${solid ? "transparent" : colors.border.default}`,
+ background: solid
+ ? clickable && hovered
+ ? `color-mix(in srgb, ${colors.text.primary} 19%, transparent)`
+ : `color-mix(in srgb, ${colors.text.primary} 16%, transparent)`
+ : colors.bg.surface,
+ color: solid ? colors.text.primary : colors.text.secondary,
+ fontSize: fontSize.xs,
+ fontWeight: fontWeight.medium,
+ fontFamily: "inherit",
+ whiteSpace: "nowrap",
+ cursor: "default",
+ transition: "background 0.15s ease, color 0.15s ease, border-color 0.15s ease",
+ WebkitAppRegion: "no-drag",
+ } as React.CSSProperties}
+ >
+ {phase.state === "downloading" ? (
+ <>
+
+
+ Downloading Update…{" "}
+
+
+ >
+ ) : (
+ <>
+
+ Restart to Complete Update
+ >
+ )}
+
+ )}
+
+ );
+}
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/hooks/useAppUpdate.ts b/clients/desktop/src/hooks/useAppUpdate.ts
new file mode 100644
index 00000000..3dd188d3
--- /dev/null
+++ b/clients/desktop/src/hooks/useAppUpdate.ts
@@ -0,0 +1,134 @@
+import { check, type Update } from "@tauri-apps/plugin-updater";
+import { relaunch } from "@tauri-apps/plugin-process";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export const LAST_UPDATE_KEY = "lookout_last_update_ts";
+const UPDATE_COOLDOWN_MS = 60_000;
+const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes
+
+export type UpdatePhase =
+ | { state: "idle" }
+ | { state: "downloading"; version: string; progress: number }
+ | { state: "ready"; version: string }
+ | { state: "restarting"; version: string };
+
+/**
+ * Ghostty-style background updater. Checks at launch and every 30 minutes,
+ * downloads a found update in the background (progress surfaces in the
+ * titlebar pill), then waits for the user to click "Restart to Complete
+ * Update". Nothing ever blocks the app at launch.
+ *
+ * install() stays behind the user's click: on Windows it exits the app to
+ * run the installer, so it must never fire automatically.
+ */
+export function useAppUpdate(): { phase: UpdatePhase; restart: () => void } {
+ const [phase, setPhase] = useState({ state: "idle" });
+ const updateRef = useRef(null);
+ const demoRef = useRef(false);
+
+ // Dev-only: run `__updatePillDemo()` in the webview console to watch the
+ // full pill lifecycle (enter → download progress → ready → restart → exit)
+ // without a real update. Clicking the pill in demo mode fakes the restart.
+ useEffect(() => {
+ if (!import.meta.env.DEV) return;
+ (window as unknown as Record).__updatePillDemo = () => {
+ demoRef.current = true;
+ let p = 0;
+ setPhase({ state: "downloading", version: "9.9.9", progress: 0 });
+ const id = setInterval(() => {
+ p += 1 + Math.random() * 4;
+ if (p >= 100) {
+ clearInterval(id);
+ setPhase({ state: "ready", version: "9.9.9" });
+ } else {
+ setPhase({ state: "downloading", version: "9.9.9", progress: Math.round(p) });
+ }
+ }, 80);
+ };
+ return () => {
+ delete (window as unknown as Record).__updatePillDemo;
+ };
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ // Right after an update-relaunch, skip the immediate re-check so a bad
+ // update manifest can't relaunch-loop the app.
+ let skipFirstCheck = false;
+ const lastUpdate = localStorage.getItem(LAST_UPDATE_KEY);
+ if (lastUpdate && Date.now() - Number(lastUpdate) < UPDATE_COOLDOWN_MS) {
+ console.log("[updater] skipping immediate check — just updated");
+ localStorage.removeItem(LAST_UPDATE_KEY);
+ skipFirstCheck = true;
+ }
+
+ const runCheck = async () => {
+ if (updateRef.current) return; // already downloading or downloaded
+ try {
+ const update = await check();
+ if (cancelled || !update) return;
+ updateRef.current = update;
+ console.log(`[updater] found v${update.version}, downloading in background`);
+ setPhase({ state: "downloading", version: update.version, progress: 0 });
+
+ let totalBytes = 0;
+ let downloadedBytes = 0;
+ await update.download((event) => {
+ if (cancelled) return;
+ if (event.event === "Started" && event.data.contentLength) {
+ totalBytes = event.data.contentLength;
+ } else if (event.event === "Progress") {
+ downloadedBytes += event.data.chunkLength;
+ const progress =
+ totalBytes > 0
+ ? Math.round((downloadedBytes / totalBytes) * 100)
+ : 0;
+ setPhase({ state: "downloading", version: update.version, progress });
+ }
+ });
+
+ if (!cancelled) {
+ console.log(`[updater] v${update.version} downloaded — waiting for restart`);
+ setPhase({ state: "ready", version: update.version });
+ }
+ } catch (e) {
+ console.warn("[updater] background update failed:", e);
+ // Clear the ref so the next interval tick retries from scratch.
+ updateRef.current = null;
+ if (!cancelled) setPhase({ state: "idle" });
+ }
+ };
+
+ if (!skipFirstCheck) runCheck();
+ const id = setInterval(runCheck, CHECK_INTERVAL_MS);
+ return () => {
+ cancelled = true;
+ clearInterval(id);
+ };
+ }, []);
+
+ const restart = useCallback(async () => {
+ if (demoRef.current) {
+ demoRef.current = false;
+ setPhase({ state: "idle" });
+ return;
+ }
+ const update = updateRef.current;
+ if (!update) return; // the pill only becomes clickable once downloaded
+ setPhase({ state: "restarting", version: update.version });
+ try {
+ // The bytes are already on disk, so install() is near-instant.
+ // relaunch() only fires after a successful install; on Windows
+ // install() itself exits the app to run the installer.
+ await update.install();
+ localStorage.setItem(LAST_UPDATE_KEY, String(Date.now()));
+ await relaunch();
+ } catch (e) {
+ console.error("[updater] install failed:", e);
+ setPhase({ state: "ready", version: update.version });
+ }
+ }, []);
+
+ return { phase, restart };
+}
diff --git a/clients/desktop/src/hooks/useBackgroundUpdate.ts b/clients/desktop/src/hooks/useBackgroundUpdate.ts
deleted file mode 100644
index 673cdba1..00000000
--- a/clients/desktop/src/hooks/useBackgroundUpdate.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { check, type Update } from "@tauri-apps/plugin-updater";
-import { relaunch } from "@tauri-apps/plugin-process";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { LAST_UPDATE_KEY } from "./useUpdateCheck.js";
-
-// How often to ask the update server whether a newer build exists.
-const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes
-
-/**
- * Periodically checks the update server while the app is running and reports
- * when a newer version becomes available. Unlike useUpdateCheck — which
- * auto-updates at launch behind a full-screen gate — this never interrupts the
- * user. It only surfaces availability so the UI can show a "restart to update"
- * banner; the actual download + install is deferred until the user opts in via
- * restart(), reusing the same flow the launch-time updater uses.
- */
-export function useBackgroundUpdate(enabled: boolean) {
- const [availableVersion, setAvailableVersion] = useState(null);
- const [restarting, setRestarting] = useState(false);
- const updateRef = useRef(null);
-
- useEffect(() => {
- if (!enabled) return;
- let cancelled = false;
-
- const runCheck = async () => {
- if (updateRef.current) return; // already found one — nothing more to do
- try {
- const update = await check();
- if (cancelled || !update) return;
- console.log(`[updater] background check found v${update.version}`);
- updateRef.current = update;
- setAvailableVersion(update.version);
- } catch (e) {
- console.warn("[updater] background check failed:", e);
- }
- };
-
- // The launch-time updater already checked once, so wait a full interval
- // before the first background check.
- const id = setInterval(runCheck, CHECK_INTERVAL_MS);
- return () => {
- cancelled = true;
- clearInterval(id);
- };
- }, [enabled]);
-
- const restart = useCallback(async () => {
- const update = updateRef.current;
- if (!update) return; // the banner only renders when we have one in hand
- setRestarting(true);
- try {
- console.log(`[updater] installing v${update.version} on user request`);
- // downloadAndInstall() and relaunch() are both Tauri's own APIs (updater
- // + process plugins) — this is the exact same sequence the launch-time
- // updater runs. relaunch() only fires after a successful install, so a
- // failed download never restarts the app.
- await update.downloadAndInstall();
- // Skip the launch-time re-check on the very next start.
- localStorage.setItem(LAST_UPDATE_KEY, String(Date.now()));
- await relaunch();
- } catch (e) {
- console.error("[updater] restart-to-update failed:", e);
- setRestarting(false);
- }
- }, []);
-
- return { availableVersion, restarting, restart };
-}
diff --git a/clients/desktop/src/hooks/useNativeCapture.ts b/clients/desktop/src/hooks/useNativeCapture.ts
index 2a7a4218..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,6 +76,12 @@ export function useNativeCapture(
// check if the user intentionally stopped (avoids auto-resume race).
const capturingRef = useRef(false);
+ // 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 = Math.max(trackedSeconds, sessionTrackedSeconds);
+
// Track blob URL for cleanup
const blobUrlRef = useRef(null);
@@ -253,6 +264,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));
@@ -274,6 +292,16 @@ export function useNativeCapture(
setError(null);
updatePreview(result.previewBase64);
}),
+ // In-between live-preview frames from the capture loop (20/min while
+ // the window is focused). Same redaction-aware capture path as the
+ // uploads, delivered through the same preview pipeline — so
+ // lastScreenshotUrl is simply live whenever someone's looking.
+ listen<{ previewBase64: string; previewWidth: number; previewHeight: number }>(
+ "capture-preview-frame",
+ (event) => {
+ updatePreview(event.payload.previewBase64);
+ },
+ ),
listen<{ message: string }>("capture-tick-error", (event) => {
console.error(`[capture] Rust capture error: ${event.payload.message}`);
setError(event.payload.message);
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/hooks/useUpdateCheck.ts b/clients/desktop/src/hooks/useUpdateCheck.ts
deleted file mode 100644
index 26f4f5a0..00000000
--- a/clients/desktop/src/hooks/useUpdateCheck.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { check } from "@tauri-apps/plugin-updater";
-import { relaunch } from "@tauri-apps/plugin-process";
-import { useState, useEffect } from "react";
-
-type UpdateStatus =
- | { state: "checking" }
- | { state: "no-update"; message: string }
- | { state: "downloading"; progress: number }
- | { state: "installing" }
- | { state: "done" }
- | { state: "idle" };
-
-export const LAST_UPDATE_KEY = "lookout_last_update_ts";
-const UPDATE_COOLDOWN_MS = 60_000;
-const FAIL_DISPLAY_MS = 1500; // Show failure message briefly before continuing
-
-export function useUpdateCheck() {
- const [status, setStatus] = useState({ state: "checking" });
-
- useEffect(() => {
- let cancelled = false;
-
- // Guard against infinite relaunch loop
- const lastUpdate = localStorage.getItem(LAST_UPDATE_KEY);
- if (lastUpdate && Date.now() - Number(lastUpdate) < UPDATE_COOLDOWN_MS) {
- console.log("[updater] skipping check — just updated");
- localStorage.removeItem(LAST_UPDATE_KEY);
- setStatus({ state: "idle" });
- return;
- }
-
- const continueAfterFail = (message: string) => {
- if (cancelled) return;
- setStatus({ state: "no-update", message });
- setTimeout(() => {
- if (!cancelled) setStatus({ state: "idle" });
- }, FAIL_DISPLAY_MS);
- };
-
- (async () => {
- try {
- const update = await check();
- if (cancelled) return;
-
- if (!update) {
- if (!cancelled) setStatus({ state: "idle" });
- return;
- }
-
- console.log(`[updater] found v${update.version}, downloading...`);
- setStatus({ state: "downloading", progress: 0 });
-
- let totalBytes = 0;
- let downloadedBytes = 0;
- await update.downloadAndInstall((event) => {
- if (cancelled) return;
- if (event.event === "Started" && event.data.contentLength) {
- totalBytes = event.data.contentLength;
- } else if (event.event === "Progress") {
- downloadedBytes += event.data.chunkLength;
- const progress =
- totalBytes > 0
- ? Math.round((downloadedBytes / totalBytes) * 100)
- : 0;
- setStatus({ state: "downloading", progress });
- } else if (event.event === "Finished") {
- setStatus({ state: "installing" });
- }
- });
-
- if (!cancelled) {
- localStorage.setItem(LAST_UPDATE_KEY, String(Date.now()));
- setStatus({ state: "done" });
- await relaunch();
- }
- } catch (e) {
- console.warn("[updater] failed:", e);
- continueAfterFail("Checking for update failed. Continuing…");
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, []);
-
- return status;
-}
diff --git a/clients/desktop/src/hooks/useWindowFocus.ts b/clients/desktop/src/hooks/useWindowFocus.ts
new file mode 100644
index 00000000..73e59252
--- /dev/null
+++ b/clients/desktop/src/hooks/useWindowFocus.ts
@@ -0,0 +1,27 @@
+import { useState, useEffect } from "react";
+
+/**
+ * Whether the app window currently has focus.
+ *
+ * Used to gate work that's pointless without the user's eyes on it — e.g.
+ * the recorder's live preview polls native captures while focused and falls
+ * back to the latest uploaded capture when not. Complements the
+ * `document.hidden` parking inside useScreenPreview: `hidden` covers
+ * minimized/other-desktop, focus covers "visible but behind another window".
+ */
+export function useWindowFocus(): boolean {
+ const [focused, setFocused] = useState(() => document.hasFocus());
+
+ useEffect(() => {
+ const onFocus = () => setFocused(true);
+ const onBlur = () => setFocused(false);
+ window.addEventListener("focus", onFocus);
+ window.addEventListener("blur", onBlur);
+ return () => {
+ window.removeEventListener("focus", onFocus);
+ window.removeEventListener("blur", onBlur);
+ };
+ }, []);
+
+ return focused;
+}
diff --git a/clients/desktop/src/main.tsx b/clients/desktop/src/main.tsx
index 15c6c8a5..53f878ea 100644
--- a/clients/desktop/src/main.tsx
+++ b/clients/desktop/src/main.tsx
@@ -8,6 +8,10 @@ import { App } from "./App.js";
declare const __APP_VERSION__: string;
+// Boot timing — measured from the webview's navigation start. Shows up in
+// the console and the backtick debug log as [boot] lines.
+console.log(`[boot] main.tsx eval at ${Math.round(performance.now())}ms`);
+
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
release: `lookout-desktop@${__APP_VERSION__}`,
@@ -31,7 +35,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 +52,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) => {
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/playground/README.md b/clients/playground/README.md
new file mode 100644
index 00000000..f380e86b
--- /dev/null
+++ b/clients/playground/README.md
@@ -0,0 +1,67 @@
+# SDK playground
+
+A harness for exercising `@lookout/react` against a real server — built to
+check the edit (cuts) flow, which is hard to eyeball inside the desktop app.
+
+```bash
+npm run dev --workspace @lookout/playground # http://localhost:5199
+```
+
+Paste an API base URL and a session token. Both persist in localStorage.
+
+## Getting an editable session
+
+Editing only exists during a session's **edit hold**, so a plain stop won't
+do. Create and stop one with the hold:
+
+```bash
+# 1. Create (needs a program API key)
+curl -X POST "$API/api/internal/sessions" \
+ -H 'Content-Type: application/json' -H "X-API-Key: $KEY" \
+ -d '{"metadata":{"why":"playground"}}'
+
+# 2. Record a few minutes in the Record tab with the returned token.
+
+# 3. Stop it WITH a hold — this is what makes it editable.
+curl -X POST "$API/api/sessions/$TOKEN/stop" \
+ -H 'Content-Type: application/json' -d '{"edit":true}'
+```
+
+The hold is a lease: it lapses about two minutes after the last
+`POST /:token/editing`. The editor renews it while open, so leaving the
+Editor tab up keeps the session alive; leaving the playground closed lets
+it publish itself, after which it is no longer editable (by design —
+published data must not change under the programs consuming it).
+
+## Tabs
+
+- **Editor** — `` inside a resizable box. Presets cover the
+ shapes that broke layout before (short, narrow, the desktop window's
+ actual minimum); the corner drags to anything else. The dock must stay
+ on screen and the video must letterbox at every size.
+- **Detail** — ``, which renders the hold's review panel.
+- **Record** — the full `` flow, including the stop modal
+ with "Edit & save".
+
+## The Server truth panel
+
+Polls `/status` and `/units` every 2s and shows them next to the editor.
+Most bugs in this feature were the client and server disagreeing, so the
+panel exists to make that visible rather than inferable from a 400:
+
+- `editable` / `editableReason` — `preparing` while the preview compiles
+ (the editor should show a progress ring, not an error), `published` once
+ it's out.
+- **Verify against server** sends the editor's current cut list to
+ `PUT /cuts` and prints the response. **`unitsCut` must equal the number
+ the editor's footer says was removed.** A mismatch there is what used to
+ surface as "Cut list would remove the entire timelapse" on Save. The
+ button writes the cut list; it does not publish.
+
+## Note on the SDK build
+
+The playground excludes `@lookout/react` from Vite's dep pre-bundling, so a
+rebuild of the SDK shows up on reload without restarting the dev server.
+The other clients don't, which is why SDK changes can appear not to take
+effect there — restart their dev server after
+`npm run build --workspace @lookout/react`.
diff --git a/clients/playground/index.html b/clients/playground/index.html
new file mode 100644
index 00000000..5bf6a7a6
--- /dev/null
+++ b/clients/playground/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Lookout SDK playground
+
+
+
+
+
+
+
diff --git a/clients/playground/package.json b/clients/playground/package.json
new file mode 100644
index 00000000..39113910
--- /dev/null
+++ b/clients/playground/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@lookout/playground",
+ "version": "0.3.7",
+ "license": "AGPL-3.0-or-later",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "dependencies": {
+ "@lookout/react": "*",
+ "@lookout/shared": "*",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/clients/playground/src/App.tsx b/clients/playground/src/App.tsx
new file mode 100644
index 00000000..a75a1275
--- /dev/null
+++ b/clients/playground/src/App.tsx
@@ -0,0 +1,521 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ LookoutProvider,
+ LookoutRecorder,
+ SessionDetail,
+ TimelapseEditor,
+ createLookoutClient,
+ setAccentColor,
+ colors,
+ fontSize,
+ fontWeight,
+ radii,
+ spacing,
+ type CutInterval,
+} from "@lookout/react";
+
+/**
+ * A harness for the edit feature.
+ *
+ * The point isn't to look like the product — it's to make the parts that
+ * are hard to eyeball checkable: the editor at arbitrary sizes, and the
+ * server's own numbers next to what the editor is claiming. Most of the
+ * bugs in this feature were disagreements between those two.
+ */
+
+type Tab = "record" | "editor" | "detail";
+
+const LS_KEY = "lookout-playground";
+
+interface Settings {
+ apiBaseUrl: string;
+ token: string;
+ /** Brand accent an embedding program would pass to LookoutProvider. */
+ accent: string;
+}
+
+function loadSettings(): Settings {
+ try {
+ const raw = localStorage.getItem(LS_KEY);
+ if (raw) return JSON.parse(raw) as Settings;
+ } catch {
+ // Fall through to defaults.
+ }
+ return {
+ apiBaseUrl: "https://lookout-stage.dino.icu",
+ token: "",
+ accent: "#3b82f6",
+ };
+}
+
+export function App() {
+ const [settings, setSettings] = useState(loadSettings);
+ const [applied, setApplied] = useState(() => {
+ const s = loadSettings();
+ return s.token ? s : null;
+ });
+ const [tab, setTab] = useState("editor");
+ const [cuts, setCuts] = useState([]);
+
+ // Mirrors what does, so the editor and
+ // both dialogs can be checked against a brand colour without wiring a
+ // provider around every tab.
+ useEffect(() => {
+ setAccentColor(applied?.accent ?? null);
+ }, [applied?.accent]);
+
+ const apply = () => {
+ localStorage.setItem(LS_KEY, JSON.stringify(settings));
+ setApplied({ ...settings });
+ };
+
+ return (
+
+
+
+ {!applied?.token ? (
+
+ ) : (
+
+
+ {tab === "record" && (
+
+
+
+ )}
+ {tab === "editor" && (
+
+ )}
+ {tab === "detail" && (
+
+ )}
+
+
+
+ )}
+
+ );
+}
+
+function Header({
+ settings,
+ onChange,
+ onApply,
+ tab,
+ onTab,
+ ready,
+}: {
+ settings: Settings;
+ onChange: (s: Settings) => void;
+ onApply: () => void;
+ tab: Tab;
+ onTab: (t: Tab) => void;
+ ready: boolean;
+}) {
+ const input: React.CSSProperties = {
+ background: colors.bg.sunken,
+ border: `1px solid ${colors.border.default}`,
+ borderRadius: radii.md,
+ color: colors.text.primary,
+ padding: "6px 10px",
+ fontSize: fontSize.md,
+ fontFamily: "inherit",
+ outline: "none",
+ };
+
+ return (
+
+
Lookout SDK
+
onChange({ ...settings, apiBaseUrl: e.target.value })}
+ />
+
onChange({ ...settings, token: e.target.value.trim() })}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") onApply();
+ }}
+ />
+
+ Accent
+ onChange({ ...settings, accent: e.target.value })}
+ style={{
+ width: 32,
+ height: 28,
+ padding: 0,
+ border: `1px solid ${colors.border.default}`,
+ borderRadius: radii.md,
+ background: "transparent",
+ cursor: "pointer",
+ }}
+ />
+
+
+ Load
+
+
+ {ready && (
+
+ {(["editor", "detail", "record"] as Tab[]).map((t) => (
+ onTab(t)}
+ style={{
+ ...input,
+ cursor: "pointer",
+ background: tab === t ? colors.bg.selected : "transparent",
+ borderColor: tab === t ? colors.border.selected : colors.border.default,
+ textTransform: "capitalize",
+ }}
+ >
+ {t}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function Empty() {
+ return (
+
+ Paste a session token to begin.
+
+
+ Stop it with an edit hold first, or the editor will report it published.
+
+
+ );
+}
+
+/**
+ * The editor inside a box you can resize to arbitrary dimensions.
+ *
+ * The clipping bug was only visible at particular window shapes, and a
+ * maximised browser window never reproduces it. Presets cover the corners:
+ * short (the dock must survive), narrow (the action row must wrap), and
+ * the real desktop window's minimum.
+ */
+function ResizableEditor({
+ settings,
+ onCuts,
+}: {
+ settings: Settings;
+ onCuts: (cuts: CutInterval[]) => void;
+}) {
+ const [size, setSize] = useState({ w: 900, h: 620 });
+ const presets: Array<[string, number, number]> = [
+ ["desktop default", 900, 620],
+ ["desktop minimum", 620, 480],
+ ["short", 900, 360],
+ ["narrow", 480, 620],
+ ["tiny", 420, 320],
+ ];
+
+ return (
+
+
+ {presets.map(([label, w, h]) => (
+ setSize({ w, h })}
+ style={{
+ background: size.w === w && size.h === h ? colors.bg.selected : "transparent",
+ border: `1px solid ${colors.border.default}`,
+ borderRadius: radii.md,
+ color: colors.text.secondary,
+ padding: "4px 10px",
+ fontSize: fontSize.sm,
+ fontFamily: "inherit",
+ cursor: "pointer",
+ }}
+ >
+ {label} · {w}×{h}
+
+ ))}
+
+ or drag the corner
+
+
+
+
+ console.log("[playground] published")}
+ onCutsChange={(cuts, dirty) => {
+ onCuts(cuts);
+ console.log("[playground] cuts", { dirty, cuts });
+ }}
+ />
+
+
+ );
+}
+
+/**
+ * What the server actually thinks, polled live.
+ *
+ * Every serious bug in this feature was the client and the server
+ * disagreeing — over-counted cut units, a stale hold, a status the editor
+ * read as terminal. Putting the server's own numbers on screen makes those
+ * disagreements visible instead of inferable from a 400.
+ */
+function ServerTruth({
+ settings,
+ cuts,
+}: {
+ settings: Settings;
+ cuts: CutInterval[];
+}) {
+ const [status, setStatus] = useState | null>(null);
+ const [units, setUnits] = useState | null>(null);
+ const [error, setError] = useState(null);
+ const [preview, setPreview] = useState | null>(null);
+ const clientRef = useRef(
+ createLookoutClient({ baseUrl: settings.apiBaseUrl, token: settings.token }),
+ );
+
+ useEffect(() => {
+ clientRef.current = createLookoutClient({
+ baseUrl: settings.apiBaseUrl,
+ token: settings.token,
+ });
+ }, [settings.apiBaseUrl, settings.token]);
+
+ // /status is the endpoint built for polling (60/min). /units presigns a
+ // URL and allows only 10/min, so it is fetched on load, when /status
+ // reports a change worth re-reading, and on demand — never on a timer.
+ const [unitsAt, setUnitsAt] = useState(0);
+ const lastUnitsRef = useRef(0);
+ const signatureRef = useRef("");
+
+ const loadUnits = useCallback(async () => {
+ // Hard floor between reads so no combination of triggers can walk
+ // into the limit.
+ if (Date.now() - lastUnitsRef.current < 6000) return;
+ lastUnitsRef.current = Date.now();
+ try {
+ const r = await fetch(
+ `${settings.apiBaseUrl}/api/sessions/${settings.token}/units`,
+ );
+ const u = (await r.json()) as Record;
+ const { units: list, originalVideoUrl: _url, ...rest } = u;
+ setUnits({ ...rest, unitCount: Array.isArray(list) ? list.length : 0 });
+ setUnitsAt(Date.now());
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ }
+ }, [settings.apiBaseUrl, settings.token]);
+
+ useEffect(() => {
+ let cancelled = false;
+ const tick = async () => {
+ try {
+ const r = await fetch(
+ `${settings.apiBaseUrl}/api/sessions/${settings.token}/status`,
+ );
+ const s = (await r.json()) as Record;
+ if (cancelled) return;
+ setError(null);
+ setStatus(s);
+ // Re-read /units only when something that changes it changed.
+ const sig = `${s.status}:${s.editable}`;
+ if (sig !== signatureRef.current) {
+ signatureRef.current = sig;
+ void loadUnits();
+ }
+ } catch (e) {
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e));
+ }
+ };
+ void tick();
+ const id = setInterval(tick, 3000);
+ return () => {
+ cancelled = true;
+ clearInterval(id);
+ };
+ }, [settings.apiBaseUrl, settings.token, loadUnits]);
+
+ // Dry-run the cut list the editor most recently reported, so the
+ // server's own arithmetic sits next to the editor's footer.
+ const dryRun = useCallback(async (cuts: CutInterval[]) => {
+ try {
+ setPreview({ ...(await clientRef.current.setCuts(cuts)) });
+ } catch (e) {
+ setPreview({ error: e instanceof Error ? e.message : String(e) });
+ }
+ }, []);
+
+ const box: React.CSSProperties = {
+ background: colors.bg.sunken,
+ border: `1px solid ${colors.border.default}`,
+ borderRadius: radii.md,
+ padding: spacing.sm,
+ fontSize: 11,
+ fontFamily: "ui-monospace, SFMono-Regular, monospace",
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-all",
+ color: colors.text.secondary,
+ };
+
+ return (
+
+
+ Server truth
+
+
+ /status every 3s; /units only on change
+ or demand (10/min limit). Compare editable and the
+ tracked-time pair against what the editor shows.
+
+
+ {error &&
{error}
}
+
+
GET /status
+
{JSON.stringify(status, null, 1)}
+
+
+ GET /units{" "}
+ void loadUnits()}
+ style={{
+ background: "transparent",
+ border: "none",
+ color: colors.text.secondary,
+ cursor: "pointer",
+ font: "inherit",
+ textDecoration: "underline",
+ padding: 0,
+ }}
+ >
+ refresh
+
+
+
+ {unitsAt ? `read ${new Date(unitsAt).toLocaleTimeString()}` : "not read yet"}
+ {" · 10/min limit, so not polled"}
+
+
{JSON.stringify(units, null, 1)}
+
+
PUT /cuts
+
void dryRun(cuts)}
+ style={{
+ background: "transparent",
+ border: `1px solid ${colors.border.hover}`,
+ borderRadius: radii.md,
+ color: colors.text.primary,
+ padding: "6px 10px",
+ fontSize: fontSize.sm,
+ fontFamily: "inherit",
+ cursor: "pointer",
+ }}
+ >
+ Verify {cuts.length} interval{cuts.length === 1 ? "" : "s"} against server
+
+
+ Sends the editor's current list and shows what the server counts.
+ unitsCut here must match the editor's "removed" — a
+ mismatch is the class of bug that made Save fail with "would remove
+ the entire timelapse". This writes the cut list (it does not
+ publish).
+
+ {preview &&
{JSON.stringify(preview, null, 1)}
}
+
+ );
+}
+
+function Label({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/clients/playground/src/main.tsx b/clients/playground/src/main.tsx
new file mode 100644
index 00000000..e586c5a6
--- /dev/null
+++ b/clients/playground/src/main.tsx
@@ -0,0 +1,4 @@
+import { createRoot } from "react-dom/client";
+import { App } from "./App.js";
+
+createRoot(document.getElementById("root")!).render( );
diff --git a/clients/playground/tsconfig.json b/clients/playground/tsconfig.json
new file mode 100644
index 00000000..3fe4ec9d
--- /dev/null
+++ b/clients/playground/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/clients/playground/vite.config.ts b/clients/playground/vite.config.ts
new file mode 100644
index 00000000..61bd235f
--- /dev/null
+++ b/clients/playground/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ // The SDK is a workspace package resolved to its built dist, which Vite
+ // would otherwise pre-bundle and cache — the reason SDK edits kept not
+ // showing up during this feature's development. Excluding it means a
+ // rebuild of @lookout/react is picked up on reload, no server restart.
+ optimizeDeps: { exclude: ["@lookout/react", "@lookout/shared"] },
+ server: { port: 5199 },
+});
diff --git a/clients/react/API.md b/clients/react/API.md
index 9d45cf9d..84ad9ee8 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -1,6 +1,6 @@
# @lookout/react — React SDK Documentation
-**Package:** `@lookout/react` v0.1.0
+**Package:** `@lookout/react` v0.3.7
**Peer Dependencies:** React 18+ or 19+
**Exports:** ESM + CJS with TypeScript declarations
@@ -97,6 +97,8 @@ Context provider that configures the API client and settings for all child hooks
| `statusPollIntervalMs` | `number` | `3000` | Compilation status poll interval (ms) |
| `autoStart` | `boolean` | `false` | Auto-start screen sharing on mount |
| `appName` | `string` | — | Host program embedding Lookout (e.g. `"Fallout"`). Reported in client telemetry as `Lookout Sdk (Fallout)/ (…)` and surfaced server-side as the session's `clientInfo`. |
+| `accentColor` | `string` | `#3b82f6` | Replace Lookout's blue with your brand colour — primary buttons, focus rings, progress. Any CSS colour. |
+| `accentTextColor` | `string` | `#fff` | Colour drawn *on* the accent. Set it if your accent is light enough that white labels would be unreadable. |
| `children` | `ReactNode` | *required* | Child components |
#### `TokenProvider`
@@ -183,7 +185,7 @@ const { state, actions } = useLookout();
| `stopSharing` | `() => void` | Stop capture source without stopping session (auto-pauses) |
| `pause` | `() => Promise` | Pause the session |
| `resume` | `() => Promise` | Resume a paused session |
-| `stop` | `(options?: { name?: string }) => Promise` | Stop the session and trigger compilation. Optionally name the timelapse before stopping. |
+| `stop` | `(options?: { name?: string; edit?: boolean }) => Promise` | Stop the session and trigger compilation. Optionally name it first. `edit: true` holds it unpublished so the user can cut it before programs see it. |
| `selectCamera` | `(deviceId: string) => void` | Select a camera device by ID. Works during preview and recording. |
| `startPreview` | `() => Promise` | Acquire camera stream for live preview without starting the capture loop. Camera mode only. |
| `stopPreview` | `() => void` | Stop the preview stream. Camera mode only. |
@@ -334,7 +336,7 @@ const session = useSession();
| `error` | `string \| null` | Error message |
| `pause` | `() => Promise` | Pause the session |
| `resume` | `() => Promise` | Resume the session |
-| `stop` | `(name?: string) => Promise` | Stop the session. Optionally name the timelapse before stopping (non-fatal if rename fails). |
+| `stop` | `(name?: string, opts?: { edit?: boolean }) => Promise` | Stop the session. Optionally name it first (non-fatal if rename fails). `edit: true` requests an edit hold. |
| `reload` | `() => Promise` | Re-fetch session from server |
| `syncStatus` | `() => Promise` | Best-effort fetch of latest server status (used when a 409 surfaces in the uploader to reconcile local state) |
| `updateTrackedSeconds` | `(seconds: number) => void` | Update tracked seconds locally |
@@ -367,6 +369,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.
@@ -468,7 +497,18 @@ 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 "Edit & save" when stopping (default `true`). Pass `false` to keep stopping a single click. |
+
+Everything else is read from context.
+
+**Stopping** opens a `` with three ways out: keep
+recording, stop and save, or edit and save. Choosing to edit stops the
+session with a hold, so it compiles without publishing, and swaps the view
+for a `` until the user publishes.
**Renders based on status:**
- `loading` — spinner
@@ -699,6 +739,107 @@ Full session detail view with video player, stats, and compilation polling. Stan
| `apiBaseUrl` | `string` | Server API base URL |
| `onBack` | `() => void?` | Back button handler |
| `onArchive` | `() => void?` | Archive button handler |
+| `onEdit` | `() => void?` | Override the review panel's "Edit & save" — open your own editor surface instead of the inline one |
+
+A session in its **edit hold** (stopped with `edit`, not yet published)
+renders a review panel instead of the compile spinner: "Edit & save" opens
+the editor, "Publish as recorded" ends the hold immediately, and a
+countdown shows when it publishes on its own. Pass `onEdit` to open your
+own editor surface (the desktop app opens a separate window).
+
+---
+
+### `useEditLease(client, active?)`
+
+Holds a held session's **edit lease** open while an editing surface is
+mounted. The server publishes a held timelapse once nothing has renewed the
+lease for ~2 minutes, so "is the user still editing?" is answered by the
+surface existing rather than by a countdown. Returns `false` once the
+session is no longer held (published, failed, or past the ceiling).
+
+Called automatically by `` and by ``'s
+review panel. Use it directly only if you build your own editing surface.
+
+---
+
+### ``
+
+The stop confirmation: keep recording, stop and save, or edit and save.
+Rendered automatically by ``; exported for custom
+recorders.
+
+**Props (`StopChoiceModalProps`):**
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `onResume` | `() => void` | Keep recording |
+| `onStopAndSave` | `(name: string \| null) => void` | Stop and publish as recorded |
+| `onEditAndSave` | `((name: string \| null) => void)?` | Stop with a hold, then edit. Omit to hide the option |
+| `withName` | `boolean?` | Show a name field (default `false`) |
+| `loading` | `boolean?` | Disable inputs while the stop is in flight |
+
+---
+
+### ``
+
+The "Edit & save" step. The session is compiled but deliberately
+**unpublished**, so nothing downstream has consumed it yet; this previews
+that video (1 second = 1 capture unit = 1 real-world minute), lets the user
+drag out cut regions on a filmstrip timeline, and publishes — with the cuts
+baked in (a lossless server-side stream copy) or without them. Standalone
+(no provider needed).
+
+```tsx
+ refetchStatus()}
+/>
+```
+
+**Props (`TimelapseEditorProps`):**
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `token` | `string` | Session token |
+| `apiBaseUrl` | `string` | Server API base URL |
+| `onApplied` | `() => void?` | The timelapse was published — return to your detail view and poll `/status` |
+| `onCancel` | `() => void?` | Dismiss the editor. Only surfaced when it can't load; there is no "leave without deciding" exit |
+| `onCutsChange` | `((cuts, dirty) => void)?` | Fires on every cut-list change, so a host can publish the working edit when the user closes it |
+
+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.
+
+`` and `` both present this in an
+**``** — a modal panel portalled to `document.body`, so it gets
+the viewport rather than whatever width the host gave the recorder, and so
+a transformed ancestor in the host page can't trap it. It is deliberately
+not dismissible: closing without deciding would leave the session
+unpublished, so Save is the way out (and if the tab goes away, the edit
+lease lapses and it publishes as recorded).
+
+The timeline ruler labels at a step chosen from the track width
+(`rulerStep`), with a grabbable playhead tag above it. While mounted the
+editor holds the session's **edit lease** (see `useEditLease`),
+so there's no deadline for the user to race: the timelapse stays
+unpublished for as long as the editor is open, and publishes on its own
+about two minutes after it closes.
+
+**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.
---
@@ -777,10 +918,14 @@ const session = await client.getSession();
| `uploadToR2` | `(uploadUrl, blob) => Promise` | PUT blob to presigned URL |
| `pause` | `() => Promise` | Pause session |
| `resume` | `() => Promise` | Resume session |
-| `stop` | `() => Promise` | Stop session |
+| `stop` | `(opts?: { edit?: boolean }) => Promise` | Stop session; `edit: true` holds it for editing before publication |
| `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 preview-video URL |
+| `setCuts` | `(cuts: CutInterval[]) => Promise` | Replace the session's cut list (`[]` clears). Only during an edit hold |
+| `applyCuts` | `() => Promise` | Publish the held timelapse with its cuts baked in |
+| `heartbeatEditing` | `() => Promise` | Renew the edit lease — "an editor is still open" |
---
@@ -792,12 +937,41 @@ 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 `showPercent`) — for waits long enough that a spinner under-informs |
+| `MinutesFlow` | A minute count with rolling digits (`@number-flow/react`), splitting into hours past 60 |
| `Badge` | Status badge with variants: `default`, `overlay` |
| `Card` | Styled card container |
| `ErrorDisplay` | Error message display with variants: `inline`, `banner`, `page` |
| `PageContainer` | Page layout wrapper |
+| `Overlay` | Modal panel portalled to `document.body` (immune to transformed ancestors), with backdrop, scroll lock, and optional dismiss |
| `Skeleton` / `GallerySkeleton` / `SessionDetailSkeleton` / `RecordPageSkeleton` | Loading skeletons |
| `colors` / `spacing` / `radii` / `fontSize` / `fontWeight` / `statusConfig` | Theme tokens |
+| `setAccentColor(accent, on?)` | Imperative accent override, for surfaces used without `` (``, ``). Pass `null` to restore the default. |
+
+### Theming the accent
+
+```tsx
+
+
+
+```
+
+That recolours the primary buttons ("Edit & save", "Save"), keyboard focus
+rings, and the compile progress ring — everywhere the UI says *this is the
+main action*. The hover shade is derived from your colour with
+`color-mix`, so you don't supply a second one.
+
+Two deliberate limits:
+
+- **Semantic colours don't change.** Success green, warning amber, and the
+ red that marks removed footage carry meaning rather than brand, and a
+ green "this will be deleted" would be worse than an off-brand one.
+- **It's set on the document root, not a wrapper.** The stop dialog and the
+ editor portal to `document.body`, so a scoped subtree wouldn't reach
+ them. The provider restores the previous value on unmount.
+
+For surfaces rendered outside a provider, call `setAccentColor("#16a34a")`
+once at startup instead.
---
diff --git a/clients/react/package.json b/clients/react/package.json
index 7376c055..90ae3677 100644
--- a/clients/react/package.json
+++ b/clients/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@lookout/react",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"type": "module",
"main": "./dist/index.cjs",
@@ -41,6 +41,8 @@
},
"dependencies": {
"@lookout/shared": "*",
+ "@number-flow/react": "^0.6.2",
+ "@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/LookoutProvider.tsx b/clients/react/src/LookoutProvider.tsx
index 26ec6f03..72fd12a0 100644
--- a/clients/react/src/LookoutProvider.tsx
+++ b/clients/react/src/LookoutProvider.tsx
@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useMemo, type ReactNode } from "r
import { buildBrowserClientInfo } from "@lookout/shared";
import { createLookoutClient, type LookoutClient } from "./api/client.js";
import { resolveConfig } from "./defaults.js";
+import { setAccentColor } from "./ui/theme.js";
import type { LookoutConfig, ResolvedConfig } from "./types.js";
// Injected at build time by tsup (see tsup.config.ts `define`). Falls back to
@@ -30,14 +31,33 @@ export function useLookoutContext(): LookoutContextValue {
export interface LookoutProviderProps extends LookoutConfig {
children: ReactNode;
+ /** Replace Lookout's blue accent with your own brand colour. Applies to
+ * primary buttons, focus rings, and progress — everywhere the UI is
+ * saying "this is the main action". Any CSS colour. */
+ accentColor?: string;
+ /** Colour drawn ON the accent (button labels). Defaults to white; set it
+ * when your accent is light enough that white would be unreadable. */
+ accentTextColor?: string;
}
export function LookoutProvider({
children,
+ accentColor,
+ accentTextColor,
...config
}: LookoutProviderProps) {
const resolved = useMemo(() => resolveConfig(config), [config]);
+ // Applied to the document root, not a wrapper: the stop dialog and the
+ // editor portal to document.body, so a scoped subtree wouldn't reach
+ // them. Restored on unmount so a page that mounts Lookout temporarily
+ // doesn't leave its accent behind.
+ useEffect(() => {
+ if (!accentColor && !accentTextColor) return;
+ setAccentColor(accentColor ?? null, accentTextColor ?? null);
+ return () => setAccentColor(null, null);
+ }, [accentColor, accentTextColor]);
+
// Telemetry string, e.g. "Lookout Sdk (Fallout)/0.2.6 (macOS 14.3; Chrome 120.0)".
const clientInfo = useMemo(
() =>
diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts
index b72c5478..fec6d634 100644
--- a/clients/react/src/api/client.ts
+++ b/clients/react/src/api/client.ts
@@ -1,4 +1,6 @@
+import { UPLOAD_STEP_TIMEOUT_MS } from "@lookout/shared";
import type {
+ CaptureFormat,
SessionResponse,
UploadUrlResponse,
ConfirmScreenshotRequest,
@@ -9,6 +11,11 @@ import type {
RenameSessionResponse,
StatusResponse,
VideoResponse,
+ UnitsResponse,
+ SetCutsResponse,
+ ApplyCutsResponse,
+ EditHeartbeatResponse,
+ CutInterval,
} from "@lookout/shared";
import type { TokenProvider } from "../types.js";
@@ -17,16 +24,41 @@ 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;
+ /** Stop the session. Pass `{ edit: true }` to hold it unpublished after
+ * compiling so the user can cut it first — programs never see
+ * `complete` until the edits are baked in. The hold auto-publishes if
+ * the user walks away, so this can never strand a timelapse. Only send
+ * it from a client that can actually render the editor. */
+ stop(opts?: { edit?: boolean }): Promise;
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;
+ /** Renew the edit lease — "an editor is still open". Call every
+ * EDIT_HEARTBEAT_SECONDS while an editing surface is showing; stop when
+ * the response reports `held: false`. */
+ heartbeatEditing(): Promise;
}
export class HttpError extends Error {
@@ -52,6 +84,27 @@ async function resolveTokenValue(provider: TokenProvider): Promise {
return result instanceof Promise ? result : result;
}
+/**
+ * An AbortSignal that fires after UPLOAD_STEP_TIMEOUT_MS.
+ *
+ * `fetch` never times out on its own, so one half-open socket would
+ * otherwise park a request forever — and the capture loop has nothing to
+ * retry until it settles. Returns undefined on engines without
+ * `AbortSignal.timeout` (pre-2022 Safari/Firefox) rather than shimming it:
+ * those users get exactly today's behaviour, nobody gets a hard failure.
+ */
+function stepDeadline(): AbortSignal | undefined {
+ return typeof AbortSignal !== "undefined" &&
+ typeof AbortSignal.timeout === "function"
+ ? AbortSignal.timeout(UPLOAD_STEP_TIMEOUT_MS)
+ : undefined;
+}
+
+/** True for the AbortError a stepDeadline fires. */
+function isTimeout(err: unknown): boolean {
+ return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
+}
+
async function fetchJson(url: string, init?: RequestInit): Promise {
const headers: Record = {};
if (init?.body) {
@@ -59,8 +112,17 @@ async function fetchJson(url: string, init?: RequestInit): Promise {
}
let res: Response;
try {
- res = await fetch(url, { ...init, headers: { ...headers, ...(init?.headers as Record) } });
+ res = await fetch(url, {
+ signal: stepDeadline(),
+ ...init,
+ headers: { ...headers, ...(init?.headers as Record) },
+ });
} catch (err) {
+ if (isTimeout(err)) {
+ throw new Error(
+ `Timed out after ${UPLOAD_STEP_TIMEOUT_MS / 1000}s fetching ${url}`,
+ );
+ }
// Network-level failure (DNS, connection refused, CORS, SSL)
// WebKit just says "Load failed" — add the URL for context
const msg = err instanceof Error ? err.message : String(err);
@@ -104,6 +166,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 +179,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,9 +188,20 @@ 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 },
+ // The step most exposed to a weak uplink: this is the multi-MB
+ // payload, and a stalled PUT used to hang the capture loop with
+ // no error for it to fall back on.
+ signal: stepDeadline(),
});
} catch (err) {
+ if (isTimeout(err)) {
+ throw new Error(
+ `R2 upload timed out after ${UPLOAD_STEP_TIMEOUT_MS / 1000}s ` +
+ `(${blob.size} bytes) — the connection stalled mid-transfer.`,
+ );
+ }
if (err instanceof TypeError) {
throw new Error(
"Upload failed: network error or CORS misconfiguration on R2 bucket.",
@@ -137,8 +211,32 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient
}
if (!res.ok) {
const text = await res.text().catch(() => "");
+ // R2 answers with S3-style XML. A bare "403" is unactionable and
+ // every cause has a different fix, so name the cause rather than
+ // making the next person bisect it.
+ const code = /([^<]+)<\/Code>/.exec(text)?.[1] ?? "";
+ const detail = /([^<]+)<\/Message>/.exec(text)?.[1] ?? "";
+ const hint =
+ code === "SignatureDoesNotMatch"
+ ? " The request didn't match the presigned URL — something between the client and R2 is altering the request (a proxy, or a rewritten method/headers)."
+ : code === "RequestTimeTooSkewed"
+ ? " The signing server's clock is off relative to R2; fix NTP on the API server."
+ : code === "AccessDenied" || /expire/i.test(detail)
+ ? " The presigned URL had expired (they last ~2 minutes) or the credentials can't write this key. A slow upload of a large clip can outrun the expiry."
+ : res.status === 403 && !text
+ ? " Empty 403 body usually means CORS stripped the response: check the R2 bucket's CORS rules allow PUT from this origin."
+ : "";
+ // The UI truncates; make the whole thing reachable in the console.
+ console.error("[lookout] R2 upload failed", {
+ status: res.status,
+ code,
+ detail,
+ body: text.slice(0, 1000),
+ });
throw new Error(
- `R2 upload failed: HTTP ${res.status}${text ? " — " + text.slice(0, 200) : ""}`,
+ `R2 upload failed: HTTP ${res.status}${code ? ` (${code})` : ""}${
+ detail ? ` — ${detail}` : text ? " — " + text.slice(0, 200) : ""
+ }${hint}`,
);
}
},
@@ -155,9 +253,12 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient
});
},
- async stop() {
+ async stop(opts) {
return fetchJson(await sessionUrl("/stop"), {
method: "POST",
+ // Old servers ignore an unknown body; omit it entirely for the
+ // plain stop so the request stays byte-identical to before.
+ ...(opts?.edit ? { body: JSON.stringify({ edit: true }) } : {}),
});
},
@@ -175,5 +276,28 @@ 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",
+ });
+ },
+
+ async heartbeatEditing() {
+ return fetchJson(await sessionUrl("/editing"), {
+ method: "POST",
+ });
+ },
};
}
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 0588adc1..f0d47a92 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";
@@ -6,14 +7,24 @@ 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;
error: string | null;
onSessionClick?: (token: string) => void;
onArchive?: (token: string) => void;
+ /** Right-click on a session card. The host decides how to present the menu. */
+ onSessionContextMenu?: (token: string, e: React.MouseEvent) => 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;
@@ -22,26 +33,37 @@ 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 }) {
+function GalleryHeader({ onAdd, onSettings }: { onAdd?: (anchor: AddAnchor) => void; onSettings?: () => void }) {
return (
Your Timelapses
{onSettings && (
-
-
-
-
+
+
)}
{onAdd && (
-
- +
+ {
+ const r = e.currentTarget.getBoundingClientRect();
+ onAdd({ x: r.left, y: r.top, width: r.width, height: r.height });
+ }}
+ title="Start"
+ aria-label="Start"
+ style={addButtonStyle}
+ >
+
)}
@@ -58,6 +80,7 @@ export function Gallery({
error,
onSessionClick,
onArchive,
+ onSessionContextMenu,
onRefresh,
onAdd,
onSettings,
@@ -154,6 +177,7 @@ export function Gallery({
session={s}
onClick={() => onSessionClick?.(s.token)}
onArchive={onArchive ? () => onArchive(s.token) : undefined}
+ onContextMenu={onSessionContextMenu ? (e) => onSessionContextMenu(s.token, e) : undefined}
/>
))}
diff --git a/clients/react/src/components/LookoutRecorder.tsx b/clients/react/src/components/LookoutRecorder.tsx
index 10bbde11..55b91ca3 100644
--- a/clients/react/src/components/LookoutRecorder.tsx
+++ b/clients/react/src/components/LookoutRecorder.tsx
@@ -1,6 +1,9 @@
-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 { StopChoiceModal } from "./StopChoiceModal.js";
import { ScreenPreview } from "./ScreenPreview.js";
import { CameraPreview } from "./CameraPreview.js";
import { CameraSelector } from "./CameraSelector.js";
@@ -10,6 +13,7 @@ import { Button } from "../ui/Button.js";
import { Spinner } from "../ui/Spinner.js";
import { ErrorDisplay } from "../ui/ErrorDisplay.js";
import { PageContainer } from "../ui/PageContainer.js";
+import { Overlay } from "../ui/Overlay.js";
import { colors, fontSize, fontWeight, spacing } from "../ui/theme.js";
/**
@@ -22,8 +26,55 @@ import { colors, fontSize, fontWeight, spacing } from "../ui/theme.js";
*
* Must be used within a `
`.
*/
-export function LookoutRecorder() {
+export interface LookoutRecorderProps {
+ /** Offer "Edit & save" when stopping (default true). Programs embedding
+ * the recorder can pass false to keep stopping a one-click action. */
+ editing?: boolean;
+}
+
+export function LookoutRecorder({ editing = true }: LookoutRecorderProps = {}) {
const { state, actions } = useLookout();
+ const { client, config } = useLookoutContext();
+ const [resolvedToken, setResolvedToken] = useState(null);
+ // Set when the user chose "Edit & save": the session is held unpublished
+ // and this view owns the editor until they publish.
+ const [editorOpen, setEditorOpen] = useState(false);
+
+ // The editor needs a concrete token string; resolve it once, up front,
+ // so opening the editor is instant when the user asks for it.
+ useEffect(() => {
+ if (!editing) return;
+ let cancelled = false;
+ client
+ .resolveToken()
+ .then((t) => {
+ if (!cancelled) setResolvedToken(t);
+ })
+ .catch(() => {
+ // Best-effort: without a token we simply don't offer editing.
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [editing, client]);
+
+ const canEdit = editing && resolvedToken !== null;
+ const [stopPrompt, setStopPrompt] = useState(false);
+ const [stopping, setStopping] = useState(false);
+
+ const confirmStop = async (withEdit: boolean) => {
+ setStopping(true);
+ try {
+ // Open the editor optimistically for the edit path: the session is
+ // held, so the editor can show its own "preparing" state while the
+ // compile runs instead of leaving the user on a dead screen.
+ if (withEdit) setEditorOpen(true);
+ await actions.stop({ edit: withEdit });
+ setStopPrompt(false);
+ } finally {
+ setStopping(false);
+ }
+ };
if (state.status === "loading") {
return (
@@ -63,12 +114,70 @@ export function LookoutRecorder() {
state.status === "failed"
) {
return (
-
-
-
+ <>
+
+
+
+
+ {/* "Edit & save" opens over the host page rather than inside the
+ recorder's own box. Embedders place the recorder in columns and
+ cards of any width, and a timeline squeezed into one is a
+ precision tool you can't be precise with; the overlay gets the
+ viewport regardless.
+
+ Deliberately not dismissible: there is no "leave without
+ deciding" exit, because the session is unpublished until
+ someone decides. Save is the way out — and if the tab goes
+ away entirely, the edit lease lapses and it publishes as
+ recorded. */}
+ {editorOpen && resolvedToken && state.status !== "failed" && (
+
+
+
+ Review your timelapse
+
+
+ Cut anything you'd rather not share. Nothing is published
+ until you save.
+
+
+
+ setEditorOpen(false)}
+ />
+
+
+ )}
+ >
);
}
@@ -111,7 +220,7 @@ export function LookoutRecorder() {
status={state.status}
onStartPreview={actions.startPreview}
onStartRecording={actions.startSharing}
- onStop={actions.stop}
+ onStop={() => setStopPrompt(true)}
/>
) : state.isPreviewing && !state.isSharing ? (
/* Phase 2: Previewing — show "Start Recording" */
@@ -127,10 +236,18 @@ export function LookoutRecorder() {
onStartSharing={actions.startSharing}
onPause={actions.pause}
onResume={actions.resume}
- onStop={actions.stop}
+ onStop={() => setStopPrompt(true)}
captureMode="camera"
/>
)}
+ {stopPrompt && (
+ setStopPrompt(false)}
+ onStopAndSave={() => void confirmStop(false)}
+ onEditAndSave={canEdit ? () => void confirmStop(true) : undefined}
+ />
+ )}
);
}
@@ -150,9 +267,17 @@ export function LookoutRecorder() {
onStartSharing={actions.startSharing}
onPause={actions.pause}
onResume={actions.resume}
- onStop={actions.stop}
+ onStop={() => setStopPrompt(true)}
captureMode="screen"
/>
+ {stopPrompt && (
+ setStopPrompt(false)}
+ onStopAndSave={() => void confirmStop(false)}
+ onEditAndSave={canEdit ? () => void confirmStop(true) : undefined}
+ />
+ )}
);
}
diff --git a/clients/react/src/components/ScreenPreview.tsx b/clients/react/src/components/ScreenPreview.tsx
index 0d402293..bd78879c 100644
--- a/clients/react/src/components/ScreenPreview.tsx
+++ b/clients/react/src/components/ScreenPreview.tsx
@@ -11,8 +11,10 @@ export function ScreenPreview({ imageUrl }: ScreenPreviewProps) {
return (
-
-
Latest screenshot
+ {/* On clip sessions this is the final frame of the latest clip (the
+ upload itself is video); on legacy sessions it's the screenshot. */}
+
+
Latest capture
);
}
diff --git a/clients/react/src/components/SessionCard.tsx b/clients/react/src/components/SessionCard.tsx
index 4cb1038a..f7f525ab 100644
--- a/clients/react/src/components/SessionCard.tsx
+++ b/clients/react/src/components/SessionCard.tsx
@@ -10,9 +10,11 @@ export interface SessionCardProps {
session: SessionSummary;
onClick?: () => void;
onArchive?: () => void;
+ /** Right-click on the card. The host decides how to present the menu. */
+ onContextMenu?: (e: React.MouseEvent) => void;
}
-export function SessionCard({ session, onClick, onArchive }: SessionCardProps) {
+export function SessionCard({ session, onClick, onArchive, onContextMenu }: SessionCardProps) {
const date = new Date(session.createdAt);
const dateStr = date.toLocaleDateString(undefined, {
month: "short",
@@ -21,7 +23,11 @@ export function SessionCard({ session, onClick, onArchive }: SessionCardProps) {
});
return (
-
+ { e.preventDefault(); onContextMenu(e); } : undefined}
+ style={{ position: "relative" }}
+ >
{/* Thumbnail */}
{session.thumbnailUrl ? (
diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx
index ffb8bc74..422d028d 100644
--- a/clients/react/src/components/SessionDetail.tsx
+++ b/clients/react/src/components/SessionDetail.tsx
@@ -1,20 +1,142 @@
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useState, useEffect, useCallback, useMemo, useRef } from "react";
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 { Overlay } from "../ui/Overlay.js";
import { ErrorDisplay } from "../ui/ErrorDisplay.js";
import { ProcessingState } from "./ProcessingState.js";
+import { TimelapseEditor } from "./TimelapseEditor.js";
+import { createLookoutClient, type LookoutClient } from "../api/client.js";
+import { useEditLease } from "../hooks/useEditLease.js";
+import { estimateBuildProgress } from "../hooks/buildProgress.js";
import { SessionDetailSkeleton } from "../ui/Skeleton.js";
import { Card } from "../ui/Card.js";
import { Badge } from "../ui/Badge.js";
import { statusConfig, colors, spacing, fontSize, fontWeight, radii } from "../ui/theme.js";
+/**
+ * Shown while a session sits in its edit hold: compiled, but deliberately
+ * unpublished so the owner can cut it before anything downstream consumes
+ * it. Both exits are one click, and doing nothing publishes it anyway —
+ * the hold can delay publication, never cancel it.
+ */
+function HoldPanel({
+ editable,
+ progress,
+ client,
+ onEdit,
+ onPublish,
+}: {
+ editable: boolean;
+ /** Real compile progress from /status, when the worker is reporting it.
+ * Null/undefined → fall back to the time estimate. */
+ progress?: number | null;
+ client: LookoutClient;
+ onEdit: () => void;
+ onPublish: () => void | Promise
;
+}) {
+ const [publishing, setPublishing] = useState(false);
+ // Sitting on this panel counts as still deciding, so it holds the lease
+ // too. Without this, reading the panel for a couple of minutes would
+ // publish the timelapse out from under the person reading it.
+ useEditLease(client, !publishing);
+
+ // Real worker progress wins when present; otherwise ease along the same
+ // asymptotic time estimate the editor uses. Either way the `editable` flip
+ // is what actually ends the wait, and the ring stays monotonic.
+ const [buildProgress, setBuildProgress] = useState(0);
+ const startedAtRef = useRef(null);
+ const sawRealRef = useRef(false);
+ useEffect(() => {
+ if (typeof progress !== "number") return;
+ sawRealRef.current = true;
+ setBuildProgress((prev) => Math.max(prev, progress));
+ }, [progress]);
+ useEffect(() => {
+ if (editable) return;
+ // Anchor the start once; a re-run must never rewind the ring.
+ if (startedAtRef.current === null) startedAtRef.current = Date.now();
+ const startedAt = startedAtRef.current;
+ const tick = () => {
+ // Once real progress has arrived it owns the ring — don't let the
+ // estimate race ahead of ground truth.
+ if (sawRealRef.current) return;
+ setBuildProgress((prev) =>
+ Math.max(prev, estimateBuildProgress(Date.now() - startedAt, 30_000)),
+ );
+ };
+ tick();
+ const id = setInterval(tick, 250);
+ return () => clearInterval(id);
+ }, [editable]);
+
+ return (
+
+
+ {!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."}
+ {" "}
+
+ If you close Lookout, it publishes as recorded.
+
+
+
+
+
+
+ Edit & save
+
+ {
+ setPublishing(true);
+ try {
+ await onPublish();
+ } finally {
+ setPublishing(false);
+ }
+ }}
+ >
+ Publish as recorded
+
+
+
+ );
+}
+
export interface SessionDetailProps {
token: string;
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;
+ /** 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({
@@ -22,6 +144,8 @@ export function SessionDetail({
apiBaseUrl,
onBack,
onArchive,
+ onComplete,
+ onEdit,
}: SessionDetailProps) {
const [sessionInfo, setSessionInfo] = useState<{ name: string; createdAt: string } | null>(null);
const [isRenaming, setIsRenaming] = useState(false);
@@ -45,6 +169,19 @@ export function SessionDetail({
const [status, setStatus] = useState(null);
const [videoUrl, setVideoUrl] = useState(null);
const [error, setError] = useState(null);
+ const [editing, setEditing] = useState(false);
+ const client = useMemo(
+ () => createLookoutClient({ baseUrl: apiBaseUrl, token }),
+ [apiBaseUrl, token],
+ );
+
+ // 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(() => {
@@ -71,6 +208,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 {
@@ -99,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}`,
@@ -129,16 +282,64 @@ export function SessionDetail({
{!status && !error && }
+ {status && editing && (
+ // Same overlay the recorder uses, so editing looks and behaves
+ // identically wherever it's entered from.
+
+
+ setEditing(false)}
+ onApplied={() => {
+ // Publishing flips the session compiling → complete (or
+ // straight to complete when there were no cuts); the poll
+ // below picks it up. Drop the cached URL so the published
+ // MP4 is re-fetched.
+ setEditing(false);
+ setVideoUrl(null);
+ fetchStatus();
+ }}
+ />
+
+
+ )}
+
+ {/* Edit hold: the recording is compiled but deliberately not
+ published yet, so this is the user's one chance to cut it. */}
+ {status && !editing && inHold && (
+ (onEdit ? onEdit() : setEditing(true))}
+ onPublish={async () => {
+ try {
+ await fetch(`${apiBaseUrl}/api/sessions/${token}/compile`, {
+ method: "POST",
+ });
+ } catch {
+ // Non-fatal: the hold publishes on its own if this fails.
+ }
+ fetchStatus();
+ }}
+ />
+ )}
+
{status && (
<>
- {/* Video area */}
-
+ {/* 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. */}
+ {!inHold && (
+
+ )}
{/* Session name + date */}
{sessionInfo && (
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...
)}
diff --git a/clients/react/src/components/StopChoiceModal.tsx b/clients/react/src/components/StopChoiceModal.tsx
new file mode 100644
index 00000000..03ec3387
--- /dev/null
+++ b/clients/react/src/components/StopChoiceModal.tsx
@@ -0,0 +1,155 @@
+import { useEffect, useRef, useState } from "react";
+import { Button } from "../ui/Button.js";
+import { Card } from "../ui/Card.js";
+import { Overlay } from "../ui/Overlay.js";
+import { colors, fontSize, fontWeight, radii, spacing } from "../ui/theme.js";
+
+export interface StopChoiceModalProps {
+ /** Keep recording — the user hit Stop by accident or changed their mind. */
+ onResume: () => void;
+ /** Stop and publish as recorded. */
+ onStopAndSave: (name: string | null) => void;
+ /** Stop, then review and cut before anything is published. Omit to hide
+ * the option (programs that don't want an editing step). */
+ onEditAndSave?: (name: string | null) => void;
+ /** Show a name field (the desktop app names timelapses at stop time). */
+ withName?: boolean;
+ loading?: boolean;
+}
+
+/**
+ * The stop confirmation. Editing is offered HERE rather than after the
+ * timelapse is published, because publishing is the point at which
+ * programs consume a session — its heartbeats, its tracked time, its
+ * video. Once that's happened, quietly changing the numbers underneath
+ * them isn't an edit, it's a rewrite of something already acted on.
+ */
+export function StopChoiceModal({
+ onResume,
+ onStopAndSave,
+ onEditAndSave,
+ withName = false,
+ loading = false,
+}: StopChoiceModalProps) {
+ const [name, setName] = useState("");
+ const inputRef = useRef
(null);
+ const [choice, setChoice] = useState<"stop" | "edit" | null>(null);
+
+ useEffect(() => {
+ if (withName) setTimeout(() => inputRef.current?.focus(), 50);
+ }, [withName]);
+
+ const value = () => name.trim() || null;
+
+ return (
+
+
+
+
+ Finish this timelapse?
+
+
+ {onEditAndSave
+ ? "Save it as recorded, or review it first and cut out anything you'd rather not share."
+ : "This ends the recording and compiles your timelapse."}
+
+
+ {withName && (
+ setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && !loading) {
+ setChoice("stop");
+ onStopAndSave(value());
+ }
+ }}
+ placeholder="My timelapse"
+ maxLength={255}
+ disabled={loading}
+ style={{
+ width: "100%",
+ padding: `${spacing.md}px ${spacing.lg}px`,
+ fontSize: fontSize.lg,
+ fontWeight: fontWeight.medium,
+ color: colors.text.primary,
+ background: colors.bg.sunken,
+ border: `1px solid ${colors.border.default}`,
+ borderRadius: radii.md,
+ outline: "none",
+ boxSizing: "border-box",
+ marginBottom: spacing.lg,
+ opacity: loading ? 0.5 : 1,
+ }}
+ />
+ )}
+
+
+ {onEditAndSave && (
+ {
+ setChoice("edit");
+ onEditAndSave(value());
+ }}
+ >
+ Edit & save
+
+ )}
+ {
+ setChoice("stop");
+ onStopAndSave(value());
+ }}
+ >
+ Stop & save
+
+
+ Keep recording
+
+
+
+
+
+ );
+}
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
new file mode 100644
index 00000000..536a2073
--- /dev/null
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -0,0 +1,1314 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { AnimatePresence, motion } from "motion/react";
+import {
+ countCutUnits,
+ type ApplyCutsResponse,
+ type CutInterval,
+ type UnitsResponse,
+} from "@lookout/shared";
+import { createLookoutClient, type LookoutClient } from "../api/client.js";
+import {
+ cutsToRegions,
+ gapIndices,
+ normalizeRegions,
+ regionAtTime,
+ regionsToCuts,
+ elapsedLabel,
+ rulerStep,
+ rulerTicks,
+ unitAtTime,
+ unitClockLabel,
+ type UnitRegion,
+} from "../hooks/editorMath.js";
+import { useEditLease } from "../hooks/useEditLease.js";
+import { compileEstimateMs, estimateBuildProgress } from "../hooks/buildProgress.js";
+import { injectEditorStyles } from "./editorStyles.js";
+import { Button } from "../ui/Button.js";
+import { MinutesFlow } from "../ui/MinutesFlow.js";
+import { Spinner } from "../ui/Spinner.js";
+import { ProgressRing } from "../ui/ProgressRing.js";
+import { ErrorDisplay } from "../ui/ErrorDisplay.js";
+import { colors, fontSize, fontWeight, radii, spacing } from "../ui/theme.js";
+
+export interface TimelapseEditorProps {
+ token: string;
+ apiBaseUrl: string;
+ /** The timelapse was published — with cuts baked in, or without them.
+ * The caller should return to its detail view and poll status. The
+ * publish response is passed through: `instant`/`complete` means it's
+ * already done (fire any redirect now), otherwise a compile is running. */
+ onApplied?: (result: ApplyCutsResponse) => void;
+ /** Dismiss the editor. Only offered when it can't load — there is no
+ * "leave without deciding" exit, because closing the editor is itself
+ * the decision: the session publishes. */
+ onCancel?: () => void;
+ /** Fired whenever the cut list changes, with the normalized list and
+ * whether it differs from what's saved. Lets a host (the desktop
+ * window) publish the current edit when the user closes it. */
+ onCutsChange?: (cuts: CutInterval[], dirty: boolean) => void;
+}
+
+const STRIP_HEIGHT = 56;
+/** Diagonal hatch marking removed stretches on the timeline — the
+ * conventional "excluded" texture, and a second channel beyond colour
+ * alone. Kept faint: it should register as texture, not as content
+ * competing with the thumbnails underneath. */
+const hatch = (periodPx: number) =>
+ `repeating-linear-gradient(45deg, ${colors.editor.cutStripe} 0 ${
+ periodPx / 2
+ }px, transparent ${periodPx / 2}px ${periodPx}px)`;
+
+const RULER_HEIGHT = 22;
+/** Playhead cap: a slim pill, bottom-aligned to the ruler so it tucks
+ * under the labels instead of covering them. Small on purpose — it marks
+ * a position, it isn't a control that should dominate the timeline. */
+const HEAD_W = 9;
+const HEAD_H = 13;
+/** Invisible grab area around the cap. The cap is too small to hit
+ * comfortably; the target isn't. */
+const HEAD_HIT = 22;
+/** Upper bound on filmstrip tiles. The real count comes from the track
+ * width (see buildFilmstrip); this only stops an ultra-wide display from
+ * queueing hundreds of seeks. */
+const FILMSTRIP_MAX_TILES = 48;
+/** Canvases are sized in device pixels and scaled down by CSS — without
+ * this a 2x display renders every thumbnail at half resolution, which
+ * reads as a blurry, low-quality preview. Capped at 2 because 3x gains
+ * nothing visible here and triples the decode cost. */
+const pixelRatio = () =>
+ Math.min(2, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
+
+type DragState =
+ | { kind: "maybe"; downUnitF: number }
+ | { kind: "scrub" }
+ | {
+ kind: "region";
+ index: number;
+ mode: "move" | "start" | "end";
+ grabOffset: number;
+ anchorUnit: number;
+ }
+ | null;
+
+/**
+ * The "Edit & save" step of stopping a recording. The session is compiled
+ * but deliberately UNPUBLISHED (held), so nothing downstream has consumed
+ * it yet; this view previews that video (1 second = 1 capture unit = 1
+ * real-world minute), lets the user drag out cut regions, and publishes —
+ * with the cuts baked in, or without them.
+ *
+ * Layout is a three-row shell: a fixed transport bar, a stage that shrinks
+ * (the only flexible row), and a dock pinned to the bottom. Every ancestor
+ * of the stage carries `min-height: 0` so the video letterboxes down
+ * instead of shoving the timeline out of the window.
+ */
+export function TimelapseEditor({
+ token,
+ apiBaseUrl,
+ onApplied,
+ onCancel,
+ onCutsChange,
+}: TimelapseEditorProps) {
+ const client = useMemo(
+ () => createLookoutClient({ baseUrl: apiBaseUrl, token }),
+ [apiBaseUrl, token],
+ );
+
+ useEffect(() => injectEditorStyles(), []);
+
+ const [data, setData] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ /** Unit count while the preview video is still compiling; null once it's
+ * ready. Deliberately a NUMBER, not an object: the poll below re-sets it
+ * every 1.5s, and a fresh object literal would change identity on every
+ * poll, re-running the progress effect and restarting the ring at 0. */
+ const [preparingUnits, setPreparingUnits] = useState(null);
+ const [buildProgress, setBuildProgress] = useState(0);
+ /** Real compile progress from /status, when the worker reports it; null
+ * until the first metered poll (or forever, for cut-apply/old workers). */
+ const [realProgress, setRealProgress] = useState(null);
+ /** Once true, real progress owns the ring and the time estimate stands down. */
+ const sawRealRef = useRef(false);
+ /** Anchored once per preparing spell, so even a genuine change in the
+ * unit count can't restart the estimate. */
+ const prepareStartRef = useRef(null);
+ const [regions, setRegions] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [time, setTime] = useState(0);
+ const [playing, setPlaying] = useState(false);
+ const [filmstrip, setFilmstrip] = useState([]);
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ const videoRef = useRef(null);
+ const timelineRef = useRef(null);
+ const dragRef = useRef(null);
+ const regionsRef = useRef(regions);
+ regionsRef.current = regions;
+ const rafRef = useRef(0);
+
+ const units = data?.units ?? [];
+ const unitCount = units.length;
+
+ // ── Load ────────────────────────────────────────────────────
+ // 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. That is the normal path, not a
+ // failure — but it must be waited out on `/status`, not `/units`.
+ //
+ // `/units` presigns a URL and is rate limited to 10/min; polling it
+ // every 1.5s is 40/min, so the wait itself would 429 after ~15s and the
+ // editor would report a rate-limit error instead of a video. `/status`
+ // is the cheap endpoint built for polling (60/min) and already carries
+ // `editable`, so wait on that and fetch `/units` only at the edges.
+ useEffect(() => {
+ let cancelled = false;
+ let timer: ReturnType | undefined;
+
+ const fail = (reason: UnitsResponse["editableReason"]) =>
+ setLoadError(
+ reason === "published"
+ ? "This timelapse has already been published, so it can't be edited."
+ : reason === "failed"
+ ? "This timelapse couldn't be compiled, so there's nothing to edit."
+ : reason === "recompiles_exhausted"
+ ? "This timelapse has reached its edit limit."
+ : "This timelapse isn't available for editing.",
+ );
+
+ const loadUnits = async () => {
+ const res = await client.getUnits();
+ if (cancelled) return;
+ if (res.editable && res.originalVideoUrl) {
+ setPreparingUnits(null);
+ setData(res);
+ setRegions(cutsToRegions(res.cuts, res.units));
+ return;
+ }
+ if (res.editableReason === "preparing" || res.editableReason === "no_original") {
+ // Keep the unit count for the progress estimate, then hand the
+ // waiting over to /status.
+ setPreparingUnits(res.expectedUnits ?? 0);
+ timer = setTimeout(waitForReady, 2000);
+ return;
+ }
+ fail(res.editableReason);
+ };
+
+ const waitForReady = async () => {
+ if (cancelled) return;
+ try {
+ const status = await client.getStatus();
+ if (cancelled) return;
+ if (typeof status.progress === "number") setRealProgress(status.progress);
+ if (status.editable) {
+ await loadUnits();
+ return;
+ }
+ if (status.status === "complete") {
+ fail("published");
+ return;
+ }
+ if (status.status === "failed") {
+ fail("failed");
+ return;
+ }
+ } catch (err) {
+ // Transient: keep waiting rather than dropping the user out of an
+ // edit because one poll failed.
+ console.warn("[editor] status poll failed:", err);
+ }
+ timer = setTimeout(waitForReady, 2000);
+ };
+
+ void (async () => {
+ try {
+ await loadUnits();
+ } catch (err) {
+ if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err));
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [client]);
+
+ // ── Build progress ──────────────────────────────────────────
+ // Real worker progress wins when the /status poll reports it. Until then
+ // (and for cut-apply/old-worker compiles that never report it) this is a
+ // time estimate scaled by how much footage there is to compile. Either
+ // source eases toward — and stops short of — 100%, and only the real
+ // thing completing ends the wait; a ring that sat at 100% while the user
+ // waited would be worse than none.
+ useEffect(() => {
+ if (realProgress === null) return;
+ sawRealRef.current = true;
+ setBuildProgress((prev) => Math.max(prev, realProgress));
+ }, [realProgress]);
+ useEffect(() => {
+ if (preparingUnits === null) {
+ prepareStartRef.current = null;
+ return;
+ }
+ if (prepareStartRef.current === null) prepareStartRef.current = Date.now();
+ const startedAt = prepareStartRef.current;
+ const estimateMs = compileEstimateMs(preparingUnits);
+ const tick = () => {
+ // Ground truth, once it arrives, owns the ring.
+ if (sawRealRef.current) return;
+ const next = estimateBuildProgress(Date.now() - startedAt, estimateMs);
+ setBuildProgress((prev) => Math.max(prev, next));
+ };
+ tick();
+ const id = setInterval(tick, 200);
+ return () => clearInterval(id);
+ }, [preparingUnits]);
+
+ // ── Edit lease ──────────────────────────────────────────────
+ // This editor being open IS the signal that editing is in progress, so
+ // it renews the lease while mounted. No countdown, no deadline to race:
+ // the session waits as long as the window is up, and publishes on its
+ // own shortly after it isn't. Stops once the session is no longer held.
+ const leaseHeld = useEditLease(client, !saving);
+ useEffect(() => {
+ if (leaseHeld || saving) return;
+ setLoadError(
+ "This timelapse was already published, so it can no longer be edited.",
+ );
+ }, [leaseHeld, saving]);
+
+ // ── Playhead tracking (rAF for a smooth 60fps playhead) ─────
+ useEffect(() => {
+ const tick = () => {
+ const v = videoRef.current;
+ if (v) setTime(v.currentTime);
+ rafRef.current = requestAnimationFrame(tick);
+ };
+ rafRef.current = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(rafRef.current);
+ }, []);
+
+ // ── Playback skips cut regions (scrubbing passes through) ──
+ useEffect(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ const onTimeUpdate = () => {
+ if (v.paused) return;
+ const region = regionAtTime(v.currentTime, regionsRef.current);
+ if (!region) return;
+ if (region.endUnit >= unitCount) {
+ v.pause();
+ v.currentTime = region.startUnit;
+ } else {
+ v.currentTime = region.endUnit;
+ }
+ };
+ v.addEventListener("timeupdate", onTimeUpdate);
+ return () => v.removeEventListener("timeupdate", onTimeUpdate);
+ }, [unitCount, data?.originalVideoUrl]);
+
+ // ── Offscreen frame source: filmstrip + scrubber preview ────
+ //
+ // Both features read pixels out of a via canvas, which needs a
+ // taint-free source. Two strategies, in order:
+ //
+ // 1. Load with crossOrigin="anonymous". If the presigned GET carries
+ // CORS headers this succeeds and the canvas stays clean. Note this
+ // is all-or-nothing: WITHOUT a fallback, a bucket that doesn't send
+ // those headers fails the load outright and you get no thumbnails
+ // at all — which is exactly what a missing CORS config looks like.
+ // 2. Otherwise pull the bytes through the app's fetch (on desktop that
+ // is Tauri's HTTP plugin, which isn't subject to browser CORS at
+ // all) and hand the video a blob: URL — same-origin by definition.
+ //
+ // Only if both fail does the timeline degrade to a plain track.
+ const [frameSrc, setFrameSrc] = useState(null);
+ useEffect(() => {
+ const src = data?.originalVideoUrl;
+ if (!src) return;
+ let cancelled = false;
+ let objectUrl: string | null = null;
+
+ const probe = (url: string, useCors: boolean) =>
+ new Promise((resolve) => {
+ const probeEl = document.createElement("video");
+ if (useCors) probeEl.crossOrigin = "anonymous";
+ probeEl.muted = true;
+ probeEl.preload = "metadata";
+ probeEl.onloadedmetadata = () => {
+ probeEl.removeAttribute("src");
+ resolve(true);
+ };
+ probeEl.onerror = () => resolve(false);
+ probeEl.src = url;
+ });
+
+ (async () => {
+ if (await probe(src, true)) {
+ if (!cancelled) setFrameSrc(src);
+ return;
+ }
+ console.warn(
+ "[editor] preview video is not CORS-readable; fetching bytes for thumbnails",
+ );
+ try {
+ const res = await fetch(src);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const blob = await res.blob();
+ if (cancelled) return;
+ objectUrl = URL.createObjectURL(blob);
+ setFrameSrc(objectUrl);
+ } catch (err) {
+ console.error("[editor] no frame source available for thumbnails:", err);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ setFrameSrc(null);
+ };
+ }, [data?.originalVideoUrl]);
+
+ // Track width drives the filmstrip: tiles are whole frames at the
+ // video's own aspect ratio, so how many fit is a function of the track,
+ // not of how many minutes were recorded.
+ const [stripWidth, setStripWidth] = useState(0);
+ useEffect(() => {
+ const el = timelineRef.current;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const ro = new ResizeObserver(([entry]) =>
+ setStripWidth(entry.contentRect.width),
+ );
+ ro.observe(el);
+ setStripWidth(el.getBoundingClientRect().width);
+ return () => ro.disconnect();
+ }, [data]);
+
+ /**
+ * Filmstrip: whole, uncropped frames tiled across the track — the
+ * Premiere / CapCut / iOS scrubber look.
+ *
+ * The math: a tile is the full frame at track height, so
+ * tileW = STRIP_HEIGHT × (videoW / videoH) — 56 × 16/9 ≈ 100px
+ * tiles = ceil(trackW / tileW) — ~9 across a 900px track
+ * Tile i covers x ∈ [i·tileW, (i+1)·tileW), so it samples the frame at
+ * its own midpoint: t = clamp(((i + 0.5)·tileW) / trackW) × duration.
+ * The last tile is clipped by the track's overflow, exactly as a real
+ * filmstrip is. Deliberately NOT one tile per minute: at 48 minutes
+ * that squeezed each frame into 19px and cropped it to a smear.
+ */
+ const [tileAspect, setTileAspect] = useState(16 / 9);
+ const [tileWidth, setTileWidth] = useState(Math.round(STRIP_HEIGHT * (16 / 9)));
+ useEffect(() => {
+ if (!frameSrc || unitCount === 0 || stripWidth <= 0) return;
+ let cancelled = false;
+ let v: HTMLVideoElement | null = null;
+
+ // Debounce: a live window drag fires dozens of resizes, and each
+ // regeneration is a series of decoder seeks.
+ const timer = setTimeout(() => {
+ v = document.createElement("video");
+ if (!frameSrc.startsWith("blob:")) v.crossOrigin = "anonymous";
+ v.muted = true;
+ v.preload = "auto";
+ v.src = frameSrc;
+ const el = v;
+
+ void (async () => {
+ try {
+ await new Promise((resolve, reject) => {
+ el.onloadedmetadata = () => resolve();
+ el.onerror = () => reject(new Error("filmstrip video load failed"));
+ });
+ if (cancelled) return;
+
+ const aspect = el.videoWidth / Math.max(1, el.videoHeight);
+ setTileAspect(aspect);
+ const tileW = Math.max(24, Math.round(STRIP_HEIGHT * aspect));
+ setTileWidth(tileW);
+
+ const tiles = Math.min(
+ FILMSTRIP_MAX_TILES,
+ Math.max(1, Math.ceil(stripWidth / tileW)),
+ );
+
+ const dpr = pixelRatio();
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.round(tileW * dpr);
+ canvas.height = Math.round(STRIP_HEIGHT * dpr);
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.imageSmoothingQuality = "high";
+
+ const thumbs: string[] = [];
+ for (let i = 0; i < tiles; i++) {
+ if (cancelled) return;
+ const frac = Math.min(1, ((i + 0.5) * tileW) / stripWidth);
+ const t = Math.min(el.duration - 0.05, frac * el.duration);
+ await new Promise((resolve) => {
+ el.onseeked = () => resolve();
+ el.currentTime = Math.max(0, t);
+ });
+ ctx.drawImage(el, 0, 0, canvas.width, canvas.height);
+ thumbs.push(canvas.toDataURL("image/jpeg", 0.82));
+ if (i % 3 === 2) setFilmstrip([...thumbs]);
+ }
+ if (!cancelled) setFilmstrip(thumbs);
+ } catch (err) {
+ console.error("[editor] filmstrip generation failed:", err);
+ } finally {
+ el.removeAttribute("src");
+ el.load();
+ }
+ })();
+ }, 220);
+
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ if (v) {
+ v.removeAttribute("src");
+ v.load();
+ }
+ };
+ }, [frameSrc, unitCount, stripWidth]);
+
+ // ── Pointer plumbing ────────────────────────────────────────
+ const unitFromEvent = useCallback(
+ (e: { clientX: number }): number => {
+ const el = timelineRef.current;
+ if (!el || unitCount === 0) return 0;
+ const rect = el.getBoundingClientRect();
+ const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ return frac * unitCount;
+ },
+ [unitCount],
+ );
+
+ const seekTo = useCallback(
+ (t: number) => {
+ const v = videoRef.current;
+ if (!v) return;
+ v.currentTime = Math.max(0, Math.min(unitCount - 0.05, t));
+ },
+ [unitCount],
+ );
+
+ const beginDrag = useCallback((e: React.PointerEvent, state: DragState) => {
+ dragRef.current = state;
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
+ e.preventDefault();
+ e.stopPropagation();
+ }, []);
+
+ const onTimelinePointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ if (saving) return;
+ beginDrag(e, { kind: "maybe", downUnitF: unitFromEvent(e) });
+ },
+ [beginDrag, saving, unitFromEvent],
+ );
+
+ const onRulerPointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ if (saving) return;
+ beginDrag(e, { kind: "scrub" });
+ seekTo(unitFromEvent(e));
+ },
+ [beginDrag, saving, seekTo, unitFromEvent],
+ );
+
+ const onPointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag) return;
+ const unitF = unitFromEvent(e);
+
+ if (drag.kind === "scrub") {
+ seekTo(unitF);
+ return;
+ }
+
+ if (drag.kind === "maybe") {
+ // Click-vs-drag disambiguation: past a third of a unit of travel,
+ // the gesture becomes a new cut region growing from the press point.
+ if (Math.abs(unitF - drag.downUnitF) < 0.34) return;
+ const a = Math.floor(Math.min(unitF, drag.downUnitF));
+ const b = Math.ceil(Math.max(unitF, drag.downUnitF));
+ setRegions((prev) => {
+ const next = [...prev, { startUnit: a, endUnit: Math.max(b, a + 1) }];
+ setSelected(next.length - 1);
+ return next;
+ });
+ dragRef.current = {
+ kind: "region",
+ index: regionsRef.current.length,
+ mode: unitF >= drag.downUnitF ? "end" : "start",
+ grabOffset: 0,
+ anchorUnit: Math.floor(drag.downUnitF),
+ };
+ return;
+ }
+
+ setRegions((prev) => {
+ const next = prev.map((r) => ({ ...r }));
+ const r = next[drag.index];
+ if (!r) return prev;
+ if (drag.mode === "move") {
+ const width = r.endUnit - r.startUnit;
+ let start = Math.round(unitF - drag.grabOffset);
+ start = Math.max(0, Math.min(unitCount - width, start));
+ r.startUnit = start;
+ r.endUnit = start + width;
+ } else if (drag.mode === "start") {
+ r.startUnit = Math.max(0, Math.min(r.endUnit - 1, Math.round(unitF)));
+ seekTo(r.startUnit + 0.02);
+ } else {
+ const anchor = drag.anchorUnit;
+ const rounded = Math.round(unitF);
+ if (rounded <= anchor) {
+ r.startUnit = Math.max(0, rounded);
+ r.endUnit = anchor + 1;
+ seekTo(r.startUnit + 0.02);
+ } else {
+ r.endUnit = Math.min(unitCount, Math.max(r.startUnit + 1, rounded));
+ seekTo(Math.min(unitCount - 0.05, r.endUnit + 0.02));
+ }
+ }
+ return next;
+ });
+ setSelected(drag.index);
+ },
+ [seekTo, unitCount, unitFromEvent],
+ );
+
+ const onPointerUp = useCallback(() => {
+ const drag = dragRef.current;
+ dragRef.current = null;
+ if (!drag) return;
+ if (drag.kind === "maybe") {
+ // A plain click on open track: seek there and drop any selection.
+ seekTo(drag.downUnitF);
+ setSelected(null);
+ return;
+ }
+ if (drag.kind === "region") {
+ // Keep the region selected after the gesture. Clearing it here meant
+ // a selection could never outlive the click that made it, so "Remove
+ // cut" was unreachable. Normalizing can merge regions and shift
+ // indices, so re-find the one the gesture ended on rather than
+ // trusting the old index.
+ const dragged = regionsRef.current[drag.index];
+ const next = normalizeRegions(regionsRef.current);
+ setRegions(next);
+ const idx = dragged
+ ? next.findIndex(
+ (r) => dragged.startUnit >= r.startUnit && dragged.startUnit < r.endUnit,
+ )
+ : -1;
+ setSelected(idx >= 0 ? idx : null);
+ }
+ }, [seekTo]);
+
+ const onRegionPointerDown = useCallback(
+ (e: React.PointerEvent, index: number, mode: "move" | "start" | "end") => {
+ if (saving) return;
+ const r = regionsRef.current[index];
+ if (!r) return;
+ setSelected(index);
+ beginDrag(e, {
+ kind: "region",
+ index,
+ mode,
+ grabOffset: unitFromEvent(e) - r.startUnit,
+ anchorUnit: r.startUnit,
+ });
+ },
+ [beginDrag, saving, unitFromEvent],
+ );
+
+ const togglePlay = useCallback(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ if (v.paused) {
+ const region = regionAtTime(v.currentTime, regionsRef.current);
+ if (region && region.endUnit < unitCount) v.currentTime = region.endUnit;
+ void v.play();
+ } else {
+ v.pause();
+ }
+ }, [unitCount]);
+
+ const cutHere = useCallback(() => {
+ const v = videoRef.current;
+ if (!v || unitCount === 0) return;
+ const at = unitAtTime(v.currentTime, unitCount);
+ setRegions((prev) => {
+ const next = normalizeRegions([...prev, { startUnit: at, endUnit: at + 1 }]);
+ setSelected(next.findIndex((r) => at >= r.startUnit && at < r.endUnit));
+ return next;
+ });
+ }, [unitCount]);
+
+ // ── Keyboard ────────────────────────────────────────────────
+ // Capture phase + preventDefault so hosting apps' global key handlers
+ // (e.g. the desktop router's Backspace-goes-back) never fire underneath
+ // an open editor — losing unsaved cuts to a stray Backspace is the worst
+ // possible outcome of this surface.
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement | null;
+ if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return;
+ if (e.metaKey || e.ctrlKey) return;
+ if (e.key === " " || e.key === "k") {
+ e.preventDefault();
+ togglePlay();
+ } else if (e.key === "x" || e.key === "c") {
+ e.preventDefault();
+ cutHere();
+ } else if (e.key === "Delete" || e.key === "Backspace") {
+ e.preventDefault();
+ if (selected !== null) {
+ setRegions((prev) => prev.filter((_, i) => i !== selected));
+ setSelected(null);
+ }
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setSelected(null);
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
+ e.preventDefault();
+ const step = e.shiftKey ? 10 : 1;
+ seekTo(
+ (videoRef.current?.currentTime ?? 0) +
+ (e.key === "ArrowLeft" ? -step : step),
+ );
+ }
+ };
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ }, [selected, seekTo, togglePlay, cutHere]);
+
+ // ── Publish ─────────────────────────────────────────────────
+ const save = useCallback(async () => {
+ if (!data) return;
+ setSaving(true);
+ setSaveError(null);
+ try {
+ const cuts = regionsToCuts(normalizeRegions(regionsRef.current), data.units);
+ await client.setCuts(cuts);
+ const result = await client.applyCuts();
+ onApplied?.(result);
+ } catch (err) {
+ setSaveError(err instanceof Error ? err.message : String(err));
+ setSaving(false);
+ }
+ }, [client, data, onApplied]);
+
+ // ── Derived display values ──────────────────────────────────
+ const normalized = useMemo(() => normalizeRegions(regions), [regions]);
+ // Count what the SERVER will count. The footer used to count region
+ // widths in unit space while the server counted timestamp membership on
+ // the serialized intervals — so the two could disagree, and the editor
+ // would happily offer a Save the server then rejected. Same input, same
+ // shared function, no daylight between them.
+ const serializedCuts = useMemo(
+ () => (data ? regionsToCuts(normalized, data.units) : []),
+ [normalized, data],
+ );
+ const unitTimesMs = useMemo(
+ () => units.map((u) => Date.parse(u.capturedAt)),
+ [units],
+ );
+ const removedUnits = useMemo(
+ () => countCutUnits(unitTimesMs, serializedCuts),
+ [unitTimesMs, serializedCuts],
+ );
+ const keptUnits = unitCount - removedUnits;
+ const allCut = unitCount > 0 && keptUnits === 0;
+ const gaps = useMemo(() => (data ? gapIndices(data.units) : []), [data]);
+ const step = useMemo(
+ () => rulerStep(unitCount, stripWidth),
+ [unitCount, stripWidth],
+ );
+ const ticks = useMemo(() => rulerTicks(unitCount, step), [unitCount, step]);
+ const currentUnit = unitAtTime(time, Math.max(1, unitCount));
+ const inCutNow = regionAtTime(time, normalized) !== null;
+ const pct = (u: number) => `${(u / Math.max(1, unitCount)) * 100}%`;
+
+ // Keep the host informed of the working cut list, so closing the
+ // window can publish exactly what's on screen.
+ const onCutsChangeRef = useRef(onCutsChange);
+ onCutsChangeRef.current = onCutsChange;
+ useEffect(() => {
+ if (!data) return;
+ const saved = JSON.stringify(data.cuts ?? []);
+ onCutsChangeRef.current?.(
+ serializedCuts,
+ JSON.stringify(serializedCuts) !== saved,
+ );
+ }, [serializedCuts, data]);
+
+ // ── Render ──────────────────────────────────────────────────
+ if (loadError) {
+ return (
+
+
+ {onCancel && (
+
+
+ Close
+
+
+ )}
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+ {preparingUnits !== null ? (
+
+ ) : (
+
+ )}
+
+
+ Preparing your timelapse
+
+
+ {preparingUnits !== null && preparingUnits > 0
+ ? `Stitching ${preparingUnits} minute${
+ preparingUnits === 1 ? "" : "s"
+ } of footage.`
+ : "oooooooooooo"}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* ── Stage: the only row that flexes ──────────────────── */}
+
+
setPlaying(true)}
+ onPause={() => setPlaying(false)}
+ // max-* rather than width:100% is what lets the stage shrink:
+ // the video letterboxes into whatever height is left instead of
+ // forcing the dock off the bottom of the window.
+ style={{
+ maxWidth: "100%",
+ maxHeight: "100%",
+ display: "block",
+ cursor: "pointer",
+ }}
+ />
+
+
+ {!playing && (
+
+
+
+ )}
+
+
+
+ {inCutNow && (
+
+
+ Will be removed
+
+
+ )}
+
+
+
+ {/* ── Dock: transport, timeline, actions ───────────────── */}
+
+ {/* Transport */}
+
+
+ {playing ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {elapsedLabel(currentUnit, unitCount)}
+
+ {" of "}
+ {elapsedLabel(unitCount, unitCount)}
+ {" · recorded at "}
+ {unitClockLabel(units[currentUnit])}
+
+
+
+
+
+
+
+ {/* Timeline */}
+
+ {/* Playhead: a slim cap at the foot of the ruler with a stem
+ through the strip. Rendered as a sibling of both lanes (not
+ inside the strip) so it isn't clipped by its overflow. */}
+ {unitCount > 0 && (
+
+ )}
+
+ {/* Ruler lane — owns scrubbing. Labels sit above their tick, at
+ a step chosen so they never crowd (see rulerStep). */}
+
+ {ticks.map(({ unit, major }) => {
+ const left = (unit / Math.max(1, unitCount)) * 100;
+ return (
+
+ {major && (
+
= unitCount ? 0 : undefined,
+ transform:
+ unit === 0 || unit >= unitCount
+ ? undefined
+ : "translateX(-50%)",
+ fontSize: fontSize.xs,
+ color: colors.text.tertiary,
+ fontVariantNumeric: "tabular-nums",
+ whiteSpace: "nowrap",
+ pointerEvents: "none",
+ }}
+ >
+ {elapsedLabel(unit, unitCount)}
+
+ )}
+
= unitCount ? -1 : -0.5,
+ width: 1,
+ height: major ? 7 : 4,
+ background: major
+ ? colors.text.tertiary
+ : colors.text.quaternary,
+ pointerEvents: "none",
+ }}
+ />
+
+ );
+ })}
+
+
+ {/* Filmstrip — drag creates a cut, click seeks */}
+
+ {/* Whole frames at the source aspect ratio, tiled left to
+ right. Fixed width (not flex) is the point: stretching
+ tiles to fill would distort them, and `cover` would crop
+ them. The final tile runs past the edge and is clipped. */}
+
+ {filmstrip.map((url, i) => (
+
+ ))}
+
+
+ {/* Pause markers: the recording stopped between these minutes */}
+ {gaps.map((i) => (
+
+ ))}
+
+ {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,
+ borderRadius: radii.sm,
+ // backgroundColor (not background) so the hover rule
+ // in editorStyles can swap the tint without dropping
+ // the hatch layered on top of it.
+ backgroundColor: colors.editor.cutFill,
+ backgroundImage: hatch(10),
+ boxShadow: isSelected
+ ? `inset 0 0 0 2px ${colors.editor.cutBorder}`
+ : `inset 0 0 0 1px ${colors.editor.cutBorder}`,
+ 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",
+ }}
+ >
+
+
+ ))}
+
+ );
+ })}
+
+
+
+
+ {/* Actions */}
+
+
+ kept
+ {removedUnits > 0 && (
+
+ {" · "}
+ removed
+
+ )}
+
+
+
+
+ {selected !== null && (
+
{
+ setRegions((prev) => prev.filter((_, i) => i !== selected));
+ setSelected(null);
+ }}
+ >
+ Remove cut
+
+ )}
+ {normalized.length > 0 && (
+
{
+ setRegions([]);
+ setSelected(null);
+ }}
+ >
+ Clear all
+
+ )}
+
+ {saving ? "Saving…" : "Save"}
+
+
+
+ {saveError && (
+
+ )}
+
+
+ );
+}
diff --git a/clients/react/src/components/editorStyles.ts b/clients/react/src/components/editorStyles.ts
new file mode 100644
index 00000000..c6c4a4c8
--- /dev/null
+++ b/clients/react/src/components/editorStyles.ts
@@ -0,0 +1,82 @@
+// Scoped stylesheet for the timelapse editor.
+//
+// The SDK styles with inline objects, which can't express :hover,
+// :focus-visible, or reduced-motion. Those states are not optional on a
+// direct-manipulation surface — you need to see what you're about to grab —
+// so the editor injects one small sheet the same way theme.ts does.
+
+const EASE_OUT_QUART = "cubic-bezier(0.25, 1, 0.5, 1)";
+
+export const EDITOR_STYLE_ID = "lookout-editor-styles";
+
+export function injectEditorStyles(): void {
+ if (typeof document === "undefined") return;
+ if (document.querySelector(`style[data-${EDITOR_STYLE_ID}]`)) return;
+
+ const style = document.createElement("style");
+ style.setAttribute(`data-${EDITOR_STYLE_ID}`, "");
+ style.textContent = `
+ .lk-ed-strip { transition: box-shadow 180ms ${EASE_OUT_QUART}; }
+ .lk-ed-strip:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--color-bg-body), 0 0 0 4px var(--color-accent);
+ }
+
+ .lk-ed-region {
+ transition: background-color 140ms ${EASE_OUT_QUART},
+ box-shadow 140ms ${EASE_OUT_QUART};
+ }
+ .lk-ed-region:hover { background-color: var(--color-cut-fill-hover); }
+
+ /* The grab target is deliberately wider than the visible grip: 12px of
+ hit area, a 3px bar. Fitts's law on a 1-second-per-minute timeline. */
+ .lk-ed-grip { transition: transform 140ms ${EASE_OUT_QUART}; }
+ .lk-ed-handle:hover .lk-ed-grip { transform: scaleX(1.6); }
+
+ /* The cap is a grab target, so it acknowledges the pointer — but
+ subtly: it marks a position, it shouldn't dominate the timeline.
+ The hover is driven from the (larger, invisible) hit area. */
+ .lk-ed-playhead { transition: transform 120ms ${EASE_OUT_QUART}; }
+ *:hover > .lk-ed-playhead { transform: scaleX(1.25); }
+ *:active > .lk-ed-playhead { transform: scaleX(1.1); }
+
+ .lk-ed-iconbtn {
+ display: inline-flex; align-items: center; justify-content: center;
+ background: transparent; cursor: pointer; padding: 0;
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border-default);
+ transition: background-color 140ms ${EASE_OUT_QUART},
+ border-color 140ms ${EASE_OUT_QUART},
+ transform 140ms ${EASE_OUT_QUART};
+ }
+ .lk-ed-iconbtn:hover {
+ background: var(--color-bg-surface);
+ border-color: var(--color-border-hover);
+ }
+ .lk-ed-iconbtn:active { transform: scale(0.94); }
+ .lk-ed-iconbtn:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--color-bg-body), 0 0 0 4px var(--color-accent);
+ }
+
+ .lk-ed-fade-in { animation: lk-ed-fade 160ms ${EASE_OUT_QUART} both; }
+ @keyframes lk-ed-fade {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: none; }
+ }
+
+ /* Motion here is all feedback — grip growth, region tint, the scrubber
+ card arriving. Under reduced-motion the states still change, they
+ just stop moving. */
+ @media (prefers-reduced-motion: reduce) {
+ .lk-ed-strip, .lk-ed-region, .lk-ed-grip, .lk-ed-iconbtn,
+ .lk-ed-playhead {
+ transition-duration: 1ms;
+ }
+ .lk-ed-fade-in { animation-duration: 1ms; }
+ .lk-ed-handle:hover .lk-ed-grip { transform: none; }
+ *:hover > .lk-ed-playhead { transform: none; }
+ }
+ `;
+ document.head.appendChild(style);
+}
diff --git a/clients/react/src/hooks/buildProgress.test.ts b/clients/react/src/hooks/buildProgress.test.ts
new file mode 100644
index 00000000..5521acc5
--- /dev/null
+++ b/clients/react/src/hooks/buildProgress.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { estimateBuildProgress } from "./buildProgress.js";
+
+describe("estimateBuildProgress", () => {
+ const estimate = 30_000;
+
+ it("starts at zero and rises", () => {
+ expect(estimateBuildProgress(0, estimate)).toBe(0);
+ expect(estimateBuildProgress(5_000, estimate)).toBeGreaterThan(0);
+ });
+
+ it("is monotonic in elapsed time", () => {
+ let prev = -1;
+ for (let t = 0; t <= 120_000; t += 500) {
+ const p = estimateBuildProgress(t, estimate);
+ expect(p).toBeGreaterThanOrEqual(prev);
+ prev = p;
+ }
+ });
+
+ it("never reaches 1 — only the video actually landing ends the wait", () => {
+ expect(estimateBuildProgress(estimate, estimate)).toBeLessThan(1);
+ expect(estimateBuildProgress(10 * estimate, estimate)).toBeLessThan(1);
+ });
+
+ it("is anchored to elapsed time, so a re-render can't rewind it", () => {
+ // The regression: the poll used to re-create its state object every
+ // 1.5s, re-running the effect and resetting `startedAt` — the ring
+ // walked 0 → 12% → 0 → 12% forever. Progress is a pure function of
+ // elapsed time, so the same elapsed value always yields the same
+ // number no matter how many times it's recomputed.
+ const a = estimateBuildProgress(9_000, estimate);
+ const b = estimateBuildProgress(9_000, estimate);
+ expect(a).toBe(b);
+ expect(estimateBuildProgress(10_500, estimate)).toBeGreaterThan(a);
+ });
+
+ it("scales with the amount of footage", () => {
+ // A long recording should be less far along at the same wall-clock
+ // moment than a short one.
+ const short = estimateBuildProgress(20_000, 20_000);
+ const long = estimateBuildProgress(20_000, 200_000);
+ expect(long).toBeLessThan(short);
+ });
+});
diff --git a/clients/react/src/hooks/buildProgress.ts b/clients/react/src/hooks/buildProgress.ts
new file mode 100644
index 00000000..1b52d0bb
--- /dev/null
+++ b/clients/react/src/hooks/buildProgress.ts
@@ -0,0 +1,31 @@
+/** Fixed cost of a compile: claim, sampling query, assembly, upload. */
+export const COMPILE_BASE_MS = 6_000;
+
+/** Marginal cost per capture unit (download + a 1s segment encode, across
+ * the worker's 8-way pool). Only used to size the estimate below. */
+export const COMPILE_MS_PER_UNIT = 350;
+
+/** How long a compile of `units` minutes of footage is expected to take. */
+export function compileEstimateMs(units: number): number {
+ return COMPILE_BASE_MS + Math.max(0, units) * COMPILE_MS_PER_UNIT;
+}
+
+/**
+ * Progress of the preview build, as a pure function of elapsed time.
+ *
+ * The worker reports no real progress, so this is an estimate — which
+ * makes two properties non-negotiable, and both come from being pure:
+ *
+ * - **Monotonic.** Progress that walks backwards reads as a broken build
+ * even when the work is fine. Depending only on elapsed time means a
+ * re-render can't rewind it (the original bug: a polling effect
+ * re-created its state object every 1.5s, resetting the anchor, so the
+ * ring cycled 0 → 12% → 0 forever).
+ * - **Never completes.** It approaches 1 asymptotically and only the
+ * video actually landing ends the wait, so the ring can't sit at 100%
+ * while the user is still waiting.
+ */
+export function estimateBuildProgress(elapsedMs: number, estimateMs: number): number {
+ if (estimateMs <= 0) return 0;
+ return 1 - Math.exp(-2.2 * (Math.max(0, elapsedMs) / estimateMs));
+}
diff --git a/clients/react/src/hooks/clipFallback.test.tsx b/clients/react/src/hooks/clipFallback.test.tsx
new file mode 100644
index 00000000..a525dac9
--- /dev/null
+++ b/clients/react/src/hooks/clipFallback.test.tsx
@@ -0,0 +1,192 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { MAX_CLIP_UPLOAD_FAILURES } from "@lookout/shared";
+import { LookoutProvider } from "../LookoutProvider.js";
+import {
+ useUploader,
+ ClipFormatRejectedError,
+ type UploadPayload,
+} from "./useUploader.js";
+
+/**
+ * Clips are an enhancement; one JPEG a minute is the contract. These tests pin
+ * the two ways that promise used to be broken on the web client, both of which
+ * the desktop client already handled:
+ *
+ * 1. A clip that encodes but fails to UPLOAD cost the entire minute — no
+ * capture, no credit — where desktop retried the tick as a JPEG.
+ * 2. A session whose clip support went away server-side re-attempted a clip
+ * every minute forever, each one failing identically.
+ */
+
+const TOKEN = "a".repeat(64);
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface TransportOptions {
+ /** Format the server grants on upload-url (drives the downgrade case). */
+ grant?: string;
+ /** Fail the R2 PUT for payloads of this content type. */
+ failPutContentType?: string;
+}
+
+function mockTransport(opts: TransportOptions = {}) {
+ const puts: { contentType: string }[] = [];
+ const capturedAts: (string | null)[] = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : String(input);
+ if (url.includes("/upload-url")) {
+ capturedAts.push(new URL(url).searchParams.get("capturedAt"));
+ const requested = new URL(url).searchParams.get("format");
+ return new Response(
+ JSON.stringify({
+ uploadUrl: "https://r2.test/put",
+ r2Key: "k",
+ screenshotId: "00000000-0000-0000-0000-000000000000",
+ minuteBucket: 0,
+ nextExpectedAt: new Date(Date.now() + 60_000).toISOString(),
+ // Absent `format` means jpeg was granted.
+ ...(requested ? { format: opts.grant ?? requested } : {}),
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ if (init?.method === "PUT") {
+ const contentType = (init.headers as Record
)[
+ "Content-Type"
+ ];
+ puts.push({ contentType });
+ if (opts.failPutContentType === contentType) {
+ return new Response("InternalError ", {
+ status: 500,
+ });
+ }
+ return new Response("", { status: 200 });
+ }
+ if (url.includes("/screenshots")) {
+ return new Response(
+ JSON.stringify({
+ confirmed: true,
+ trackedSeconds: 60,
+ nextExpectedAt: new Date(Date.now() + 60_000).toISOString(),
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ return new Response("{}", { status: 200 });
+ }),
+ );
+ return { puts, capturedAts };
+}
+
+/** A clip payload carrying its cut-time JPEG snapshot, as ClipRecorder emits. */
+function clipPayload(capturedAtMs: number): UploadPayload {
+ return {
+ blob: new Blob(["clip-bytes"], { type: "video/mp4" }),
+ width: 1920,
+ height: 1080,
+ capturedAtMs,
+ format: "mp4",
+ previewBlob: new Blob(["jpeg-bytes"], { type: "image/jpeg" }),
+ };
+}
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("a clip upload that fails", () => {
+ it("is retried as a JPEG at the SAME capture moment, so the minute credits", async () => {
+ // Clips fail, JPEGs succeed — a server rejecting the clip container, or a
+ // link that dies on the larger payload.
+ const { puts, capturedAts } = mockTransport({
+ failPutContentType: "video/mp4",
+ });
+ const { result } = renderHook(() => useUploader(), { wrapper });
+ const capturedAtMs = Date.now() - 5_000;
+
+ // Mirrors useLookout's uploadWithFallback: clip first, JPEG snapshot after.
+ let confirmed: { trackedSeconds: number } | null = null;
+ await act(async () => {
+ const payload = clipPayload(capturedAtMs);
+ try {
+ confirmed = await result.current.captureUploadConfirm(payload);
+ } catch {
+ confirmed = await result.current.captureUploadConfirm({
+ blob: payload.previewBlob!,
+ width: payload.width,
+ height: payload.height,
+ capturedAtMs: payload.capturedAtMs,
+ });
+ }
+ });
+
+ // The clip exhausts its normal retry budget first (a transient failure
+ // shouldn't cost the clip), and only then does one JPEG go up.
+ const types = puts.map((p) => p.contentType);
+ expect(types.filter((t) => t === "video/mp4").length).toBeGreaterThan(1);
+ expect(types.filter((t) => t === "image/jpeg")).toEqual(["image/jpeg"]);
+ expect(types[types.length - 1]).toBe("image/jpeg");
+ // The minute still credited.
+ expect(confirmed!.trackedSeconds).toBe(60);
+ // And crucially the retry did NOT re-stamp the time: a capturedAt of "now"
+ // would drift the streak anchor and eventually stop crediting.
+ expect(capturedAts).toHaveLength(2);
+ expect(capturedAts[0]).toBe(new Date(capturedAtMs).toISOString());
+ expect(capturedAts[1]).toBe(capturedAts[0]);
+ });
+});
+
+describe("a session whose clip support went away", () => {
+ it("reports a distinguishable error rather than a generic failure", async () => {
+ // The server grants jpeg for an mp4 request — clips were turned off.
+ mockTransport({ grant: "jpeg" });
+ const { result } = renderHook(() => useUploader(), { wrapper });
+
+ let caught: unknown;
+ await act(async () => {
+ try {
+ await result.current.captureUploadConfirm(clipPayload(Date.now()));
+ } catch (err) {
+ caught = err;
+ }
+ });
+
+ // Typed, so the capture loop can latch clips off immediately instead of
+ // retrying something that will never succeed.
+ expect(caught).toBeInstanceOf(ClipFormatRejectedError);
+ expect((caught as ClipFormatRejectedError).granted).toBe("jpeg");
+ });
+
+ it("never uploads the clip against a mismatched grant", async () => {
+ // The presigned URL is signed for the GRANTED content type, so uploading
+ // the clip would fail the signature anyway — fail before spending the
+ // bytes.
+ const { puts } = mockTransport({ grant: "jpeg" });
+ const { result } = renderHook(() => useUploader(), { wrapper });
+
+ await act(async () => {
+ await result.current
+ .captureUploadConfirm(clipPayload(Date.now()))
+ .catch(() => {});
+ });
+
+ expect(puts).toHaveLength(0);
+ });
+});
+
+describe("the failure budget", () => {
+ it("is small enough that a broken encoder can't waste a session", () => {
+ // Three strikes: enough to ride out a patch of bad network (each attempt
+ // already retries internally), few enough that a structurally broken
+ // clip path costs minutes, not hours.
+ expect(MAX_CLIP_UPLOAD_FAILURES).toBeGreaterThanOrEqual(2);
+ expect(MAX_CLIP_UPLOAD_FAILURES).toBeLessThanOrEqual(5);
+ });
+});
diff --git a/clients/react/src/hooks/clipRecorder.test.ts b/clients/react/src/hooks/clipRecorder.test.ts
new file mode 100644
index 00000000..c64b31df
--- /dev/null
+++ b/clients/react/src/hooks/clipRecorder.test.ts
@@ -0,0 +1,143 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ CLIP_FRAME_INTERVAL_MS,
+ FRAMES_PER_CLIP,
+ MAX_CLIP_FRAME_OVERRUN,
+ MAX_CLIP_BYTES,
+} from "@lookout/shared";
+import { ClipRecorder } from "./clipRecorder.js";
+
+/**
+ * A clip is cut by its upload tick, not by a timer — so when uploads run
+ * behind, the clip keeps recording. These tests pin the bound on that, which
+ * is what stops a bad connection from turning into lost footage:
+ *
+ * uncapped, a multi-minute stall produced a clip with several times the
+ * nominal frame count, which blew MAX_CLIP_BYTES and was refused server-side
+ * — costing the entire window — while still only ever rendering as ONE
+ * second of output video.
+ */
+
+/** Bytes the fake encoder emits per drawn frame — mid-range for 1080p at the
+ * measured web bitrate (see CLIP_WEB_VIDEO_BITS_PER_SECOND's table). */
+const BYTES_PER_FRAME = 300_000;
+
+let framesRequested = 0;
+
+class FakeMediaRecorder {
+ static isTypeSupported = (mime: string) => mime === "video/mp4;codecs=avc1.640028";
+ state = "inactive";
+ ondataavailable: ((e: { data: Blob }) => void) | null = null;
+ onstop: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ constructor(
+ _stream: MediaStream,
+ public opts: { mimeType: string; videoBitsPerSecond: number },
+ ) {}
+ start() {
+ this.state = "recording";
+ }
+ stop() {
+ this.state = "inactive";
+ // One blob sized to the frames the canvas actually pushed.
+ this.ondataavailable?.({
+ data: new Blob([new Uint8Array(framesRequested * BYTES_PER_FRAME)]),
+ });
+ this.onstop?.();
+ }
+}
+
+/** A -alike with decoded dimensions, plus a canvas whose
+ * captureStream/getContext/toBlob are stubbed enough for the recorder. */
+function fakeVideo(): HTMLVideoElement {
+ return { videoWidth: 1920, videoHeight: 1080 } as HTMLVideoElement;
+}
+
+beforeEach(() => {
+ framesRequested = 0;
+ vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
+ vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
+ if (tag !== "canvas") throw new Error(`unexpected createElement(${tag})`);
+ const track = {
+ requestFrame: () => {
+ framesRequested++;
+ },
+ stop: () => {},
+ };
+ const stream = { getVideoTracks: () => [track], getTracks: () => [track] };
+ return {
+ width: 0,
+ height: 0,
+ captureStream: () => stream,
+ getContext: () => ({ drawImage: () => {}, imageSmoothingQuality: "" }),
+ toBlob: (cb: (b: Blob | null) => void) => cb(new Blob(["preview"])),
+ };
+ }) as typeof document.createElement);
+ // isSupported() probes for captureStream on the prototype; happy-dom has
+ // no such method, and the recorder never calls the prototype's copy.
+ (
+ HTMLCanvasElement.prototype as unknown as { captureStream?: () => void }
+ ).captureStream ??= () => {};
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe("clip frame cap", () => {
+ it("stops recording frames once a stalled window hits the cap", async () => {
+ const recorder = new ClipRecorder(fakeVideo(), CLIP_FRAME_INTERVAL_MS);
+ recorder.start();
+
+ const cap = FRAMES_PER_CLIP * MAX_CLIP_FRAME_OVERRUN;
+ // Ten intervals' worth of frame ticks — a multi-minute upload stall.
+ for (let i = 0; i < cap * 10; i++) {
+ (recorder as unknown as { drawFrame(): void }).drawFrame();
+ }
+
+ const clip = await recorder.cut();
+ expect(clip).not.toBeNull();
+ expect(clip!.frameCount).toBeLessThanOrEqual(cap);
+ expect(clip!.truncated).toBe(true);
+ // The point of the cap: the clip is still small enough to be accepted.
+ expect(clip!.blob.size).toBeLessThan(MAX_CLIP_BYTES);
+ recorder.stop();
+ });
+
+ it("leaves a normal clip untruncated and uncapped", async () => {
+ const recorder = new ClipRecorder(fakeVideo(), CLIP_FRAME_INTERVAL_MS);
+ recorder.start();
+ // start() draws one frame; add the rest of a nominal window.
+ for (let i = 1; i < FRAMES_PER_CLIP; i++) {
+ (recorder as unknown as { drawFrame(): void }).drawFrame();
+ }
+
+ const clip = await recorder.cut();
+ expect(clip!.truncated).toBe(false);
+ // The nominal window's frames plus the one cut() draws to close the clip
+ // — the closing frame is also what capturedAt is stamped against.
+ expect(clip!.frameCount).toBe(FRAMES_PER_CLIP + 1);
+ recorder.stop();
+ });
+
+ it("keeps the encoder bitrate when an oversize clip was merely stalled", async () => {
+ const recorder = new ClipRecorder(fakeVideo(), CLIP_FRAME_INTERVAL_MS);
+ recorder.start();
+ const before = (recorder as unknown as { bitrate: number }).bitrate;
+
+ // Force the clip over the byte cap by making each frame enormous, and
+ // over the frame cap so it registers as stalled rather than mis-tuned.
+ const cap = FRAMES_PER_CLIP * MAX_CLIP_FRAME_OVERRUN;
+ for (let i = 0; i < cap * 2; i++) {
+ (recorder as unknown as { drawFrame(): void }).drawFrame();
+ }
+ (recorder as unknown as { maxFrames: number }).maxFrames = 1;
+
+ await recorder.cut();
+ // The backoff is permanent, so charging a network stall to the encoder
+ // would leave the rest of the session soft.
+ expect((recorder as unknown as { bitrate: number }).bitrate).toBe(before);
+ recorder.stop();
+ });
+});
diff --git a/clients/react/src/hooks/clipRecorder.ts b/clients/react/src/hooks/clipRecorder.ts
new file mode 100644
index 00000000..b2074b13
--- /dev/null
+++ b/clients/react/src/hooks/clipRecorder.ts
@@ -0,0 +1,401 @@
+import {
+ MAX_WIDTH,
+ MAX_HEIGHT,
+ JPEG_QUALITY,
+ CLIP_WEB_VIDEO_BITS_PER_SECOND,
+ CLIP_WEB_MIN_BITS_PER_SECOND,
+ MAX_CLIP_FRAME_OVERRUN,
+ MAX_CLIP_BYTES,
+ SCREENSHOT_INTERVAL_MS,
+ 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;
+ /** True when the clip hit its frame cap, i.e. the window it covers ran
+ * long because the previous upload was still draining. The clip is
+ * still perfectly usable; the caller may want to log the stall. */
+ truncated: boolean;
+ /** 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: H.264/MP4 first, WebM only as a fallback for engines
+ * that cannot record MP4 (Firefox).
+ *
+ * This is the opposite of what raw compression efficiency suggests, and
+ * it is deliberate. Our content is sparse 1080p screen frames, which is
+ * exactly where the browsers' realtime libvpx configuration falls apart.
+ * Measured at matched output size (Chromium 148, 1080p, PSNR vs source):
+ *
+ * ~115 KB/frame H.264 30.9 dB VP9 22.3 dB
+ * ~190 KB/frame H.264 34.3 dB VP9 24.7 dB
+ * ~335 KB/frame H.264 38.6 dB VP9 28.6 dB
+ *
+ * H.264 is 8-10 dB better for the same bytes, and its quality is even
+ * across the clip, where VP9 spends nearly everything on the keyframe
+ * and leaves the other 14 frames soft. (VP8 is worse still: its rate
+ * control is inert below ~10 Mbps — identical bytes at 0.8M, 2M and 5M.)
+ * This mirrors the desktop app's own benchmarks, which rejected libvpx
+ * for the same workload.
+ *
+ * The profile-specific strings come first so we get High profile where
+ * it is offered; bare "video/mp4" is the Safari path. */
+const MIME_CANDIDATES: MimeCandidate[] = [
+ { mime: "video/mp4;codecs=avc1.640028", format: "mp4" }, // H.264 High 4.0
+ { mime: "video/mp4;codecs=avc1.4d0028", format: "mp4" }, // H.264 Main 4.0
+ { mime: "video/mp4;codecs=avc1.42e01e", format: "mp4" }, // H.264 Baseline 3.0
+ { mime: "video/mp4", format: "mp4" },
+ { mime: "video/webm;codecs=vp9", format: "webm" },
+ { mime: "video/webm;codecs=vp8", format: "webm" },
+ { mime: "video/webm", format: "webm" },
+];
+
+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;
+ /** Faster cadence for the FIRST clip only. The opening clip is cut after
+ * CLIP_FIRST_CUT_DELAY_MS (fast session activation), which is shorter
+ * than one frame interval — so at the normal cadence it would hold a
+ * single frame. The compiler drops the seed unit from the video anyway,
+ * so this is about the recorder having something to show and something
+ * to upload, not about output quality. After the first cut the recorder
+ * reverts to `frameIntervalMs`. */
+ openingFrameIntervalMs?: number;
+}
+
+/**
+ * Records the shared screen into per-minute video clips.
+ *
+ * Owns an offscreen canvas fed from the caller's `` (the
+ * getDisplayMedia sink) every `frameIntervalMs`, streamed through
+ * `canvas.captureStream()` into a bitrate-capped MediaRecorder. `cut()`
+ * finalizes the current clip and immediately starts the next one, so the
+ * serial per-minute upload pipeline stays exactly as it is for JPEGs — a
+ * clip is one capture unit.
+ *
+ * Clips are VFR: on a static screen the encoder legitimately emits few
+ * frames. That's fine — the worker demuxes and normalizes each clip to
+ * one second of output video regardless of frame count.
+ */
+export class ClipRecorder {
+ private video: HTMLVideoElement;
+ private frameIntervalMs: number;
+ private openingFrameIntervalMs: number | null;
+ private canvas: HTMLCanvasElement | null = null;
+ private stream: MediaStream | null = null;
+ private recorder: MediaRecorder | null = null;
+ private parts: Blob[] = [];
+ private frameCount = 0;
+ private frameTimer: ReturnType | null = null;
+ private mime: MimeCandidate;
+ /** Live encoder bitrate. Starts at the measured-optimal web rate and
+ * halves whenever a finished clip overruns MAX_CLIP_BYTES — see
+ * `cut()`. Browsers whose rate control does honour real frame spacing
+ * would otherwise blow the cap on every single clip. */
+ private bitrate = CLIP_WEB_VIDEO_BITS_PER_SECOND;
+ /** Hard frame cap for one clip — see MAX_CLIP_FRAME_OVERRUN. Derived from
+ * the SERVER's cadence, not the default constant, so a server that
+ * dictates a different frameIntervalMs still gets a correct cap. */
+ private maxFrames: number;
+ // Opening cadence lives in its own field (cleared after the first cut),
+ // so it's excluded from the always-resolved options.
+ private opts: Required>;
+
+ static isSupported(): boolean {
+ return (
+ typeof HTMLCanvasElement !== "undefined" &&
+ typeof HTMLCanvasElement.prototype.captureStream === "function" &&
+ pickMimeCandidate() !== null
+ );
+ }
+
+ constructor(
+ video: HTMLVideoElement,
+ frameIntervalMs: number,
+ opts?: ClipRecorderOptions,
+ ) {
+ const mime = pickMimeCandidate();
+ if (!mime) throw new Error("Clip recording not supported in this browser");
+ this.video = video;
+ this.frameIntervalMs = frameIntervalMs;
+ this.openingFrameIntervalMs = opts?.openingFrameIntervalMs ?? null;
+ this.maxFrames =
+ Math.ceil(SCREENSHOT_INTERVAL_MS / Math.max(1, frameIntervalMs)) *
+ MAX_CLIP_FRAME_OVERRUN;
+ this.mime = mime;
+ this.opts = {
+ maxWidth: opts?.maxWidth ?? MAX_WIDTH,
+ maxHeight: opts?.maxHeight ?? MAX_HEIGHT,
+ jpegQuality: opts?.jpegQuality ?? JPEG_QUALITY,
+ };
+ }
+
+ /** Start recording a fresh clip. No-op if already recording. */
+ start(): void {
+ if (this.recorder) return;
+ if (this.video.videoWidth === 0 || this.video.videoHeight === 0) {
+ throw new Error("Video not ready — cannot start clip recorder");
+ }
+
+ const scale = Math.min(
+ this.opts.maxWidth / this.video.videoWidth,
+ this.opts.maxHeight / this.video.videoHeight,
+ 1,
+ );
+ const canvas = document.createElement("canvas");
+ // Encoder-friendly even dimensions; the size is fixed for the clip's
+ // life (MediaRecorder requires a constant stream resolution).
+ canvas.width = Math.max(2, Math.round((this.video.videoWidth * scale) / 2) * 2);
+ canvas.height = Math.max(2, Math.round((this.video.videoHeight * scale) / 2) * 2);
+ this.canvas = canvas;
+
+ // captureStream(0) = frames only on explicit requestFrame(), keeping
+ // encode work at exactly our cadence. Some engines put requestFrame on
+ // the track (spec), others on the stream (older Firefox), some lack it
+ // entirely — fall back to auto-capture on canvas change.
+ let stream: MediaStream;
+ try {
+ stream = canvas.captureStream(0);
+ if (!this.streamHasRequestFrame(stream)) {
+ stream.getTracks().forEach((t) => t.stop());
+ stream = canvas.captureStream();
+ }
+ } catch {
+ stream = canvas.captureStream();
+ }
+ this.stream = stream;
+
+ this.parts = [];
+ this.frameCount = 0;
+ const recorder = new MediaRecorder(stream, {
+ mimeType: this.mime.mime,
+ videoBitsPerSecond: this.bitrate,
+ });
+ recorder.ondataavailable = (e) => {
+ if (e.data && e.data.size > 0) this.parts.push(e.data);
+ };
+ this.recorder = recorder;
+ recorder.start();
+
+ this.drawFrame();
+ const cadence = this.openingFrameIntervalMs ?? this.frameIntervalMs;
+ this.frameTimer = setInterval(() => this.drawFrame(), cadence);
+ }
+
+ private streamHasRequestFrame(stream: MediaStream): boolean {
+ const track = stream.getVideoTracks()[0] as MediaStreamTrack & {
+ requestFrame?: () => void;
+ };
+ const s = stream as MediaStream & { requestFrame?: () => void };
+ return (
+ typeof track?.requestFrame === "function" ||
+ typeof s.requestFrame === "function"
+ );
+ }
+
+ private drawFrame(): void {
+ const canvas = this.canvas;
+ if (!canvas || this.video.videoWidth === 0 || this.video.videoHeight === 0)
+ return;
+ // Frame cap. A clip is cut by its upload tick, so a slow uplink stretches
+ // the window this clip covers — and every extra frame is more bytes
+ // against MAX_CLIP_BYTES, for a clip that renders as one second either
+ // way. Past the cap, stop feeding the encoder and stop the timer: the
+ // clip stays uploadable, and we stop burning CPU compositing frames
+ // nothing will ever see.
+ if (this.frameCount >= this.maxFrames) {
+ if (this.frameTimer) {
+ clearInterval(this.frameTimer);
+ this.frameTimer = null;
+ }
+ return;
+ }
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ // Only matters when the source is larger than the clip canvas, but a
+ // bilinear-ish downscale of 1080p+ UI text aliases badly. The desktop
+ // client area-averages for the same reason.
+ ctx.imageSmoothingQuality = "high";
+ ctx.drawImage(this.video, 0, 0, canvas.width, canvas.height);
+ const track = this.stream?.getVideoTracks()[0] as
+ | (MediaStreamTrack & { requestFrame?: () => void })
+ | undefined;
+ if (typeof track?.requestFrame === "function") {
+ track.requestFrame();
+ } else {
+ (
+ this.stream as (MediaStream & { requestFrame?: () => void }) | null
+ )?.requestFrame?.();
+ }
+ this.frameCount++;
+ }
+
+ /**
+ * Finalize the current clip and immediately start the next one.
+ * Returns null when the clip is unusable (no frames / empty blob) —
+ * callers should fall back to a single-JPEG capture for that tick.
+ */
+ async cut(): Promise {
+ const recorder = this.recorder;
+ const canvas = this.canvas;
+ if (!recorder || !canvas) return null;
+
+ // Final frame + timestamp: capturedAt is the moment the clip is cut,
+ // which is what keeps the per-minute credit cadence monotonic.
+ this.drawFrame();
+ const capturedAtMs = Date.now();
+ const frameCount = this.frameCount;
+ const truncated = frameCount >= this.maxFrames;
+ if (this.frameTimer) {
+ clearInterval(this.frameTimer);
+ this.frameTimer = null;
+ }
+
+ const stopped = new Promise((resolve) => {
+ recorder.onstop = () => resolve();
+ recorder.onerror = () => resolve();
+ });
+ try {
+ recorder.stop();
+ } catch {
+ // stop() throws if already inactive — treat as stopped
+ }
+ // Never let a wedged encoder stall the capture loop.
+ await Promise.race([
+ stopped,
+ new Promise((resolve) => setTimeout(resolve, 10_000)),
+ ]);
+
+ const parts = this.parts;
+ const width = canvas.width;
+ const height = canvas.height;
+ const previewBlob = await new Promise((resolve) => {
+ canvas.toBlob((b) => resolve(b), "image/jpeg", this.opts.jpegQuality);
+ setTimeout(() => resolve(null), 5_000);
+ });
+
+ const blob =
+ parts.length > 0 && frameCount > 0
+ ? new Blob(parts, { type: this.mime.mime.split(";")[0] })
+ : null;
+
+ // Oversize clips are rejected server-side (HeadObject vs
+ // MAX_CLIP_BYTES), which would cost the whole minute. We tune the
+ // bitrate for the rate-control behaviour browsers actually have, so
+ // this should never fire — but an engine that instead budgets over
+ // the clip's real 60s wall clock would overshoot every time. Halve
+ // and carry on rather than upload a clip we know will be refused;
+ // the floor is the coarsest setting worth uploading.
+ let oversize = false;
+ if (blob && blob.size > MAX_CLIP_BYTES) {
+ oversize = true;
+ // ...but only when the clip was a NORMAL one. A truncated clip is
+ // oversize because the network stalled and it covers several minutes,
+ // not because the encoder is mis-tuned. The backoff is permanent
+ // (bitrate never ratchets back up), so blaming the encoder for a
+ // network event would leave the rest of the session soft — the exact
+ // failure mode where a user on bad wifi ends up with a worse
+ // timelapse than one on no wifi at all.
+ const reduced = truncated
+ ? this.bitrate
+ : Math.max(CLIP_WEB_MIN_BITS_PER_SECOND, Math.round(this.bitrate / 2));
+ if (reduced !== this.bitrate) {
+ console.warn(
+ `[lookout] clip was ${blob.size} bytes (cap ${MAX_CLIP_BYTES}) — ` +
+ `dropping encoder bitrate ${this.bitrate} -> ${reduced}`,
+ );
+ this.bitrate = reduced;
+ } else if (truncated) {
+ console.warn(
+ `[lookout] clip was ${blob.size} bytes (cap ${MAX_CLIP_BYTES}) after ` +
+ `running long on a slow upload — keeping bitrate at ${this.bitrate}`,
+ );
+ }
+ }
+
+ // Tear down and restart for the next minute — after the size check, so
+ // any backoff above applies to the clip we're about to start. The
+ // opening cadence only ever applies to the first clip; every later
+ // interval is full-length.
+ this.openingFrameIntervalMs = null;
+ this.teardown();
+ try {
+ this.start();
+ } catch {
+ // Video may be momentarily not-ready; the caller's next tick falls
+ // back to JPEG and recording resumes when start() next succeeds.
+ }
+
+ // A null/empty/oversize clip falls back to a single JPEG for this
+ // tick, so the capture cadence and credit streak never skip.
+ if (!blob || blob.size === 0 || oversize) return null;
+
+ return {
+ blob,
+ format: this.mime.format,
+ width,
+ height,
+ frameCount,
+ truncated,
+ capturedAtMs,
+ previewBlob,
+ };
+ }
+
+ /** Stop and discard the in-progress clip (pause/stop/unmount). */
+ stop(): void {
+ try {
+ if (this.recorder && this.recorder.state !== "inactive") {
+ this.recorder.stop();
+ }
+ } catch {
+ // already inactive
+ }
+ this.teardown();
+ }
+
+ private teardown(): void {
+ if (this.frameTimer) {
+ clearInterval(this.frameTimer);
+ this.frameTimer = null;
+ }
+ this.recorder = null;
+ this.stream?.getTracks().forEach((t) => t.stop());
+ this.stream = null;
+ this.canvas = null;
+ this.parts = [];
+ this.frameCount = 0;
+ }
+}
diff --git a/clients/react/src/hooks/editorMath.test.ts b/clients/react/src/hooks/editorMath.test.ts
new file mode 100644
index 00000000..50f838ee
--- /dev/null
+++ b/clients/react/src/hooks/editorMath.test.ts
@@ -0,0 +1,272 @@
+import { describe, expect, it } from "vitest";
+import { countCutUnits, type CutInterval, type VideoUnit } from "@lookout/shared";
+import {
+ regionsToCuts,
+ cutsToRegions,
+ normalizeRegions,
+ cutUnitCount,
+ unitIsCut,
+ regionAtTime,
+ gapIndices,
+ elapsedLabel,
+ rulerStep,
+ rulerTicks,
+ 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 [firstCutUnit, firstKeptUnit)", () => {
+ const units = makeUnits(5);
+ const cuts = regionsToCuts([{ startUnit: 1, endUnit: 3 }], units);
+ // End is exclusive and anchored to the next kept capture, so that
+ // capture is excluded exactly regardless of the gap before it. On an
+ // even 60s cadence that coincides with lastCut + 60s.
+ expect(cuts).toEqual([
+ { start: units[1].capturedAt, end: units[3].capturedAt },
+ ]);
+ });
+
+ it("drops empty regions", () => {
+ const units = makeUnits(5);
+ expect(regionsToCuts([{ startUnit: 2, endUnit: 2 }], units)).toEqual([]);
+ });
+});
+
+describe("regionsToCuts agrees with the server's membership rule", () => {
+ /** Server-side count: timestamp membership over the serialized list. */
+ const serverCutCount = (units: VideoUnit[], cuts: CutInterval[]) =>
+ countCutUnits(units.map((u) => Date.parse(u.capturedAt)), cuts);
+
+ it("does not over-cut when captures arrive early", () => {
+ // The reported bug: a 3-minute timelapse with 2 minutes selected was
+ // rejected as "would remove the entire timelapse". Captures jitter
+ // (the server credits anything within ±30s of the mark), so a 57s gap
+ // put the next capture inside an interval that assumed a 60s stride.
+ const T = Date.parse("2026-07-27T14:58:00.000Z");
+ const units: VideoUnit[] = [
+ { capturedAt: new Date(T).toISOString(), screenshotId: "a" },
+ { capturedAt: new Date(T + 57_000).toISOString(), screenshotId: "b" },
+ { capturedAt: new Date(T + 114_000).toISOString(), screenshotId: "c" },
+ ];
+ const cuts = regionsToCuts([{ startUnit: 0, endUnit: 2 }], units);
+ expect(serverCutCount(units, cuts)).toBe(2);
+ expect(cutsToRegions(cuts, units)).toEqual([{ startUnit: 0, endUnit: 2 }]);
+ });
+
+ it("holds across a spread of realistic jitter", () => {
+ for (const gap of [40_000, 52_000, 57_000, 59_999, 60_000, 63_000, 75_000]) {
+ const T = Date.parse("2026-07-27T09:00:00.000Z");
+ const units: VideoUnit[] = Array.from({ length: 6 }, (_, i) => ({
+ capturedAt: new Date(T + i * gap).toISOString(),
+ screenshotId: `u${i}`,
+ }));
+ for (const region of [
+ { startUnit: 0, endUnit: 2 },
+ { startUnit: 2, endUnit: 4 },
+ { startUnit: 4, endUnit: 6 },
+ ]) {
+ const cuts = regionsToCuts([region], units);
+ expect(serverCutCount(units, cuts)).toBe(region.endUnit - region.startUnit);
+ }
+ }
+ });
+
+ it("never swallows more than an interval across a pause", () => {
+ // Anchoring to the next kept capture must not extend a cut across a
+ // three-hour pause and remove captures that live inside it.
+ const units = makeUnits(6, { 2: 180 });
+ const cuts = regionsToCuts([{ startUnit: 1, endUnit: 3 }], units);
+ const span = Date.parse(cuts[0].end) - Date.parse(units[2].capturedAt);
+ expect(span).toBeLessThanOrEqual(60_000);
+ expect(serverCutCount(units, cuts)).toBe(2);
+ });
+
+ it("agrees for a cut running to the very end", () => {
+ const units = makeUnits(5);
+ const cuts = regionsToCuts([{ startUnit: 3, endUnit: 5 }], units);
+ expect(serverCutCount(units, cuts)).toBe(2);
+ });
+});
+
+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("elapsedLabel", () => {
+ it("is never mistakable for a duration in the wrong unit", () => {
+ // The bug this replaced: a 17-minute timelapse labelled with wall
+ // clock ("1:29" … "1:45") is correct but reads as 1m29s, making the
+ // whole timeline look broken. Short sessions get an explicit unit.
+ expect(elapsedLabel(0, 17)).toBe("0m");
+ expect(elapsedLabel(5, 17)).toBe("5m");
+ expect(elapsedLabel(16, 17)).toBe("16m");
+ });
+
+ it("switches to hours:minutes once minutes stop being readable", () => {
+ expect(elapsedLabel(0, 180)).toBe("0:00");
+ expect(elapsedLabel(65, 180)).toBe("1:05");
+ expect(elapsedLabel(120, 180)).toBe("2:00");
+ });
+
+ it("rounds half-step tick positions to whole minutes", () => {
+ expect(elapsedLabel(7.5, 17)).toBe("8m");
+ });
+});
+
+describe("rulerStep", () => {
+ it("picks a step people read without arithmetic", () => {
+ // 48 minutes across 900px → ~19px/min; a label needs ~88px, so ~5min.
+ expect(rulerStep(48, 900)).toBe(5);
+ // The same recording in a narrow window steps up rather than crowding.
+ expect(rulerStep(48, 300)).toBeGreaterThan(rulerStep(48, 900));
+ // A long session steps up too.
+ expect(rulerStep(600, 900)).toBeGreaterThanOrEqual(60);
+ });
+
+ it("only ever returns round values", () => {
+ const allowed = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 360, 720];
+ for (const units of [3, 17, 48, 121, 400, 1200]) {
+ for (const w of [200, 480, 900, 1600]) {
+ expect(allowed).toContain(rulerStep(units, w));
+ }
+ }
+ });
+
+ it("guarantees labels clear the minimum spacing", () => {
+ for (const units of [10, 48, 300]) {
+ for (const w of [300, 900, 1600]) {
+ const step = rulerStep(units, w, 88);
+ const pxPerLabel = (step / units) * w;
+ // The largest step is a ceiling, so only clamp-limited cases may
+ // fall short — everything else must satisfy the spacing rule.
+ if (step !== 720) expect(pxPerLabel).toBeGreaterThanOrEqual(88);
+ }
+ }
+ });
+
+ it("degrades safely on empty input", () => {
+ expect(rulerStep(0, 900)).toBe(1);
+ expect(rulerStep(48, 0)).toBe(1);
+ });
+});
+
+describe("rulerTicks", () => {
+ it("emits a major tick on each step and a minor between", () => {
+ const ticks = rulerTicks(20, 5);
+ expect(ticks.filter((t) => t.major).map((t) => t.unit)).toEqual([0, 5, 10, 15, 20]);
+ expect(ticks.filter((t) => !t.major).map((t) => t.unit)).toEqual([2.5, 7.5, 12.5, 17.5]);
+ });
+
+ it("marks majors correctly despite half-step float drift", () => {
+ // 0.5 increments accumulate error; majors must not be missed.
+ const ticks = rulerTicks(60, 1);
+ expect(ticks.filter((t) => t.major)).toHaveLength(61);
+ });
+
+ it("degrades safely on empty input", () => {
+ expect(rulerTicks(0, 5)).toEqual([]);
+ expect(rulerTicks(20, 0)).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..c8623114
--- /dev/null
+++ b/clients/react/src/hooks/editorMath.ts
@@ -0,0 +1,208 @@
+// 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) => {
+ const lastCut = Date.parse(units[r.endUnit - 1].capturedAt);
+ const nextKept =
+ r.endUnit < units.length ? Date.parse(units[r.endUnit].capturedAt) : null;
+ // The end is exclusive, so anchoring it to the next KEPT capture's
+ // real timestamp excludes that capture exactly. Assuming a 60s
+ // stride instead was wrong: captures jitter (the server credits
+ // anything within ±30s of the mark), so a neighbour landing at +57s
+ // fell inside the interval and the server counted one more unit cut
+ // than the editor showed — enough, on a short recording, to look
+ // like the whole thing was selected.
+ //
+ // Still capped at one interval: across a pause the next capture can
+ // be hours later, and the cut shouldn't swallow that whole span.
+ const end =
+ nextKept === null
+ ? lastCut + SCREENSHOT_INTERVAL_MS
+ : Math.min(nextKept, lastCut + SCREENSHOT_INTERVAL_MS);
+ return {
+ start: units[r.startUnit].capturedAt,
+ end: new Date(end).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 (local) for a unit. Includes AM/PM where the locale
+ * uses it — this is the one place the *time of day* is stated, so it must
+ * not be mistakable for a duration. */
+export function unitClockLabel(unit: VideoUnit): string {
+ const d = new Date(unit.capturedAt);
+ return d.toLocaleTimeString(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * Ruler label: how far into the *recording* a unit sits, since one unit is
+ * one recorded minute.
+ *
+ * Deliberately not wall-clock. A ruler reading "1:29 … 1:45" on a
+ * 17-minute timelapse is correct (those are times of day) but reads as
+ * "1 minute 29 seconds", which makes the whole timeline look wrong. Under
+ * an hour this is "5m"; past that, "1:05" as hours:minutes.
+ */
+export function elapsedLabel(unitIndex: number, totalUnits: number): string {
+ const m = Math.max(0, Math.round(unitIndex));
+ if (totalUnits < 60) return `${m}m`;
+ const h = Math.floor(m / 60);
+ return `${h}:${String(m % 60).padStart(2, "0")}`;
+}
+
+/** Steps a person reads without doing arithmetic — the reason a ruler
+ * labels 0/8/16/24 and never 0/7/14/21. In units (= minutes). */
+const NICE_STEPS = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 360, 720];
+
+/**
+ * Choose a ruler labelling interval: the smallest "nice" step that keeps
+ * labels at least `minLabelPx` apart at the current track width. Returns
+ * the step in units, so the caller can place a label every `step` and a
+ * minor tick every `step / 2`.
+ */
+export function rulerStep(
+ unitCount: number,
+ trackWidthPx: number,
+ minLabelPx = 88,
+): number {
+ if (unitCount <= 0 || trackWidthPx <= 0) return 1;
+ const pxPerUnit = trackWidthPx / unitCount;
+ const needed = minLabelPx / pxPerUnit;
+ return NICE_STEPS.find((s) => s >= needed) ?? NICE_STEPS[NICE_STEPS.length - 1];
+}
+
+/** Tick positions for a ruler: every `step` units, plus the midpoints. */
+export function rulerTicks(
+ unitCount: number,
+ step: number,
+): Array<{ unit: number; major: boolean }> {
+ const ticks: Array<{ unit: number; major: boolean }> = [];
+ if (unitCount <= 0 || step <= 0) return ticks;
+ const half = step / 2;
+ for (let u = 0; u <= unitCount; u += half) {
+ // Floating-point half-steps land a hair off an integer multiple;
+ // compare on the rounded value so majors are never missed.
+ const major = Math.abs(u / step - Math.round(u / step)) < 1e-9;
+ ticks.push({ unit: u, major });
+ }
+ return ticks;
+}
diff --git a/clients/react/src/hooks/uploadTiming.test.tsx b/clients/react/src/hooks/uploadTiming.test.tsx
new file mode 100644
index 00000000..e50291bd
--- /dev/null
+++ b/clients/react/src/hooks/uploadTiming.test.tsx
@@ -0,0 +1,145 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { LookoutProvider } from "../LookoutProvider.js";
+import { useUploader } from "./useUploader.js";
+import { deriveDisplaySeconds, MAX_INTERPOLATION_S } from "./useSessionTimer.js";
+
+/**
+ * Tracked time must not depend on how long an upload takes.
+ *
+ * The server credits a capture by its `capturedAt` — when the frame or
+ * clip was grabbed — measured against a streak anchor with a ±30s window.
+ * So the one defect that would silently cost users credited minutes is
+ * `capturedAt` drifting to reflect upload or encode time: on a slow uplink
+ * every capture would land outside the window, reset the streak, and earn
+ * nothing. These tests pin it to the capture moment.
+ */
+
+const TOKEN = "a".repeat(64);
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Fake transport. The R2 PUT is deliberately slow. */
+function mockTransport(uploadDelayMs: number) {
+ const uploadUrlCalls: string[] = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : String(input);
+ if (url.includes("/upload-url")) {
+ uploadUrlCalls.push(url);
+ return new Response(
+ JSON.stringify({
+ uploadUrl: "https://r2.test/put",
+ r2Key: "k",
+ screenshotId: "00000000-0000-0000-0000-000000000000",
+ minuteBucket: 0,
+ nextExpectedAt: new Date(Date.now() + 60_000).toISOString(),
+ format: "webm",
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ if (init?.method === "PUT") {
+ await new Promise((r) => setTimeout(r, uploadDelayMs));
+ return new Response("", { status: 200 });
+ }
+ if (url.includes("/screenshots")) {
+ return new Response(
+ JSON.stringify({
+ confirmed: true,
+ trackedSeconds: 60,
+ nextExpectedAt: new Date(Date.now() + 60_000).toISOString(),
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ return new Response("{}", { status: 200 });
+ }),
+ );
+ return { uploadUrlCalls };
+}
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("upload duration and credited time", () => {
+ it("stamps capturedAt at capture time even when the upload is slow", async () => {
+ const { uploadUrlCalls } = mockTransport(400);
+ const { result } = renderHook(() => useUploader(), { wrapper });
+
+ // A clip finalized 45s ago: the gap a long encode or a queued upload
+ // introduces between grabbing footage and shipping it.
+ const capturedAtMs = Date.now() - 45_000;
+
+ await act(async () => {
+ await result.current.captureUploadConfirm({
+ blob: new Blob(["clip"], { type: "video/webm" }),
+ width: 1920,
+ height: 1080,
+ capturedAtMs,
+ format: "webm",
+ });
+ });
+
+ expect(uploadUrlCalls).toHaveLength(1);
+ const sent = new URL(uploadUrlCalls[0]).searchParams.get("capturedAt");
+ expect(sent).toBe(new Date(capturedAtMs).toISOString());
+ // Not "roughly now" — exactly the capture moment. A drift of even a
+ // few seconds per capture accumulates into a lost streak.
+ expect(Date.parse(sent!)).toBe(capturedAtMs);
+ });
+
+ it("reports the server's tracked seconds, never a locally derived count", async () => {
+ mockTransport(10);
+ const { result } = renderHook(() => useUploader(), { wrapper });
+
+ await act(async () => {
+ await result.current.captureUploadConfirm({
+ blob: new Blob(["x"], { type: "image/jpeg" }),
+ width: 100,
+ height: 100,
+ capturedAtMs: Date.now(),
+ });
+ });
+
+ // The confirm said 60; a client that counted successful uploads would
+ // say something else the moment a capture landed out of window.
+ expect(result.current.trackedSeconds).toBe(60);
+ });
+});
+
+describe("the display timer while an upload is in flight", () => {
+ const base = 120;
+
+ it("ticks smoothly through a normal round trip", () => {
+ // Credits arrive ~60s apart, so the cap is reached just as the next
+ // one lands: no visible stall in the steady state, however long the
+ // individual upload took.
+ expect(deriveDisplaySeconds(base, 0, true, 10_000)).toBe(base + 10);
+ expect(deriveDisplaySeconds(base, 0, true, 45_000)).toBe(base + 45);
+ expect(deriveDisplaySeconds(base, 0, true, 59_000)).toBe(base + 59);
+ });
+
+ it("holds instead of inflating once a credit is overdue", () => {
+ // Past one interval the credit is genuinely late — uploads stalling,
+ // or captures falling outside the ±30s window and earning nothing.
+ // Holding is honest: those seconds may never be credited.
+ expect(deriveDisplaySeconds(base, 0, true, 90_000)).toBe(base + MAX_INTERPOLATION_S);
+ expect(deriveDisplaySeconds(base, 0, true, 600_000)).toBe(base + MAX_INTERPOLATION_S);
+ });
+
+ it("resumes from the held value rather than jumping when it lands", () => {
+ // Why the cap is exactly one interval: the held number and the
+ // incoming credit are the same, so a late upload costs smoothness for
+ // a moment but never shows the user time going backwards.
+ const held = deriveDisplaySeconds(base, 0, true, 90_000);
+ const afterCredit = deriveDisplaySeconds(base + MAX_INTERPOLATION_S, 1_000, true, 1_000);
+ expect(afterCredit).toBe(held);
+ });
+});
diff --git a/clients/react/src/hooks/useEditLease.ts b/clients/react/src/hooks/useEditLease.ts
new file mode 100644
index 00000000..21f3c6e0
--- /dev/null
+++ b/clients/react/src/hooks/useEditLease.ts
@@ -0,0 +1,50 @@
+import { useEffect, useRef, useState } from "react";
+import { EDIT_HEARTBEAT_SECONDS } from "@lookout/shared";
+import type { LookoutClient } from "../api/client.js";
+
+/**
+ * Holds a session's edit lease open for as long as an editing surface is
+ * mounted.
+ *
+ * The server publishes a held session once nothing has renewed the lease
+ * for a lease term, so "am I still editing?" is answered by the surface
+ * actually existing rather than by a countdown the user has to race. Any
+ * view that represents active editing — the editor itself, the review
+ * panel — should call this; when the last one unmounts, the session
+ * publishes on its own a lease later.
+ *
+ * Returns false once the server reports the session is no longer held
+ * (published, failed, or past the ceiling), so callers can stop showing
+ * editing affordances.
+ */
+export function useEditLease(client: LookoutClient, active = true): boolean {
+ const [held, setHeld] = useState(true);
+ // Read inside the interval so a lease that lapses doesn't keep polling.
+ const heldRef = useRef(true);
+ heldRef.current = held;
+
+ useEffect(() => {
+ if (!active) return;
+ let cancelled = false;
+
+ const beat = async () => {
+ if (cancelled || !heldRef.current) return;
+ try {
+ const res = await client.heartbeatEditing();
+ if (!cancelled && !res.held) setHeld(false);
+ } catch {
+ // Transient failures are fine: the lease is longer than several
+ // heartbeats, so a dropped request never ends an edit on its own.
+ }
+ };
+
+ void beat();
+ const id = setInterval(beat, EDIT_HEARTBEAT_SECONDS * 1000);
+ return () => {
+ cancelled = true;
+ clearInterval(id);
+ };
+ }, [client, active]);
+
+ return held;
+}
diff --git a/clients/react/src/hooks/useGallery.ts b/clients/react/src/hooks/useGallery.ts
index 7f3a8e0e..d2df75a5 100644
--- a/clients/react/src/hooks/useGallery.ts
+++ b/clients/react/src/hooks/useGallery.ts
@@ -15,9 +15,39 @@ export interface UseGallery {
interface CachedSession {
summary: SessionSummary;
- thumbnailUrlFetchedAt: number;
+ fetchedAt: number;
}
-const globalSessionsCache: Record = {};
+
+// Persisted across app restarts so the gallery paints instantly from the
+// last known state (and thumbnails come out of the HTTP cache) while a
+// background refresh runs.
+const CACHE_STORAGE_KEY = "lookout:gallery-cache:v2";
+const CACHE_MAX_ENTRIES = 500;
+
+function loadPersistedCache(): Record {
+ if (typeof localStorage === "undefined") return {};
+ try {
+ const raw = localStorage.getItem(CACHE_STORAGE_KEY);
+ const parsed = raw ? (JSON.parse(raw) as Record) : {};
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function persistCache(cache: Record): void {
+ if (typeof localStorage === "undefined") return;
+ try {
+ const entries = Object.entries(cache)
+ .sort(([, a], [, b]) => b.fetchedAt - a.fetchedAt)
+ .slice(0, CACHE_MAX_ENTRIES);
+ localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
+ } catch {
+ // Quota exceeded or storage unavailable — cache is best-effort.
+ }
+}
+
+const globalSessionsCache: Record = loadPersistedCache();
export function useGallery({ apiBaseUrl, tokens }: UseGalleryOptions): UseGallery {
const validTokens = tokens.filter((t) => /^[a-f0-9]{64}$/i.test(t));
@@ -83,33 +113,17 @@ export function useGallery({ apiBaseUrl, tokens }: UseGalleryOptions): UseGaller
.then((results) => ({ sessions: results.flatMap((r) => r.sessions ?? []) }))
.then((data: { sessions: SessionSummary[] }) => {
if (!cancelled) {
+ // Thumbnail URLs are permanent (/api/media/:id/thumbnail.jpg) and
+ // the endpoint serves proper cache headers, so the browser HTTP
+ // cache handles image reuse — just store the latest summaries.
const now = Date.now();
- const THUMBNAIL_EXPIRY = 45 * 60 * 1000; // 45 mins
-
- const mergedSessions = (data.sessions ?? []).map(newSession => {
- const cached = globalSessionsCache[newSession.token];
- let thumbnailUrl = newSession.thumbnailUrl;
- let fetchedAt = now;
-
- if (cached && cached.summary.thumbnailUrl) {
- const isImageSame = newSession.screenshotCount === cached.summary.screenshotCount;
- const isFresh = now - cached.thumbnailUrlFetchedAt < THUMBNAIL_EXPIRY;
-
- if (isImageSame && isFresh) {
- thumbnailUrl = cached.summary.thumbnailUrl;
- fetchedAt = cached.thumbnailUrlFetchedAt;
- }
- }
-
- const resultSession = { ...newSession, thumbnailUrl };
- globalSessionsCache[newSession.token] = {
- summary: resultSession,
- thumbnailUrlFetchedAt: fetchedAt
- };
- return resultSession;
- });
-
- setSessions(mergedSessions);
+ const newSessions = data.sessions ?? [];
+ for (const session of newSessions) {
+ globalSessionsCache[session.token] = { summary: session, fetchedAt: now };
+ }
+ persistCache(globalSessionsCache);
+
+ setSessions(newSessions);
setError(null);
}
})
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/hooks/useLookout.ts b/clients/react/src/hooks/useLookout.ts
index b2030848..770b78bf 100644
--- a/clients/react/src/hooks/useLookout.ts
+++ b/clients/react/src/hooks/useLookout.ts
@@ -1,12 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ CLIP_FRAME_INTERVAL_MS,
+ CLIP_FIRST_CUT_DELAY_MS,
+ MAX_CLIP_UPLOAD_FAILURES,
+} from "@lookout/shared";
import { useLookoutContext } from "../LookoutProvider.js";
import { useScreenCapture } from "./useScreenCapture.js";
import { useCameraCapture } from "./useCameraCapture.js";
-import { useUploader } from "./useUploader.js";
+import {
+ useUploader,
+ ClipFormatRejectedError,
+ type UploadPayload,
+ type UploadConfirmResult,
+} from "./useUploader.js";
import { useSession } from "./useSession.js";
import { useSessionTimer } from "./useSessionTimer.js";
import { useSilentAudioKeepAlive } from "./useSilentAudioKeepAlive.js";
import { computeBestTrackedSeconds } from "./computeBestTracked.js";
+import { ClipRecorder } from "./clipRecorder.js";
import type { LookoutState, LookoutActions, RecorderStatus } from "../types.js";
/**
@@ -49,6 +60,9 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
// Holds either a setInterval ID (legacy bucket-mode fallback) or
// setTimeout ID (credit-mode self-scheduling chain). Cleared on unmount.
const intervalRef = useRef | null>(null);
+ // Clip recorder for sessions with clips enabled (screen mode only).
+ // Null = classic one-JPEG-per-minute captures.
+ const clipRecorderRef = useRef(null);
const capturingRef = useRef(false);
const prevStatusRef = useRef(session.status);
const intentionalPauseRef = useRef(false);
@@ -108,33 +122,144 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
capturingRef.current = true;
let cancelled = false;
- // Serial capture-upload chain — matches the desktop Rust loop in
- // `clients/desktop/src-tauri/src/lib.rs::capture_loop_task`. Each
- // tick takes a screenshot, awaits the full upload+confirm round
- // trip, and reads the FRESH `nextExpectedAt` from THIS capture's
- // own confirm response. No shared ref, no race.
+ // Clip mode: the session accepts clips (known from the session fetch,
+ // BEFORE any upload), this browser can encode them, and we're capturing
+ // the screen (camera mode stays on JPEG). The recorder starts grabbing
+ // frames immediately so the very first upload is already a clip — the
+ // compiled timelapse has motion from second zero, never a still.
+ const frameIntervalMs = session.frameIntervalMs ?? CLIP_FRAME_INTERVAL_MS;
+ let clipRecorder: ClipRecorder | null = null;
+ if (
+ captureMode !== "camera" &&
+ session.clipsEnabled &&
+ ClipRecorder.isSupported()
+ ) {
+ const video = screenCapture.getVideo();
+ if (video) {
+ try {
+ clipRecorder = new ClipRecorder(video, frameIntervalMs, {
+ maxWidth: config.capture.maxWidth,
+ maxHeight: config.capture.maxHeight,
+ jpegQuality: config.capture.jpegQuality,
+ // Denser cadence for the short opening clip, which is cut after
+ // CLIP_FIRST_CUT_DELAY_MS — well under one frame interval — so
+ // that first upload carries a few frames rather than one.
+ openingFrameIntervalMs: Math.max(
+ 500,
+ Math.round(CLIP_FIRST_CUT_DELAY_MS / 4),
+ ),
+ });
+ clipRecorder.start();
+ } catch (err) {
+ console.warn(
+ "[lookout] clip recorder unavailable, using JPEG captures:",
+ err,
+ );
+ clipRecorder = null;
+ }
+ }
+ }
+ clipRecorderRef.current = clipRecorder;
+
+ // Capture-upload chain — mirrors the desktop Rust loop in
+ // `clients/desktop/src-tauri/src/lib.rs::capture_loop_task`, including
+ // its concurrency shape.
//
- // As long as the round trip stays under config.capture.intervalMs,
- // captures land exactly on the server's authoritative schedule. If
- // it exceeds the interval, delay clamps to 0 (one catch-up fire)
- // and the next cycle is back on schedule.
- const tick = async () => {
- if (cancelled) return;
- let nextExpectedAt: string | null = null;
+ // The upload runs CONCURRENTLY with recording rather than blocking it.
+ // The previous version awaited the full round trip before scheduling the
+ // next tick, which made every second of upload latency a second the
+ // recorder wasn't cutting on schedule. On a slow uplink that compounds:
+ // clips stretch to cover minutes each (a clip renders as ONE second of
+ // video however long it took to record, so that footage is genuinely
+ // lost), capturedAt drifts past the server's ±30s streak window so the
+ // minute credits nothing, and the oversized clip is refused on arrival.
+ // Uploading off the critical path keeps the cut cadence tied to the
+ // clock instead of to the network.
+ //
+ // Strictly ONE upload in flight, exactly as desktop does it: the next
+ // tick settles the previous upload before cutting. That preserves
+ // capturedAt monotonicity and the per-session rate-limit assumptions,
+ // and it is what stops a bad connection from fanning out into parallel
+ // uploads that make the congestion worse.
+ let inFlight: Promise | null = null;
+
+ // Clip-failure accounting, mirroring the desktop loop's latch. Clips are
+ // an enhancement; a JPEG a minute is the contract. Anything that makes
+ // clips unworkable must degrade to that instead of costing the user
+ // minutes, however many devices and browsers this runs on.
+ let clipFailures = 0;
+ const disableClips = (why: string) => {
+ if (!clipRecorderRef.current) return;
+ console.warn(
+ `[lookout] ${why} — recording one JPEG per minute for the rest of ` +
+ `this session.`,
+ );
+ clipRecorderRef.current.stop();
+ clipRecorderRef.current = null;
+ };
+
+ /**
+ * Upload a capture, and if a CLIP upload fails, retry the same tick as a
+ * single JPEG.
+ *
+ * The retry reuses the clip's own cut-time JPEG snapshot and — critically
+ * — its `capturedAtMs`, so the capture still lands inside the server's
+ * ±30s streak window and the minute credits. Without this a failed clip
+ * upload cost the whole minute: the desktop client had the fallback, the
+ * web client didn't.
+ */
+ const uploadWithFallback = async (
+ payload: UploadPayload,
+ ): Promise => {
+ const isClip = payload.format != null && payload.format !== "jpeg";
try {
- const captureResult = await takeScreenshotRef.current();
- if (captureResult) {
- callbacksRef.current.onCapture?.(captureResult);
- const result = await captureUploadConfirmRef.current(captureResult);
- nextExpectedAt = result.nextExpectedAt;
+ const result = await captureUploadConfirmRef.current(payload);
+ if (isClip) clipFailures = 0; // a clip landed: earlier trouble was transient
+ return result;
+ } catch (err) {
+ if (!isClip) throw err;
+
+ // The session no longer accepts clips. Retrying is pointless.
+ if (err instanceof ClipFormatRejectedError) {
+ disableClips("the server no longer accepts clips for this session");
+ } else if (++clipFailures >= MAX_CLIP_UPLOAD_FAILURES) {
+ disableClips(
+ `${clipFailures} consecutive clip uploads failed`,
+ );
}
+
+ if (!payload.previewBlob) throw err;
+ console.warn("[lookout] clip upload failed — retrying as a JPEG:", err);
+ return await captureUploadConfirmRef.current({
+ blob: payload.previewBlob,
+ width: payload.width,
+ height: payload.height,
+ capturedAtMs: payload.capturedAtMs,
+ });
+ }
+ };
+
+ /** Await the in-flight upload, if any, and fold its result into the
+ * schedule. Returns the server's fresh nextExpectedAt, or null. */
+ const settleInFlight = async (): Promise => {
+ if (!inFlight) return null;
+ const pending = inFlight;
+ try {
+ return (await pending).nextExpectedAt;
} catch (err) {
- // Pipeline failure (network / server / 409). Schedule next tick
- // on the local fallback so the chain stays alive.
- console.warn("[lookout] capture cycle failed:", err);
+ // Pipeline failure (network / server / 409). The chain stays alive
+ // on the local fallback cadence; useUploader has already surfaced
+ // the error and any 409 conflict.
+ console.warn("[lookout] capture upload failed:", err);
+ return null;
+ } finally {
+ // Only clear if nothing newer replaced it.
+ if (inFlight === pending) inFlight = null;
}
- if (cancelled) return;
+ };
+ const scheduleNext = (nextExpectedAt: string | null) => {
+ if (cancelled) return;
const target = nextExpectedAt
? Date.parse(nextExpectedAt)
: Date.now() + config.capture.intervalMs;
@@ -145,10 +270,75 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
config.capture.intervalMs * 2,
Math.max(0, target - Date.now()),
);
+ if (intervalRef.current !== null) clearTimeout(intervalRef.current);
intervalRef.current = setTimeout(tick, delay);
};
- tick();
+ const tick = async () => {
+ if (cancelled) return;
+
+ // Settle the previous upload BEFORE cutting, so uploads stay ordered.
+ // Every step of it is deadline-bounded (UPLOAD_STEP_TIMEOUT_MS), so a
+ // dead socket can't park the loop here indefinitely.
+ let nextExpectedAt = await settleInFlight();
+ if (cancelled) return;
+
+ try {
+ // cut() finalizes the last interval's clip and immediately starts
+ // recording the next one. A null cut (empty clip, encoder hiccup)
+ // falls back to a single JPEG so the tick — and the session's
+ // credit streak — never skips a beat.
+ let payload: UploadPayload | null =
+ (await clipRecorderRef.current?.cut()) ?? null;
+ if (payload?.truncated) {
+ console.warn(
+ "[lookout] clip hit its frame cap — uploads are running behind " +
+ "the capture cadence, so this clip covers a longer window.",
+ );
+ }
+ if (!payload) {
+ payload = await takeScreenshotRef.current();
+ }
+ if (payload) {
+ callbacksRef.current.onCapture?.(payload);
+ // Fire and hold, don't await: recording of the next clip is
+ // already underway and must not wait on this.
+ inFlight = uploadWithFallback(payload);
+ // Refine the schedule the moment the confirm lands, if that
+ // happens before the next tick — the same role desktop's third
+ // select arm plays. Rejections are handled by settleInFlight;
+ // swallow here so this never becomes an unhandled rejection.
+ const pending = inFlight;
+ pending
+ .then((result) => {
+ if (cancelled || inFlight !== pending) return;
+ inFlight = null;
+ scheduleNext(result.nextExpectedAt);
+ })
+ .catch(() => {});
+ }
+ } catch (err) {
+ // Capture-side failure (canvas, encoder, no video). The upload
+ // path has its own handling.
+ console.warn("[lookout] capture cycle failed:", err);
+ }
+ if (cancelled) return;
+
+ // Provisional: one interval out, refined above when the confirm
+ // lands. When the upload outlives the interval the next tick settles
+ // it first, which is what keeps uploads serialized.
+ scheduleNext(nextExpectedAt);
+ };
+
+ if (clipRecorder) {
+ // Give the opening clip a few frames before the first cut. Fixed, not
+ // a multiple of the frame interval: this delay is how long the user
+ // waits for the session to activate, and the seed clip is dropped
+ // from the compiled video regardless.
+ intervalRef.current = setTimeout(tick, CLIP_FIRST_CUT_DELAY_MS);
+ } else {
+ tick();
+ }
return () => {
capturingRef.current = false;
@@ -157,8 +347,21 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
clearTimeout(intervalRef.current);
intervalRef.current = null;
}
+ clipRecorderRef.current?.stop();
+ clipRecorderRef.current = null;
};
- }, [capture.isSharing, isActive, config.capture.intervalMs]);
+ }, [
+ capture.isSharing,
+ isActive,
+ config.capture.intervalMs,
+ config.capture.maxWidth,
+ config.capture.maxHeight,
+ config.capture.jpegQuality,
+ captureMode,
+ session.clipsEnabled,
+ session.frameIntervalMs,
+ screenCapture.getVideo,
+ ]);
// Auto-resume when screen sharing *starts* while session is paused
// (e.g., user clicked "Share Screen & Resume" after a reload).
@@ -190,6 +393,8 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
+ clipRecorderRef.current?.stop();
+ clipRecorderRef.current = null;
callbacksRef.current.onShareStop?.();
}
// Both cases: pause the server session so it doesn't accumulate dead time
@@ -264,6 +469,10 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
+ // Discard the in-progress clip right away — don't keep grabbing
+ // frames while the pause request is in flight.
+ clipRecorderRef.current?.stop();
+ clipRecorderRef.current = null;
capturingRef.current = false;
try {
await session.pause();
@@ -285,17 +494,19 @@ 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) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
+ clipRecorderRef.current?.stop();
+ clipRecorderRef.current = null;
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/useScreenCapture.ts b/clients/react/src/hooks/useScreenCapture.ts
index 1b63e1d2..f8f37879 100644
--- a/clients/react/src/hooks/useScreenCapture.ts
+++ b/clients/react/src/hooks/useScreenCapture.ts
@@ -107,5 +107,10 @@ export function useScreenCapture(overrides?: CaptureSettings) {
setIsSharing(false);
}, []);
- return { isSharing, startSharing, takeScreenshot, stopSharing };
+ // Stable accessor for the live capture — for consumers (like the
+ // clip recorder) that need the current element inside effects/callbacks
+ // without re-render staleness.
+ const getVideo = useCallback(() => videoRef.current, []);
+
+ return { isSharing, startSharing, takeScreenshot, stopSharing, getVideo };
}
diff --git a/clients/react/src/hooks/useSession.ts b/clients/react/src/hooks/useSession.ts
index 3f99b6b7..caca59b3 100644
--- a/clients/react/src/hooks/useSession.ts
+++ b/clients/react/src/hooks/useSession.ts
@@ -11,6 +11,14 @@ interface SessionState {
startedAt: string | null;
createdAt: string | null;
totalActiveSeconds: number;
+ /** Whether this session accepts clip uploads (~6 frames/min video).
+ * Known BEFORE the first capture — this fetch is the session-recovery
+ * load — so the very first upload can already be a clip. False when
+ * the server predates clips. */
+ clipsEnabled: boolean;
+ /** Server-authoritative clip cadence (ms between frames). Null when the
+ * server predates clips. */
+ frameIntervalMs: number | null;
error: string | null;
}
@@ -26,6 +34,8 @@ export function useSession() {
startedAt: null,
createdAt: null,
totalActiveSeconds: 0,
+ clipsEnabled: false,
+ frameIntervalMs: null,
error: null,
});
@@ -54,6 +64,8 @@ export function useSession() {
startedAt: data.startedAt,
createdAt: data.createdAt,
totalActiveSeconds: data.totalActiveSeconds,
+ clipsEnabled: data.clipsEnabled === true,
+ frameIntervalMs: data.frameIntervalMs ?? null,
error: null,
});
@@ -130,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 {
@@ -144,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/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/hooks/useUploader.ts b/clients/react/src/hooks/useUploader.ts
index cc83c33b..c213a179 100644
--- a/clients/react/src/hooks/useUploader.ts
+++ b/clients/react/src/hooks/useUploader.ts
@@ -1,7 +1,12 @@
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
+import {
+ CAPTURE_FORMAT_CONTENT_TYPES,
+ ClockOffset,
+ type CaptureFormat,
+} from "@lookout/shared";
import { useLookoutContext } from "../LookoutProvider.js";
import { HttpError } from "../api/client.js";
-import type { CaptureResult, UploadState } from "../types.js";
+import type { UploadState } from "../types.js";
/** Whether to opt into credit-mode tracking by sending `capturedAt` to the
* server on every upload. The new desktop / web build enables this on new
@@ -26,6 +31,42 @@ async function retry(
throw new Error("Unreachable");
}
+/**
+ * The server granted a different format than the clip we hold.
+ *
+ * Distinct from a transient failure on purpose: it means the session's clip
+ * support went away underneath us, so retrying the same clip will fail
+ * identically forever. The capture loop reacts by switching the session to
+ * JPEG captures rather than burning a minute an hour on it.
+ */
+export class ClipFormatRejectedError extends Error {
+ readonly granted: CaptureFormat;
+ constructor(requested: CaptureFormat, granted: CaptureFormat) {
+ super(
+ `Server granted "${granted}" for a "${requested}" clip — switching to JPEG captures`,
+ );
+ this.name = "ClipFormatRejectedError";
+ this.granted = granted;
+ }
+}
+
+/** Unified upload payload: a single JPEG frame (format omitted/"jpeg")
+ * or a per-minute clip ("webm"/"mp4" from the ClipRecorder). */
+export interface UploadPayload {
+ blob: Blob;
+ width: number;
+ height: number;
+ capturedAtMs?: number;
+ format?: CaptureFormat;
+ /** Frames inside a clip. Omitted for JPEG captures. */
+ frameCount?: number;
+ /** Set when a clip hit its frame cap because uploads were running behind
+ * the capture cadence. Client-side telemetry only — never sent. */
+ truncated?: boolean;
+ /** JPEG used for the UI preview when `blob` isn't an image. */
+ previewBlob?: Blob | null;
+}
+
export interface UploadConfirmResult {
trackedSeconds: number;
nextExpectedAt: string;
@@ -36,7 +77,7 @@ export interface UploaderResult {
* fresh `nextExpectedAt` from THIS capture's confirm response.
* Throws on failure (after retries) — the caller (the capture-loop
* scheduler) catches and falls back to a local interval. */
- captureUploadConfirm: (capture: CaptureResult) => Promise;
+ captureUploadConfirm: (capture: UploadPayload) => Promise;
/** Current upload state. */
uploads: UploadState;
/** Server-reported tracked seconds after latest confirmation. */
@@ -80,22 +121,73 @@ export function useUploader(): UploaderResult {
const resetConflict = useCallback(() => setSessionConflict(false), []);
+ // Running estimate of how far this device's clock is from the server's.
+ // A ref, not state: it's read on the next capture, and a re-render on every
+ // upload would be pure noise. Every upload-url response carries the
+ // server's own clock, so the estimate improves once a minute for free.
+ const clockOffsetRef = useRef(new ClockOffset());
+
const captureUploadConfirm = useCallback(
- async (capture: CaptureResult): Promise => {
+ async (capture: UploadPayload): Promise => {
setUploads((s) => ({ ...s, pending: s.pending + 1 }));
try {
+ // Correct the capture moment into server time. A no-op for a healthy
+ // clock; for a skewed one it's the difference between every capture
+ // landing in the ±30s credit window and none of them doing so.
+ const localCapturedAtMs = capture.capturedAtMs ?? Date.now();
const capturedAt = ENABLE_CREDIT_MODE
- ? new Date(capture.capturedAtMs ?? Date.now()).toISOString()
+ ? new Date(clockOffsetRef.current.correct(localCapturedAtMs)).toISOString()
: undefined;
+ const format: CaptureFormat = capture.format ?? "jpeg";
- const { uploadUrl, screenshotId } = await retry(
- () => client.getUploadUrl({ capturedAt }),
+ const sentAt = Date.now();
+ const urlResponse = await retry(
+ () =>
+ client.getUploadUrl({
+ capturedAt,
+ format: format === "jpeg" ? undefined : format,
+ }),
maxRetries,
retryDelays,
);
+ // Fold the server's clock into the estimate. Bracketed by the local
+ // instants either side of the request so the round trip isn't charged
+ // to the offset.
+ if (urlResponse.serverTime) {
+ clockOffsetRef.current.observe(
+ urlResponse.serverTime,
+ sentAt,
+ Date.now(),
+ );
+ if (urlResponse.capturedAtAdopted) {
+ console.warn(
+ `[lookout] this device's clock is ~${Math.round(
+ clockOffsetRef.current.offset / 1000,
+ )}s off from the server, so that capture was stamped on arrival. ` +
+ `Later captures are corrected automatically.`,
+ );
+ }
+ }
+ const { uploadUrl, screenshotId } = urlResponse;
+ // Defense-in-depth: the capture loop only records clips when the
+ // session said clipsEnabled, so a downgrade here (granted format ≠
+ // requested) means server state changed under us. The presigned URL
+ // is signed for the granted content type — uploading the clip
+ // against it would fail the signature, so fail fast instead.
+ if (format !== "jpeg" && urlResponse.format !== format) {
+ throw new ClipFormatRejectedError(
+ format,
+ urlResponse.format ?? "jpeg",
+ );
+ }
await retry(
- () => client.uploadToR2(uploadUrl, capture.blob),
+ () =>
+ client.uploadToR2(
+ uploadUrl,
+ capture.blob,
+ CAPTURE_FORMAT_CONTENT_TYPES[format],
+ ),
maxRetries,
retryDelays,
);
@@ -107,16 +199,23 @@ export function useUploader(): UploaderResult {
width: capture.width,
height: capture.height,
fileSize: capture.blob.size,
+ ...(capture.frameCount ? { frameCount: capture.frameCount } : {}),
}),
maxRetries,
retryDelays,
);
setTrackedSeconds(result.trackedSeconds);
- setLastScreenshotUrl((prev) => {
- if (prev) URL.revokeObjectURL(prev);
- return URL.createObjectURL(capture.blob);
- });
+ // Clips aren't -renderable — preview with the cut-time JPEG
+ // snapshot instead, and keep the previous preview if none came.
+ const previewBlob =
+ format === "jpeg" ? capture.blob : capture.previewBlob ?? null;
+ if (previewBlob) {
+ setLastScreenshotUrl((prev) => {
+ if (prev) URL.revokeObjectURL(prev);
+ return URL.createObjectURL(previewBlob);
+ });
+ }
setUploads((s) => ({
...s,
pending: s.pending - 1,
diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts
index dfaa1ff1..e2f7a441 100644
--- a/clients/react/src/index.ts
+++ b/clients/react/src/index.ts
@@ -4,6 +4,21 @@ 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 { StopChoiceModal } from "./components/StopChoiceModal.js";
+export type { StopChoiceModalProps } from "./components/StopChoiceModal.js";
+export { useEditLease } from "./hooks/useEditLease.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";
@@ -24,7 +39,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";
@@ -35,8 +50,21 @@ export { useLookout } from "./hooks/useLookout.js";
export { useScreenCapture } from "./hooks/useScreenCapture.js";
export { useCameraCapture } from "./hooks/useCameraCapture.js";
export { useUploader } from "./hooks/useUploader.js";
+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";
@@ -68,8 +96,9 @@ export type {
} from "./types.js";
// Re-export shared types consumers need
-export type { SessionStatus, SessionSummary } from "@lookout/shared";
+export type { SessionStatus, SessionSummary, CutInterval } from "@lookout/shared";
export { SESSION_STATUSES } from "@lookout/shared";
// UI primitives
export * from "./ui/index.js";
+export { setAccentColor } from "./ui/theme.js";
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/react/src/ui/Button.tsx b/clients/react/src/ui/Button.tsx
index e53e1823..ab6d9830 100644
--- a/clients/react/src/ui/Button.tsx
+++ b/clients/react/src/ui/Button.tsx
@@ -13,7 +13,7 @@ export interface ButtonProps extends React.ButtonHTMLAttributes = {
- primary: { background: colors.status.info, color: "#fff", border: "1px solid transparent" },
+ primary: { background: colors.accent.base, color: colors.accent.on, border: "1px solid transparent" },
success: { background: colors.status.success, color: "#fff", border: "1px solid transparent" },
danger: { background: colors.status.danger, color: "#fff", border: "1px solid transparent" },
warning: { background: colors.status.warning, color: "#000", border: "1px solid transparent" },
@@ -44,7 +44,17 @@ export function Button({
const idleBackground = background ?? variantStyles[variant].background;
const idleBorder = border ?? variantStyles[variant].border;
const hoverBackground =
- background ?? (variant === "ghost" ? colors.bg.selected : variant === "secondary" ? colors.bg.surface : variantStyles[variant].background);
+ background ??
+ (variant === "ghost"
+ ? colors.bg.selected
+ : variant === "secondary"
+ ? colors.bg.surface
+ : // The accent darkens on hover. Derived with color-mix so a
+ // brand colour supplied by an embedder gets a matching hover
+ // state without them having to provide a second shade.
+ variant === "primary"
+ ? colors.accent.hover
+ : variantStyles[variant].background);
const hoverBorder =
border ?? (variant === "ghost" ? "1px solid transparent" : variantStyles[variant].border);
diff --git a/clients/react/src/ui/Card.tsx b/clients/react/src/ui/Card.tsx
index ebc05f9b..a8785180 100644
--- a/clients/react/src/ui/Card.tsx
+++ b/clients/react/src/ui/Card.tsx
@@ -5,11 +5,12 @@ import { colors, radii } from "./theme.js";
export interface CardProps {
children: React.ReactNode;
onClick?: () => void;
+ onContextMenu?: (e: React.MouseEvent) => void;
padding?: number | string;
style?: React.CSSProperties;
}
-export function Card({ children, onClick, padding, style }: CardProps) {
+export function Card({ children, onClick, onContextMenu, padding, style }: CardProps) {
const content = (
+ {content}
+
+ ) : (
+ content
+ );
}
return (
+ m
+
+ );
+ }
+ return (
+
+ h{" "}
+ m
+
+ );
+}
diff --git a/clients/react/src/ui/Overlay.tsx b/clients/react/src/ui/Overlay.tsx
new file mode 100644
index 00000000..fd3adca1
--- /dev/null
+++ b/clients/react/src/ui/Overlay.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { motion } from "motion/react";
+import { colors, spacing } from "./theme.js";
+
+export interface OverlayProps {
+ children: ReactNode;
+ /** Accessible name for the dialog. */
+ label: string;
+ /** Panel width/height. Numbers are px; strings pass through, so callers
+ * can clamp against the viewport. */
+ width?: number | string;
+ height?: number | string;
+ /** Called on backdrop click / Escape. Omit for a dialog that can only be
+ * left through its own actions. */
+ onDismiss?: () => void;
+}
+
+/**
+ * A centred modal panel over a backdrop.
+ *
+ * Rendered through a portal to `document.body` rather than in place. The
+ * SDK gets dropped into pages we don't control, and `position: fixed`
+ * silently resolves against the nearest transformed/filtered ancestor
+ * instead of the viewport — so an embedder with a `transform` anywhere up
+ * the tree would otherwise get a modal pinned inside their card.
+ */
+export function Overlay({
+ children,
+ label,
+ width = "min(1100px, 94vw)",
+ height,
+ onDismiss,
+}: OverlayProps) {
+ const panelRef = useRef(null);
+
+ useEffect(() => {
+ // Move focus into the dialog so keyboard users aren't left behind it.
+ panelRef.current?.focus();
+ const prevOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = prevOverflow;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!onDismiss) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onDismiss();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onDismiss]);
+
+ if (typeof document === "undefined") return null;
+
+ return createPortal(
+ {
+ if (onDismiss && e.target === e.currentTarget) onDismiss();
+ }}
+ >
+
+ {children}
+
+
,
+ document.body,
+ );
+}
diff --git a/clients/react/src/ui/ProgressRing.tsx b/clients/react/src/ui/ProgressRing.tsx
new file mode 100644
index 00000000..742caeed
--- /dev/null
+++ b/clients/react/src/ui/ProgressRing.tsx
@@ -0,0 +1,82 @@
+import NumberFlow from "@number-flow/react";
+import { colors, fontSize, fontWeight } from "./theme.js";
+
+export interface ProgressRingProps {
+ /** 0–1. Values outside are clamped. */
+ progress: number;
+ size?: number;
+ strokeWidth?: number;
+ /** Percentage shown in the centre, with rolling digits. Omit for a
+ * bare ring. */
+ showPercent?: boolean;
+ 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,
+ showPercent = false,
+ 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 (
+
+
+
+
+
+ {showPercent && (
+
+ %
+
+ )}
+
+ );
+}
diff --git a/clients/react/src/ui/index.ts b/clients/react/src/ui/index.ts
index 6a8c2e8b..cd1117ef 100644
--- a/clients/react/src/ui/index.ts
+++ b/clients/react/src/ui/index.ts
@@ -2,6 +2,10 @@ 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 { MinutesFlow } from "./MinutesFlow.js";
+export type { MinutesFlowProps } from "./MinutesFlow.js";
+export type { ProgressRingProps } from "./ProgressRing.js";
export { Badge } from "./Badge.js";
export type { BadgeProps } from "./Badge.js";
export { ErrorDisplay } from "./ErrorDisplay.js";
@@ -9,6 +13,8 @@ export type { ErrorDisplayProps } from "./ErrorDisplay.js";
export { Card } from "./Card.js";
export type { CardProps } from "./Card.js";
export { PageContainer } from "./PageContainer.js";
+export { Overlay } from "./Overlay.js";
+export type { OverlayProps } from "./Overlay.js";
export type { PageContainerProps } from "./PageContainer.js";
export { Skeleton, GallerySkeleton, SessionDetailSkeleton, RecordPageSkeleton } from "./Skeleton.js";
export type { SkeletonProps } from "./Skeleton.js";
diff --git a/clients/react/src/ui/theme.ts b/clients/react/src/ui/theme.ts
index d0ef70ea..d39f425d 100644
--- a/clients/react/src/ui/theme.ts
+++ b/clients/react/src/ui/theme.ts
@@ -36,6 +36,24 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--color-archive-border: rgba(255, 255, 255, 0.1);
--color-archive-hover-bg: rgba(255, 255, 255, 0.1);
--color-archive-hover-border: rgba(255, 255, 255, 0.2);
+ /* Editor: a recessed well the footage sits in, and the removed-region
+ vocabulary. Deliberately translucent so the window's vibrancy still
+ reads through the chrome. */
+ --color-well: rgba(0, 0, 0, 0.45);
+ --color-well-border: rgba(255, 255, 255, 0.08);
+ --color-cut-fill: rgba(248, 113, 113, 0.26);
+ --color-cut-fill-hover: rgba(248, 113, 113, 0.36);
+ --color-cut-border: #f87171;
+ --color-cut-stripe: rgba(248, 113, 113, 0.13);
+ --color-track: rgba(255, 255, 255, 0.06);
+ /* Accent: the one colour an embedding program can replace. Drives
+ primary buttons, focus rings, and progress. Semantic status
+ colours (success/warning/danger) stay put — those carry meaning,
+ not brand. */
+ --color-accent: #3b82f6;
+ --color-accent-hover: #2f6fd0;
+ --color-accent-hover: color-mix(in oklab, var(--color-accent) 88%, black);
+ --color-on-accent: #ffffff;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
@@ -69,6 +87,17 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--color-archive-border: rgba(0, 0, 0, 0.1);
--color-archive-hover-bg: rgba(255, 255, 255, 1);
--color-archive-hover-border: rgba(0, 0, 0, 0.2);
+ --color-well: rgba(0, 0, 0, 0.10);
+ --color-well-border: rgba(0, 0, 0, 0.08);
+ --color-cut-fill: rgba(220, 38, 38, 0.20);
+ --color-cut-fill-hover: rgba(220, 38, 38, 0.30);
+ --color-cut-border: #dc2626;
+ --color-cut-stripe: rgba(220, 38, 38, 0.12);
+ --color-track: rgba(0, 0, 0, 0.06);
+ --color-accent: #3b82f6;
+ --color-accent-hover: #2f6fd0;
+ --color-accent-hover: color-mix(in oklab, var(--color-accent) 88%, black);
+ --color-on-accent: #ffffff;
}
}
:root[data-theme="light"] {
@@ -102,6 +131,17 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--color-archive-border: rgba(0, 0, 0, 0.1);
--color-archive-hover-bg: rgba(255, 255, 255, 1);
--color-archive-hover-border: rgba(0, 0, 0, 0.2);
+ --color-well: rgba(0, 0, 0, 0.10);
+ --color-well-border: rgba(0, 0, 0, 0.08);
+ --color-cut-fill: rgba(220, 38, 38, 0.20);
+ --color-cut-fill-hover: rgba(220, 38, 38, 0.30);
+ --color-cut-border: #dc2626;
+ --color-cut-stripe: rgba(220, 38, 38, 0.12);
+ --color-track: rgba(0, 0, 0, 0.06);
+ --color-accent: #3b82f6;
+ --color-accent-hover: #2f6fd0;
+ --color-accent-hover: color-mix(in oklab, var(--color-accent) 88%, black);
+ --color-on-accent: #ffffff;
}`;
document.head.appendChild(style);
}
@@ -112,6 +152,25 @@ export const colors = {
border: { default: "var(--color-border-default)", hover: "var(--color-border-hover)", selected: "var(--color-border-selected)" },
icon: { selected: "var(--color-icon-selected)" },
spinner: { base: "var(--color-spinner-base)", track: "var(--color-spinner-track)" },
+ /** The brand accent. Replaceable per-app via `` or {@link setAccentColor}. */
+ accent: {
+ base: "var(--color-accent)",
+ hover: "var(--color-accent-hover)",
+ /** Text/icon colour that sits ON the accent. */
+ on: "var(--color-on-accent)",
+ },
+ /** Editor surfaces: the recessed well footage sits in, the timeline
+ * track, and the removed-region vocabulary. */
+ editor: {
+ well: "var(--color-well)",
+ wellBorder: "var(--color-well-border)",
+ track: "var(--color-track)",
+ cutFill: "var(--color-cut-fill)",
+ cutFillHover: "var(--color-cut-fill-hover)",
+ cutBorder: "var(--color-cut-border)",
+ cutStripe: "var(--color-cut-stripe)",
+ },
skeleton: { bg: "var(--color-skeleton-bg)", shimmer: "var(--color-skeleton-shimmer)" },
badge: {
primaryBg: "var(--color-badge-primary-bg)",
@@ -143,3 +202,26 @@ export const statusConfig: Record = {
complete: { label: "Complete", color: colors.status.success },
failed: { label: "Failed", color: colors.status.danger },
};
+
+/**
+ * Replace the accent colour for every Lookout surface on the page.
+ *
+ * Set on the document root rather than a wrapper, because the overlays
+ * portal to `document.body` and would otherwise fall outside a scoped
+ * subtree. `null` restores the default.
+ *
+ * `on` is the colour drawn on top of the accent (button labels). It can't
+ * be derived reliably in CSS, so pass it when the brand colour is light
+ * enough that white text would be unreadable.
+ */
+export function setAccentColor(
+ accent: string | null,
+ on?: string | null,
+): void {
+ if (typeof document === "undefined") return;
+ const root = document.documentElement;
+ if (accent) root.style.setProperty("--color-accent", accent);
+ else root.style.removeProperty("--color-accent");
+ if (on) root.style.setProperty("--color-on-accent", on);
+ else root.style.removeProperty("--color-on-accent");
+}
diff --git a/clients/web/package.json b/clients/web/package.json
index 4b613619..814ba287 100644
--- a/clients/web/package.json
+++ b/clients/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@lookout/web",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
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/docker-compose.coolify.yml b/docker-compose.coolify.yml
new file mode 100644
index 00000000..331eb9c8
--- /dev/null
+++ b/docker-compose.coolify.yml
@@ -0,0 +1,79 @@
+# Coolify variant of docker-compose.prod.yml: no host port binding —
+# Coolify's proxy (Traefik) routes the assigned domain to the server
+# container over the Docker network, so binding host port 3000 would
+# only conflict with whatever else runs on the box. Point Coolify's
+# "Docker Compose Location" at this file.
+services:
+ server:
+ build:
+ context: .
+ dockerfile: Dockerfile.server
+ expose:
+ - "3000"
+ environment:
+ - DATABASE_URL=postgresql://lookout:${POSTGRES_PASSWORD:?}@postgres:5432/lookout
+ - R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
+ - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
+ - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
+ - R2_BUCKET_NAME=${R2_BUCKET_NAME}
+ - R2_PUBLIC_DOMAIN=${R2_PUBLIC_DOMAIN}
+ # Admin dashboard (/admin) for managing per-program API keys. Required to
+ # mint program keys; leave unset to disable the dashboard.
+ - ADMIN_USERNAME=${ADMIN_USERNAME}
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD}
+ - BASE_URL=${BASE_URL:-http://localhost:3000}
+ - PORT=3000
+ networks:
+ - frontend
+ - backend
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: unless-stopped
+
+ worker:
+ build:
+ context: .
+ dockerfile: Dockerfile.worker
+ environment:
+ - DATABASE_URL=postgresql://lookout:${POSTGRES_PASSWORD:?}@postgres:5432/lookout
+ - R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
+ - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
+ - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
+ - R2_BUCKET_NAME=${R2_BUCKET_NAME}
+ - R2_PUBLIC_DOMAIN=${R2_PUBLIC_DOMAIN}
+ networks:
+ - backend
+ deploy:
+ resources:
+ limits:
+ memory: 4G
+ cpus: "2.0"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: unless-stopped
+
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: lookout
+ POSTGRES_USER: lookout
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ networks:
+ - backend
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U lookout"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ pgdata:
+
+networks:
+ frontend:
+ backend:
diff --git a/docs/edit-feature-plan.md b/docs/edit-feature-plan.md
new file mode 100644
index 00000000..c6e2f9b0
--- /dev/null
+++ b/docs/edit-feature-plan.md
@@ -0,0 +1,393 @@
+# Edit feature (cuts) — implementation plan v3
+
+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:
+
+```
+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
+ └ lease lapses (~2 min unrenewed) ─────> complete
+```
+
+**The hold is a lease, not a countdown.** Whatever surface represents
+active editing — the editor, the review panel — renews it every 30s via
+`POST /:token/editing`, and the server holds the session 120s past the last
+renewal. A fixed deadline was wrong in both directions: it cut off someone
+carefully trimming a long recording, and left an abandoned session
+unpublished for half an hour. A lease has neither failure. An absolute
+ceiling (`EDIT_HOLD_MAX_MINUTES`, from the stop) bounds an editor left open
+overnight.
+
+**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 once the lease lapses, 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,
+ 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` |
+
+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
+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 — 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` | Lease deadline. While set and in the future the compiled video stays unpublished (`status` `stopped`, `video_r2_key` null) so the owner can cut it. Extended by each `POST /:token/editing`; cleared by the publish call or the expiry job |
+
+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.
+
+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 → opens a lease (`edit_hold_until = now +
+ EDIT_LEASE_SECONDS`) 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`.
+6. **`GET /api/sessions/:token`**, **`/status`**, **`/batch`**, internal
+ session endpoint — add `cuts`, `cutSeconds`, `uncutTrackedSeconds`,
+ `editable`, `editHoldUntil`. `trackedSeconds` becomes post-cut
+ everywhere via the dispatcher.
+7. **`POST /api/sessions/:token/editing`** — renew the lease. Extends
+ `edit_hold_until` to `now + EDIT_LEASE_SECONDS`, bounded by the ceiling.
+ Renews even through a term that lapsed seconds ago (a network stall
+ shouldn't end an edit), but the `status IN ('stopped','compiling')`
+ guard means it can never resurrect a published session. Returns
+ `held: false` once the session is out, so clients stop renewing.
+8. **Lease expiry** (`lib/timeouts.ts`, on the existing every-minute cron):
+ publish any `stopped` session whose lease lapsed **or** that passed the
+ ceiling, via the shared `publishHeldSession` helper. This is what makes
+ offering the edit step safe — the hold delays publication, never
+ cancels it.
+
+## 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
+ `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 + 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.
+- `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`, 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`, 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, 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
+
+`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 = ``. Scrubbing is native video
+ seeking; the filmstrip is generated client-side by seeking a second
+ hidden video element and drawing frames to canvas (the proven Lapse
+ `makeFilmstrip` approach) — no extra endpoints, works for jpeg and clip
+ sessions identically.
+ - Timeline is the video's own time axis (1 s per minute), with a
+ wall-clock ruler derived from `video_units` and gap markers where
+ consecutive units are > ~90 s apart (pauses).
+ - **Region-based cutting** (the settled UX): drag on the timeline creates
+ a cut region in one gesture; regions are first-class objects with edge
+ handles, selection, delete-key removal; plain click seeks; ruler lane
+ owns scrubbing; while dragging an edge the preview shows the boundary
+ frame. Edges snap to whole seconds (= unit boundaries, inherent) and to
+ pause gaps. Playback preview mode skips cut regions (jump
+ `currentTime` past them on `timeupdate`); scrubbing moves through them
+ (dimmed) so edges can be judged.
+ - Region ↔ interval serialization: selected units `[i..j]` →
+ `start = video_units[i].capturedAt`,
+ `end = video_units[j].capturedAt + 60 s`; server normalizes.
+ - 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.
+ - **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. While mounted it holds the
+ edit lease, so there is no deadline to race — the copy just states
+ that nothing is published until you save.
+ - 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.
+- 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), holds the lease while it's showing — reading the
+ panel for two minutes must not publish underneath the reader — and
+ suppresses the compile spinner there, since "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 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`)
+
+- 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
+
+- `packages/server/API.md`: new endpoints/fields, membership rule, cut
+ semantics, recompile limits, retention of originals.
+- `docs/integration.md`: "Edits and cuts" section for program authors —
+ `trackedSeconds` now reflects cuts, `/timings` filters by default, new
+ fields (`cuts`, `cutSeconds`, `uncutTrackedSeconds`), stale-cache note,
+ and that adopting requires nothing.
+- `clients/react/API.md`: ``, new client methods.
+
+## 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_LEASE_SECONDS`, `EDIT_HEARTBEAT_SECONDS`, `EDIT_HOLD_MAX_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.
+
+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
+
+- **Bucket-mode / legacy sessions**: membership falls back to
+ `requested_at`; `cut_seconds` = distinct cut minute-buckets × 60.
+- **Mixed-format sessions**: irrelevant post-compile — cuts operate on the
+ compiled video's second grid.
+- **Build-failure holes**: `video_units` records what's actually in the
+ video, so the mapping stays exact even when compile skipped undecodable
+ units.
+- **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 e6917b18..fe0591ce 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -62,6 +62,68 @@ Response:
- `sessionId` — the server-side ID.
- `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` — set `false` to opt this session OUT of [clips](#clips-6-frames-per-minute) and back to 1 JPEG/min. Default `true` (~6 frames/min video → 6× smoother timelapses); 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 (6 frames per minute)
+
+Sessions record **clips** by default: instead of one JPEG per minute, the
+recording client uploads one ~60s video file per minute containing ~6 frames
+captured 10s apart. The compiled timelapse has the same length but is 6×
+smoother, with motion from the very first second. Pass `"clips": false` at
+creation to opt out.
+
+What this means for your program:
+
+- **Nothing in your integration changes.** A clip is still one capture unit
+ per minute — `trackedSeconds`, `screenshotCount`, `/timings` (still one
+ timestamp per minute → Hackatime forwarding unchanged), `videoUrl`, and
+ every response shape are identical between clips and non-clips sessions.
+- **Clients negotiate automatically.** The hosted web recorder and React SDK
+ (≥0.4) detect the flag on the session and record clips; older clients and
+ the desktop app keep uploading JPEGs to the same session, which stays fully
+ valid (formats can even mix within one session).
+- **Network:** a clip is capped at 8 MB/min server-side. At 6 frames/min a
+ typical screen measures ~1.1 MB/min and a deliberately incompressible one
+ ~1.4 MB/min — under half of what the same content cost at 15 frames/min,
+ since bandwidth scales with the frame count and the per-frame quality
+ budget is held constant.
+- **Frame quality:** clip frames are bitrate-capped rather than encoded
+ independently, but the budget is sized per frame to hold q0.85-JPEG-class
+ detail at 1080p even on busy screens, and it is rescaled whenever the
+ cadence changes — so frames stay legible at any frame rate. For review
+ purposes you get 6× more moments per minute.
+- The flag is per session, so you can disable it for a fraction of new
+ sessions and compare, or turn it off entirely for a program that needs the
+ legacy payload.
### Get session info
@@ -389,6 +451,61 @@ Notes:
> **Note:** The original screenshot images are only retained for 7 days after a session stops, after which the JPEGs are deleted from storage. The capture timestamps (and the compiled video and thumbnail) are kept.
+## Edits and cuts
+
+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.
+
+**You get this for free.** It ships inside the recorder, so any program
+that redirects users to the hosted recorder, or embeds
+``, already has it: the stop button opens the choice
+dialog, and picking "Edit & save" opens the editor as a modal over your
+page. No code change, no new version to adopt, nothing to call. Both
+dialogs render into `document.body`, so they aren't constrained by the
+width of the container you put the recorder in.
+
+The exception is a program driving the headless `useLookout()` hook with
+its own recording UI. That UI owns its own stop button, so it opts in by
+passing `actions.stop({ edit: true })` and rendering ``
+(see the [SDK reference](../clients/react/API.md)).
+
+What this means for your program:
+
+- **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 is a lease the
+ open editor renews, not a fixed deadline: editing takes as long as it
+ takes, and once nothing is renewing it (window closed, app quit) the
+ session publishes as recorded within about two minutes. It can delay
+ publication, never cancel it. If you poll, treat a slightly longer
+ `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.
+- **Matching your brand:** SDK embedders can pass
+ `` to replace Lookout's blue on
+ primary buttons, focus rings, and progress. See the
+ [SDK reference](../clients/react/API.md).
+
## Client telemetry
Every recording client reports a free-form **client info** string on each `upload-url` request (query param `clientInfo`). It's like an HTTP User-Agent but with Lookout-specific info — for telemetry and debugging. The server stores it opaquely (never parses it) and surfaces the session's first recorded value as `clientInfo` on `GET /api/sessions/:token`, the timings endpoint, and the internal admin endpoint.
diff --git a/package-lock.json b/package-lock.json
index e9629aaa..acf031fe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,8 @@
"packages/worker",
"clients/react",
"clients/web",
- "clients/desktop"
+ "clients/desktop",
+ "clients/playground"
],
"devDependencies": {
"concurrently": "^9.2.1"
@@ -20,12 +21,13 @@
},
"clients/desktop": {
"name": "@lookout/desktop",
- "version": "0.2.11",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@lookout/react": "*",
"@lookout/shared": "*",
- "@number-flow/react": "^0.6.0",
+ "@number-flow/react": "^0.6.2",
+ "@phosphor-icons/react": "^2.1.10",
"@sentry/react": "^10.45.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-deep-link": "^2.4.7",
@@ -51,12 +53,32 @@
"vite": "^6.0.0"
}
},
+ "clients/playground": {
+ "name": "@lookout/playground",
+ "version": "0.3.7",
+ "license": "AGPL-3.0-or-later",
+ "dependencies": {
+ "@lookout/react": "*",
+ "@lookout/shared": "*",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0"
+ }
+ },
"clients/react": {
"name": "@lookout/react",
- "version": "0.2.11",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@lookout/shared": "*",
+ "@number-flow/react": "^0.6.2",
+ "@phosphor-icons/react": "^2.1.10",
"@squircle-js/react": "^1.3.0",
"@videojs/react": "^10.0.0-beta.8",
"motion": "^12.38.0"
@@ -79,7 +101,7 @@
},
"clients/web": {
"name": "@lookout/web",
- "version": "0.2.11",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@lookout/react": "*",
@@ -2483,6 +2505,10 @@
"resolved": "clients/desktop",
"link": true
},
+ "node_modules/@lookout/playground": {
+ "resolved": "clients/playground",
+ "link": true
+ },
"node_modules/@lookout/react": {
"resolved": "clients/react",
"link": true
@@ -2525,13 +2551,13 @@
"license": "MIT"
},
"node_modules/@number-flow/react": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.0.tgz",
- "integrity": "sha512-77Yfc9+zkV2UDSP8phhZzxJGuwxi/Tt1TikmipL+1r3e9GFKEYDZ1XwInj67NoSt3OnOB0KLvvcl3lfPZgBHVQ==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.2.tgz",
+ "integrity": "sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4",
- "number-flow": "0.6.0"
+ "number-flow": "0.6.2"
},
"peerDependencies": {
"react": "^18 || ^19",
@@ -3047,6 +3073,19 @@
"@opentelemetry/api": "^1.1.0"
}
},
+ "node_modules/@phosphor-icons/react": {
+ "version": "2.1.10",
+ "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
+ "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8",
+ "react-dom": ">= 16.8"
+ }
+ },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -5443,16 +5482,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
- "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.6",
- "@vitest/utils": "4.1.6",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -5461,13 +5500,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
- "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.6",
+ "@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -5488,9 +5527,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
- "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5501,13 +5540,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
- "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.6",
+ "@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
@@ -5515,14 +5554,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
- "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.6",
- "@vitest/utils": "4.1.6",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -5531,9 +5570,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
- "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -5541,13 +5580,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
- "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.6",
+ "@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -7442,9 +7481,9 @@
"license": "MIT"
},
"node_modules/number-flow": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.0.tgz",
- "integrity": "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.2.tgz",
+ "integrity": "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4"
@@ -9142,7 +9181,6 @@
"os": [
"aix"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9160,7 +9198,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9178,7 +9215,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9196,7 +9232,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9214,7 +9249,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9232,7 +9266,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9250,7 +9283,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9268,7 +9300,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9286,7 +9317,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9304,7 +9334,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9322,7 +9351,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9340,7 +9368,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9358,7 +9385,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9376,7 +9402,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9394,7 +9419,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9412,7 +9436,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9430,7 +9453,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9448,7 +9470,6 @@
"os": [
"netbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9466,7 +9487,6 @@
"os": [
"netbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9484,7 +9504,6 @@
"os": [
"openbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9502,7 +9521,6 @@
"os": [
"openbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9520,7 +9538,6 @@
"os": [
"openharmony"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9538,7 +9555,6 @@
"os": [
"sunos"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9556,7 +9572,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9574,7 +9589,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9592,7 +9606,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -9811,19 +9824,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
- "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.6",
- "@vitest/mocker": "4.1.6",
- "@vitest/pretty-format": "4.1.6",
- "@vitest/runner": "4.1.6",
- "@vitest/snapshot": "4.1.6",
- "@vitest/spy": "4.1.6",
- "@vitest/utils": "4.1.6",
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -9851,12 +9864,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.6",
- "@vitest/browser-preview": "4.1.6",
- "@vitest/browser-webdriverio": "4.1.6",
- "@vitest/coverage-istanbul": "4.1.6",
- "@vitest/coverage-v8": "4.1.6",
- "@vitest/ui": "4.1.6",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -10100,7 +10113,7 @@
},
"packages/server": {
"name": "@lookout/server",
- "version": "0.2.11",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
@@ -10124,7 +10137,7 @@
},
"packages/shared": {
"name": "@lookout/shared",
- "version": "0.2.11",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later"
},
"packages/web": {
@@ -10147,7 +10160,7 @@
},
"packages/worker": {
"name": "@lookout/worker",
- "version": "0.2.11",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
@@ -10160,7 +10173,8 @@
"devDependencies": {
"@types/pg": "^8.11.0",
"tsx": "^4.19.0",
- "typescript": "^5.7.0"
+ "typescript": "^5.7.0",
+ "vitest": "^4.1.10"
}
}
}
diff --git a/package.json b/package.json
index 1180aca5..df65acbd 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
"packages/worker",
"clients/react",
"clients/web",
- "clients/desktop"
+ "clients/desktop",
+ "clients/playground"
],
"scripts": {
"dev": "concurrently \"npm run dev -w clients/web\" \"npm run dev -w packages/server\"",
diff --git a/packages/server/API.md b/packages/server/API.md
index afaac2dc..30b9b274 100644
--- a/packages/server/API.md
+++ b/packages/server/API.md
@@ -122,6 +122,25 @@ Pre-0.2.1 the bucket count caused timer jump-back when two captures arrived in t
---
+## Clips
+
+Each capture unit is a **clip** by default: a per-minute video file (~6 frames captured 10s apart, WebM from Chromium/Firefox MediaRecorder, MP4 from Safari and desktop) that compiles into a 6×-smoother timelapse. Pass `"clips": false` on the [internal create endpoint](#create-session) to opt a session out and pin it to the legacy one-JPEG-per-minute payload.
+
+A clip is still **one capture unit** — one `upload-url` request, one R2 PUT, one confirm per minute. Nothing about rate limits, session caps, credit/bucket tracking math, `trackedSeconds`, `screenshotCount`, or the `/timings` endpoint changes with clips: one confirmed unit per minute, one timestamp per minute.
+
+**Contract:**
+
+- **Session-level, immutable opt-out.** `clips_enabled` defaults to true, is set at creation, and is enforced server-side on every `upload-url`. It cannot be changed later — a session's capture character never changes mid-recording.
+- **Capability discovery before the first upload.** `GET /api/sessions/:token` returns `clipsEnabled` and `frameIntervalMs`. A clip-capable client checks these on its session-recovery fetch and, when enabled, records clips from the very first upload — timelapses start with motion, not a still frame.
+- **Granted format is law.** The client requests a format with `?format=webm|mp4`; the response's `format` is what the server *granted* (clip requests on non-clips sessions silently downgrade to `jpeg`). The presigned URL is signed with the granted format's content type, so uploading anything else fails the signature. Confirm re-validates the stored object's content type against the granted format.
+- **Server-authoritative cadence.** `frameIntervalMs` (default 10000 = 6 frames/min) is dictated by the server; clients capture at exactly that rate and expose no override. Clips are VFR — a static screen legitimately produces fewer encoded frames, and the compiler derives real counts by demuxing (the confirm body's `frameCount` is telemetry only).
+- **Size cap:** clips are validated at ≤ 4 MB via HeadObject (clients cap their encoder at ~400 kbps ≈ 3 MB/min worst case; static screen content lands far below since VBR undershoots easy content).
+- **Mixed sessions are legal.** A clip client that hits an encoder hiccup falls back to a JPEG for that minute; the compiler handles formats per capture unit.
+
+Sessions without the flag — and every pre-clips client — behave exactly as before.
+
+---
+
## Client Info
Recording clients report a free-form **client telemetry string** on every `upload-url` request (query param `clientInfo`). It is **not** the HTTP `User-Agent` — it's explicit info the Lookout client builds for telemetry/debugging. The server stores it opaquely per screenshot (**never parses it**) and surfaces the session's first recorded value on session-info endpoints (`GET /api/sessions/:token`, `GET /api/sessions/:token/timings`, internal admin).
@@ -170,12 +189,19 @@ Returns the current state of a session.
"createdAt": "2024-01-01T11:50:00.000Z",
"thumbnailUrl": "https://...",
"videoUrl": "https://...",
+ "clipsEnabled": false,
+ "frameIntervalMs": 4000,
+ "redirectUrl": null,
"metadata": {}
}
```
`clientInfo` is the [client telemetry string](#client-info) recorded on the session's **first** screenshot upload. It is `null` for sessions recorded before this was added, or where the client sent none.
+`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
@@ -230,6 +256,7 @@ Generates a presigned PUT URL for uploading a screenshot to R2. Activates pendin
|------|------|-------------|
| `capturedAt` | ISO-8601 (optional) | Client-attested moment the frame was grabbed. Presence on the **first** upload of a session sticks it to **credit mode** for life; absence sticks it to **bucket mode**. Subsequent uploads on a credit-mode session **must** include it. Must fall within ±5 min of server time and must be strictly monotonic across uploads. |
| `clientInfo` | string (optional) | [Client telemetry string](#client-info) (User-Agent-like). Stored opaquely per screenshot; the session's first non-empty value is surfaced on session-info endpoints. Best-effort — never parsed or validated, silently truncated to 1024 chars; an invalid/oversized value never fails the upload. |
+| `format` | `jpeg` \| `webm` \| `mp4` (optional) | Payload format for this capture unit. Omitted = `jpeg` (legacy single frame). `webm`/`mp4` request a [clip](#clips) upload. The response's `format` is the **granted** format — clip requests on sessions without clips enabled are silently downgraded to `jpeg`, and the client must upload exactly what was granted (the presigned URL is signed with that content type). |
**Response `200 OK`:**
```json
@@ -240,11 +267,14 @@ Generates a presigned PUT URL for uploading a screenshot to R2. Activates pendin
"minuteBucket": 1,
"nextExpectedAt": "2024-01-01T12:01:00.000Z",
"serverTime": "2024-01-01T12:00:00.000Z",
- "trackingMode": "credit"
+ "trackingMode": "credit",
+ "format": "jpeg",
+ "clipsEnabled": false,
+ "frameIntervalMs": 4000
}
```
-`nextExpectedAt` is the server's authoritative target for the **next** capture's `capturedAt` — clients should schedule from it (see Tracking Modes below).
+`nextExpectedAt` is the server's authoritative target for the **next** capture's `capturedAt` — clients should schedule from it (see Tracking Modes below). `format` is the granted payload format (see [Clips](#clips)); `r2Key` carries the matching extension (`.jpg`/`.webm`/`.mp4`).
**Errors:**
- `400` — `captured_at_future`, `captured_at_too_old`, `captured_at_before_session_start`, `captured_at_not_monotonic`, `captured_at_invalid`, or `credit_mode_requires_captured_at`
@@ -254,9 +284,9 @@ Generates a presigned PUT URL for uploading a screenshot to R2. Activates pendin
**Notes:**
- Presigned URL expires after 2 minutes
-- Client should PUT the JPEG image directly to `uploadUrl`
+- Client should PUT the image/clip directly to `uploadUrl` with the granted format's content type
- Max 4320 upload requests per session
-- Pre-0.2.1 binaries that don't send `capturedAt` continue to receive a usable response — additive fields (`serverTime`, `trackingMode`) are gracefully ignored
+- Pre-0.2.1 binaries that don't send `capturedAt` continue to receive a usable response — additive fields (`serverTime`, `trackingMode`, `format`, `clipsEnabled`, `frameIntervalMs`) are gracefully ignored
---
@@ -279,7 +309,8 @@ Confirms that a screenshot was successfully uploaded to R2. The server verifies
"screenshotId": "uuid",
"width": 1920,
"height": 1080,
- "fileSize": 125000
+ "fileSize": 125000,
+ "frameCount": 20
}
```
@@ -289,6 +320,7 @@ Confirms that a screenshot was successfully uploaded to R2. The server verifies
| `width` | integer | yes | ≥ 1 |
| `height` | integer | yes | ≥ 1 |
| `fileSize` | integer | yes | ≥ 1 |
+| `frameCount` | integer | no | 1–600. Frames inside an uploaded [clip](#clips); informational (the compiler demuxes for the real count). Omit for JPEG captures. |
**Response `200 OK`:**
```json
@@ -303,7 +335,7 @@ Confirms that a screenshot was successfully uploaded to R2. The server verifies
`trackedSeconds` here is the **server's authoritative count after this capture has been credited (or not)**. Use this value to drive your timer display — see the [Tracking Modes](#tracking-modes) section for client display guidance. `nextExpectedAt` is the target for the next capture's `capturedAt`.
**Errors:**
-- `400` — Invalid content type (must be `image/jpeg`), file too large (max 2 MB), or object not found in R2
+- `400` — Content type doesn't match the granted format (`image/jpeg` / `video/webm` / `video/mp4`), file too large (2 MB for JPEG, 8 MB for clips), or object not found in R2
- `404` — Session or screenshot not found
- `409` — Session not in `pending` or `active` state
- `429` — Rate limit exceeded, or max confirmed screenshots reached (720)
@@ -385,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. Opens a lease the client must renew (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. It is one lease term (~2 min) — keep it alive with `POST /:token/editing` for as long as your editor is open.
+
**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 will actually open an editing surface and renew the lease. An abandoned hold still publishes on its own, so nothing is lost either way — but a client that asks for a hold and never renews it just makes the user wait a lease for no reason.
---
@@ -435,6 +480,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
@@ -445,11 +494,18 @@ GET /api/sessions/:token/timings
Returns the ISO-8601 capture timestamps of **every confirmed screenshot** in the session, oldest first. Token-gated public endpoint. Uses each screenshot's `capturedAt` (client-attested capture moment); rows predating the `captured_at` column fall back to `requestedAt` so the array is never sparse.
+**Cuts are respected by default.** Captures whose timestamp falls inside the session's [cut list](#edits-cuts) are excluded from `timestamps` (so heartbeat forwarders honor user edits with no code changes) and surfaced separately: `cuts` carries the intervals, `cutCount` the number of removed captures, and `?includeCut=true` adds a `cutTimestamps` array.
+
**Path Parameters:**
| Name | Type | Description |
|------|------|-------------|
| `token` | string | 64-char hex session token |
+**Query Parameters:**
+| Name | Type | Description |
+|------|------|-------------|
+| `includeCut` | boolean | Optional. When `true`, adds `cutTimestamps` (the removed captures) to the response |
+
**Response `200 OK`:**
```json
{
@@ -473,7 +529,10 @@ Returns the ISO-8601 capture timestamps of **every confirmed screenshot** in the
| `first` | string \| null | Earliest timestamp (= `timestamps[0]`); `null` if no screenshots |
| `last` | string \| null | Latest timestamp (= last element); `null` if no screenshots |
| `clientInfo` | string \| null | [Client telemetry string](#client-info) from the session's first screenshot upload; `null` if none recorded |
-| `timestamps` | string[] | ISO-8601 timestamps, ascending |
+| `timestamps` | string[] | ISO-8601 timestamps of KEPT captures, ascending |
+| `cuts` | array | The session's cut list (`[{start, end}]`); `[]` when never edited |
+| `cutCount` | integer | Confirmed captures removed by the cut list |
+| `cutTimestamps` | string[] | Only with `?includeCut=true`: the removed timestamps, ascending |
> **ℹ️ `count` is the number of screenshots, not minutes.** More than one capture can fall within the same minute (retries, resume, clock jitter), so `count` can exceed the number of distinct minutes — it is **not** a count of tracked minutes. For tracked time use `trackedSeconds`.
@@ -491,6 +550,140 @@ Returns the ISO-8601 capture timestamps of **every confirmed screenshot** in the
---
+### Edits (Cuts)
+
+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
+[
+ { "start": "2026-07-26T14:03:00.000Z", "end": "2026-07-26T14:11:00.000Z" },
+ { "start": "2026-07-26T15:40:00.000Z", "end": "2026-07-26T15:44:00.000Z" }
+]
+```
+
+Cuts are intervals (not video offsets) because Lookout is heartbeat-based — the same list drives all three derived views consistently:
+
+| Consumer | Effect |
+|----------|--------|
+| Published video | Capture units inside a cut are removed (the video gets 1 second shorter per cut minute) |
+| [`GET /timings`](#get-capture-timings) | Removed captures are excluded from `timestamps` by default |
+| `trackedSeconds` | Reported as raw − `cutSeconds` on every endpoint (raw stays available as `uncutTrackedSeconds`) |
+
+**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).
+
+#### 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.
+
+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
+ └─ lease lapses (~2 min unrenewed) ──────────────> complete
+```
+
+**The hold is a lease, not a countdown.** An open editing surface calls `POST /:token/editing` every 30 s; the server holds the session for 120 s past the last renewal. So editing takes exactly as long as it takes — there's no deadline to race on a three-hour recording — and an abandoned session publishes about two minutes later instead of sitting unpublished for half an hour. An absolute ceiling of 120 minutes from the stop bounds the pathological case (an editor left open overnight).
+
+The hold can only **delay** publication, never cancel it. A stop without `{"edit": true}` behaves exactly as it always has, so existing clients are unaffected.
+
+#### Renew the Edit Lease
+
+```
+POST /api/sessions/:token/editing
+```
+
+"An editor is still open." Extends the hold to `now + 120s`. Idempotent and cheap; call it every 30 s while an editing surface is showing. Rate limit: 20 req/min per token.
+
+**Response `200 OK`:** `{ "editHoldUntil": ISO-8601, "held": boolean }`
+
+`held: false` means the session is no longer holdable — it published, failed, or passed the ceiling. Stop renewing and show the published state; the call never resurrects a published session.
+
+**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
+
+```
+GET /api/sessions/:token/units
+```
+
+Editor metadata. Rate limit: 10 req/min per token.
+
+**Response `200 OK`:**
+```json
+{
+ "units": [ { "capturedAt": "2026-07-26T14:00:12.000Z", "screenshotId": "…" } ],
+ "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. 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 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
+
+```
+PUT /api/sessions/:token/cuts
+Body: { "cuts": [{ "start": ISO-8601, "end": ISO-8601 }, …] }
+```
+
+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
+{
+ "cuts": [ { "start": "…", "end": "…" } ],
+ "unitsTotal": 47,
+ "unitsCut": 8,
+ "trackedSeconds": 2340,
+ "uncutTrackedSeconds": 2820
+}
+```
+
+**Errors:** `400` invalid/entire-timelapse cut list · `409` compiling or not editable · `429` rate limit.
+
+#### Publish (End the Hold)
+
+```
+POST /api/sessions/:token/compile
+```
+
+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.
+- **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.
+
+**Response `200 OK`:** `{ "status": "compiling" | "complete", "instant": boolean, "recompilesRemaining": number }`
+**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.
+
+---
+
### Get Video URL
```
@@ -637,6 +830,8 @@ 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 | Whether this session accepts [clip uploads](#clips) (~6 frames/min video). Default **`true`**; pass `false` to opt out and get the legacy 1 JPEG/min payload. **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
@@ -677,6 +872,7 @@ Returns full session details including internal fields.
"lastScreenshotAt": "...",
"resumedAt": "...",
"totalActiveSeconds": 123,
+ "clipsEnabled": false,
"videoUrl": null,
"thumbnailUrl": null,
"createdAt": "...",
diff --git a/packages/server/drizzle/0015_clips.sql b/packages/server/drizzle/0015_clips.sql
new file mode 100644
index 00000000..4588361a
--- /dev/null
+++ b/packages/server/drizzle/0015_clips.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "screenshots" ADD COLUMN "format" text DEFAULT 'jpeg' NOT NULL;--> statement-breakpoint
+ALTER TABLE "screenshots" ADD COLUMN "frame_count" integer;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "clips_enabled" boolean DEFAULT false NOT NULL;
\ No newline at end of file
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/0018_session_edits.sql b/packages/server/drizzle/0018_session_edits.sql
new file mode 100644
index 00000000..66a01d3f
--- /dev/null
+++ b/packages/server/drizzle/0018_session_edits.sql
@@ -0,0 +1,7 @@
+ALTER TABLE "sessions" ADD COLUMN "cuts" jsonb;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "cut_seconds" integer;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "video_units" jsonb;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "original_video_r2_key" text;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "video_copy_aligned" boolean;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "recompile_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "last_edit_compile_at" timestamp with time zone;
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/0020_compile_progress.sql b/packages/server/drizzle/0020_compile_progress.sql
new file mode 100644
index 00000000..8b701856
--- /dev/null
+++ b/packages/server/drizzle/0020_compile_progress.sql
@@ -0,0 +1 @@
+ALTER TABLE "sessions" ADD COLUMN "compile_progress" real;
\ No newline at end of file
diff --git a/packages/server/drizzle/0021_original_is_preview.sql b/packages/server/drizzle/0021_original_is_preview.sql
new file mode 100644
index 00000000..4657aaf3
--- /dev/null
+++ b/packages/server/drizzle/0021_original_is_preview.sql
@@ -0,0 +1 @@
+ALTER TABLE "sessions" ADD COLUMN "original_is_preview" boolean DEFAULT false NOT NULL;
diff --git a/packages/server/drizzle/0022_clips_default_on.sql b/packages/server/drizzle/0022_clips_default_on.sql
new file mode 100644
index 00000000..258c4263
--- /dev/null
+++ b/packages/server/drizzle/0022_clips_default_on.sql
@@ -0,0 +1,6 @@
+-- Clips become the default capture mode; programs opt OUT with clips:false.
+--
+-- Only the DEFAULT changes. Existing rows are deliberately left alone: a
+-- session's capture character is immutable by design, so flipping in-flight
+-- sessions would change what a running recorder is expected to upload.
+ALTER TABLE "sessions" ALTER COLUMN "clips_enabled" SET DEFAULT true;
diff --git a/packages/server/drizzle/meta/0015_snapshot.json b/packages/server/drizzle/meta/0015_snapshot.json
new file mode 100644
index 00000000..4475fd27
--- /dev/null
+++ b/packages/server/drizzle/meta/0015_snapshot.json
@@ -0,0 +1,685 @@
+{
+ "id": "b0ac680b-a96c-429c-b720-4db2c25acfb5",
+ "prevId": "473364e1-e955-4325-a01d-ce45da4902e9",
+ "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
+ },
+ "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/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 d44af93b..0499763f 100644
--- a/packages/server/drizzle/meta/_journal.json
+++ b/packages/server/drizzle/meta/_journal.json
@@ -106,6 +106,62 @@
"when": 1784770755102,
"tag": "0014_early_dazzler",
"breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "7",
+ "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
+ },
+ {
+ "idx": 18,
+ "version": "7",
+ "when": 1785077227872,
+ "tag": "0018_session_edits",
+ "breakpoints": true
+ },
+ {
+ "idx": 19,
+ "version": "7",
+ "when": 1785080224937,
+ "tag": "0019_edit_hold",
+ "breakpoints": true
+ },
+ {
+ "idx": 20,
+ "version": "7",
+ "when": 1785090000000,
+ "tag": "0020_compile_progress",
+ "breakpoints": true
+ },
+ {
+ "idx": 21,
+ "version": "7",
+ "when": 1785100000000,
+ "tag": "0021_original_is_preview",
+ "breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "7",
+ "when": 1785110000000,
+ "tag": "0022_clips_default_on",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/server/package.json b/packages/server/package.json
index 2ceae985..7b661f8f 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@lookout/server",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
diff --git a/packages/server/src/config/r2.ts b/packages/server/src/config/r2.ts
index 52bde11a..6c18c654 100644
--- a/packages/server/src/config/r2.ts
+++ b/packages/server/src/config/r2.ts
@@ -18,9 +18,15 @@ if (!R2_BUCKET_NAME) {
throw new Error("R2_BUCKET_NAME environment variable is required but not set");
}
+// Local development escape hatch: point the S3 client (and therefore the
+// presigned URLs it hands clients) at any S3-compatible endpoint instead of
+// real R2. Unset in production, where the account-derived R2 host is used.
+const R2_ENDPOINT =
+ process.env.R2_ENDPOINT || `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
+
export const r2Client = new S3Client({
region: "auto",
- endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
+ endpoint: R2_ENDPOINT,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts
index 8bc942a2..cf6aeda2 100644
--- a/packages/server/src/db/schema.ts
+++ b/packages/server/src/db/schema.ts
@@ -5,6 +5,7 @@ import {
text,
timestamp,
integer,
+ real,
boolean,
jsonb,
index,
@@ -69,6 +70,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(),
@@ -112,6 +116,23 @@ export const sessions = pgTable(
trackingMode: text("tracking_mode").notNull().default("bucket"),
streakAnchorAt: timestamp("streak_anchor_at", { withTimezone: true }),
streakCreditedCount: integer("streak_credited_count").notNull().default(0),
+ // Whether this session accepts per-minute video clip uploads (~6
+ // frames/min) instead of single JPEGs. Enforced on every upload-url
+ // (disallowed formats are downgraded to jpeg) and immutable thereafter —
+ // a session's capture character never changes mid-recording.
+ //
+ // Defaults TRUE: clips are the normal capture mode, and a program opts
+ // OUT with `clips: false` on the internal create endpoint. Existing rows
+ // were deliberately NOT backfilled when the default flipped — a session's
+ // mode is immutable, so in-flight sessions keep the mode they started
+ // with. Clients that can't record clips are unaffected either way: they
+ // keep uploading JPEGs to the same session, which stays fully valid.
+ clipsEnabled: boolean("clips_enabled").notNull().default(true),
+ // 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
@@ -124,6 +145,66 @@ export const sessions = pgTable(
thumbnailUrl: text("thumbnail_url"),
thumbnailR2Key: text("thumbnail_r2_key"),
compileAttempts: integer("compile_attempts").notNull().default(0),
+ // Real compile progress (0..~0.95), written by the worker's per-unit
+ // download+encode loop so /status can report ground truth instead of the
+ // client's time estimate. NULL when not compiling, when the worker
+ // predates this column, or for cut-apply compiles (no per-unit stage to
+ // meter) — the client falls back to the time estimate in every such case.
+ // Capped below 1: assembly/thumbnail/upload still run after the last
+ // unit, so the ring must never reach 100% while the user is still waiting.
+ compileProgress: real("compile_progress"),
+ // ── Edits (cuts) ──
+ // Normalized cut list: [{start, end}] ISO wall-clock intervals removed
+ // from every output (video, /timings, trackedSeconds). NULL/[] = no
+ // edits. Canonical semantics live in @lookout/shared cuts.ts.
+ cuts: jsonb("cuts").$type<{ start: string; end: string }[]>(),
+ // Credited seconds removed by `cuts`. Reported trackedSeconds is
+ // tracked_seconds − cut_seconds (raw value stays untouched as the audit
+ // trail). Recomputed on every cuts write; authoritative at cut-compile.
+ cutSeconds: integer("cut_seconds"),
+ // Units that actually made it into the compiled ORIGINAL video, in
+ // output order: [{capturedAt, screenshotId}]. Array index = video
+ // second = real-world minute — THE video-time ↔ wall-clock map (sampled
+ // rows alone can't provide it: compile skips undecodable units). NULL
+ // for sessions compiled before edit support (not editable).
+ videoUnits: jsonb("video_units").$type<
+ { capturedAt: string; screenshotId: string }[]
+ >(),
+ // The UNCUT compiled video. Equal to video_r2_key until an edited
+ // compile repoints video_r2_key at edited.mp4. Cut-compiles always
+ // start from this file, so edits never compound quality loss. NULLed by
+ // the retention job once an edited session's edit window closes (the
+ // cut content must eventually be truly gone).
+ originalVideoR2Key: text("original_video_r2_key"),
+ // True when assembly used the stream-copy path, guaranteeing the pinned
+ // 1s closed-GOP grid that makes lossless second-boundary cutting
+ // possible. False → the cut-compile re-encodes instead.
+ videoCopyAligned: boolean("video_copy_aligned"),
+ // True when original_video_r2_key holds a PREVIEW-grade build: reduced
+ // resolution, cheap encoder settings, made only so the editor can open
+ // promptly on a long session. Such a file must never be published — the
+ // publish step re-encodes from the capture units at full quality instead
+ // of stream-copying it.
+ //
+ // NULL/false means the original is publish-grade, which is both the
+ // legacy shape (every session compiled before the two-tier split) and
+ // what a session that never entered the edit flow still builds. That
+ // makes the flag safe to read as "false unless proven otherwise".
+ originalIsPreview: boolean("original_is_preview").notNull().default(false),
+ // 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 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(),
@@ -154,6 +235,15 @@ export const screenshots = pgTable(
height: integer("height"),
fileSizeBytes: integer("file_size_bytes"),
sampled: boolean("sampled").notNull().default(false),
+ // Payload format of this capture unit: 'jpeg' (legacy single frame) or
+ // 'webm'/'mp4' (per-minute clip of ~6 frames). Decided per upload by the
+ // client's `format` query param, gated by sessions.clips_enabled —
+ // sessions may mix formats (e.g. a clip client falling back to jpeg
+ // mid-session); the compiler handles both per row.
+ format: text("format").notNull().default("jpeg"),
+ // Client-reported frame count inside a clip. Informational/telemetry —
+ // the compiler derives the real count by demuxing. NULL for jpeg rows.
+ frameCount: integer("frame_count"),
// Client-attested (or server-fallback) capture time. Populated for ALL
// new rows post-migration 0007 regardless of mode — credit-mode rows use
// it for streak math, bucket-mode rows store it as debug-only data.
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index bb392eb1..59876d42 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -27,9 +27,20 @@ const app = Fastify({ logger: true });
const IS_DEV = process.env.NODE_ENV !== "production";
+// Self-hosted deployments serve the admin panel from BASE_URL, so the
+// server's own public hostname must pass CORS alongside *.hackclub.com.
+const BASE_URL_HOSTNAME = (() => {
+ try {
+ return process.env.BASE_URL ? new URL(process.env.BASE_URL).hostname : null;
+ } catch {
+ return null;
+ }
+})();
+
await app.register(cors, {
origin: (origin, cb) => {
- // Allow: no origin (server-to-server), *.hackclub.com, tauri app
+ // Allow: no origin (server-to-server), *.hackclub.com, BASE_URL's own
+ // host (self-hosted deployments), tauri app
// Tauri uses tauri:// on macOS/Linux but http://tauri.localhost on Windows
if (
!origin ||
@@ -44,6 +55,7 @@ await app.register(cors, {
const isAllowed =
/\.hackclub\.com$/.test(hostname) ||
hostname === "hackclub.com" ||
+ (BASE_URL_HOSTNAME !== null && hostname === BASE_URL_HOSTNAME) ||
// Only allow localhost origins in development
(IS_DEV && /^https?:\/\/localhost(:\d+)?$/.test(origin));
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 492aab64..64e64370 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,
@@ -17,6 +18,8 @@ import {
STUCK_COMPILING_TIMEOUT_MINUTES,
MAX_COMPILE_ATTEMPTS,
SCREENSHOT_RETENTION_DAYS,
+ EDIT_WINDOW_DAYS,
+ EDIT_HOLD_MAX_MINUTES,
} from "@lookout/shared";
/**
@@ -49,9 +52,62 @@ export async function registerTimeoutJobs() {
});
}
+/**
+ * Publish sessions whose edit lease lapsed — nothing has said "still
+ * editing" for a lease term — or that hit the absolute ceiling.
+ *
+ * This is the promise that makes "Edit & Save" safe to offer: a user who
+ * closes the app mid-edit still gets their timelapse, uncut, about a lease
+ * later. The hold can delay publication, never cancel it.
+ */
+async function publishExpiredHolds() {
+ const now = new Date();
+ const ceilingCutoff = new Date(
+ now.getTime() - EDIT_HOLD_MAX_MINUTES * 60_000,
+ );
+
+ const expired = await db
+ .select({ id: schema.sessions.id })
+ .from(schema.sessions)
+ .where(
+ and(
+ eq(schema.sessions.status, "stopped"),
+ isNotNull(schema.sessions.editHoldUntil),
+ sql`(${schema.sessions.editHoldUntil} < ${now}
+ OR ${schema.sessions.stoppedAt} < ${ceilingCutoff})`,
+ ),
+ );
+
+ 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,
@@ -308,4 +364,57 @@ async function cleanupCompletedScreenshots() {
.set({ screenshotsPurgedAt: new Date() })
.where(eq(schema.sessions.id, session.id));
}
+
+ await purgeEditedOriginals();
+}
+
+/**
+ * Privacy backstop for the edit feature: cut minutes vanish from the
+ * published video but survive inside the (token-gated) uncut original. Once
+ * an EDITED session's re-edit window closes, delete the original so the cut
+ * content is truly gone; nulling original_video_r2_key freezes further
+ * editing. Sessions whose published video IS the original (never edited, or
+ * edits cleared) keep their single video file forever, as always.
+ */
+async function purgeEditedOriginals() {
+ const threshold = new Date(
+ Date.now() - EDIT_WINDOW_DAYS * 24 * 60 * 60_000,
+ );
+
+ const editedSessions = await db
+ .select({
+ id: schema.sessions.id,
+ originalVideoR2Key: schema.sessions.originalVideoR2Key,
+ })
+ .from(schema.sessions)
+ .where(
+ and(
+ eq(schema.sessions.status, "complete"),
+ isNotNull(schema.sessions.originalVideoR2Key),
+ isNotNull(schema.sessions.videoR2Key),
+ sql`${schema.sessions.videoR2Key} <> ${schema.sessions.originalVideoR2Key}`,
+ lt(schema.sessions.lastEditCompileAt, threshold),
+ ),
+ );
+
+ for (const session of editedSessions) {
+ try {
+ await r2Client.send(
+ new DeleteObjectCommand({
+ Bucket: R2_BUCKET,
+ Key: session.originalVideoR2Key!,
+ }),
+ );
+ } catch {
+ console.warn(
+ `Failed to delete original video for session ${session.id}, will retry next run`,
+ );
+ continue;
+ }
+
+ await db
+ .update(schema.sessions)
+ .set({ originalVideoR2Key: null, updatedAt: new Date() })
+ .where(eq(schema.sessions.id, session.id));
+ }
}
diff --git a/packages/server/src/lib/timing.test.ts b/packages/server/src/lib/timing.test.ts
index 8c3dc2e6..8833cfb5 100644
--- a/packages/server/src/lib/timing.test.ts
+++ b/packages/server/src/lib/timing.test.ts
@@ -5,7 +5,12 @@ import {
CAPTURED_AT_FUTURE_TOLERANCE_MS,
SCREENSHOT_INTERVAL_MS,
} from "@lookout/shared";
-import { creditCapture, validateCapturedAt } from "./timing.js";
+import {
+ creditCapture,
+ validateCapturedAt,
+ adoptedCapturedAt,
+ isClockSkewError,
+} from "./timing.js";
const T0 = new Date("2025-01-01T00:00:00.000Z");
const ms = (d: Date, deltaMs: number) => new Date(d.getTime() + deltaMs);
@@ -325,3 +330,86 @@ describe("browser-throttle simulation (validates ~50% halving report)", () => {
expect(totalCredit).toBe(19 * 60);
});
});
+
+/**
+ * A wrong system clock is common, invisible to the user, and none of their
+ * doing. It used to cost them the whole recording: every upload-url request
+ * 400'd on the envelope check, so no presigned URL was ever issued and
+ * nothing uploaded at all. These tests pin the "adopt, don't break" rule.
+ */
+describe("adoptedCapturedAt (client clock skew)", () => {
+ const serverNow = new Date("2025-01-01T12:00:00.000Z");
+ const startedAt = ms(serverNow, -120_000);
+
+ it("passes a healthy clock through untouched", () => {
+ const cap = ms(serverNow, -500);
+ const r = adoptedCapturedAt(cap, serverNow, startedAt, null);
+ expect(r).toEqual({ ok: true, capturedAt: cap, adopted: false });
+ });
+
+ it("adopts server time for a clock hours FAST instead of rejecting", () => {
+ const cap = ms(serverNow, 3 * 60 * 60_000);
+ const r = adoptedCapturedAt(cap, serverNow, startedAt, null);
+ expect(r).toEqual({ ok: true, capturedAt: serverNow, adopted: true });
+ });
+
+ it("adopts server time for a clock hours SLOW instead of rejecting", () => {
+ const cap = ms(serverNow, -3 * 60 * 60_000);
+ const r = adoptedCapturedAt(cap, serverNow, startedAt, null);
+ expect(r).toEqual({ ok: true, capturedAt: serverNow, adopted: true });
+ });
+
+ it("adopts even for an absurd timestamp — nothing is gained by lying", () => {
+ // Server time is unforgeable, so substituting it removes the incentive to
+ // send a wild value rather than creating one. The capture is simply
+ // stamped when the server saw it.
+ for (const cap of [new Date(0), new Date("2099-01-01T00:00:00.000Z")]) {
+ const r = adoptedCapturedAt(cap, serverNow, startedAt, null);
+ expect(r).toEqual({ ok: true, capturedAt: serverNow, adopted: true });
+ }
+ });
+
+ it("still refuses a non-monotonic timestamp — that is not a clock problem", () => {
+ // Adoption must not become a way to bypass replay protection: a later
+ // capture claiming an earlier moment is a request-level fault, and
+ // server time cannot rescue it either.
+ const latest = ms(serverNow, 30_000);
+ const r = adoptedCapturedAt(ms(serverNow, 10_000), serverNow, startedAt, latest);
+ expect(r).toEqual({ ok: false, code: "captured_at_not_monotonic" });
+ });
+
+ it("still refuses a pre-session timestamp", () => {
+ const later = ms(serverNow, 10 * 60_000);
+ const r = adoptedCapturedAt(ms(serverNow, -60_000), serverNow, later, null);
+ expect(r.ok).toBe(false);
+ });
+
+ it("classifies only envelope failures as clock skew", () => {
+ expect(isClockSkewError("captured_at_future")).toBe(true);
+ expect(isClockSkewError("captured_at_too_old")).toBe(true);
+ expect(isClockSkewError("captured_at_not_monotonic")).toBe(false);
+ expect(isClockSkewError("captured_at_before_session_start")).toBe(false);
+ });
+
+ it("keeps a skewed client crediting minute after minute", () => {
+ // The point of the whole exercise: a device an hour fast should still
+ // build a normal streak, because each adopted stamp is a real server
+ // instant one interval after the last.
+ let anchor: Date | null = null;
+ let count = 0;
+ let credited = 0;
+ for (let i = 0; i < 5; i++) {
+ const server = ms(serverNow, i * SCREENSHOT_INTERVAL_MS);
+ const skewed = ms(server, 60 * 60_000); // an hour fast
+ const r = adoptedCapturedAt(skewed, server, startedAt, null);
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ const d = creditCapture(r.capturedAt, anchor, count);
+ anchor = d.newAnchor;
+ count = d.newCount;
+ credited += d.credit;
+ }
+ // Seed credits 0, the next four credit 60 each.
+ expect(credited).toBe(4 * 60);
+ });
+});
diff --git a/packages/server/src/lib/timing.ts b/packages/server/src/lib/timing.ts
index 70e326e1..26113704 100644
--- a/packages/server/src/lib/timing.ts
+++ b/packages/server/src/lib/timing.ts
@@ -43,6 +43,76 @@ export type CapturedAtValidation =
| CapturedAtValidationOk
| CapturedAtValidationFail;
+/**
+ * True for the two failures that mean "this client's clock is wrong" rather
+ * than "this client is misbehaving".
+ *
+ * The distinction matters because the two deserve opposite treatment. A
+ * non-monotonic or pre-session timestamp says something is wrong with the
+ * request; a timestamp five minutes off says nothing except that the user's
+ * system clock is off, which is common, invisible to them, and none of their
+ * doing. Rejecting the latter used to fail the upload-url request outright,
+ * so a skewed clock cost the user their entire recording — every upload 400'd
+ * before a presigned URL was ever issued. The caller substitutes server time
+ * for these instead. See adoptedCapturedAt.
+ */
+export function isClockSkewError(code: CapturedAtValidationError): boolean {
+ return code === "captured_at_future" || code === "captured_at_too_old";
+}
+
+/**
+ * Resolve the `captured_at` to actually use, adopting server time when the
+ * client's clock is too far off to trust.
+ *
+ * Server time is authoritative and unforgeable, so substituting it is
+ * strictly SAFER than accepting the client's claim — a hostile client gains
+ * nothing by sending a wild timestamp, it just gets its capture stamped with
+ * the moment the server saw it. What it costs is precision: a capture stamped
+ * on arrival includes upload latency, so a skewed client's credit is measured
+ * a little late rather than not at all. That is the right trade against
+ * losing the recording.
+ *
+ * Returns the timestamp to store plus whether a substitution happened, so the
+ * route can tell the client (which can then correct its own offset from the
+ * `serverTime` in the response) and operators can see it in telemetry.
+ */
+export function adoptedCapturedAt(
+ clientCapturedAt: Date,
+ serverNow: Date,
+ sessionStartedAt: Date,
+ latestCapturedAt: Date | null,
+):
+ | { ok: true; capturedAt: Date; adopted: boolean }
+ | { ok: false; code: CapturedAtValidationError } {
+ const first = validateCapturedAt(
+ clientCapturedAt,
+ serverNow,
+ sessionStartedAt,
+ latestCapturedAt,
+ );
+ if (first.ok) {
+ return { ok: true, capturedAt: clientCapturedAt, adopted: false };
+ }
+ if (!isClockSkewError(first.code)) {
+ return { ok: false, code: first.code };
+ }
+
+ // Clock skew: stamp with server time instead. Re-validate, because the
+ // substituted value still has to satisfy monotonicity and the session
+ // start — if it doesn't, something other than the clock is wrong and the
+ // caller should still refuse.
+ const second = validateCapturedAt(
+ serverNow,
+ serverNow,
+ sessionStartedAt,
+ latestCapturedAt,
+ );
+ if (!second.ok) {
+ return { ok: false, code: second.code };
+ }
+ return { ok: true, capturedAt: serverNow, adopted: true };
+}
+
/**
* Validate a client-attested `capturedAt` against the trust envelope and the
* session's existing state. Returns a tagged result so the caller can map to
@@ -54,6 +124,9 @@ export type CapturedAtValidation =
* is only allowed when the caller is in an idempotent retry path (same
* screenshotId); that check lives at the route handler since it requires
* the row lookup.
+ *
+ * Envelope failures are usually a wrong clock rather than a bad actor — use
+ * `adoptedCapturedAt` to absorb them rather than calling this directly.
*/
export function validateCapturedAt(
capturedAt: Date,
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' +
'set URL ' +
+ 'set icon ' +
'delete ' +
" ";
@@ -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 33a7aa33..94a7410f 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 };
+ Body: {
+ name?: string;
+ metadata?: Record;
+ clips?: boolean;
+ redirectUrl?: string;
+ };
}>(
"/api/internal/sessions",
{
@@ -29,19 +34,36 @@ export async function internalRoutes(app: FastifyInstance) {
properties: {
name: { type: "string" as const, minLength: 1, maxLength: 255 },
metadata: { type: "object" as const, maxProperties: 50 },
+ // Opt OUT of clip uploads (per-minute videos of ~6 frames).
+ // Defaults TRUE; pass false to pin this session to the legacy
+ // 1 JPEG/min payload. 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 } = request.body || {};
+ const { name, metadata, clips, redirectUrl } = request.body || {};
const [session] = await db
.insert(schema.sessions)
.values({
...(name ? { name } : {}),
metadata: metadata ?? {},
+ // Opt-OUT: clips are the default capture mode. `clips: false`
+ // pins a session to the legacy one-JPEG-per-minute payload.
+ clipsEnabled: clips ?? true,
+ 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.
@@ -91,16 +113,29 @@ export async function internalRoutes(app: FastifyInstance) {
),
);
- // Exclude internal R2 storage keys and build proper media URLs
+ // Exclude internal R2 storage keys (and the editor's unit map, which
+ // is bulky plumbing) and build proper media URLs
const baseUrl = process.env.BASE_URL || "http://localhost:3000";
- const { videoR2Key, thumbnailR2Key, ...sessionData } = session;
+ const {
+ videoR2Key,
+ thumbnailR2Key,
+ originalVideoR2Key: _originalVideoR2Key,
+ videoUnits: _videoUnits,
+ ...sessionData
+ } = session;
// Bucket-mode: tracked = (distinct buckets - 1) * 60.
// Credit-mode: read session.trackedSeconds directly (maintained per-credit).
const liveBucketTracked = Math.max(0, (Number(count) - 1) * 60);
- const trackedSeconds =
+ const uncutTrackedSeconds =
session.trackingMode === "credit"
? session.trackedSeconds ?? 0
: session.trackedSeconds ?? liveBucketTracked;
+ // Reported tracked time honors the session's cut list (user edits can
+ // only shrink it); the raw value is surfaced alongside.
+ const trackedSeconds = Math.max(
+ 0,
+ uncutTrackedSeconds - (session.cutSeconds ?? 0),
+ );
const [{ confirmedCount }] = await db
.select({ confirmedCount: sql`count(*)::int` })
.from(schema.screenshots)
@@ -147,6 +182,7 @@ export async function internalRoutes(app: FastifyInstance) {
: null,
},
trackedSeconds,
+ uncutTrackedSeconds,
screenshotCount: Number(confirmedCount),
clientInfo: firstClient?.clientInfo ?? null,
ja4: firstJa4?.ja4 ?? null,
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 8f97485d..a217592c 100644
--- a/packages/server/src/routes/sessions.ts
+++ b/packages/server/src/routes/sessions.ts
@@ -1,27 +1,47 @@
import type { FastifyInstance } from "fastify";
import { eq, sql, and, inArray, isNotNull } from "drizzle-orm";
-import { PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
+import {
+ PutObjectCommand,
+ HeadObjectCommand,
+ GetObjectCommand,
+} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
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,
checkGenericRateLimit,
creditCapture,
- validateCapturedAt,
+ adoptedCapturedAt,
} from "../lib/timing.js";
import { now } from "../lib/clock.js";
import { extractJa4 } from "../lib/ja4.js";
import {
SCREENSHOT_INTERVAL_MS,
+ CLIP_FRAME_INTERVAL_MS,
PRESIGNED_URL_EXPIRY_SECONDS,
MAX_SCREENSHOT_BYTES,
+ MAX_CLIP_BYTES,
MAX_SCREENSHOTS_PER_SESSION,
MAX_UPLOAD_REQUESTS_PER_SESSION,
CLIENT_INFO_MAX_BYTES,
+ CAPTURE_FORMATS,
+ CAPTURE_FORMAT_CONTENT_TYPES,
+ MAX_USER_RECOMPILES,
+ EDIT_LEASE_SECONDS,
+ EDIT_HOLD_MAX_MINUTES,
+ normalizeCuts,
+ isCutAt,
+ countCutUnits,
+ computeCutSeconds,
+ type CaptureFormat,
+ type CaptureRowForCuts,
+ type CutInterval,
+ type VideoUnit,
} from "@lookout/shared";
/** Tracked-seconds dispatcher. Routes to bucket-count math for legacy
@@ -53,6 +73,112 @@ async function getTrackedSecondsBucket(sessionId: string): Promise {
return Math.max(0, (Number(count) - 1) * 60);
}
+/** Reported tracked seconds: raw minus what the session's cut list removed.
+ * Cuts are user edits — they can only shrink the number, and /timings
+ * excludes the same captures, so every consumer tells one story. The raw
+ * value stays untouched in the DB (and is surfaced as
+ * uncutTrackedSeconds). */
+function reportedTrackedSeconds(
+ rawTrackedSeconds: number,
+ session: { cutSeconds: number | null },
+): number {
+ return Math.max(0, rawTrackedSeconds - (session.cutSeconds ?? 0));
+}
+
+/** The session's cut list, always as an array. */
+function sessionCuts(session: { cuts: unknown }): CutInterval[] {
+ return Array.isArray(session.cuts) ? (session.cuts as CutInterval[]) : [];
+}
+
+/** 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;
+ editHoldUntil: Date | null;
+}): {
+ editable: boolean;
+ reason?:
+ | "preparing"
+ | "no_original"
+ | "recompiles_exhausted"
+ | "not_ready"
+ | "failed"
+ | "published";
+} {
+ if (session.status === "complete") {
+ return { editable: false, reason: "published" };
+ }
+ 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: "preparing" };
+ }
+ if (session.recompileCount >= MAX_USER_RECOMPILES) {
+ return { editable: false, reason: "recompiles_exhausted" };
+ }
+ return { editable: true };
+}
+
+/** Confirmed capture rows in the shape the shared cut math expects —
+ * the same coalesce and rows the worker uses, so PUT /cuts previews are
+ * exactly what the cut-compile persists. */
+async function getCaptureRowsForCuts(
+ sessionId: string,
+): Promise {
+ const rows = await db
+ .select({
+ ts: sql`coalesce(${schema.screenshots.capturedAt}, ${schema.screenshots.requestedAt})`,
+ creditedSeconds: schema.screenshots.creditedSeconds,
+ minuteBucket: schema.screenshots.minuteBucket,
+ })
+ .from(schema.screenshots)
+ .where(
+ and(
+ eq(schema.screenshots.sessionId, sessionId),
+ eq(schema.screenshots.confirmed, true),
+ ),
+ );
+ return rows.map((r) => ({
+ timeMs: (r.ts instanceof Date ? r.ts : new Date(r.ts)).getTime(),
+ creditedSeconds: r.creditedSeconds,
+ minuteBucket: r.minuteBucket,
+ }));
+}
+
// ── Shared schema fragments ─────────────────────────────────
const tokenParamSchema = {
@@ -166,16 +292,24 @@ export async function sessionRoutes(app: FastifyInstance) {
const ja4 = await getFirstJa4(session.id);
// Prefer stored value (survives screenshot cleanup), fall back to live count.
// For credit mode, both paths read sessions.tracked_seconds so they match.
- const trackedSeconds =
+ const rawTrackedSeconds =
session.trackingMode === "credit"
? liveTrackedSeconds
: session.trackedSeconds ?? liveTrackedSeconds;
+ const trackedSeconds = reportedTrackedSeconds(rawTrackedSeconds, session);
const baseUrl = process.env.BASE_URL || "http://localhost:3000";
return {
name: session.name,
status: session.status,
trackedSeconds,
+ cuts: sessionCuts(session),
+ cutSeconds: session.cutSeconds ?? 0,
+ uncutTrackedSeconds: rawTrackedSeconds,
+ editable: sessionEditability(session).editable,
+ editHoldUntil: holdActive(session)
+ ? session.editHoldUntil!.toISOString()
+ : undefined,
screenshotCount,
clientInfo,
ja4,
@@ -191,6 +325,13 @@ export async function sessionRoutes(app: FastifyInstance) {
// Backwards compat: legacy clients keyed off this. Points at a static
// "please update" video when the session is otherwise playable.
videoWebmUrl: session.videoR2Key ? `${baseUrl}/please-update.webm` : null,
+ // Clip capability, surfaced on the session-recovery fetch so clients
+ // know BEFORE their first capture whether to record clips — the very
+ // first upload of a clips session is already a clip (no static
+ // opening frame in the timelapse). Old clients ignore these.
+ clipsEnabled: session.clipsEnabled,
+ frameIntervalMs: CLIP_FRAME_INTERVAL_MS,
+ redirectUrl: session.redirectUrl,
metadata: session.metadata ?? {},
};
},
@@ -243,7 +384,7 @@ export async function sessionRoutes(app: FastifyInstance) {
// is sticky thereafter. See plan doc for details.
app.get<{
Params: { token: string };
- Querystring: { capturedAt?: string; clientInfo?: string };
+ Querystring: { capturedAt?: string; clientInfo?: string; format?: CaptureFormat };
}>(
"/api/sessions/:token/upload-url",
{
@@ -257,6 +398,12 @@ export async function sessionRoutes(app: FastifyInstance) {
// Intentionally NOT length-capped here — schema validation failure
// would 400 the whole upload. Best-effort: truncated in the handler.
clientInfo: { type: "string" as const },
+ // Payload format. Omitted = 'jpeg' (legacy single frame). Clip
+ // clients pass 'webm'/'mp4' for per-minute video clips. The
+ // response echoes back the GRANTED format — requests for clip
+ // formats on sessions without clips enabled are silently
+ // downgraded to 'jpeg', and clients must upload what was granted.
+ format: { type: "string" as const, enum: CAPTURE_FORMATS },
},
additionalProperties: false,
},
@@ -294,7 +441,9 @@ export async function sessionRoutes(app: FastifyInstance) {
const serverNow = now();
const clientCapturedAtRaw = request.query.capturedAt;
- const clientCapturedAt = clientCapturedAtRaw
+ // `let`: an out-of-envelope value is replaced with server time below
+ // rather than rejected, so a wrong client clock can't cost a recording.
+ let clientCapturedAt = clientCapturedAtRaw
? new Date(clientCapturedAtRaw)
: null;
if (clientCapturedAt && Number.isNaN(clientCapturedAt.getTime())) {
@@ -397,11 +546,10 @@ export async function sessionRoutes(app: FastifyInstance) {
startedAt = session.startedAt!;
}
- // Resolve the row's `captured_at` value — populated in both modes for
- // debugging. In bucket mode it's never read for math.
- const rowCapturedAt = clientCapturedAt ?? serverNow;
-
// Credit-mode: capturedAt is required and must pass the envelope.
+ // Set when the client's clock was too far off to trust and server time
+ // was substituted — reported back so the client can correct itself.
+ let capturedAtAdopted = false;
let nextExpectedAt: Date;
if (trackingMode === "credit") {
if (!clientCapturedAt) {
@@ -418,15 +566,31 @@ export async function sessionRoutes(app: FastifyInstance) {
.orderBy(sql`${schema.screenshots.capturedAt} DESC NULLS LAST`)
.limit(1);
- const validation = validateCapturedAt(
+ // A wrong system clock must not cost the user their recording. An
+ // out-of-envelope timestamp is adopted as server time rather than
+ // 400'd; anything else (non-monotonic, pre-session) is still refused.
+ const resolved = adoptedCapturedAt(
clientCapturedAt,
serverNow,
startedAt,
latest?.capturedAt ?? null,
);
- if (!validation.ok) {
- return reply.code(400).send({ error: validation.code });
+ if (!resolved.ok) {
+ return reply.code(400).send({ error: resolved.code });
+ }
+ if (resolved.adopted) {
+ capturedAtAdopted = true;
+ request.log.warn(
+ {
+ sessionId: session.id,
+ clientCapturedAt: clientCapturedAt.toISOString(),
+ serverNow: serverNow.toISOString(),
+ skewMs: clientCapturedAt.getTime() - serverNow.getTime(),
+ },
+ "client clock outside the trust envelope — stamping capture with server time",
+ );
}
+ clientCapturedAt = resolved.capturedAt;
// Predict nextExpectedAt assuming this capture will credit. The
// confirm response returns the authoritative post-credit value.
@@ -451,7 +615,20 @@ export async function sessionRoutes(app: FastifyInstance) {
const minuteBucket = computeMinuteBucket(serverNow, startedAt);
const screenshotId = randomUUID();
- const r2Key = `screenshots/${session.id}/${screenshotId}.jpg`;
+ // Payload format for this capture unit. A clip is still ONE unit per
+ // minute — identical cadence, credit math, and rate limits as jpeg.
+ // The clips gate is enforced HERE, per upload: sessions without
+ // clips_enabled get clip-format requests silently downgraded to jpeg
+ // (the presign + granted-format echo both say jpeg, so a conforming
+ // client falls back without an error round-trip).
+ const requestedFormat: CaptureFormat = request.query.format ?? "jpeg";
+ const format: CaptureFormat =
+ requestedFormat !== "jpeg" && !session.clipsEnabled
+ ? "jpeg"
+ : requestedFormat;
+ const contentType = CAPTURE_FORMAT_CONTENT_TYPES[format];
+ const ext = format === "jpeg" ? "jpg" : format;
+ const r2Key = `screenshots/${session.id}/${screenshotId}.${ext}`;
// Optional client telemetry from the query param. Stored opaquely and
// never parsed. Truncate (don't reject) so a malformed/oversized value
@@ -465,6 +642,11 @@ export async function sessionRoutes(app: FastifyInstance) {
// app layer. NULL when the edge didn't set it (local dev, etc).
const ja4 = extractJa4(request);
+ // Resolve the row's `captured_at` value — populated in both modes for
+ // debugging. In bucket mode it's never read for math. Read AFTER the
+ // credit block so it picks up an adopted server timestamp.
+ const rowCapturedAt = clientCapturedAt ?? serverNow;
+
// Create screenshot record (unconfirmed)
await db.insert(schema.screenshots).values({
id: screenshotId,
@@ -476,6 +658,7 @@ export async function sessionRoutes(app: FastifyInstance) {
capturedAt: rowCapturedAt,
clientInfo,
ja4,
+ format,
});
// Generate presigned PUT URL
@@ -485,7 +668,7 @@ export async function sessionRoutes(app: FastifyInstance) {
const command = new PutObjectCommand({
Bucket: R2_BUCKET,
Key: r2Key,
- ContentType: "image/jpeg",
+ ContentType: contentType,
});
const uploadUrl = await getSignedUrl(r2Client, command, {
@@ -499,7 +682,15 @@ export async function sessionRoutes(app: FastifyInstance) {
minuteBucket,
nextExpectedAt: nextExpectedAt.toISOString(),
serverTime: serverNow.toISOString(),
+ // True when this capture's timestamp was replaced with server time
+ // because the client's clock was outside the trust envelope. The
+ // upload still succeeded; a client seeing this should re-derive its
+ // offset from `serverTime` so later captures are stamped accurately.
+ ...(capturedAtAdopted ? { capturedAtAdopted: true } : {}),
trackingMode,
+ format,
+ clipsEnabled: session.clipsEnabled,
+ frameIntervalMs: CLIP_FRAME_INTERVAL_MS,
};
},
);
@@ -512,6 +703,7 @@ export async function sessionRoutes(app: FastifyInstance) {
width: number;
height: number;
fileSize: number;
+ frameCount?: number;
};
}>(
"/api/sessions/:token/screenshots",
@@ -526,6 +718,9 @@ export async function sessionRoutes(app: FastifyInstance) {
width: { type: "integer" as const, minimum: 1 },
height: { type: "integer" as const, minimum: 1 },
fileSize: { type: "integer" as const, minimum: 1 },
+ // Frames inside an uploaded clip. Informational (the worker
+ // demuxes for the real count); omitted for jpeg captures.
+ frameCount: { type: "integer" as const, minimum: 1, maximum: 600 },
},
additionalProperties: false,
},
@@ -558,7 +753,7 @@ export async function sessionRoutes(app: FastifyInstance) {
.send({ error: `Session is ${session.status}, cannot confirm` });
}
- const { screenshotId, width, height, fileSize } = request.body;
+ const { screenshotId, width, height, fileSize, frameCount } = request.body;
// Validate screenshot belongs to this session and isn't already confirmed
const screenshot = await db.query.screenshots.findFirst({
@@ -604,15 +799,22 @@ export async function sessionRoutes(app: FastifyInstance) {
new HeadObjectCommand({ Bucket: R2_BUCKET, Key: screenshot.r2Key }),
);
- // Validate ContentType is image/jpeg
- if (head.ContentType !== "image/jpeg") {
+ // Validate ContentType matches the format the upload-url granted.
+ // The presigned PUT was signed with this content type, so a mismatch
+ // means the object was not uploaded through the granted URL.
+ const rowFormat = (screenshot.format ?? "jpeg") as CaptureFormat;
+ const expectedContentType = CAPTURE_FORMAT_CONTENT_TYPES[rowFormat];
+ if (head.ContentType !== expectedContentType) {
return reply
.code(400)
- .send({ error: "Invalid content type — expected image/jpeg" });
+ .send({ error: `Invalid content type — expected ${expectedContentType}` });
}
- // Validate file size is within limits
- if (head.ContentLength && head.ContentLength > MAX_SCREENSHOT_BYTES) {
+ // Validate file size is within the per-format limit. Clips get a
+ // larger budget than single frames, bounded by the client bitrate cap.
+ const maxBytes =
+ rowFormat === "jpeg" ? MAX_SCREENSHOT_BYTES : MAX_CLIP_BYTES;
+ if (head.ContentLength && head.ContentLength > maxBytes) {
return reply.code(400).send({ error: "Uploaded object is too large" });
}
} catch {
@@ -675,6 +877,7 @@ export async function sessionRoutes(app: FastifyInstance) {
width,
height,
fileSizeBytes: fileSize,
+ frameCount: frameCount ?? null,
creditedSeconds: decision.credit,
expectedAt: decision.expectedAt,
})
@@ -720,6 +923,7 @@ export async function sessionRoutes(app: FastifyInstance) {
width,
height,
fileSizeBytes: fileSize,
+ frameCount: frameCount ?? null,
})
.where(eq(schema.screenshots.id, screenshotId));
@@ -877,11 +1081,27 @@ 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 },
+ },
+ // Deliberately permissive. This route accepted (and ignored) any
+ // body before `edit` existed, so rejecting unknown fields would
+ // turn a working custom client into a 400 for no benefit.
+ additionalProperties: true,
+ },
+ },
},
async (request, reply) => {
// Rate limit: 10 req/min per token (actions)
@@ -922,6 +1142,16 @@ 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.
+ // This is the first lease term — the editor renews it as soon as it
+ // opens, so a client that promises an editor and never shows one
+ // publishes a lease later rather than stranding the session.
+ const screenshotCount = await getScreenshotCount(session.id);
+ const wantsEdit = request.body?.edit === true && screenshotCount > 0;
+ const editHoldUntil = wantsEdit
+ ? new Date(stopNow.getTime() + EDIT_LEASE_SECONDS * 1000)
+ : null;
+
const [updated] = await db
.update(schema.sessions)
.set({
@@ -929,6 +1159,7 @@ export async function sessionRoutes(app: FastifyInstance) {
stoppedAt: stopNow,
totalActiveSeconds,
trackedSeconds,
+ editHoldUntil,
updatedAt: stopNow,
})
.where(and(
@@ -942,7 +1173,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 {
@@ -957,6 +1187,9 @@ export async function sessionRoutes(app: FastifyInstance) {
status: "stopped" as const,
trackedSeconds,
totalActiveSeconds,
+ ...(editHoldUntil
+ ? { editHoldUntil: editHoldUntil.toISOString() }
+ : {}),
};
},
);
@@ -983,7 +1216,7 @@ export async function sessionRoutes(app: FastifyInstance) {
const liveTrackedSeconds = await getTrackedSecondsForSession(session);
// For credit mode, dispatcher already reads from session.trackedSeconds.
- const trackedSeconds =
+ const rawTrackedSeconds =
session.trackingMode === "credit"
? liveTrackedSeconds
: session.trackedSeconds ?? liveTrackedSeconds;
@@ -991,6 +1224,10 @@ export async function sessionRoutes(app: FastifyInstance) {
const baseUrl = process.env.BASE_URL || "http://localhost:3000";
return {
status: session.status,
+ // Real compile progress (0..~0.95) when the worker is metering an
+ // original build; absent for cut-apply compiles and pre-column
+ // workers, where the client falls back to its time estimate.
+ progress: session.compileProgress ?? undefined,
videoUrl: session.videoR2Key
? `${baseUrl}/api/media/${session.id}/video.mp4`
: undefined,
@@ -998,7 +1235,16 @@ export async function sessionRoutes(app: FastifyInstance) {
videoWebmUrl: session.videoR2Key
? `${baseUrl}/please-update.webm`
: undefined,
- trackedSeconds,
+ trackedSeconds: reportedTrackedSeconds(rawTrackedSeconds, session),
+ // 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,
};
},
);
@@ -1008,10 +1254,23 @@ export async function sessionRoutes(app: FastifyInstance) {
// the session, oldest first. Uses captured_at (client-attested capture
// moment); pre-migration rows that predate captured_at fall back to
// requested_at so the array is never sparse.
- app.get<{ Params: { token: string } }>(
+ //
+ // Captures inside the session's cut list are EXCLUDED from `timestamps` by
+ // default, so heartbeat forwarders (→ Hackatime) respect user edits with
+ // no code changes. The removed points are available via ?includeCut=true.
+ app.get<{ Params: { token: string }; Querystring: { includeCut?: boolean } }>(
"/api/sessions/:token/timings",
{
- schema: { params: tokenParamSchema },
+ schema: {
+ params: tokenParamSchema,
+ querystring: {
+ type: "object" as const,
+ properties: {
+ includeCut: { type: "boolean" as const },
+ },
+ additionalProperties: false,
+ },
+ },
},
async (request, reply) => {
// Rate limit: 30 req/min per token (read-only, potentially large body)
@@ -1043,10 +1302,22 @@ export async function sessionRoutes(app: FastifyInstance) {
);
// node-postgres may hand timestamps back as strings; coerce before toISOString.
- const timestamps = rows.map((r) =>
+ const allTimestamps = rows.map((r) =>
(r.ts instanceof Date ? r.ts : new Date(r.ts)).toISOString(),
);
+ // Partition by the cut list (kept is the default view).
+ const cuts = sessionCuts(session);
+ const timestamps: string[] = [];
+ const cutTimestamps: string[] = [];
+ for (const iso of allTimestamps) {
+ if (cuts.length > 0 && isCutAt(Date.parse(iso), cuts)) {
+ cutTimestamps.push(iso);
+ } else {
+ timestamps.push(iso);
+ }
+ }
+
const clientInfo = await getFirstClientInfo(session.id);
const ja4 = await getFirstJa4(session.id);
@@ -1061,6 +1332,415 @@ export async function sessionRoutes(app: FastifyInstance) {
clientInfo,
ja4,
timestamps,
+ cuts,
+ cutCount: cutTimestamps.length,
+ ...(request.query.includeCut ? { cutTimestamps } : {}),
+ };
+ },
+ );
+
+ // ── Edits (cuts) ─────────────────────────────────────────────
+ // An edit is a cut list of absolute wall-clock intervals removed from
+ // every output: the published video, /timings, and trackedSeconds. See
+ // @lookout/shared cuts.ts for the canonical semantics and
+ // docs/edit-feature-plan.md for the architecture.
+
+ // Editor metadata: the original video's unit map (video second i ↔ wall
+ // clock), current cuts, and a presigned URL for the UNCUT original.
+ // Deliberately NOT the public media URL — after an edit, cut content
+ // exists only in the original, which must stay reachable through the
+ // secret token alone.
+ app.get<{ Params: { token: string } }>(
+ "/api/sessions/:token/units",
+ {
+ schema: { params: tokenParamSchema },
+ },
+ async (request, reply) => {
+ const rl = checkGenericRateLimit("session-units", request.params.token, 10);
+ if (!rl.allowed) {
+ reply.header(
+ "Retry-After",
+ String(Math.ceil((rl.retryAfterMs ?? 60_000) / 1000)),
+ );
+ return reply.code(429).send({ error: "Rate limit exceeded" });
+ }
+
+ const session = await findSession(request.params.token);
+ if (!session) return reply.code(404).send({ error: "Session not found" });
+
+ const { editable, reason } = sessionEditability(session);
+
+ let originalVideoUrl: string | null = null;
+ if (editable) {
+ originalVideoUrl = await getSignedUrl(
+ r2Client,
+ new GetObjectCommand({
+ Bucket: R2_BUCKET,
+ Key: session.originalVideoR2Key!,
+ }),
+ { expiresIn: 3600 },
+ );
+ }
+
+ return {
+ units: (session.videoUnits as VideoUnit[] | null) ?? [],
+ cuts: sessionCuts(session),
+ editable,
+ ...(editable ? {} : { editableReason: reason }),
+ 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. Minus the seed capture, which the
+ // compiler excludes from the video (see dropSeedUnit): counting it
+ // would make the waiting-room copy promise one minute more than
+ // the finished timelapse holds.
+ expectedUnits: Math.max(0, (await getScreenshotCount(session.id)) - 1),
+ originalVideoUrl,
+ recompilesRemaining: Math.max(
+ 0,
+ MAX_USER_RECOMPILES - session.recompileCount,
+ ),
+ };
+ },
+ );
+
+ // Renew the edit lease: "someone still has this open".
+ //
+ // The hold is a lease rather than a countdown, so an open editor keeps
+ // the session unpublished for as long as it's genuinely being used, and
+ // an abandoned one publishes about a lease later. Cheap and idempotent —
+ // clients call it every EDIT_HEARTBEAT_SECONDS.
+ app.post<{ Params: { token: string } }>(
+ "/api/sessions/:token/editing",
+ {
+ schema: { params: tokenParamSchema },
+ },
+ async (request, reply) => {
+ const rl = checkGenericRateLimit("session-editing", request.params.token, 20);
+ if (!rl.allowed) {
+ reply.header(
+ "Retry-After",
+ String(Math.ceil((rl.retryAfterMs ?? 60_000) / 1000)),
+ );
+ return reply.code(429).send({ error: "Rate limit exceeded" });
+ }
+
+ const session = await findSession(request.params.token);
+ if (!session) return reply.code(404).send({ error: "Session not found" });
+
+ // Already out the door — tell the caller to stop renewing.
+ if (session.status === "complete" || session.status === "failed") {
+ return { editHoldUntil: new Date().toISOString(), held: false };
+ }
+ if (session.editHoldUntil === null) {
+ return { editHoldUntil: new Date().toISOString(), held: false };
+ }
+
+ // The absolute ceiling is measured from the stop, so an editor left
+ // open indefinitely can't keep a program waiting forever.
+ const ceiling = session.stoppedAt
+ ? session.stoppedAt.getTime() + EDIT_HOLD_MAX_MINUTES * 60_000
+ : Number.POSITIVE_INFINITY;
+ if (Date.now() >= ceiling) {
+ return { editHoldUntil: session.editHoldUntil.toISOString(), held: false };
+ }
+
+ const next = new Date(
+ Math.min(ceiling, Date.now() + EDIT_LEASE_SECONDS * 1000),
+ );
+ // Renew even if the previous term lapsed moments ago but the expiry
+ // job hasn't run: a brief network stall shouldn't end someone's edit.
+ // The status guard is what makes that safe — a published session
+ // can't be pulled back.
+ const [updated] = await db
+ .update(schema.sessions)
+ .set({ editHoldUntil: next, updatedAt: new Date() })
+ .where(
+ and(
+ eq(schema.sessions.id, session.id),
+ sql`${schema.sessions.status} IN ('stopped', 'compiling')`,
+ isNotNull(schema.sessions.editHoldUntil),
+ ),
+ )
+ .returning({ id: schema.sessions.id });
+
+ return updated
+ ? { editHoldUntil: next.toISOString(), held: true }
+ : { editHoldUntil: new Date().toISOString(), held: false };
+ },
+ );
+
+ // Replace the session's cut list. Idempotent full replace — no patch
+ // 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 }> };
+ }>(
+ "/api/sessions/:token/cuts",
+ {
+ schema: {
+ params: tokenParamSchema,
+ body: {
+ type: "object" as const,
+ required: ["cuts"] as const,
+ properties: {
+ cuts: {
+ type: "array" as const,
+ maxItems: 200,
+ items: {
+ type: "object" as const,
+ required: ["start", "end"] as const,
+ properties: {
+ start: { type: "string" as const },
+ end: { type: "string" as const },
+ },
+ additionalProperties: false,
+ },
+ },
+ },
+ additionalProperties: false,
+ },
+ },
+ },
+ async (request, reply) => {
+ const rl = checkGenericRateLimit("session-cuts", request.params.token, 20);
+ if (!rl.allowed) {
+ reply.header(
+ "Retry-After",
+ String(Math.ceil((rl.retryAfterMs ?? 60_000) / 1000)),
+ );
+ return reply.code(429).send({ error: "Rate limit exceeded" });
+ }
+
+ const session = await findSession(request.params.token);
+ if (!session) return reply.code(404).send({ error: "Session not found" });
+
+ if (session.status === "compiling") {
+ return reply
+ .code(409)
+ .send({ error: "Session is compiling — retry once it completes" });
+ }
+ const { editable, reason } = sessionEditability(session);
+ if (!editable) {
+ return reply
+ .code(409)
+ .send({ error: `Session is not editable (${reason})` });
+ }
+
+ const boundsMin = session.startedAt?.getTime();
+ const boundsMax = (session.stoppedAt ?? session.updatedAt)?.getTime();
+ const normalized = normalizeCuts(
+ request.body.cuts,
+ boundsMin !== undefined && boundsMax !== undefined
+ ? { minMs: boundsMin, maxMs: boundsMax }
+ : undefined,
+ );
+ if (!normalized.ok) {
+ return reply.code(400).send({ error: normalized.error });
+ }
+
+ const videoUnits = session.videoUnits as VideoUnit[];
+ const unitTimesMs = videoUnits.map((u) => Date.parse(u.capturedAt));
+ const unitsCut = countCutUnits(unitTimesMs, normalized.cuts);
+ if (normalized.cuts.length > 0 && unitsCut >= videoUnits.length) {
+ return reply
+ .code(400)
+ .send({ error: "Cut list would remove the entire timelapse" });
+ }
+
+ // Same rows + same pure function as the worker's authoritative
+ // cut-compile write, so this preview is exactly what lands.
+ const liveTrackedSeconds = await getTrackedSecondsForSession(session);
+ const rawTrackedSeconds =
+ session.trackingMode === "credit"
+ ? liveTrackedSeconds
+ : session.trackedSeconds ?? liveTrackedSeconds;
+ const cutSeconds = computeCutSeconds(
+ await getCaptureRowsForCuts(session.id),
+ session.trackingMode === "credit" ? "credit" : "bucket",
+ rawTrackedSeconds,
+ 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({
+ cuts: normalized.cuts,
+ cutSeconds,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(schema.sessions.id, session.id),
+ eq(schema.sessions.status, "stopped"),
+ sql`${schema.sessions.editHoldUntil} > now()`,
+ ),
+ )
+ .returning({ id: schema.sessions.id });
+ if (!updated) {
+ return reply
+ .code(409)
+ .send({ error: "Edit window closed — the timelapse was already published" });
+ }
+
+ return {
+ cuts: normalized.cuts,
+ unitsTotal: videoUnits.length,
+ unitsCut,
+ trackedSeconds: Math.max(0, rawTrackedSeconds - cutSeconds),
+ uncutTrackedSeconds: rawTrackedSeconds,
+ };
+ },
+ );
+
+ // 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",
+ {
+ schema: { params: tokenParamSchema },
+ },
+ async (request, reply) => {
+ const rl = checkGenericRateLimit("session-compile", request.params.token, 5);
+ if (!rl.allowed) {
+ reply.header(
+ "Retry-After",
+ String(Math.ceil((rl.retryAfterMs ?? 60_000) / 1000)),
+ );
+ return reply.code(429).send({ error: "Rate limit exceeded" });
+ }
+
+ 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,
+ );
+
+ // Already publishing — treat as success so client retries are safe.
+ //
+ // "compiling" covers two different runs, and only one of them is a
+ // publish. With an original already built, the in-flight job is the
+ // cut-compile that publishes, so a repeat request is a duplicate: 202.
+ // With no original yet, the in-flight job is the PREVIEW build, and
+ // this request means "don't bother, publish as recorded" — which the
+ // hold-drop branch below handles. Without the originalVideoR2Key
+ // guard this shadowed that branch, so a user who declined editing
+ // mid-preview got a cheerful 202 while their session stayed held, then
+ // waited for the very preview they had just declined.
+ if (session.status === "compiling" && session.originalVideoR2Key) {
+ return reply
+ .code(202)
+ .send({
+ status: "compiling" as const,
+ instant: false,
+ recompilesRemaining,
+ redirectUrl: session.redirectUrl,
+ });
+ }
+ 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,
+ redirectUrl: session.redirectUrl,
+ };
+ }
+
+ 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,
+ redirectUrl: session.redirectUrl,
+ };
+ }
+
+ if (!editable) {
+ return reply
+ .code(409)
+ .send({ error: `Session is not editable (${reason})` });
+ }
+
+ const cuts = sessionCuts(session);
+
+ 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,
+ redirectUrl: session.redirectUrl,
+ };
+ }
+
+ // 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, "stopped"),
+ ),
+ )
+ .returning({ id: schema.sessions.id });
+ if (!updated) {
+ return reply
+ .code(409)
+ .send({ error: "Session state changed concurrently, please retry" });
+ }
+
+ await boss.send(COMPILE_JOB, { sessionId: session.id });
+
+ return {
+ status: "compiling" as const,
+ instant: false,
+ recompilesRemaining: Math.max(
+ 0,
+ MAX_USER_RECOMPILES - (session.recompileCount + 1),
+ ),
+ redirectUrl: session.redirectUrl,
};
},
);
@@ -1230,7 +1910,7 @@ export async function sessionRoutes(app: FastifyInstance) {
// Credit-mode: trust sessions.tracked_seconds (maintained per-credit).
// Bucket-mode: prefer stored value (survives screenshot cleanup),
// fall back to live screenshot bucket count for active sessions.
- const trackedSeconds =
+ const rawTrackedSeconds =
s.trackingMode === "credit"
? s.trackedSeconds ?? 0
: s.trackedSeconds ?? c.bucketTrackedSeconds;
@@ -1238,7 +1918,7 @@ export async function sessionRoutes(app: FastifyInstance) {
token: s.token,
name: s.name,
status: s.status,
- trackedSeconds,
+ trackedSeconds: reportedTrackedSeconds(rawTrackedSeconds, s),
screenshotCount: c.screenshotCount,
startedAt: s.startedAt?.toISOString() ?? null,
createdAt: s.createdAt.toISOString(),
@@ -1284,14 +1964,40 @@ export async function sessionRoutes(app: FastifyInstance) {
return reply.code(404).send({ error: "Thumbnail not available" });
}
+ // Stream the bytes instead of redirecting to a presigned URL: the
+ // presigned URL changes on every request, which defeats the browser
+ // HTTP cache entirely. Thumbnails are the session's first frame, so
+ // they almost never change — a stable URL + ETag makes repeat app
+ // opens a disk-cache hit or a 304.
+ const cacheControl = "public, max-age=86400, stale-while-revalidate=604800";
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
- const url = await getSignedUrl(r2Client, new GetObjectCommand({
- Bucket: R2_BUCKET,
- Key: session.thumbnailR2Key,
- }), { expiresIn: 3600 });
-
- reply.header("Cache-Control", "public, max-age=1800");
- return reply.redirect(url);
+ const ifNoneMatch = request.headers["if-none-match"];
+ try {
+ const obj = await r2Client.send(new GetObjectCommand({
+ Bucket: R2_BUCKET,
+ Key: session.thumbnailR2Key,
+ IfNoneMatch: ifNoneMatch,
+ }));
+ reply.header("Cache-Control", cacheControl);
+ reply.header("Content-Type", "image/jpeg");
+ if (obj.ETag) reply.header("ETag", obj.ETag);
+ if (obj.ContentLength !== undefined) {
+ reply.header("Content-Length", String(obj.ContentLength));
+ }
+ return reply.send(obj.Body);
+ } catch (err) {
+ const status = (err as { $metadata?: { httpStatusCode?: number } })
+ .$metadata?.httpStatusCode;
+ if (status === 304) {
+ reply.header("Cache-Control", cacheControl);
+ if (ifNoneMatch) reply.header("ETag", ifNoneMatch);
+ return reply.code(304).send();
+ }
+ if (status === 404) {
+ return reply.code(404).send({ error: "Thumbnail not available" });
+ }
+ throw err;
+ }
},
);
diff --git a/packages/server/test/clips.integration.test.ts b/packages/server/test/clips.integration.test.ts
new file mode 100644
index 00000000..4d6789b4
--- /dev/null
+++ b/packages/server/test/clips.integration.test.ts
@@ -0,0 +1,326 @@
+/**
+ * Integration tests for clip uploads (per-minute video capture units)
+ * against a real Postgres.
+ *
+ * Covers: the session-level clips gate (grant vs silent downgrade), the
+ * clip-first upload path (a webm/mp4 as the session's FIRST upload must
+ * activate the session and seed credit-mode exactly like a JPEG), per-format
+ * confirm validation (content type + size cap), frameCount persistence, the
+ * capability fields on the session GET, the internal-API opt-in, and — most
+ * importantly — that clients which know nothing about clips see byte-identical
+ * legacy behavior.
+ *
+ * Requires the test postgres on port 5434 (see test/setup.ts).
+ */
+import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { FastifyInstance } from "fastify";
+import { sql, eq } from "drizzle-orm";
+import { buildApp } from "../src/app.js";
+import { db, schema } from "../src/db/index.js";
+import { setClock, resetClock } from "../src/lib/clock.js";
+import { CLIP_FRAME_INTERVAL_MS } from "@lookout/shared";
+
+let app: FastifyInstance;
+const baseTime = new Date("2025-06-01T12:00:00.000Z");
+let virtualNow = baseTime.getTime();
+
+function advanceVirtualMs(ms: number) {
+ virtualNow += ms;
+}
+function nowIso(): string {
+ return new Date(virtualNow).toISOString();
+}
+
+beforeEach(async () => {
+ await db.execute(sql`TRUNCATE screenshots, sessions RESTART IDENTITY CASCADE`);
+ if (!app) {
+ app = await buildApp();
+ }
+ virtualNow = baseTime.getTime();
+ setClock(() => new Date(virtualNow));
+});
+
+afterEach(() => {
+ delete (globalThis as any).__r2HeadObjectOverride;
+});
+
+afterAll(async () => {
+ resetClock();
+ if (app) await app.close();
+ await (db.$client as any).end?.();
+});
+
+async function createSession(clipsEnabled: boolean): Promise<{ id: string; token: string }> {
+ const [s] = await db
+ .insert(schema.sessions)
+ .values({ name: "clips-test-session", clipsEnabled })
+ .returning({ id: schema.sessions.id, token: schema.sessions.token });
+ return s;
+}
+
+async function getUploadUrl(
+ token: string,
+ opts: { capturedAt?: string; format?: string } = {},
+): Promise<{ status: number; body: any }> {
+ const params = new URLSearchParams();
+ if (opts.capturedAt) params.set("capturedAt", opts.capturedAt);
+ if (opts.format) params.set("format", opts.format);
+ const qs = params.toString();
+ const r = await app.inject({
+ method: "GET",
+ url: `/api/sessions/${token}/upload-url${qs ? `?${qs}` : ""}`,
+ });
+ return { status: r.statusCode, body: r.json() };
+}
+
+async function confirmUpload(
+ token: string,
+ screenshotId: string,
+ extra: Record = {},
+): Promise<{ status: number; body: any }> {
+ const r = await app.inject({
+ method: "POST",
+ url: `/api/sessions/${token}/screenshots`,
+ payload: { screenshotId, width: 1920, height: 1080, fileSize: 12345, ...extra },
+ });
+ return { status: r.statusCode, body: r.json() };
+}
+
+describe("clip format grant / downgrade", () => {
+ it("grants webm on a clips-enabled session, with a .webm key", async () => {
+ const s = await createSession(true);
+ const { status, body } = await getUploadUrl(s.token, {
+ capturedAt: nowIso(),
+ format: "webm",
+ });
+ expect(status).toBe(200);
+ expect(body.format).toBe("webm");
+ expect(body.clipsEnabled).toBe(true);
+ expect(body.frameIntervalMs).toBe(CLIP_FRAME_INTERVAL_MS);
+ expect(body.r2Key).toMatch(/\.webm$/);
+ });
+
+ it("grants mp4 on a clips-enabled session (Safari / desktop path)", async () => {
+ const s = await createSession(true);
+ const { status, body } = await getUploadUrl(s.token, {
+ capturedAt: nowIso(),
+ format: "mp4",
+ });
+ expect(status).toBe(200);
+ expect(body.format).toBe("mp4");
+ expect(body.r2Key).toMatch(/\.mp4$/);
+ });
+
+ it("silently downgrades clip requests to jpeg when clips are disabled", async () => {
+ const s = await createSession(false);
+ const { status, body } = await getUploadUrl(s.token, {
+ capturedAt: nowIso(),
+ format: "webm",
+ });
+ // Not an error: the downgrade is the contract. A conforming client
+ // reads the granted format and uploads a JPEG instead.
+ expect(status).toBe(200);
+ expect(body.format).toBe("jpeg");
+ expect(body.clipsEnabled).toBe(false);
+ expect(body.r2Key).toMatch(/\.jpg$/);
+
+ const row = await db.query.screenshots.findFirst({
+ where: eq(schema.screenshots.id, body.screenshotId),
+ });
+ expect(row?.format).toBe("jpeg");
+ });
+
+ it("keeps legacy requests (no format param) byte-identical to before", async () => {
+ const s = await createSession(false);
+ const { status, body } = await getUploadUrl(s.token, { capturedAt: nowIso() });
+ expect(status).toBe(200);
+ expect(body.format).toBe("jpeg");
+ expect(body.r2Key).toMatch(/\.jpg$/);
+ // All pre-clips fields still present and shaped as before.
+ expect(body.uploadUrl).toBeTruthy();
+ expect(body.screenshotId).toBeTruthy();
+ expect(typeof body.minuteBucket).toBe("number");
+ expect(body.nextExpectedAt).toBeTruthy();
+ expect(body.trackingMode).toBe("credit");
+ });
+});
+
+describe("clip-first sessions (no JPEG ever)", () => {
+ it("activates, seeds credit mode, and credits the second clip", async () => {
+ const s = await createSession(true);
+
+ // First upload of the session is a clip — activation + credit-mode
+ // flip must work exactly as they do for a JPEG first upload.
+ const first = await getUploadUrl(s.token, {
+ capturedAt: nowIso(),
+ format: "webm",
+ });
+ expect(first.status).toBe(200);
+ expect(first.body.format).toBe("webm");
+ const firstConfirm = await confirmUpload(s.token, first.body.screenshotId, {
+ frameCount: 20,
+ });
+ expect(firstConfirm.status).toBe(200);
+ // Seed capture: credits 0, like every credit-mode session.
+ expect(firstConfirm.body.trackedSeconds).toBe(0);
+
+ const session = await db.query.sessions.findFirst({
+ where: eq(schema.sessions.id, s.id),
+ });
+ expect(session?.status).toBe("active");
+ expect(session?.trackingMode).toBe("credit");
+
+ // Second clip lands on the 60s mark → credits a full minute.
+ advanceVirtualMs(60_000);
+ const second = await getUploadUrl(s.token, {
+ capturedAt: nowIso(),
+ format: "webm",
+ });
+ expect(second.status).toBe(200);
+ const secondConfirm = await confirmUpload(s.token, second.body.screenshotId, {
+ frameCount: 20,
+ });
+ expect(secondConfirm.status).toBe(200);
+ expect(secondConfirm.body.trackedSeconds).toBe(60);
+ });
+
+ it("persists frameCount and format on the confirmed row", async () => {
+ const s = await createSession(true);
+ const up = await getUploadUrl(s.token, { capturedAt: nowIso(), format: "webm" });
+ const confirm = await confirmUpload(s.token, up.body.screenshotId, {
+ frameCount: 17,
+ });
+ expect(confirm.status).toBe(200);
+
+ const row = await db.query.screenshots.findFirst({
+ where: eq(schema.screenshots.id, up.body.screenshotId),
+ });
+ expect(row?.confirmed).toBe(true);
+ expect(row?.format).toBe("webm");
+ expect(row?.frameCount).toBe(17);
+ });
+
+ it("mixed sessions are legal: a jpeg fallback minute confirms fine", async () => {
+ const s = await createSession(true);
+ const clip = await getUploadUrl(s.token, { capturedAt: nowIso(), format: "webm" });
+ await confirmUpload(s.token, clip.body.screenshotId, { frameCount: 20 });
+
+ // Client hit an encoder hiccup and fell back to a JPEG for this tick.
+ advanceVirtualMs(60_000);
+ const jpeg = await getUploadUrl(s.token, { capturedAt: nowIso() });
+ expect(jpeg.body.format).toBe("jpeg");
+ const confirm = await confirmUpload(s.token, jpeg.body.screenshotId);
+ expect(confirm.status).toBe(200);
+ expect(confirm.body.trackedSeconds).toBe(60);
+ });
+});
+
+describe("per-format confirm validation", () => {
+ it("rejects a clip row whose stored object is not the granted content type", async () => {
+ const s = await createSession(true);
+ const up = await getUploadUrl(s.token, { capturedAt: nowIso(), format: "webm" });
+ // Simulate an object that bypassed the presigned URL's signed type.
+ (globalThis as any).__r2HeadObjectOverride = {
+ ContentType: "image/jpeg",
+ ContentLength: 1024,
+ };
+ const confirm = await confirmUpload(s.token, up.body.screenshotId, {
+ frameCount: 20,
+ });
+ expect(confirm.status).toBe(400);
+ expect(confirm.body.error).toContain("video/webm");
+ });
+
+ it("rejects a clip larger than the clip size cap", async () => {
+ const s = await createSession(true);
+ const up = await getUploadUrl(s.token, { capturedAt: nowIso(), format: "webm" });
+ (globalThis as any).__r2HeadObjectOverride = {
+ ContentType: "video/webm",
+ ContentLength: 9 * 1024 * 1024,
+ };
+ const confirm = await confirmUpload(s.token, up.body.screenshotId, {
+ frameCount: 20,
+ });
+ expect(confirm.status).toBe(400);
+ expect(confirm.body.error).toContain("too large");
+ });
+});
+
+describe("capability discovery", () => {
+ it("session GET carries clipsEnabled + frameIntervalMs", async () => {
+ const on = await createSession(true);
+ const off = await createSession(false);
+
+ const rOn = await app.inject({ method: "GET", url: `/api/sessions/${on.token}` });
+ expect(rOn.statusCode).toBe(200);
+ expect(rOn.json().clipsEnabled).toBe(true);
+ expect(rOn.json().frameIntervalMs).toBe(CLIP_FRAME_INTERVAL_MS);
+
+ const rOff = await app.inject({ method: "GET", url: `/api/sessions/${off.token}` });
+ expect(rOff.statusCode).toBe(200);
+ expect(rOff.json().clipsEnabled).toBe(false);
+ });
+});
+
+describe("internal API opt-out", () => {
+ async function makeApiKey(): Promise {
+ const [row] = await db
+ .insert(schema.apiKeys)
+ .values({ name: `clips-test-${Date.now()}-${Math.random()}` })
+ .returning({ key: schema.apiKeys.key });
+ return row.key;
+ }
+
+ it("enables clips by default, and only clips:false opts out", async () => {
+ const key = await makeApiKey();
+
+ // Explicit true, omitted, and explicit false — the three ways a program
+ // can express intent. Only the last one turns clips off.
+ const withClips = await app.inject({
+ method: "POST",
+ url: "/api/internal/sessions",
+ headers: { "x-api-key": key },
+ payload: { name: "clips-on", clips: true },
+ });
+ expect(withClips.statusCode).toBe(201);
+
+ const without = await app.inject({
+ method: "POST",
+ url: "/api/internal/sessions",
+ headers: { "x-api-key": key },
+ payload: { name: "clips-default" },
+ });
+ expect(without.statusCode).toBe(201);
+
+ const optedOut = await app.inject({
+ method: "POST",
+ url: "/api/internal/sessions",
+ headers: { "x-api-key": key },
+ payload: { name: "clips-off", clips: false },
+ });
+ expect(optedOut.statusCode).toBe(201);
+
+ const onRow = await db.query.sessions.findFirst({
+ where: eq(schema.sessions.id, withClips.json().sessionId),
+ });
+ const defaultRow = await db.query.sessions.findFirst({
+ where: eq(schema.sessions.id, without.json().sessionId),
+ });
+ const offRow = await db.query.sessions.findFirst({
+ where: eq(schema.sessions.id, optedOut.json().sessionId),
+ });
+ expect(onRow?.clipsEnabled).toBe(true);
+ // The whole point of the flip: saying nothing gets you clips.
+ expect(defaultRow?.clipsEnabled).toBe(true);
+ expect(offRow?.clipsEnabled).toBe(false);
+
+ // Internal GET surfaces the flag for program backends/ops.
+ const detail = await app.inject({
+ method: "GET",
+ url: `/api/internal/sessions/${withClips.json().sessionId}`,
+ headers: { "x-api-key": key },
+ });
+ expect(detail.statusCode).toBe(200);
+ expect(detail.json().session.clipsEnabled).toBe(true);
+ });
+});
diff --git a/packages/server/test/cuts.unit.test.ts b/packages/server/test/cuts.unit.test.ts
new file mode 100644
index 00000000..2079be9c
--- /dev/null
+++ b/packages/server/test/cuts.unit.test.ts
@@ -0,0 +1,144 @@
+/**
+ * Pure unit tests for the shared cut-list semantics (@lookout/shared
+ * cuts.ts) — the single membership/normalization/tracked-time
+ * implementation used by the server routes, the worker's cut-compile, and
+ * the React editor. No DB required.
+ */
+import { describe, expect, it } from "vitest";
+import {
+ normalizeCuts,
+ isCutAt,
+ computeKeptRanges,
+ countCutUnits,
+ computeCutSeconds,
+ MAX_CUT_INTERVALS,
+ type CutInterval,
+ type CaptureRowForCuts,
+} from "@lookout/shared";
+
+const T0 = Date.parse("2026-07-01T10:00:00.000Z");
+const iso = (offsetMin: number) => new Date(T0 + offsetMin * 60_000).toISOString();
+const cut = (a: number, b: number): CutInterval => ({ start: iso(a), end: iso(b) });
+
+describe("normalizeCuts", () => {
+ it("accepts an empty list", () => {
+ const r = normalizeCuts([]);
+ expect(r).toEqual({ ok: true, cuts: [] });
+ });
+
+ it("rejects non-arrays and malformed entries", () => {
+ expect(normalizeCuts("nope").ok).toBe(false);
+ expect(normalizeCuts([{ start: 5, end: 6 }]).ok).toBe(false);
+ expect(normalizeCuts([{ start: "not a date", end: iso(1) }]).ok).toBe(false);
+ });
+
+ it("rejects end <= start", () => {
+ expect(normalizeCuts([cut(5, 5)]).ok).toBe(false);
+ expect(normalizeCuts([cut(6, 5)]).ok).toBe(false);
+ });
+
+ it("rejects lists over the interval cap", () => {
+ const many = Array.from({ length: MAX_CUT_INTERVALS + 1 }, (_, i) =>
+ cut(i * 2, i * 2 + 1),
+ );
+ expect(normalizeCuts(many).ok).toBe(false);
+ });
+
+ it("sorts and merges overlapping and adjacent intervals", () => {
+ const r = normalizeCuts([cut(10, 15), cut(3, 6), cut(14, 20), cut(6, 8)]);
+ expect(r).toEqual({
+ ok: true,
+ cuts: [cut(3, 8), cut(10, 20)],
+ });
+ });
+
+ it("clamps to the session envelope and drops fully-outside intervals", () => {
+ const bounds = { minMs: T0, maxMs: T0 + 30 * 60_000 };
+ const r = normalizeCuts([cut(-30, 5), cut(50, 60)], bounds);
+ expect(r.ok).toBe(true);
+ if (r.ok) {
+ expect(r.cuts).toHaveLength(1);
+ // Clamped to minMs − 5min slack.
+ expect(Date.parse(r.cuts[0].start)).toBe(T0 - 5 * 60_000);
+ expect(r.cuts[0].end).toBe(iso(5));
+ }
+ });
+});
+
+describe("isCutAt", () => {
+ const cuts = [cut(5, 10)];
+ it("is end-exclusive, start-inclusive", () => {
+ expect(isCutAt(T0 + 5 * 60_000, cuts)).toBe(true);
+ expect(isCutAt(T0 + 10 * 60_000 - 1, cuts)).toBe(true);
+ expect(isCutAt(T0 + 10 * 60_000, cuts)).toBe(false);
+ expect(isCutAt(T0 + 4 * 60_000, cuts)).toBe(false);
+ });
+});
+
+describe("computeKeptRanges", () => {
+ // 10 units captured one per minute.
+ const unitTimes = Array.from({ length: 10 }, (_, i) => T0 + i * 60_000);
+
+ it("keeps everything with no cuts", () => {
+ expect(computeKeptRanges(unitTimes, [])).toEqual([{ start: 0, end: 10 }]);
+ });
+
+ it("splits around a middle cut", () => {
+ // Cut minutes 3..5 (units 3, 4).
+ expect(computeKeptRanges(unitTimes, [cut(3, 5)])).toEqual([
+ { start: 0, end: 3 },
+ { start: 5, end: 10 },
+ ]);
+ });
+
+ it("handles cuts at the ends and multiple regions", () => {
+ expect(
+ computeKeptRanges(unitTimes, [cut(0, 2), cut(4, 5), cut(8, 60)]),
+ ).toEqual([
+ { start: 2, end: 4 },
+ { start: 5, end: 8 },
+ ]);
+ });
+
+ it("returns [] when everything is cut", () => {
+ expect(computeKeptRanges(unitTimes, [cut(0, 60)])).toEqual([]);
+ });
+
+ it("counts cut units consistently", () => {
+ expect(countCutUnits(unitTimes, [cut(3, 5)])).toBe(2);
+ });
+});
+
+describe("computeCutSeconds", () => {
+ const rows: CaptureRowForCuts[] = Array.from({ length: 10 }, (_, i) => ({
+ timeMs: T0 + i * 60_000,
+ creditedSeconds: i === 0 ? 0 : 60, // seed capture credits 0, like real streaks
+ minuteBucket: i,
+ }));
+ const rawCredit = rows.reduce((n, r) => n + (r.creditedSeconds ?? 0), 0); // 540
+
+ it("returns 0 for no cuts", () => {
+ expect(computeCutSeconds(rows, "credit", rawCredit, [])).toBe(0);
+ });
+
+ it("credit mode sums credited seconds of cut rows", () => {
+ // Cut minutes 3..5 → units 3 and 4, each credited 60.
+ expect(computeCutSeconds(rows, "credit", rawCredit, [cut(3, 5)])).toBe(120);
+ // Cutting the 0-credit seed removes nothing.
+ expect(computeCutSeconds(rows, "credit", rawCredit, [cut(0, 1)])).toBe(0);
+ });
+
+ it("bucket mode mirrors the (buckets − 1) × 60 formula", () => {
+ const rawBucket = (10 - 1) * 60; // 540
+ // Cutting 2 buckets leaves 8 → kept = 7 × 60 = 420 → cut = 120.
+ expect(computeCutSeconds(rows, "bucket", rawBucket, [cut(3, 5)])).toBe(120);
+ // Empty cut list must be exactly 0 (kept formula matches raw).
+ expect(computeCutSeconds(rows, "bucket", rawBucket, [])).toBe(0);
+ });
+
+ it("never exceeds the raw tracked value", () => {
+ expect(
+ computeCutSeconds(rows, "credit", 60, [cut(0, 60)]),
+ ).toBeLessThanOrEqual(60);
+ });
+});
diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts
new file mode 100644
index 00000000..c297ca9f
--- /dev/null
+++ b/packages/server/test/edits.integration.test.ts
@@ -0,0 +1,527 @@
+/**
+ * Integration tests for the stop-time edit flow against a real Postgres.
+ *
+ * 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";
+import { sql, eq } from "drizzle-orm";
+import { EDIT_LEASE_SECONDS, EDIT_HOLD_MAX_MINUTES } from "@lookout/shared";
+import { buildApp } from "../src/app.js";
+import { db, schema } from "../src/db/index.js";
+
+let app: FastifyInstance;
+
+const UNITS = 10;
+
+/**
+ * Fixture clock, anchored so the seeded session stopped one minute ago.
+ *
+ * Deliberately RELATIVE. The edit hold has an absolute ceiling measured from
+ * `stoppedAt` (EDIT_HOLD_MAX_MINUTES after the stop), so a session whose stop
+ * is further in the past than that can never renew its lease. This was pinned
+ * to a hardcoded "2026-07-01" — a future date when it was written, which
+ * passed CI happily until the calendar caught up and then failed every lease
+ * test with `held: false` for reasons that had nothing to do with leases.
+ * Anchoring to now keeps the fixture describing a just-stopped session, which
+ * is the state these tests are actually about.
+ */
+const T0 = new Date(Date.now() - (UNITS + 1) * 60_000);
+const minute = (i: number) => new Date(T0.getTime() + i * 60_000);
+const iso = (i: number) => minute(i).toISOString();
+
+beforeEach(async () => {
+ await db.execute(sql`TRUNCATE screenshots, sessions RESTART IDENTITY CASCADE`);
+ if (!app) {
+ app = await buildApp();
+ }
+});
+
+afterAll(async () => {
+ if (app) await app.close();
+ await (db.$client as any).end?.();
+});
+
+/**
+ * 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 seedHeldSession(
+ overrides: Partial = {},
+) {
+ const [s] = await db
+ .insert(schema.sessions)
+ .values({
+ name: "edit-test",
+ status: "stopped",
+ trackingMode: "credit",
+ trackedSeconds: 540,
+ startedAt: minute(0),
+ stoppedAt: minute(UNITS),
+ editHoldUntil: new Date(Date.now() + EDIT_LEASE_SECONDS * 1000),
+ videoR2Key: null,
+ originalVideoR2Key: "timelapses/x/original.mp4",
+ thumbnailR2Key: "timelapses/x/thumbnail.jpg",
+ videoCopyAligned: true,
+ videoUnits: Array.from({ length: UNITS }, (_, i) => ({
+ capturedAt: iso(i),
+ screenshotId: `00000000-0000-0000-0000-0000000000${String(i).padStart(2, "0")}`,
+ })),
+ ...overrides,
+ })
+ .returning({ id: schema.sessions.id, token: schema.sessions.token });
+
+ await db.insert(schema.screenshots).values(
+ Array.from({ length: UNITS }, (_, i) => ({
+ sessionId: s.id,
+ r2Key: `screenshots/${s.id}/${i}.jpg`,
+ requestedAt: minute(i),
+ capturedAt: minute(i),
+ minuteBucket: i,
+ confirmed: true,
+ sampled: true,
+ creditedSeconds: i === 0 ? 0 : 60,
+ })),
+ );
+
+ return s;
+}
+
+/** An active session with one confirmed capture, ready to be stopped. */
+async function seedActiveSession() {
+ const [s] = await db
+ .insert(schema.sessions)
+ .values({
+ name: "active-test",
+ status: "active",
+ trackingMode: "credit",
+ trackedSeconds: 60,
+ startedAt: minute(0),
+ })
+ .returning({ id: schema.sessions.id, token: schema.sessions.token });
+ await db.insert(schema.screenshots).values({
+ sessionId: s.id,
+ r2Key: `screenshots/${s.id}/0.jpg`,
+ requestedAt: minute(0),
+ capturedAt: minute(0),
+ minuteBucket: 0,
+ confirmed: true,
+ creditedSeconds: 0,
+ });
+ return s;
+}
+
+const load = (id: string) =>
+ db.query.sessions.findFirst({ where: eq(schema.sessions.id, id) });
+
+async function putCuts(token: string, cuts: unknown) {
+ const r = await app.inject({
+ method: "PUT",
+ url: `/api/sessions/${token}/cuts`,
+ payload: { cuts },
+ });
+ return { status: r.statusCode, body: r.json() };
+}
+
+const getJson = async (url: string) => (await app.inject({ method: "GET", url })).json();
+
+describe("POST /stop with { edit }", () => {
+ it("opens a short first lease 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");
+ // One lease term, not a long fixed window: a client that asks for an
+ // edit and never opens an editor publishes ~2 minutes later, not ~30.
+ const heldForMs = row!.editHoldUntil!.getTime() - Date.now();
+ expect(heldForMs).toBeGreaterThan((EDIT_LEASE_SECONDS - 20) * 1000);
+ expect(heldForMs).toBeLessThanOrEqual(EDIT_LEASE_SECONDS * 1000 + 1000);
+ });
+
+ 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("ignores an unrecognised body instead of rejecting it", async () => {
+ // /stop accepted and ignored any body before `edit` existed. A custom
+ // client sending its own field must not start getting 400s.
+ const s = await seedActiveSession();
+ const r = await app.inject({
+ method: "POST",
+ url: `/api/sessions/${s.token}/stop`,
+ payload: { reason: "user pressed stop", edit: false },
+ });
+ expect(r.statusCode).toBe(200);
+ 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);
+ expect(r.json().editHoldUntil).toBeUndefined();
+ // No screenshots → failed, as before.
+ expect((await load(s.id))!.status).toBe("failed");
+ });
+});
+
+describe("POST /editing (lease renewal)", () => {
+ it("extends the hold so an open editor is never cut off", async () => {
+ const s = await seedHeldSession({
+ // Down to the last few seconds of its term.
+ editHoldUntil: new Date(Date.now() + 3_000),
+ });
+ const before = (await load(s.id))!.editHoldUntil!.getTime();
+
+ const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/editing` });
+ expect(r.statusCode).toBe(200);
+ expect(r.json().held).toBe(true);
+
+ const after = (await load(s.id))!.editHoldUntil!.getTime();
+ expect(after).toBeGreaterThan(before);
+ // A full fresh lease, not a fixed deadline ticking down.
+ expect(after - Date.now()).toBeGreaterThan((EDIT_LEASE_SECONDS - 10) * 1000);
+ });
+
+ it("renews through a lapse the expiry job hasn't processed yet", async () => {
+ // A stalled network shouldn't end someone's edit in the seconds
+ // between the term lapsing and the cron running.
+ const s = await seedHeldSession({ editHoldUntil: new Date(Date.now() - 5_000) });
+ const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/editing` });
+ expect(r.json().held).toBe(true);
+ expect((await load(s.id))!.editHoldUntil!.getTime()).toBeGreaterThan(Date.now());
+ });
+
+ it("reports not-held once the session published, and doesn't resurrect it", 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}/editing` });
+ expect(r.statusCode).toBe(200);
+ expect(r.json().held).toBe(false);
+ const row = await load(s.id);
+ expect(row!.status).toBe("complete");
+ expect(row!.editHoldUntil).toBeNull();
+ });
+
+ it("stops renewing past the absolute ceiling", async () => {
+ // An editor left open overnight must not hold a program's session
+ // forever, so the ceiling is measured from the stop.
+ const s = await seedHeldSession({
+ stoppedAt: new Date(Date.now() - (EDIT_HOLD_MAX_MINUTES + 5) * 60_000),
+ });
+ const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/editing` });
+ expect(r.json().held).toBe(false);
+ });
+});
+
+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(UNITS);
+ expect(body.units[3].capturedAt).toBe(iso(3));
+ expect(body.originalVideoUrl).toContain("https://");
+ expect(body.editHoldUntil).toBeTruthy();
+ });
+
+ 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("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. One less than
+ // the capture count: the compiler excludes the seed capture from the
+ // video (see dropSeedUnit), so promising UNITS would overstate the
+ // finished timelapse by a minute.
+ expect(body.expectedUnits).toBe(UNITS - 1);
+ });
+
+ 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",
+ 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 /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(UNITS);
+ expect(body.unitsCut).toBe(2);
+ expect(body.uncutTrackedSeconds).toBe(540);
+ expect(body.trackedSeconds).toBe(420);
+
+ const row = await load(s.id);
+ expect(row!.cuts).toEqual([{ start: iso(3), end: iso(5) }]);
+ expect(row!.cutSeconds).toBe(120);
+ });
+
+ 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 getJson(`/api/sessions/${s.token}`);
+ expect(session.trackedSeconds).toBe(420);
+ expect(session.uncutTrackedSeconds).toBe(540);
+ expect(session.cutSeconds).toBe(120);
+
+ 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.cutTimestamps).toBeUndefined();
+
+ const withCut = await getJson(`/api/sessions/${s.token}/timings?includeCut=true`);
+ expect(withCut.cutTimestamps).toEqual([iso(3), iso(4)]);
+
+ const batch = (
+ await app.inject({
+ method: "POST",
+ url: "/api/sessions/batch",
+ payload: { tokens: [s.token] },
+ })
+ ).json();
+ expect(batch.sessions[0].trackedSeconds).toBe(420);
+ });
+
+ it("clears edits with an empty list", async () => {
+ 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 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 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 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("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();
+ });
+
+ it("cannot touch a session mid-publish", async () => {
+ const s = await seedHeldSession({ status: "compiling" });
+ expect((await putCuts(s.token, [])).status).toBe(409);
+ });
+});
+
+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 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("echoes the session's redirectUrl on both publish paths", async () => {
+ // The recording client fires the redirect hook straight off this
+ // response the instant publish lands, so it must carry the URL —
+ // whether the publish is instant (no cuts) or a worker hand-off.
+ const instant = await seedHeldSession({ redirectUrl: "https://example.com/done" });
+ const r1 = await app.inject({
+ method: "POST",
+ url: `/api/sessions/${instant.token}/compile`,
+ });
+ expect(r1.json()).toMatchObject({
+ status: "complete",
+ instant: true,
+ redirectUrl: "https://example.com/done",
+ });
+
+ const withCuts = await seedHeldSession({ redirectUrl: "https://example.com/done" });
+ await putCuts(withCuts.token, [{ start: iso(3), end: iso(5) }]);
+ const r2 = await app.inject({
+ method: "POST",
+ url: `/api/sessions/${withCuts.token}/compile`,
+ });
+ expect(r2.json()).toMatchObject({
+ status: "compiling",
+ instant: false,
+ redirectUrl: "https://example.com/done",
+ });
+
+ // No redirect configured → null, not undefined or omitted.
+ const none = await seedHeldSession();
+ const r3 = await app.inject({
+ method: "POST",
+ url: `/api/sessions/${none.token}/compile`,
+ });
+ expect(r3.json().redirectUrl).toBeNull();
+ });
+
+ 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 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("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: "complete", instant: true });
+ });
+
+ 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 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);
+ });
+
+ 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", () => {
+ 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/server/test/sessions.integration.test.ts b/packages/server/test/sessions.integration.test.ts
index 30aabc0a..49553be3 100644
--- a/packages/server/test/sessions.integration.test.ts
+++ b/packages/server/test/sessions.integration.test.ts
@@ -9,7 +9,7 @@
*/
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import type { FastifyInstance } from "fastify";
-import { sql } from "drizzle-orm";
+import { sql, eq } from "drizzle-orm";
import { buildApp } from "../src/app.js";
import { db, schema } from "../src/db/index.js";
import { setClock, resetClock } from "../src/lib/clock.js";
@@ -227,22 +227,73 @@ describe("credit mode envelope", () => {
return sess;
}
- it("rejects capturedAt > serverNow + 5min as captured_at_future", async () => {
+ // A skewed system clock is the user's misfortune, not their fault, and it
+ // must not cost them the recording. Rejecting here failed the upload-url
+ // request, so no presigned URL was issued and NOTHING uploaded for the whole
+ // session. The server adopts its own clock for these captures instead.
+ it("adopts server time for a clock skewed into the future", async () => {
const { token } = await seedCreditSession();
advanceVirtualMs(60_000);
const cap = new Date(virtualNow + 6 * 60_000).toISOString();
const up = await postUpload(token, cap);
- expect(up.status).toBe(400);
- expect(up.body.error).toBe("captured_at_future");
+ expect(up.status).toBe(200);
+ expect(up.body.capturedAtAdopted).toBe(true);
+
+ // Stamped with server time, not the client's claim.
+ const row = await db.query.screenshots.findFirst({
+ where: eq(schema.screenshots.id, up.body.screenshotId),
+ });
+ expect(row?.capturedAt?.getTime()).toBe(virtualNow);
+
+ // And the response still carries what the client needs to correct itself.
+ expect(Date.parse(up.body.serverTime)).toBe(virtualNow);
});
- it("rejects capturedAt < serverNow - 5min as captured_at_too_old", async () => {
+ it("adopts server time for a clock skewed into the past", async () => {
const { token } = await seedCreditSession();
advanceVirtualMs(60_000);
const cap = new Date(virtualNow - 6 * 60_000).toISOString();
const up = await postUpload(token, cap);
- expect(up.status).toBe(400);
- expect(up.body.error).toBe("captured_at_too_old");
+ expect(up.status).toBe(200);
+ expect(up.body.capturedAtAdopted).toBe(true);
+ const row = await db.query.screenshots.findFirst({
+ where: eq(schema.screenshots.id, up.body.screenshotId),
+ });
+ expect(row?.capturedAt?.getTime()).toBe(virtualNow);
+ });
+
+ it("keeps crediting a badly-skewed client minute after minute", async () => {
+ // The outcome that matters: a device an hour fast records normally.
+ const { token } = await seedCreditSession();
+ let credited = 0;
+ for (let i = 0; i < 3; i++) {
+ advanceVirtualMs(60_000);
+ const skewed = new Date(virtualNow + 60 * 60_000).toISOString();
+ const up = await postUpload(token, skewed);
+ expect(up.status).toBe(200);
+ const c = await confirmUpload(token, up.body.screenshotId);
+ credited = c.body.trackedSeconds;
+ }
+ // The seed capture credits 0 (it opens the streak); the three adopted
+ // captures each land on their expected mark and credit a full minute.
+ expect(credited).toBe(3 * 60);
+ });
+
+ it("does NOT let adoption bypass replay protection", async () => {
+ // Adoption is for clocks, not for requests. A duplicate/non-monotonic
+ // claim must still be refused — server time can't rescue it, so the
+ // envelope's anti-tamper role survives the change.
+ const { token } = await seedCreditSession();
+ advanceVirtualMs(60_000);
+ const ahead = await postUpload(token, new Date(virtualNow).toISOString());
+ expect(ahead.status).toBe(200);
+ await confirmUpload(token, ahead.body.screenshotId);
+
+ // Now claim a moment already used, well inside the envelope so adoption
+ // is not triggered.
+ const replay = await postUpload(token, new Date(virtualNow - 1_000).toISOString());
+ expect(replay.status).toBe(400);
+ expect(replay.body.error).toBe("captured_at_not_monotonic");
});
it("rejects non-monotonic capturedAt", async () => {
@@ -907,3 +958,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/server/test/setup.ts b/packages/server/test/setup.ts
index 9d16a4df..8719cebe 100644
--- a/packages/server/test/setup.ts
+++ b/packages/server/test/setup.ts
@@ -33,7 +33,19 @@ vi.mock("@aws-sdk/client-s3", async (orig) => {
async send(command: any) {
const name = command?.constructor?.name ?? "";
if (name === "HeadObjectCommand") {
- return { ContentType: "image/jpeg", ContentLength: 1024 };
+ // Per-test override for simulating mismatched/oversized uploads.
+ const override = (globalThis as any).__r2HeadObjectOverride;
+ if (override) return override;
+ // Derive ContentType from the key extension — mirrors a client
+ // that uploaded through the presigned URL (which is signed with
+ // the format's content type).
+ const key: string = command?.input?.Key ?? "";
+ const contentType = key.endsWith(".webm")
+ ? "video/webm"
+ : key.endsWith(".mp4")
+ ? "video/mp4"
+ : "image/jpeg";
+ return { ContentType: contentType, ContentLength: 1024 };
}
if (name === "PutObjectCommand" || name === "DeleteObjectCommand" || name === "GetObjectCommand") {
return {};
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 46116bed..cfd6a94c 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@lookout/shared",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
@@ -8,6 +8,7 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
- "dev": "tsc --watch"
+ "dev": "tsc --watch",
+ "test": "vitest run"
}
}
diff --git a/packages/shared/src/clockOffset.test.ts b/packages/shared/src/clockOffset.test.ts
new file mode 100644
index 00000000..8886ee1d
--- /dev/null
+++ b/packages/shared/src/clockOffset.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import { ClockOffset, CLOCK_OFFSET_DEADBAND_MS } from "./clockOffset.js";
+
+/**
+ * The client half of clock-skew tolerance. The server adopts its own time for
+ * a capture it can't trust, which stops a wrong clock costing the recording;
+ * this is what stops it costing precision too.
+ */
+describe("ClockOffset", () => {
+ const iso = (ms: number) => new Date(ms).toISOString();
+
+ it("is a no-op before it has seen anything", () => {
+ const c = new ClockOffset();
+ expect(c.offset).toBe(0);
+ expect(c.isSignificant).toBe(false);
+ expect(c.correct(1_000)).toBe(1_000);
+ });
+
+ it("leaves a healthy clock's timestamps byte-identical", () => {
+ // Well inside the deadband: correcting here would add noise, not accuracy,
+ // and would make every healthy client's behaviour depend on jitter.
+ const c = new ClockOffset();
+ const local = 1_700_000_000_000;
+ c.observe(iso(local + 300), local, local + 100);
+ expect(c.isSignificant).toBe(false);
+ expect(c.correct(local)).toBe(local);
+ });
+
+ it("corrects a clock that is minutes SLOW on the first sample", () => {
+ // First sample is adopted outright — a badly wrong clock must be fixed on
+ // the very next capture, not eased into over ten minutes of lost credit.
+ const c = new ClockOffset();
+ const local = 1_700_000_000_000;
+ const skew = 7 * 60_000;
+ c.observe(iso(local + skew), local, local + 40);
+ expect(c.isSignificant).toBe(true);
+ expect(c.correct(local)).toBeCloseTo(local + skew, -2);
+ });
+
+ it("corrects a clock that is minutes FAST", () => {
+ const c = new ClockOffset();
+ const local = 1_700_000_000_000;
+ const skew = -9 * 60_000;
+ c.observe(iso(local + skew), local, local + 40);
+ expect(c.correct(local)).toBeCloseTo(local + skew, -2);
+ });
+
+ it("charges the round trip to latency, not to the offset", () => {
+ // The server's timestamp was taken somewhere inside the request window,
+ // so the midpoint is the honest local counterpart. Attributing the whole
+ // round trip to skew would bias every estimate by half the RTT — which on
+ // a slow link is exactly the population we most need to be right about.
+ const c = new ClockOffset();
+ const local = 1_700_000_000_000;
+ const rtt = 4_000;
+ // A perfectly synced clock, observed over a slow request.
+ c.observe(iso(local + rtt / 2), local, local + rtt);
+ expect(Math.abs(c.offset)).toBeLessThan(CLOCK_OFFSET_DEADBAND_MS);
+ expect(c.isSignificant).toBe(false);
+ });
+
+ it("smooths later samples so jitter doesn't wobble the stamps", () => {
+ const c = new ClockOffset();
+ const local = 1_700_000_000_000;
+ const skew = 60_000;
+ c.observe(iso(local + skew), local, local + 20);
+ const afterFirst = c.offset;
+ // One wild outlier must not move the estimate far.
+ c.observe(iso(local + skew + 30_000), local, local + 20);
+ expect(c.offset).toBeGreaterThan(afterFirst);
+ expect(c.offset).toBeLessThan(afterFirst + 30_000);
+ });
+
+ it("ignores a sample whose local window ran backwards", () => {
+ // The local clock being corrected (NTP, sleep/wake) mid-request would
+ // otherwise fold a jump straight into the estimate.
+ const c = new ClockOffset();
+ const local = 1_700_000_000_000;
+ c.observe(iso(local + 60_000), local, local - 5_000);
+ expect(c.offset).toBe(0);
+ });
+
+ it("ignores an unparseable server timestamp", () => {
+ const c = new ClockOffset();
+ c.observe("not a date", 1_000, 1_010);
+ expect(c.offset).toBe(0);
+ });
+
+ it("brings a skewed capture inside the credit window", () => {
+ // End to end: a device 6 minutes fast is outside the ±5min envelope, so
+ // every capture would have been refused. After one observation its stamps
+ // land within a second of server time — comfortably inside the ±30s
+ // streak window.
+ const c = new ClockOffset();
+ const serverMs = 1_700_000_000_000;
+ const deviceMs = serverMs + 6 * 60_000;
+ c.observe(iso(serverMs), deviceMs, deviceMs + 50);
+ const corrected = c.correct(deviceMs);
+ expect(Math.abs(corrected - serverMs)).toBeLessThan(1_000);
+ });
+});
diff --git a/packages/shared/src/clockOffset.ts b/packages/shared/src/clockOffset.ts
new file mode 100644
index 00000000..af4aa652
--- /dev/null
+++ b/packages/shared/src/clockOffset.ts
@@ -0,0 +1,85 @@
+// Client clock-offset estimation.
+//
+// Every timestamp the credit system reads comes from the client's own clock,
+// measured against a ±30s streak window inside a ±5min trust envelope. A
+// system clock that is merely a few minutes off — common, invisible to the
+// user, and none of their doing — therefore used to break recording outright:
+// the server refused every upload-url request before issuing a presigned URL.
+//
+// The server now adopts its own time for such captures, so the recording is
+// never lost. This closes the other half: clients learn how far off they are
+// from the server's own timestamps and correct their stamps, so a skewed clock
+// costs nothing at all rather than costing precision.
+
+/** Smallest offset worth correcting for. Below this, the "correction" would
+ * be indistinguishable from network jitter and would only add noise. */
+export const CLOCK_OFFSET_DEADBAND_MS = 2_000;
+
+/**
+ * A running estimate of `serverNow - clientNow`.
+ *
+ * Deliberately tiny and dependency-free so both the web SDK and any other
+ * client can share the arithmetic. Not a time sync protocol: we only need to
+ * be well inside a 30-second window, and one sample per minute arrives for
+ * free on every upload.
+ */
+export class ClockOffset {
+ private offsetMs = 0;
+ private samples = 0;
+
+ /**
+ * Fold in one observation.
+ *
+ * `serverTime` is the server's clock when it handled the request, and
+ * `requestSentAtMs`/`responseReceivedAtMs` bracket it on the local clock.
+ * The server's timestamp was taken somewhere inside that window, so the
+ * local instant that best corresponds to it is the midpoint — which removes
+ * most of the round trip from the estimate rather than charging all of it to
+ * the offset. This is the same reasoning NTP uses, minus the rigour we
+ * don't need.
+ */
+ observe(
+ serverTime: string,
+ requestSentAtMs: number,
+ responseReceivedAtMs: number,
+ ): void {
+ const serverMs = Date.parse(serverTime);
+ if (!Number.isFinite(serverMs)) return;
+ // A negative or absurd interval means the local clock moved under us
+ // mid-request (NTP correction, sleep/wake). Treat the sample as
+ // untrustworthy rather than folding a jump into the estimate.
+ if (responseReceivedAtMs < requestSentAtMs) return;
+
+ const localMidpoint =
+ requestSentAtMs + (responseReceivedAtMs - requestSentAtMs) / 2;
+ const sample = serverMs - localMidpoint;
+
+ // First sample is adopted outright — a badly wrong clock should be
+ // corrected on the very next capture, not eased into over many minutes.
+ // Later samples are smoothed, so ordinary jitter doesn't wobble the
+ // stamps we send.
+ this.offsetMs =
+ this.samples === 0 ? sample : this.offsetMs * 0.75 + sample * 0.25;
+ this.samples++;
+ }
+
+ /** Current estimate of how far the local clock is behind the server's. */
+ get offset(): number {
+ return this.samples === 0 ? 0 : this.offsetMs;
+ }
+
+ /** True once an offset large enough to matter has been observed. */
+ get isSignificant(): boolean {
+ return Math.abs(this.offset) >= CLOCK_OFFSET_DEADBAND_MS;
+ }
+
+ /**
+ * Correct a local timestamp into server time.
+ *
+ * A no-op inside the deadband, so a healthy client's timestamps are passed
+ * through byte-identical and nothing about its behaviour changes.
+ */
+ correct(localMs: number): number {
+ return this.isSignificant ? Math.round(localMs + this.offset) : localMs;
+ }
+}
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 878d5d4e..79ef578f 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -57,6 +57,193 @@ export const STREAK_WINDOW_MS = 30_000;
* Default: 60 */
export const CREDIT_PER_CAPTURE_S = 60;
+// ──────────────────────────────────────────────────────────
+// Clips (6 frames/minute via per-minute video uploads)
+// ──────────────────────────────────────────────────────────
+
+/** Upload payload formats the server accepts on upload-url.
+ * "jpeg" is the legacy single-screenshot-per-minute payload.
+ * "webm"/"mp4" are per-minute video clips holding ~6 frames captured
+ * seconds apart (webm from Chromium/Firefox MediaRecorder; mp4 from
+ * Safari MediaRecorder and the desktop hardware encoder). The
+ * per-minute request cadence, credit math, and rate limits are
+ * identical in all formats — a clip is still ONE capture unit.
+ * Clips are gated per session by `sessions.clips_enabled`, which defaults
+ * to TRUE — a program opts OUT with `clips: false` at creation. Immutable
+ * thereafter. */
+export const CAPTURE_FORMATS = ["jpeg", "webm", "mp4"] as const;
+export type CaptureFormat = (typeof CAPTURE_FORMATS)[number];
+
+/** R2/HTTP content type for each capture format. The presigned PUT is
+ * signed with this content type and confirm re-validates it via
+ * HeadObject, so client and server must agree exactly. */
+export const CAPTURE_FORMAT_CONTENT_TYPES: Record = {
+ jpeg: "image/jpeg",
+ webm: "video/webm",
+ mp4: "video/mp4",
+};
+
+/** How often a clip-recording client grabs a frame into the current
+ * clip. 10000ms = 6 frames per SCREENSHOT_INTERVAL_MS. The cadence is
+ * server-authoritative: it's sent to clients as `frameIntervalMs` on
+ * the session GET and upload-url responses, clients capture at exactly
+ * that rate, and no client exposes an override.
+ *
+ * Every knob that used to be denominated in "frames" is now derived from
+ * this value (per-frame byte budget, native encoder bitrate, the stall
+ * cap, container size), so changing the cadence is a one-line change and
+ * per-frame QUALITY is held constant — see CLIP_FRAME_BYTE_BUDGET.
+ * Timelapse smoothness scales directly with it: each capture unit becomes
+ * one second of output video, so 6/min renders 6 distinct images per
+ * output second.
+ * Default: 10000 (10 seconds) */
+export const CLIP_FRAME_INTERVAL_MS = 10_000;
+
+/** Nominal frames per clip (SCREENSHOT_INTERVAL_MS / CLIP_FRAME_INTERVAL_MS).
+ * Informational — clips are VFR and static screens legitimately emit
+ * fewer encoded frames. The worker derives real counts by demuxing.
+ * Default: 6 */
+export const FRAMES_PER_CLIP = Math.round(
+ SCREENSHOT_INTERVAL_MS / CLIP_FRAME_INTERVAL_MS,
+);
+
+/** Hard cap on frames the client records into a SINGLE clip, as a multiple
+ * of the nominal count.
+ *
+ * A clip is cut when its upload tick fires, so a slow uplink stretches the
+ * clip: the recorder keeps grabbing frames at the cadence while the
+ * previous upload drains. Uncapped, a 5-minute network stall produced a
+ * 30-frame clip that (a) blew MAX_CLIP_BYTES and was refused server-side,
+ * costing the whole window, and (b) still rendered as ONE second of
+ * output. Capping frames bounds the container instead: the tail of a
+ * stalled window is dropped, the clip still uploads, and the minute still
+ * credits.
+ * Default: 3 */
+export const MAX_CLIP_FRAME_OVERRUN = 3;
+
+/** Absolute frame cap for one clip. See MAX_CLIP_FRAME_OVERRUN. */
+export const MAX_FRAMES_PER_CLIP = FRAMES_PER_CLIP * MAX_CLIP_FRAME_OVERRUN;
+
+/** Consecutive clip-upload failures a client tolerates before giving up on
+ * clips and recording plain JPEGs for the rest of the session.
+ *
+ * Every individual failure is already survivable — the tick retries as a
+ * single JPEG, so the minute still credits. This bound is about not
+ * re-attempting something structurally broken once a minute for hours:
+ * a browser whose encoder emits containers the server rejects, or a session
+ * whose clip support went away underneath the client. Any successful clip
+ * upload resets the count, so a patch of bad network never disables clips.
+ * Matches the desktop client's MAX_CLIP_ENCODER_FAILURES.
+ * Default: 3 */
+export const MAX_CLIP_UPLOAD_FAILURES = 3;
+
+/** Wall-clock delay from capture start to the FIRST upload tick.
+ *
+ * Deliberately NOT a multiple of CLIP_FRAME_INTERVAL_MS. The opening clip
+ * is the session's seed capture: it credits 0 seconds and the compiler
+ * drops it from the video entirely (see the worker's dropSeedUnit), so its
+ * frame density is irrelevant. What this delay actually controls is how
+ * long the user waits for the session to activate — tying it to the
+ * cadence turned every slower cadence into a 20-second-plus wait on a
+ * blank recorder.
+ * Default: 8000 (8 seconds) */
+export const CLIP_FIRST_CUT_DELAY_MS = 8_000;
+
+/** Per-frame byte budget for a natively-encoded clip frame — the ACTUAL
+ * quality dial, and the reason the native bitrate is derived rather than
+ * hardcoded.
+ *
+ * Sized for TEXT LEGIBILITY: ~400 KB buys a JPEG-q85-class keyframe at
+ * 1080p, the bar the legacy single-screenshot pipeline set. Native
+ * encoders receive each frame's real presentation timestamp, so their
+ * bitrate is denominated in bits per second of MEDIA time — meaning the
+ * same bitrate buys 2.5x the bytes per frame when frames sit 10s apart
+ * instead of 4s. Expressing the tuned number per-frame keeps quality
+ * invariant when the cadence changes, in both directions.
+ * Default: 400000 (400 KB) */
+export const CLIP_FRAME_BYTE_BUDGET = 400_000;
+
+/** Bitrate (bits/second of media time) a native encoder should be given to
+ * land CLIP_FRAME_BYTE_BUDGET per frame at the supplied cadence.
+ *
+ * At the historical 4s cadence this returns exactly 800 kbps — the value
+ * that was measured and tuned by hand (133k and 400k were tried first and
+ * produced visibly soft H.264). A VBR ceiling, not a floor: static screen
+ * content undershoots it heavily. The desktop encoders mirror this
+ * formula in Rust; keep the two in step. */
+export function nativeClipBitsPerSecond(frameIntervalMs: number): number {
+ const intervalS = Math.max(frameIntervalMs, 1) / 1000;
+ return Math.round((CLIP_FRAME_BYTE_BUDGET * 8) / intervalS);
+}
+
+/** Native encoder bitrate at the default cadence. Prefer
+ * `nativeClipBitsPerSecond(frameIntervalMs)` wherever the real
+ * server-supplied cadence is in hand. */
+export const CLIP_VIDEO_BITS_PER_SECOND = nativeClipBitsPerSecond(
+ CLIP_FRAME_INTERVAL_MS,
+);
+
+/** Floor for the browser recorder's adaptive bitrate backoff. NOT derived
+ * from the native figure: the two are denominated in different things (see
+ * CLIP_WEB_VIDEO_BITS_PER_SECOND), this is just the coarsest setting worth
+ * uploading at all.
+ * Default: 800000 */
+export const CLIP_WEB_MIN_BITS_PER_SECOND = 800_000;
+
+/** Encoder bitrate cap for clips recorded by a BROWSER (MediaRecorder's
+ * `videoBitsPerSecond`). Deliberately ~50x the native constant, because
+ * the two numbers are denominated in different things.
+ *
+ * A native encoder gets each frame's true presentation time (4s apart)
+ * plus an explicit ~1fps rate-control hint, so 800 kbps really does
+ * buy ~400 KB per frame. MediaRecorder's rate control ignores wall-clock
+ * frame spacing entirely — measured on Chromium 148 at 1080p, recording
+ * the same frames 4000ms apart and 125ms apart produces BYTE-IDENTICAL
+ * output. There is no "× 60 seconds" budget to spend; the encoder just
+ * allocates a per-frame quantizer from a nominal cadence.
+ *
+ * At 800 kbps the browser encoder is therefore pinned at its maximum
+ * quantizer — as coarse as it is allowed to be — and still overshoots
+ * the request. The whole 0.8–2 Mbps range is byte-identical, which is
+ * why raising the shared constant 400k → 800k sharpened the desktop and
+ * did nothing whatsoever for the web.
+ *
+ * Measured sweep (1080p, worst-case dense-text content, PSNR vs source):
+ *
+ * bitrate KB/frame PSNR
+ * 0.8M 53.7 25.8 dB <- previous setting
+ * 5M 114.6 30.9 dB
+ * 20M 192.0 34.3 dB
+ * 40M 335.4 38.6 dB <- knee; ~parity with native
+ * 80M 582.3 43.7 dB worst case exceeds MAX_CLIP_BYTES
+ *
+ * 40 Mbps lands at ~335 KB/frame — the same order as the native encoder's
+ * CLIP_FRAME_BYTE_BUDGET. Because the allocation is per-frame and NOT
+ * per-second, this constant is cadence-independent: it needs no change
+ * when CLIP_FRAME_INTERVAL_MS moves, and the clip simply carries fewer
+ * frames. Measured over a full 15-frame clip (the 4s-cadence shape),
+ * against the 8 MB MAX_CLIP_BYTES cap:
+ *
+ * before (vp9 @ 800k) after (h264 @ 40M)
+ * busy screen 0.89 MB 23.8 dB 3.24 MB 43.3 dB
+ * typical screen 0.37 MB 23.8 dB 2.46 MB 43.3 dB
+ *
+ * so even incompressible content sat at 40% of the cap; at 6 frames/min
+ * the same content is under half of that. (0.37 MB matches the ~400 KB/min
+ * these clips were measured at in the field, which is what makes the rest
+ * of the table trustworthy.)
+ * ClipRecorder additionally backs the rate off if a clip ever does
+ * exceed the cap, so a browser with different rate-control semantics
+ * self-corrects instead of failing every upload.
+ * Default: 40000000 */
+export const CLIP_WEB_VIDEO_BITS_PER_SECOND = 40_000_000;
+
+/** Max clip file size in bytes, validated server-side via HeadObject
+ * after upload. Sized above the bitrate budget (800 kbps × 60s ≈ 6 MB)
+ * to absorb encoder overshoot and container overhead.
+ * Default: 8388608 (8 MB) */
+export const MAX_CLIP_BYTES = 8 * 1024 * 1024;
+
// ──────────────────────────────────────────────────────────
// Auto-timeout thresholds
// ──────────────────────────────────────────────────────────
@@ -175,6 +362,18 @@ export const MAX_HEIGHT = 1080;
* Default: 3 */
export const MAX_UPLOAD_RETRIES = 3;
+/** Per-step deadline for one upload attempt: the presigned-URL request, the
+ * R2 PUT, or the confirm POST. Matches the desktop client's STEP_TIMEOUT.
+ *
+ * `fetch` has no default timeout, so without this a half-open socket or a
+ * trickling uplink parks an upload attempt indefinitely, and everything
+ * downstream of it stalls with no error to retry on. A bounded step turns
+ * a dead connection into a normal retryable failure. Generous enough for a
+ * multi-megabyte clip on a weak link — this is a stall detector, not a
+ * bandwidth requirement.
+ * Default: 30000 (30 seconds) */
+export const UPLOAD_STEP_TIMEOUT_MS = 30_000;
+
/** Retry delays in ms (exponential backoff).
* Default: [2000, 4000, 8000] */
export const UPLOAD_RETRY_DELAYS_MS = [2_000, 4_000, 8_000];
diff --git a/packages/shared/src/cuts.ts b/packages/shared/src/cuts.ts
new file mode 100644
index 00000000..01296224
--- /dev/null
+++ b/packages/shared/src/cuts.ts
@@ -0,0 +1,244 @@
+// Cut lists: the canonical representation of session edits.
+//
+// An edit is a list of ABSOLUTE wall-clock intervals of the session that
+// should not exist in any output — never "video offset + duration". Lookout
+// is heartbeat-based: the session's identity is its per-minute capture
+// timestamps, and the compiled video, /timings (→ Hackatime heartbeats), and
+// trackedSeconds are all derived views of them. Expressing the edit in the
+// same domain lets one list drive all three consistently.
+//
+// THE membership rule (shared by server, worker, and clients — never
+// reimplement it): a capture unit is cut iff its capture timestamp
+// (coalesce(captured_at, requested_at)) falls in [start, end) of any
+// interval. Granularity is therefore whole capture units (minutes), which is
+// also heartbeat granularity.
+
+/** One cut interval. ISO-8601 UTC wall-clock times; `end` exclusive. */
+export interface CutInterval {
+ start: string;
+ end: string;
+}
+
+/** Max intervals per session. Bounds hostile payloads; a 12h session has at
+ * most 720 units, and real edits are a handful of regions. */
+export const MAX_CUT_INTERVALS = 120;
+
+/** Max user-initiated cut-compiles per session. Each is cheap (stream copy)
+ * but enqueues worker jobs — bound the loop. */
+export const MAX_USER_RECOMPILES = 5;
+
+/**
+ * The edit hold is a LEASE, not a countdown.
+ *
+ * A session stopped with `{edit: true}` stays unpublished while an editing
+ * surface is actually open: that surface renews the lease every
+ * EDIT_HEARTBEAT_SECONDS, and the server holds the session for
+ * EDIT_LEASE_SECONDS past the last renewal. Stop renewing — close the
+ * window, quit the app, lose the machine — and it publishes within about a
+ * lease.
+ *
+ * A fixed deadline was wrong in both directions: it cut off someone
+ * carefully trimming a long recording, and it made an abandoned session sit
+ * unpublished for half an hour. A lease has neither failure: edit for as
+ * long as you like, and walking away is detected in a minute or two.
+ *
+ * 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); the data must be final
+ * the first time they see it.
+ */
+export const EDIT_LEASE_SECONDS = 120;
+
+/** How often an open editing surface renews the lease. Comfortably inside
+ * EDIT_LEASE_SECONDS so one dropped request never ends an edit. */
+export const EDIT_HEARTBEAT_SECONDS = 30;
+
+/**
+ * Absolute ceiling on a hold, measured from the stop. A safety valve, not
+ * the mechanism: an editor left open overnight must not keep a program
+ * waiting on a session forever.
+ */
+export const EDIT_HOLD_MAX_MINUTES = 120;
+
+/** 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
+ * being clamped. Mirrors the capture-time trust envelope. */
+export const CUT_BOUNDS_SLACK_MS = 5 * 60_000;
+
+export type NormalizeCutsResult =
+ | { ok: true; cuts: CutInterval[] }
+ | { ok: false; error: string };
+
+/**
+ * Validate and canonicalize a raw cut list: parseable ISO dates, end > start,
+ * clamped to the session bounds, sorted by start, overlapping/adjacent
+ * intervals merged. The canonical form is what gets persisted, so equality
+ * checks and previews are stable regardless of how the client drew regions.
+ */
+export function normalizeCuts(
+ raw: unknown,
+ bounds?: { minMs: number; maxMs: number },
+): NormalizeCutsResult {
+ if (!Array.isArray(raw)) {
+ return { ok: false, error: "cuts must be an array" };
+ }
+ if (raw.length > MAX_CUT_INTERVALS) {
+ return { ok: false, error: `cuts cannot exceed ${MAX_CUT_INTERVALS} intervals` };
+ }
+
+ const minMs = bounds ? bounds.minMs - CUT_BOUNDS_SLACK_MS : -Infinity;
+ const maxMs = bounds ? bounds.maxMs + CUT_BOUNDS_SLACK_MS : Infinity;
+
+ const parsed: Array<{ startMs: number; endMs: number }> = [];
+ for (const entry of raw) {
+ if (typeof entry !== "object" || entry === null) {
+ return { ok: false, error: "each cut must be an object with start and end" };
+ }
+ const { start, end } = entry as Record;
+ if (typeof start !== "string" || typeof end !== "string") {
+ return { ok: false, error: "cut start and end must be ISO-8601 strings" };
+ }
+ const startMs = Date.parse(start);
+ const endMs = Date.parse(end);
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
+ return { ok: false, error: "cut start and end must be valid ISO-8601 dates" };
+ }
+ if (endMs <= startMs) {
+ return { ok: false, error: "cut end must be after start" };
+ }
+ // Clamp to the session envelope; drop intervals entirely outside it.
+ const clampedStart = Math.max(startMs, minMs);
+ const clampedEnd = Math.min(endMs, maxMs);
+ if (clampedEnd <= clampedStart) continue;
+ parsed.push({ startMs: clampedStart, endMs: clampedEnd });
+ }
+
+ parsed.sort((a, b) => (a.startMs !== b.startMs ? a.startMs - b.startMs : a.endMs - b.endMs));
+
+ const merged: Array<{ startMs: number; endMs: number }> = [];
+ for (const cur of parsed) {
+ const last = merged[merged.length - 1];
+ if (last && cur.startMs <= last.endMs) {
+ last.endMs = Math.max(last.endMs, cur.endMs);
+ } else {
+ merged.push({ ...cur });
+ }
+ }
+
+ return {
+ ok: true,
+ cuts: merged.map((m) => ({
+ start: new Date(m.startMs).toISOString(),
+ end: new Date(m.endMs).toISOString(),
+ })),
+ };
+}
+
+/** Membership: is a capture taken at `timeMs` removed by `cuts`?
+ * Interval semantics are [start, end) — end-exclusive. */
+export function isCutAt(timeMs: number, cuts: CutInterval[]): boolean {
+ for (const c of cuts) {
+ const s = Date.parse(c.start);
+ const e = Date.parse(c.end);
+ if (timeMs >= s && timeMs < e) return true;
+ }
+ return false;
+}
+
+/** A contiguous run of KEPT units, as half-open video-second indices.
+ * Because one unit = exactly one second of compiled output, these double
+ * as ffmpeg inpoint/outpoint pairs on the original video. */
+export interface KeptRange {
+ /** First kept unit index (inclusive) = video inpoint in seconds. */
+ start: number;
+ /** One past the last kept unit index = video outpoint in seconds. */
+ end: number;
+}
+
+/**
+ * Map unit capture times (epoch ms, in video order) through the cut list to
+ * the contiguous kept ranges of the compiled video. Returns [] when
+ * everything is cut.
+ */
+export function computeKeptRanges(
+ unitTimesMs: number[],
+ cuts: CutInterval[],
+): KeptRange[] {
+ const ranges: KeptRange[] = [];
+ let open: KeptRange | null = null;
+ for (let i = 0; i < unitTimesMs.length; i++) {
+ if (isCutAt(unitTimesMs[i], cuts)) {
+ if (open) {
+ ranges.push(open);
+ open = null;
+ }
+ } else {
+ if (open) open.end = i + 1;
+ else open = { start: i, end: i + 1 };
+ }
+ }
+ if (open) ranges.push(open);
+ return ranges;
+}
+
+/** Count units removed by the cut list. */
+export function countCutUnits(unitTimesMs: number[], cuts: CutInterval[]): number {
+ let n = 0;
+ for (const t of unitTimesMs) if (isCutAt(t, cuts)) n++;
+ return n;
+}
+
+/** One entry of `sessions.video_units`: a capture unit that made it into the
+ * compiled original video, in output order. Index in the array = the second
+ * of the video the unit occupies = its minute of real time. */
+export interface VideoUnit {
+ /** Capture moment (coalesce(captured_at, requested_at)), ISO-8601. */
+ capturedAt: string;
+ /** Screenshot row id, for debugging/traceability. */
+ screenshotId: string;
+}
+
+/** The slice of a confirmed screenshot row that cut/tracked-time math needs. */
+export interface CaptureRowForCuts {
+ /** coalesce(captured_at, requested_at), epoch ms. */
+ timeMs: number;
+ /** Credit-mode per-capture credit (0 or 60); null on bucket-mode rows. */
+ creditedSeconds: number | null;
+ minuteBucket: number;
+}
+
+/**
+ * Credited seconds removed by `cuts`, per tracking mode — the delta between
+ * raw tracked time and what the kept captures are worth. Used identically by
+ * the server's PUT /cuts preview and the worker's authoritative cut-compile
+ * write, so the preview a user sees is exactly what lands.
+ *
+ * - credit mode: sum of credited_seconds over CUT rows.
+ * - bucket mode: raw − max(0, (distinct kept minute buckets − 1) × 60),
+ * mirroring the legacy bucket formula so an empty cut list yields 0.
+ */
+export function computeCutSeconds(
+ rows: CaptureRowForCuts[],
+ trackingMode: "credit" | "bucket",
+ rawTrackedSeconds: number,
+ cuts: CutInterval[],
+): number {
+ if (cuts.length === 0) return 0;
+ if (trackingMode === "credit") {
+ let cut = 0;
+ for (const r of rows) {
+ if (isCutAt(r.timeMs, cuts)) cut += r.creditedSeconds ?? 0;
+ }
+ return Math.min(cut, rawTrackedSeconds);
+ }
+ const keptBuckets = new Set();
+ for (const r of rows) {
+ if (!isCutAt(r.timeMs, cuts)) keptBuckets.add(r.minuteBucket);
+ }
+ const keptTracked = Math.max(0, (keptBuckets.size - 1) * 60);
+ return Math.max(0, rawTrackedSeconds - keptTracked);
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 95d4ce20..e7a93ad7 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,3 +1,5 @@
export * from "./constants.js";
export * from "./types.js";
export * from "./clientInfo.js";
+export * from "./cuts.js";
+export * from "./clockOffset.js";
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 31450f55..6ce5c523 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -1,4 +1,5 @@
-import type { SessionStatus } from "./constants.js";
+import type { CaptureFormat, SessionStatus } from "./constants.js";
+import type { CutInterval, VideoUnit } from "./cuts.js";
export interface Session {
id: string;
@@ -31,6 +32,12 @@ export interface Screenshot {
height: number | null;
fileSizeBytes: number | null;
sampled: boolean;
+ /** Payload format of this capture unit. "jpeg" = legacy single frame;
+ * "webm"/"mp4" = per-minute clip. */
+ format: CaptureFormat;
+ /** Client-reported frame count inside a clip. Informational only —
+ * the worker derives the real count by demuxing. NULL for jpeg rows. */
+ frameCount: number | null;
createdAt: string;
}
@@ -54,6 +61,15 @@ export type ClientInfo = string;
export interface CreateSessionRequest {
name?: string;
metadata?: Record;
+ /** Whether this session receives clip uploads (per-minute videos of ~6
+ * frames) instead of one JPEG per minute. Defaults to TRUE — pass false
+ * to opt this session out and pin it to the legacy payload. 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 {
@@ -79,6 +95,26 @@ export interface SessionResponse {
clientInfo?: ClientInfo | null;
/** First recorded JA4 TLS fingerprint (edge-observed); `null` if none. */
ja4?: string | null;
+ /** Whether this session accepts clip uploads. Clients read this BEFORE
+ * the first capture (this endpoint is the session-recovery fetch) so
+ * the very first upload can already be a clip. Absent on pre-clips
+ * servers — treat as false. */
+ clipsEnabled?: boolean;
+ /** 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;
+ /** The session's cut list; `[]` when never edited. Absent on pre-edits
+ * servers. Note `trackedSeconds` already reflects these cuts. */
+ cuts?: CutInterval[];
+ /** Credited seconds removed by `cuts` (trackedSeconds = uncut − this). */
+ cutSeconds?: number;
+ /** Tracked seconds before subtracting cuts. */
+ uncutTrackedSeconds?: number;
+ /** Whether the compiled timelapse can (still) be edited. */
+ editable?: boolean;
metadata: Record;
}
@@ -93,7 +129,84 @@ export interface TimingsResponse {
clientInfo: ClientInfo | null;
/** First recorded JA4 TLS fingerprint (edge-observed); `null` if none. */
ja4: string | null;
+ /** Kept capture timestamps — captures inside a cut interval are EXCLUDED
+ * so heartbeat forwarders respect edits with no code changes. */
timestamps: string[];
+ /** The session's cut list; `[]` when never edited. Absent on pre-edits
+ * servers. */
+ cuts?: CutInterval[];
+ /** Number of confirmed captures removed by `cuts`. */
+ cutCount?: number;
+ /** Capture timestamps removed by `cuts`. Only present when the request
+ * passed `?includeCut=true`. */
+ cutTimestamps?: string[];
+}
+
+// -- Edits (cuts) --
+
+export interface UnitsResponse {
+ /** Units of the compiled ORIGINAL video, in output order: array index =
+ * video second = real-world minute. Empty for sessions compiled before
+ * edit support (not editable). */
+ units: VideoUnit[];
+ /** Current cut list ([] = no edits). */
+ cuts: CutInterval[];
+ /** 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.
+ * `"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.
+ * Null when not editable. */
+ originalVideoUrl: string | null;
+ /** Remaining user-initiated cut-compiles. */
+ recompilesRemaining: number;
+}
+
+export interface SetCutsRequest {
+ cuts: CutInterval[];
+}
+
+export interface SetCutsResponse {
+ /** Normalized (sorted, merged, clamped) cut list as persisted. */
+ cuts: CutInterval[];
+ /** Units in the original video. */
+ unitsTotal: number;
+ /** Units removed by the normalized list. */
+ unitsCut: number;
+ /** Post-cut tracked seconds (what GET /sessions/:token will report once
+ * the cuts are applied). */
+ trackedSeconds: number;
+ /** Tracked seconds before subtracting cuts. */
+ uncutTrackedSeconds: number;
+}
+
+export interface ApplyCutsResponse {
+ status: SessionStatus;
+ /** True when the change was applied instantly without a compile job
+ * (clearing all cuts just repoints the published video at the original). */
+ instant: boolean;
+ recompilesRemaining: number;
+ /** The session's redirect hook URL (immutable, set at creation). Echoed
+ * here so the recording client can fire the redirect the instant publish
+ * completes — no second request, no race. Null when none was configured. */
+ redirectUrl: string | null;
}
export interface UploadUrlResponse {
@@ -103,11 +216,34 @@ export interface UploadUrlResponse {
minuteBucket: number;
nextExpectedAt: string;
/** Server wall-clock time at the moment this response was generated.
- * Optional — not present on responses from pre-0.3 servers. Clients
- * may use it for diagnostics; scheduling needs only `nextExpectedAt`. */
+ * Optional — not present on responses from pre-0.3 servers. Clients use
+ * it to learn their own clock offset (see `capturedAtAdopted`);
+ * scheduling needs only `nextExpectedAt`. */
serverTime?: string;
+ /** Set when the server replaced this capture's `capturedAt` with its own
+ * clock because the client's was outside the trust envelope.
+ *
+ * The upload still succeeded — a wrong system clock never costs a
+ * recording. But the capture was stamped on ARRIVAL, so it carries upload
+ * latency and its credit is measured slightly late. A client seeing this
+ * should re-derive its offset from `serverTime` and apply it to later
+ * timestamps. Absent on servers that predate skew adoption. */
+ capturedAtAdopted?: boolean;
/** Sticky tracking mode for the session. Optional for backwards compat. */
trackingMode?: TrackingMode;
+ /** Echo of the GRANTED capture format — may differ from the requested
+ * one (the server downgrades clip formats to "jpeg" on sessions where
+ * clips are disabled). Absent on pre-clips servers — clients MUST
+ * treat absence as "server only supports jpeg". The client must
+ * upload exactly this format. */
+ format?: CaptureFormat;
+ /** Whether this session accepts clip uploads. Absent on pre-clips
+ * servers (treat as false). */
+ clipsEnabled?: boolean;
+ /** Server-authoritative clip cadence (ms between frames inside a
+ * clip). Absent on pre-clips servers. Clients must capture at exactly
+ * this rate — there is deliberately no client-side override. */
+ frameIntervalMs?: number;
}
export interface ConfirmScreenshotRequest {
@@ -115,6 +251,8 @@ export interface ConfirmScreenshotRequest {
width: number;
height: number;
fileSize: number;
+ /** Frames inside the uploaded clip. Omit for jpeg captures. */
+ frameCount?: number;
}
export interface ConfirmScreenshotResponse {
@@ -137,20 +275,60 @@ 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 is a lease the open
+ * editor renews (see `POST /:token/editing`); it publishes on its own
+ * once nothing is renewing it. Only send this from a client that will
+ * actually open an editing surface. */
+ edit?: boolean;
+}
+
+export interface EditHeartbeatResponse {
+ /** Extended lease deadline. The session publishes at this time unless
+ * renewed again. */
+ editHoldUntil: string;
+ /** False once the session published anyway (lease lapsed earlier, or the
+ * absolute ceiling was hit) — the caller should stop renewing and show
+ * the published state. */
+ held: 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 {
status: SessionStatus;
+ /** Real compile progress as a fraction in [0, ~0.95], reported by the
+ * worker while it builds an original timelapse (the per-unit
+ * download+encode stage — the part whose cost scales with session
+ * length). Capped below 1: assembly/upload still run after the last unit,
+ * and only the status flip ends the wait. Absent for cut-apply compiles
+ * and workers predating the column — fall back to the time estimate. */
progress?: number;
videoUrl?: string;
/** @deprecated WebM is no longer produced. Populated only for legacy clients —
* 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;
+ /** 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/package.json b/packages/worker/package.json
index 61c263d0..62cdda64 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -1,13 +1,14 @@
{
"name": "@lookout/worker",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"build": "tsc",
- "dev": "tsx watch --env-file=../../.env src/index.ts"
+ "dev": "tsx watch --env-file=../../.env src/index.ts",
+ "test": "vitest run"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
@@ -20,6 +21,7 @@
"devDependencies": {
"@types/pg": "^8.11.0",
"tsx": "^4.19.0",
- "typescript": "^5.7.0"
+ "typescript": "^5.7.0",
+ "vitest": "^4.1.10"
}
}
diff --git a/packages/worker/src/compile.ts b/packages/worker/src/compile.ts
index 4754bd4a..3462a9d6 100644
--- a/packages/worker/src/compile.ts
+++ b/packages/worker/src/compile.ts
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as fs from "node:fs/promises";
import * as path from "node:path";
+import { randomBytes } from "node:crypto";
import {
S3Client,
GetObjectCommand,
@@ -12,10 +13,98 @@ import {
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import { eq, and, sql } from "drizzle-orm";
+import {
+ computeKeptRanges,
+ computeCutSeconds,
+ type CaptureRowForCuts,
+ type CutInterval,
+ type KeptRange,
+ type VideoUnit,
+} from "@lookout/shared";
import * as schema from "./schema.js";
+import {
+ buildSegment,
+ cutVideoToKeptRanges,
+ dropSeedUnit,
+ segmentEncodeArgs,
+ PREVIEW_WIDTH,
+ PREVIEW_HEIGHT,
+ SEGMENT_CONCURRENCY,
+ SEGMENT_FPS,
+ SEGMENT_GOP_ARGS,
+ ASSEMBLE_TIMEOUT_MS,
+ type SegmentQuality,
+} from "./segments.js";
const execFileAsync = promisify(execFile);
+/**
+ * R2 key for a session's UNCUT original — deliberately unguessable.
+ *
+ * This file is the one piece of a session that is not meant to be shareable:
+ * during the edit hold it holds every minute the user is about to cut out, and
+ * it stays readable until the publish deletes it. The key used to be
+ * `timelapses//original.mp4`, and sessionId is public — it appears
+ * in the `/api/media/:sessionId/...` URLs handed out with any shared timelapse.
+ * So anyone holding a share link could reconstruct the original's URL and, if
+ * the bucket is readable at all (R2_PUBLIC_DOMAIN fronts it publicly in the
+ * documented setup), fetch the footage the user had cut — bypassing the token
+ * gate that `/units` presigns behind.
+ *
+ * 128 bits of randomness in the key closes that whether or not the bucket is
+ * public, which is the property worth having: it doesn't depend on an ACL
+ * staying right. The published video keeps a predictable key — it is meant to
+ * be fetched — and every reader gets this key from
+ * `sessions.original_video_r2_key` rather than rebuilding it.
+ */
+function uncutOriginalKey(sessionId: string): string {
+ return `timelapses/${sessionId}/original-${randomBytes(16).toString("hex")}.mp4`;
+}
+
+/** Whether a session is currently inside its edit hold. */
+function holdActiveOn(session: { editHoldUntil: Date | null }): boolean {
+ return (
+ session.editHoldUntil != null &&
+ session.editHoldUntil.getTime() > Date.now()
+ );
+}
+
+/**
+ * Post-build capture cleanup: drop the R2 objects for units that didn't make
+ * it into the video, and the rows for uploads that were never confirmed.
+ *
+ * SAMPLED units are deliberately kept. They were always kept (so /timings and
+ * the credit history stay queryable), and the two-tier split makes it load-
+ * bearing: a preview-grade original can't be published, so the publish step
+ * re-encodes from exactly these objects. Deleting them here would strand a
+ * held session with nothing to publish from.
+ */
+async function cleanUpCaptureLeftovers(sessionId: string): Promise {
+ const unsampled = await db
+ .select({ r2Key: schema.screenshots.r2Key, id: schema.screenshots.id })
+ .from(schema.screenshots)
+ .where(
+ and(
+ eq(schema.screenshots.sessionId, sessionId),
+ eq(schema.screenshots.confirmed, true),
+ eq(schema.screenshots.sampled, false),
+ ),
+ );
+
+ for (const ss of unsampled) {
+ await deleteObjectQuiet(ss.r2Key);
+ }
+
+ await db
+ .delete(schema.screenshots)
+ .where(
+ and(
+ eq(schema.screenshots.sessionId, sessionId),
+ eq(schema.screenshots.confirmed, false),
+ ),
+ );
+}
+
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL environment variable must be set");
@@ -26,7 +115,12 @@ const db = drizzle(pool, { schema });
const r2Client = new S3Client({
region: "auto",
- endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
+ // R2_ENDPOINT is the local-development escape hatch (an S3-compatible
+ // server instead of real R2); unset in production. Must stay in step with
+ // the server's config/r2.ts — the two read and write the same objects.
+ endpoint:
+ process.env.R2_ENDPOINT ||
+ `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
@@ -42,9 +136,27 @@ const R2_PUBLIC_DOMAIN = process.env.R2_PUBLIC_DOMAIN || "";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
-/** Shared video filter: scale to 1920x1080 with pillarboxing. */
-const SCALE_FILTER =
- "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2";
+/** Ceiling for reported per-unit progress. The unit loop is the only metered
+ * stage; assembly, thumbnail and upload still run after the last unit lands,
+ * so the ring must stop short of 100% — only the status flip to
+ * complete/editable ends the wait. Mirrors the client's asymptotic estimate. */
+const PROGRESS_UNIT_CAP = 0.95;
+
+/** Write real compile progress for /status to report. `greatest(...)` keeps it
+ * monotonic in the DB even if a pg-boss retry re-claims and re-counts from 0,
+ * and never rewinds a value a prior attempt already reached. */
+async function writeCompileProgress(
+ sessionId: string,
+ fraction: number,
+): Promise {
+ const clamped = Math.max(0, Math.min(PROGRESS_UNIT_CAP, fraction));
+ await db
+ .update(schema.sessions)
+ .set({
+ compileProgress: sql`greatest(coalesce(${schema.sessions.compileProgress}, 0), ${clamped})`,
+ })
+ .where(eq(schema.sessions.id, sessionId));
+}
/** Verify a video file with ffprobe: check file size > 0 and frame count within tolerance. */
async function verifyVideo(
@@ -114,6 +226,80 @@ async function uploadAndVerify(
}
}
+/** Download an R2 object to a local file, with retries. */
+async function downloadObject(r2Key: string, destPath: string): Promise {
+ let lastErr: unknown;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ const response = await r2Client.send(
+ new GetObjectCommand({ Bucket: R2_BUCKET, Key: r2Key }),
+ );
+ const body = await response.Body!.transformToByteArray();
+ await fs.writeFile(destPath, body);
+ return;
+ } catch (err) {
+ lastErr = err;
+ }
+ }
+ throw new Error(`Failed to download ${r2Key} after 3 attempts`, {
+ cause: lastErr,
+ });
+}
+
+/** Extract the session thumbnail (first frame) from a compiled video. */
+async function extractThumbnail(
+ videoPath: string,
+ thumbnailPath: string,
+): Promise {
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-i", videoPath,
+ "-vframes", "1",
+ "-vf", "scale=480:-1",
+ "-q:v", "5",
+ "-y",
+ thumbnailPath,
+ ],
+ { timeout: 30_000 },
+ );
+}
+
+/** Best-effort R2 delete (orphans are acceptable; jobs must not fail on it). */
+async function deleteObjectQuiet(r2Key: string): Promise {
+ try {
+ await r2Client.send(
+ new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: r2Key }),
+ );
+ } catch {
+ // Non-fatal: orphaned R2 objects can be cleaned up later
+ }
+}
+
+/** Confirmed capture rows in the shape the shared cut/tracked-time math
+ * expects. The coalesce mirrors the timings endpoint exactly. */
+async function getCaptureRowsForCuts(
+ sessionId: string,
+): Promise {
+ const rows = await db.execute<{
+ ts: Date | string;
+ credited_seconds: number | string | null;
+ minute_bucket: number | string;
+ }>(sql`
+ SELECT coalesce(captured_at, requested_at) AS ts,
+ credited_seconds,
+ minute_bucket
+ FROM screenshots
+ WHERE session_id = ${sessionId} AND confirmed = true
+ `);
+ return rows.rows.map((r) => ({
+ timeMs: (r.ts instanceof Date ? r.ts : new Date(r.ts)).getTime(),
+ creditedSeconds:
+ r.credited_seconds === null ? null : Number(r.credited_seconds),
+ minuteBucket: Number(r.minute_bucket),
+ }));
+}
+
export async function compileTimelapse(sessionId: string): Promise<{
videoUrl: string;
videoR2Key: string;
@@ -135,7 +321,10 @@ export async function compileTimelapse(sessionId: string): Promise<{
// Allow re-entry from 'compiling' so pg-boss retries can re-claim after a crash.
const [claimed] = await db
.update(schema.sessions)
- .set({ status: "compiling", updatedAt: new Date() })
+ // Clear any progress a prior attempt left behind, so a cut-apply compile
+ // (which never meters) reports NULL → estimate, and a re-run of a
+ // half-built original starts the metered value fresh.
+ .set({ status: "compiling", compileProgress: null, updatedAt: new Date() })
.where(
and(
eq(schema.sessions.id, sessionId),
@@ -154,15 +343,50 @@ export async function compileTimelapse(sessionId: string): Promise<{
await fs.mkdir(tmpDir, { recursive: true });
try {
+ const cuts: CutInterval[] = Array.isArray(session.cuts)
+ ? (session.cuts as CutInterval[])
+ : [];
+
+ // ── Half B: cut apply ─────────────────────────────────────
+ // The original video already exists with its unit map — this is a
+ // user-initiated edit (or an un-cut). Never touches capture units, so
+ // it works even after the screenshot retention purge.
+ if (session.originalVideoR2Key && (session.videoUnits?.length ?? 0) > 0) {
+ return await applyCutCompile(session, cuts, tmpDir);
+ }
+
+ // ── Half A: original build (the pre-existing pipeline) ───────
+
+ // Two-tier decision, made BEFORE any encoding.
+ //
+ // A session stopped with `{edit: true}` carries an edit hold, which means
+ // the only consumer of this build is the editor: the published video is
+ // re-encoded from the capture units when the user publishes (see
+ // publishFromUnits). So build the cheap tier and skip the quality this
+ // file will never deliver. A session with no hold publishes THIS file
+ // directly, so it must be publish-grade — that path is unchanged.
+ const buildQuality: SegmentQuality = holdActiveOn(session)
+ ? "preview"
+ : "publish";
+ if (buildQuality === "preview") {
+ console.log(
+ `Session ${sessionId}: building PREVIEW-grade original ` +
+ `(${PREVIEW_WIDTH}x${PREVIEW_HEIGHT}) — the editor opens on this, ` +
+ `and publishing re-encodes from capture units at full quality.`,
+ );
+ }
+
// Step 1: Sample selection — pick best screenshot per minute bucket
// Using raw SQL for DISTINCT ON which Drizzle doesn't support directly
const sampledScreenshots = await db.execute<{
id: string;
r2_key: string;
minute_bucket: number;
- requested_at: Date;
+ requested_at: Date | string;
+ captured_at: Date | string | null;
+ format: string;
}>(sql`
- SELECT DISTINCT ON (minute_bucket) id, r2_key, minute_bucket, requested_at
+ SELECT DISTINCT ON (minute_bucket) id, r2_key, minute_bucket, requested_at, captured_at, format
FROM screenshots
WHERE session_id = ${sessionId} AND confirmed = true
ORDER BY minute_bucket,
@@ -177,7 +401,7 @@ export async function compileTimelapse(sessionId: string): Promise<{
// No screenshots — mark failed (no video possible)
await db
.update(schema.sessions)
- .set({ status: "failed", updatedAt: new Date() })
+ .set({ status: "failed", compileProgress: null, updatedAt: new Date() })
.where(eq(schema.sessions.id, sessionId));
return {
videoUrl: "",
@@ -187,8 +411,18 @@ export async function compileTimelapse(sessionId: string): Promise<{
};
}
- // Mark sampled screenshots
- const sampledIds = sampledScreenshots.rows.map((s) => s.id);
+ // The seed capture opens the recording instead of closing a minute: it
+ // credits 0 tracked seconds, and in clips mode its clip spans only the
+ // ~8s before the first cut. Including it made the video one second
+ // longer than the tracked minute count and put a slow-motion second at
+ // the head of every timelapse. See dropSeedUnit.
+ const unitRows = dropSeedUnit(sampledScreenshots.rows);
+
+ // Mark sampled screenshots. The seed is deliberately NOT marked: it is
+ // not in the video, so its R2 object is cleaned up with the other
+ // unsampled captures (the row itself stays, so /timings and the credit
+ // history are untouched).
+ const sampledIds = unitRows.map((s) => s.id);
for (const id of sampledIds) {
await db
.update(schema.screenshots)
@@ -196,115 +430,227 @@ export async function compileTimelapse(sessionId: string): Promise<{
.where(eq(schema.screenshots.id, id));
}
- // Step 2: Download sampled screenshots from R2 (worker pool)
- const total = sampledScreenshots.rows.length;
- const DOWNLOAD_CONCURRENCY = 10;
- const downloaded: boolean[] = new Array(total).fill(false);
+ // Steps 2+3, pipelined: one worker pool downloads each unit and
+ // immediately builds its 1-second normalized segment — no barrier
+ // between the stages, so early units encode while later units are
+ // still downloading. Every unit — legacy JPEG or clip — becomes
+ // exactly one second of 30fps output with identical pinned encoder
+ // parameters, so the final timelapse is a stream-copy concatenation
+ // instead of one giant whole-session encode. Wall clock scales with
+ // units/SEGMENT_CONCURRENCY, and a corrupt unit is caught and skipped
+ // per-minute rather than poisoning the full encode.
+ const total = unitRows.length;
+ const unitExt = (format: string) => (format === "jpeg" ? "jpg" : format);
+ const segmentPaths: (string | null)[] = new Array(total).fill(null);
+ let downloadFailures = 0;
+ let buildFailures = 0;
{
let next = 0;
+ // Real progress: units finished (built OR skipped — a skip still
+ // advances the wait) over total, capped and written throttled. The
+ // event loop is single-threaded, so the shared counters need no lock.
+ let done = 0;
+ let lastWrittenFrac = 0;
+ const reportUnitDone = async () => {
+ done++;
+ const frac = PROGRESS_UNIT_CAP * (done / total);
+ // Write at most once per 1% of movement (≤ ~95 writes even for a
+ // 12-hour session), always flushing the final unit.
+ if (frac - lastWrittenFrac < 0.01 && done < total) return;
+ lastWrittenFrac = frac;
+ try {
+ await writeCompileProgress(sessionId, frac);
+ } catch {
+ // Progress is cosmetic; a failed write must never fail the compile.
+ }
+ };
const worker = async () => {
while (next < total) {
const i = next++;
- const ss = sampledScreenshots.rows[i];
- const filePath = path.join(tmpDir, `dl_${i}.jpg`);
+ const ss = unitRows[i];
+ const unitPath = path.join(tmpDir, `dl_${i}.${unitExt(ss.format)}`);
+
+ let downloadedUnit = false;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await r2Client.send(
new GetObjectCommand({ Bucket: R2_BUCKET, Key: ss.r2_key }),
);
const body = await response.Body!.transformToByteArray();
- await fs.writeFile(filePath, body);
- downloaded[i] = true;
+ await fs.writeFile(unitPath, body);
+ downloadedUnit = true;
break;
} catch {
if (attempt === 2) {
console.warn(
- `Skipping frame ${i + 1}: download failed after 3 attempts (${ss.r2_key})`,
+ `Skipping unit ${i + 1}: download failed after 3 attempts (${ss.r2_key})`,
);
}
}
}
+ if (!downloadedUnit) {
+ downloadFailures++;
+ await reportUnitDone();
+ continue;
+ }
+
+ try {
+ segmentPaths[i] = await buildSegment(
+ tmpDir,
+ i,
+ unitPath,
+ ss.format,
+ buildQuality,
+ );
+ } catch (err) {
+ buildFailures++;
+ console.warn(
+ `Skipping unit ${i + 1}: segment build failed (${ss.r2_key})`,
+ err,
+ );
+ }
+ await reportUnitDone();
}
};
await Promise.all(
- Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, total) }, worker),
+ Array.from({ length: Math.min(SEGMENT_CONCURRENCY, total) }, worker),
);
}
- // Renumber successfully downloaded frames sequentially for ffmpeg
- const failed = downloaded.filter((d) => !d).length;
- if (failed > 5) {
+ const segments = segmentPaths.filter((p): p is string => p !== null);
+ const unitsIncluded = segments.length;
+ if (downloadFailures > 5) {
throw new Error(
- `Too many failed frame downloads: ${failed}/${total} failed`,
+ `Too many failed unit downloads: ${downloadFailures}/${total} failed`,
);
}
- if (failed > 0) {
- console.warn(`${failed}/${total} frames failed to download, continuing`);
+ if (unitsIncluded === 0) {
+ throw new Error("No usable capture units after segment build");
}
- let seq = 1;
- for (let i = 0; i < total; i++) {
- if (downloaded[i]) {
- await fs.rename(
- path.join(tmpDir, `dl_${i}.jpg`),
- path.join(tmpDir, `${String(seq).padStart(5, "0")}.jpg`),
- );
- seq++;
- }
+ if (buildFailures > 5) {
+ throw new Error(
+ `Too many failed segment builds: ${buildFailures}/${total - downloadFailures} failed`,
+ );
+ }
+ if (downloadFailures + buildFailures > 0) {
+ console.warn(
+ `${downloadFailures} download / ${buildFailures} build failures out of ${total} units, continuing`,
+ );
}
- const actualFrames = seq - 1;
- // Step 3: Run ffmpeg — MP4 only (H.264)
- const mp4Path = path.join(tmpDir, "timelapse.mp4");
- const inputPattern = path.join(tmpDir, "%05d.jpg");
+ // The units that actually made it in, in output order. Array index =
+ // video second = real-world minute — the exact video-time ↔ wall-clock
+ // map the edit feature scrubs and cuts against. Built from the segment
+ // list (not the sampled rows) so build-failure holes never desync it.
+ const videoUnits: VideoUnit[] = [];
+ for (let i = 0; i < total; i++) {
+ if (segmentPaths[i] === null) continue;
+ const ss = unitRows[i];
+ const ts = ss.captured_at ?? ss.requested_at;
+ videoUnits.push({
+ capturedAt: (ts instanceof Date ? ts : new Date(ts)).toISOString(),
+ screenshotId: ss.id,
+ });
+ }
- await execFileAsync(
- "ffmpeg",
- [
- "-framerate",
- "1",
- "-i",
- inputPattern,
- "-c:v",
- "libx264",
- "-preset",
- "fast",
- "-crf",
- "28",
- "-r",
- "30",
- "-pix_fmt",
- "yuv420p",
- "-movflags",
- "+faststart",
- "-vf",
- SCALE_FILTER,
- "-y",
- mp4Path,
- ],
- { timeout: 600_000 },
+ // Step 4: Assemble — stream-copy concat of the segments, remuxed to MP4.
+ // No re-encoding on the happy path: segments share pinned parameters and
+ // each starts on an IDR frame, so this is I/O-bound (seconds, even for a
+ // 12-hour session).
+ const concatListPath = path.join(tmpDir, "segments.txt");
+ await fs.writeFile(
+ concatListPath,
+ segments.map((p) => `file '${p}'`).join("\n") + "\n",
);
- // Step 4: Verify output
- const mp4Size = await verifyVideo(mp4Path, actualFrames, 30, "MP4");
+ const originalPath = path.join(tmpDir, "original.mp4");
+ let originalSize: number;
+ try {
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "concat",
+ "-safe", "0",
+ "-i", concatListPath,
+ "-c", "copy",
+ "-movflags", "+faststart",
+ "-y",
+ originalPath,
+ ],
+ { timeout: ASSEMBLE_TIMEOUT_MS },
+ );
+ originalSize = await verifyVideo(
+ originalPath,
+ unitsIncluded,
+ SEGMENT_FPS,
+ "MP4",
+ );
+ } catch (err) {
+ // Safety net: if the copied stream doesn't verify (e.g. an encoder
+ // parameter drifted between segments), re-encode the already-built
+ // segments into one uniform stream. One extra ffmpeg pass over 1s
+ // segments — not a second pipeline. The GOP args keep the fallback
+ // output on the same 1s IDR grid as the copy path, so the video stays
+ // losslessly cuttable by the edit feature.
+ console.warn("Stream-copy assembly failed, re-encoding segments:", err);
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "concat",
+ "-safe", "0",
+ "-i", concatListPath,
+ // Match the segment encoder for this TIER — the fallback must not
+ // be a quality downgrade on the publish tier, and must not be an
+ // expensive upgrade on the throwaway preview tier.
+ ...segmentEncodeArgs(buildQuality, { singleThreaded: false }),
+ "-r", String(SEGMENT_FPS),
+ "-movflags", "+faststart",
+ "-y",
+ originalPath,
+ ],
+ { timeout: ASSEMBLE_TIMEOUT_MS },
+ );
+ originalSize = await verifyVideo(
+ originalPath,
+ unitsIncluded,
+ SEGMENT_FPS,
+ "MP4 (re-encoded)",
+ );
+ }
+ // Both assembly paths land on the pinned 1s closed-GOP grid.
+ const videoCopyAligned = true;
+
+ // 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 =
+ cuts.length > 0 &&
+ keptRanges.reduce((n, r) => n + (r.end - r.start), 0) < videoUnits.length;
+ if (cuts.length > 0 && keptRanges.length === 0) {
+ throw new Error("Cut list removes every capture unit — refusing to compile an empty video");
+ }
+
+ let publishPath = originalPath;
+ let publishSize = originalSize;
+ // Reuse the existing key on a recompile so the old object is overwritten
+ // rather than orphaned; mint a fresh unguessable one otherwise.
+ const originalR2Key =
+ session.originalVideoR2Key ?? uncutOriginalKey(sessionId);
+ let publishR2Key = originalR2Key;
+
+ if (hasEffectiveCuts) {
+ publishPath = await cutVideoToKeptRanges(tmpDir, originalPath, keptRanges, videoCopyAligned);
+ publishSize = (await fs.stat(publishPath)).size;
+ publishR2Key = `timelapses/${sessionId}/edited.mp4`;
+ }
- // Step 4.5: Extract thumbnail from first frame
+ // Step 4.5: Extract thumbnail from the PUBLISHED video's first frame
+ // (post-cut when edited, so a cut first minute never leaks a stale frame).
const thumbnailPath = path.join(tmpDir, "thumbnail.jpg");
- await execFileAsync(
- "ffmpeg",
- [
- "-i",
- mp4Path,
- "-vframes",
- "1",
- "-vf",
- "scale=480:-1",
- "-q:v",
- "5",
- "-y",
- thumbnailPath,
- ],
- { timeout: 30_000 },
- );
+ await extractThumbnail(publishPath, thumbnailPath);
// Step 5: Upload all artifacts to R2 and verify
const thumbnailR2Key = `timelapses/${sessionId}/thumbnail.jpg`;
@@ -319,70 +665,482 @@ export async function compileTimelapse(sessionId: string): Promise<{
}),
);
- const videoR2Key = `timelapses/${sessionId}/timelapse.mp4`;
- await uploadAndVerify(mp4Path, videoR2Key, "video/mp4", mp4Size, "MP4");
+ await uploadAndVerify(
+ originalPath,
+ originalR2Key,
+ "video/mp4",
+ originalSize,
+ "MP4 (original)",
+ );
+ if (hasEffectiveCuts) {
+ await uploadAndVerify(
+ publishPath,
+ publishR2Key,
+ "video/mp4",
+ publishSize,
+ "MP4 (edited)",
+ );
+ }
+
+ // Authoritative cut-seconds for the tracked-time subtraction.
+ const cutSeconds = hasEffectiveCuts
+ ? computeCutSeconds(
+ await getCaptureRowsForCuts(sessionId),
+ session.trackingMode === "credit" ? "credit" : "bucket",
+ session.trackedSeconds ?? 0,
+ cuts,
+ )
+ : 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;
const videoUrl = R2_PUBLIC_DOMAIN
- ? `https://${R2_PUBLIC_DOMAIN}/${videoR2Key}`
- : videoR2Key;
+ ? `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();
+
+ // A PREVIEW-grade build may never publish, hold or no hold — the file is
+ // low-resolution and exists only for the editor. So it always records
+ // itself as the unpublished original, and if the hold lapsed while we
+ // were encoding (the one race the two-tier split introduces) it hands
+ // straight over to the publish path, which re-encodes from the capture
+ // units at full quality. That keeps exactly one implementation of
+ // "produce the published video" instead of a second copy here.
+ if (buildQuality === "preview") {
+ await db
+ .update(schema.sessions)
+ .set({
+ status: "stopped",
+ compileProgress: null,
+ videoUrl: null,
+ videoR2Key: null,
+ originalVideoR2Key: originalR2Key,
+ originalIsPreview: true,
+ videoUnits,
+ videoCopyAligned,
+ cutSeconds,
+ thumbnailUrl,
+ thumbnailR2Key,
+ updatedAt: new Date(),
+ })
+ .where(eq(schema.sessions.id, sessionId));
+
+ if (holdActive) {
+ console.log(
+ `Session ${sessionId} preview built, held for editing until ` +
+ `${current!.editHoldUntil!.toISOString()}`,
+ );
+ await cleanUpCaptureLeftovers(sessionId);
+ return {
+ videoUrl,
+ videoR2Key: publishR2Key,
+ thumbnailUrl,
+ thumbnailR2Key,
+ };
+ }
+
+ console.warn(
+ `Session ${sessionId}: edit hold lapsed during the preview build — ` +
+ `publishing at full quality from capture units instead.`,
+ );
+ const fresh = await db.query.sessions.findFirst({
+ where: eq(schema.sessions.id, sessionId),
+ });
+ return await applyCutCompile(fresh!, cuts, tmpDir);
+ }
await db
.update(schema.sessions)
.set({
- status: "complete",
- videoUrl,
- videoR2Key,
+ status: holdActive ? "stopped" : "complete",
+ // The build is done — the wait now hinges on the status flip, not a
+ // fraction. Clear it so a later reopen doesn't show stale progress.
+ compileProgress: null,
+ videoUrl: holdActive ? null : videoUrl,
+ videoR2Key: holdActive ? null : publishR2Key,
+ originalVideoR2Key: originalR2Key,
+ videoUnits,
+ videoCopyAligned,
+ cutSeconds,
+ ...(hasEffectiveCuts ? { lastEditCompileAt: new Date() } : {}),
thumbnailUrl,
thumbnailR2Key,
updatedAt: new Date(),
})
.where(eq(schema.sessions.id, sessionId));
- // Step 7: Cleanup unsampled screenshots from R2
- const unsampled = await db
- .select({ r2Key: schema.screenshots.r2Key, id: schema.screenshots.id })
- .from(schema.screenshots)
- .where(
- and(
- eq(schema.screenshots.sessionId, sessionId),
- eq(schema.screenshots.confirmed, true),
- eq(schema.screenshots.sampled, false),
- ),
+ if (holdActive) {
+ console.log(
+ `Session ${sessionId} built and held for editing until ${current!.editHoldUntil!.toISOString()}`,
);
+ }
+
+ // Step 7: Cleanup unsampled screenshots from R2
+ await cleanUpCaptureLeftovers(sessionId);
+
+ return {
+ videoUrl,
+ videoR2Key: publishR2Key,
+ thumbnailUrl,
+ thumbnailR2Key,
+ };
+ } finally {
+ // Always clean up temp directory
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
+ }
+}
+
+/**
+ * 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.
+ */
+/**
+ * Build the PUBLISHED video from the session's capture units at full quality,
+ * including only the kept ranges.
+ *
+ * This is the second tier of the two-tier compile: the editor ran against a
+ * cheap preview, and this is where the timelapse that actually goes out is
+ * made. Cuts are applied by simply not encoding the removed units, so no
+ * separate cut step (and no generation of loss) is involved, and a session
+ * with half its minutes cut costs half as much to publish.
+ *
+ * Returns null when the units can no longer be read — the caller decides how
+ * to degrade rather than having a failure imposed on it.
+ */
+async function buildPublishFromUnits(
+ sessionId: string,
+ tmpDir: string,
+ keptRanges: KeptRange[],
+ totalUnits: number,
+): Promise<{ path: string; size: number } | null> {
+ // The unit map's index space is what keptRanges refer to: index i is the
+ // i-th unit in the compiled video, which is the i-th sampled screenshot in
+ // minute-bucket order (the same DISTINCT ON contract Half A uses, minus the
+ // dropped seed unit).
+ const sampled = await db.execute<{ r2_key: string; format: string }>(sql`
+ SELECT DISTINCT ON (minute_bucket) r2_key, format
+ FROM screenshots
+ WHERE session_id = ${sessionId} AND confirmed = true AND sampled = true
+ ORDER BY minute_bucket ASC, captured_at ASC NULLS LAST, requested_at ASC
+ `);
+ const rows = sampled.rows;
+ if (rows.length !== totalUnits) {
+ console.warn(
+ `Session ${sessionId}: expected ${totalUnits} sampled units for a ` +
+ `publish re-encode, found ${rows.length} — falling back.`,
+ );
+ return null;
+ }
+
+ const keptIndices: number[] = [];
+ for (const r of keptRanges) {
+ for (let i = r.start; i < r.end; i++) keptIndices.push(i);
+ }
+ if (keptIndices.length === 0) return null;
- for (const ss of unsampled) {
+ const segmentPaths: (string | null)[] = new Array(keptIndices.length).fill(null);
+ let failures = 0;
+ let next = 0;
+ const worker = async () => {
+ while (next < keptIndices.length) {
+ const slot = next++;
+ const unitIndex = keptIndices[slot];
+ const row = rows[unitIndex];
+ const ext = row.format === "jpeg" ? "jpg" : row.format;
+ const unitPath = path.join(tmpDir, `pub_${slot}.${ext}`);
try {
- await r2Client.send(
- new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: ss.r2Key }),
+ const response = await r2Client.send(
+ new GetObjectCommand({ Bucket: R2_BUCKET, Key: row.r2_key }),
+ );
+ await fs.writeFile(
+ unitPath,
+ await response.Body!.transformToByteArray(),
);
} catch {
- // Non-fatal: orphaned R2 objects can be cleaned up later
+ failures++;
+ continue;
+ }
+ try {
+ // `pub_` index space, so these never collide with the preview run's
+ // segment files still sitting in tmpDir.
+ segmentPaths[slot] = await buildSegment(
+ tmpDir,
+ 10_000 + slot,
+ unitPath,
+ row.format,
+ "publish",
+ );
+ } catch (err) {
+ failures++;
+ console.warn(`Session ${sessionId}: publish segment ${slot} failed`, err);
}
}
+ };
+ await Promise.all(
+ Array.from(
+ { length: Math.min(SEGMENT_CONCURRENCY, keptIndices.length) },
+ worker,
+ ),
+ );
- // Delete unconfirmed screenshot records
- await db
- .delete(schema.screenshots)
- .where(
- and(
- eq(schema.screenshots.sessionId, sessionId),
- eq(schema.screenshots.confirmed, false),
- ),
+ const segments = segmentPaths.filter((p): p is string => p !== null);
+ // A gap would silently shorten the timelapse and desync the unit map the
+ // editor and /timings both read, so this is all-or-nothing.
+ if (failures > 0 || segments.length !== keptIndices.length) {
+ console.warn(
+ `Session ${sessionId}: ${failures} unit(s) unavailable for the publish ` +
+ `re-encode (${segments.length}/${keptIndices.length} built).`,
+ );
+ return null;
+ }
+
+ const concatListPath = path.join(tmpDir, "publish_concat.txt");
+ await fs.writeFile(
+ concatListPath,
+ segments.map((p) => `file '${p}'`).join("\n") + "\n",
+ );
+ const outPath = path.join(tmpDir, "publish.mp4");
+ try {
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "concat",
+ "-safe", "0",
+ "-i", concatListPath,
+ "-c", "copy",
+ "-movflags", "+faststart",
+ "-y",
+ outPath,
+ ],
+ { timeout: ASSEMBLE_TIMEOUT_MS },
+ );
+ const size = await verifyVideo(
+ outPath,
+ segments.length,
+ SEGMENT_FPS,
+ "Published MP4 (from units)",
+ );
+ return { path: outPath, size };
+ } catch (err) {
+ // Same safety net as Half A's assembly: re-encode the segments into one
+ // uniform stream, keeping the pinned grid so the result stays cuttable.
+ console.warn(
+ `Session ${sessionId}: stream-copy assembly of the published video ` +
+ `failed, re-encoding segments:`,
+ err,
+ );
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "concat",
+ "-safe", "0",
+ "-i", concatListPath,
+ ...segmentEncodeArgs("publish", { singleThreaded: false }),
+ "-r", String(SEGMENT_FPS),
+ "-movflags", "+faststart",
+ "-y",
+ outPath,
+ ],
+ { timeout: ASSEMBLE_TIMEOUT_MS },
+ );
+ const size = await verifyVideo(
+ outPath,
+ segments.length,
+ SEGMENT_FPS,
+ "Published MP4 (from units, re-encoded)",
+ );
+ return { path: outPath, size };
+ }
+}
+
+async function applyCutCompile(
+ session: typeof schema.sessions.$inferSelect,
+ cuts: CutInterval[],
+ tmpDir: string,
+): Promise<{
+ videoUrl: string;
+ videoR2Key: string;
+ thumbnailUrl: string;
+ thumbnailR2Key: string;
+}> {
+ const sessionId = session.id;
+ const originalR2Key = session.originalVideoR2Key!;
+ const editedR2Key = `timelapses/${sessionId}/edited.mp4`;
+ const videoUnits = session.videoUnits as VideoUnit[];
+
+ const unitTimesMs = videoUnits.map((u) => Date.parse(u.capturedAt));
+ const keptRanges = computeKeptRanges(unitTimesMs, cuts);
+ const keptUnits = keptRanges.reduce((n, r) => n + (r.end - r.start), 0);
+ const hasEffectiveCuts = cuts.length > 0 && keptUnits < videoUnits.length;
+
+ if (cuts.length > 0 && keptRanges.length === 0) {
+ throw new Error(
+ "Cut list removes every capture unit — refusing to compile an empty video",
+ );
+ }
+
+ let publishPath: string;
+ let publishR2Key: string;
+
+ if (session.originalIsPreview) {
+ // The original is the throwaway preview: it is low-resolution, so it can
+ // neither be published nor cut-copied. Build the published video from the
+ // capture units at full quality, encoding ONLY the kept ones — which
+ // makes a heavily-cut session cheaper here than an uncut one, not dearer.
+ const built = await buildPublishFromUnits(
+ sessionId,
+ tmpDir,
+ keptRanges,
+ videoUnits.length,
+ );
+ if (built) {
+ publishPath = built.path;
+ publishR2Key = hasEffectiveCuts ? editedR2Key : originalR2Key;
+ } else {
+ // The units are gone (retention purge, or an R2 outage that outlasted
+ // the retries). Publishing the preview is a visible quality drop, but a
+ // held session that can never publish is worse — the user's recording
+ // would be lost. Take the copy path and say so loudly.
+ console.error(
+ `Session ${sessionId}: cannot re-encode from capture units — ` +
+ `publishing the PREVIEW-grade original instead. The timelapse will ` +
+ `be ${PREVIEW_WIDTH}x${PREVIEW_HEIGHT} rather than full resolution.`,
+ );
+ const originalPath = path.join(tmpDir, "original.mp4");
+ await downloadObject(originalR2Key, originalPath);
+ publishPath = originalPath;
+ publishR2Key = originalR2Key;
+ if (hasEffectiveCuts) {
+ publishPath = await cutVideoToKeptRanges(
+ tmpDir,
+ originalPath,
+ keptRanges,
+ session.videoCopyAligned === true,
+ );
+ publishR2Key = editedR2Key;
+ }
+ }
+ } else {
+ // Publish-grade original (a legacy session, or one that never entered the
+ // edit flow): cut it losslessly, exactly as before.
+ const originalPath = path.join(tmpDir, "original.mp4");
+ await downloadObject(originalR2Key, originalPath);
+ publishPath = originalPath;
+ publishR2Key = originalR2Key;
+
+ if (hasEffectiveCuts) {
+ publishPath = await cutVideoToKeptRanges(
+ tmpDir,
+ originalPath,
+ keptRanges,
+ session.videoCopyAligned === true,
);
+ publishR2Key = editedR2Key;
+ }
+ }
- return {
+ // Thumbnail follows the published video: a cut first minute must not leak
+ // a stale first frame, and un-cutting must restore the original's.
+ const thumbnailPath = path.join(tmpDir, "thumbnail.jpg");
+ await extractThumbnail(publishPath, thumbnailPath);
+
+ const thumbnailR2Key = `timelapses/${sessionId}/thumbnail.jpg`;
+ const thumbnailBytes = await fs.readFile(thumbnailPath);
+ await r2Client.send(
+ new PutObjectCommand({
+ Bucket: R2_BUCKET,
+ Key: thumbnailR2Key,
+ Body: thumbnailBytes,
+ ContentType: "image/jpeg",
+ CacheControl: "public, max-age=86400",
+ }),
+ );
+
+ if (hasEffectiveCuts) {
+ const publishSize = (await fs.stat(publishPath)).size;
+ await uploadAndVerify(
+ publishPath,
+ publishR2Key,
+ "video/mp4",
+ publishSize,
+ "MP4 (edited)",
+ );
+ } else if (session.videoR2Key && session.videoR2Key !== originalR2Key) {
+ // Un-cut: the original becomes the published video again; drop the now
+ // stale edited artifact.
+ await deleteObjectQuiet(session.videoR2Key);
+ }
+
+ const cutSeconds = hasEffectiveCuts
+ ? computeCutSeconds(
+ await getCaptureRowsForCuts(sessionId),
+ session.trackingMode === "credit" ? "credit" : "bucket",
+ session.trackedSeconds ?? 0,
+ cuts,
+ )
+ : 0;
+
+ const thumbnailUrl = R2_PUBLIC_DOMAIN
+ ? `https://${R2_PUBLIC_DOMAIN}/${thumbnailR2Key}`
+ : thumbnailR2Key;
+ const videoUrl = R2_PUBLIC_DOMAIN
+ ? `https://${R2_PUBLIC_DOMAIN}/${publishR2Key}`
+ : publishR2Key;
+
+ await db
+ .update(schema.sessions)
+ .set({
+ status: "complete",
videoUrl,
- videoR2Key,
+ videoR2Key: publishR2Key,
+ cutSeconds,
+ // 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,
- };
- } finally {
- // Always clean up temp directory
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
+ updatedAt: new Date(),
+ })
+ .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 83aebe56..1acf5c9b 100644
--- a/packages/worker/src/schema.ts
+++ b/packages/worker/src/schema.ts
@@ -9,6 +9,7 @@ import {
text,
timestamp,
integer,
+ real,
boolean,
jsonb,
index,
@@ -39,11 +40,35 @@ export const sessions = pgTable(
resumedAt: timestamp("resumed_at", { withTimezone: true }),
totalActiveSeconds: integer("total_active_seconds").notNull().default(0),
trackedSeconds: integer("tracked_seconds"),
+ // 'bucket' (legacy distinct-minute count) or 'credit' (per-capture
+ // acceptance window). Needed for cut-seconds math at cut-compile.
+ trackingMode: text("tracking_mode").notNull().default("bucket"),
videoUrl: text("video_url"),
videoR2Key: text("video_r2_key"),
thumbnailUrl: text("thumbnail_url"),
thumbnailR2Key: text("thumbnail_r2_key"),
compileAttempts: integer("compile_attempts").notNull().default(0),
+ // Real per-unit compile progress (0..~0.95). See the server schema for
+ // full docs; the worker's compile loop writes it, /status reports it.
+ compileProgress: real("compile_progress"),
+ // ── Edits (cuts) — see the server schema for full docs ──
+ cuts: jsonb("cuts").$type<{ start: string; end: string }[]>(),
+ cutSeconds: integer("cut_seconds"),
+ videoUnits: jsonb("video_units").$type<
+ { capturedAt: string; screenshotId: string }[]
+ >(),
+ originalVideoR2Key: text("original_video_r2_key"),
+ videoCopyAligned: boolean("video_copy_aligned"),
+ // True when original_video_r2_key is a throwaway PREVIEW build (reduced
+ // resolution, cheap encoder settings) made only so the editor opens
+ // promptly. Such a file must never be published — publishing re-encodes
+ // from the capture units instead. Mirrors the server schema.
+ originalIsPreview: boolean("original_is_preview").notNull().default(false),
+ recompileCount: integer("recompile_count").notNull().default(0),
+ lastEditCompileAt: timestamp("last_edit_compile_at", {
+ withTimezone: true,
+ }),
+ editHoldUntil: timestamp("edit_hold_until", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
@@ -74,6 +99,15 @@ export const screenshots = pgTable(
height: integer("height"),
fileSizeBytes: integer("file_size_bytes"),
sampled: boolean("sampled").notNull().default(false),
+ // 'jpeg' (legacy single frame) or 'webm'/'mp4' (per-minute clip).
+ format: text("format").notNull().default("jpeg"),
+ // Client-reported frames per clip; the compiler demuxes for the truth.
+ frameCount: integer("frame_count"),
+ // Client-attested capture time; NULL for pre-migration rows (fall back
+ // to requestedAt — same coalesce the timings endpoint uses).
+ capturedAt: timestamp("captured_at", { withTimezone: true }),
+ // Credit-mode only: 0 or 60. NULL for bucket-mode rows.
+ creditedSeconds: integer("credited_seconds"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
diff --git a/packages/worker/src/segments.ts b/packages/worker/src/segments.ts
new file mode 100644
index 00000000..f9b48711
--- /dev/null
+++ b/packages/worker/src/segments.ts
@@ -0,0 +1,407 @@
+// Segment building: normalizing capture units (legacy JPEGs and per-minute
+// clips) into uniform 1-second video segments that the compiler can
+// stream-copy concatenate into the final timelapse. Also the cut step of
+// the edit feature, which exploits the same pinned grid. Kept free of DB/R2
+// imports so the ffmpeg contracts are testable in isolation.
+
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+import type { KeptRange } from "@lookout/shared";
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Drop the session's seed capture from the units that become video.
+ * `rows` must be ordered by minute bucket ascending (the `DISTINCT ON`
+ * contract in the compiler), so the seed is simply the first entry.
+ *
+ * The seed is the capture that STARTS the recording rather than closing a
+ * recorded minute, and it is special in two measurable ways:
+ *
+ * - It credits 0 tracked seconds. Both tracking modes agree: bucket mode
+ * reports `(distinct buckets - 1) * 60`, and in credit mode the seed
+ * capture is explicitly worth 0. Every other unit is worth 60s. Giving
+ * the seed a video second is therefore the exact reason a session
+ * reported N seconds of video against N-1 minutes of tracked time.
+ * - In clips mode it covers a fraction of a minute. The recorder cuts the
+ * opening clip after CLIP_FIRST_CUT_DELAY_MS (~8s) so the session
+ * activates quickly, so that clip holds ~8s of wall clock where every
+ * later clip holds 60s. Rendered as an equal one-second segment it plays
+ * at ~8x while the rest of the timelapse plays at 60x — a visible
+ * slow-motion lurch at the head of every video.
+ *
+ * Excluding it makes the rule uniform: a capture earns video time exactly
+ * when it earns tracked time. The timelapse still opens on motion (the
+ * first shown unit is a full ~15-frame clip), which is what the dense
+ * opening cadence was originally for.
+ *
+ * A single-unit session keeps its one unit — a zero-length video is worse
+ * than an imprecise one.
+ */
+export function dropSeedUnit(rows: T[]): T[] {
+ return rows.length > 1 ? rows.slice(1) : rows;
+}
+
+/**
+ * Which of the two compile tiers a build belongs to.
+ *
+ * - `publish` — the video that actually goes out. Full resolution, visually
+ * lossless. This is the only tier a non-edited session ever builds.
+ * - `preview` — a throwaway scrubbing copy built ONLY to open the editor
+ * quickly, then deleted when the session publishes. Nothing derives from
+ * it: the published video is re-encoded from the capture units, so this
+ * tier's quality never reaches a viewer and can be as cheap as remains
+ * useful for choosing cuts.
+ *
+ * Both tiers keep the pinned 1-second closed GOP (see SEGMENT_GOP_ARGS): the
+ * editor maps video seconds to capture units and seeks by second, so the
+ * grid is load-bearing for the preview too.
+ */
+export type SegmentQuality = "publish" | "preview";
+
+/** Preview tier resolution. 720p is a quarter of 1080p's pixels — the
+ * dominant term in encode cost — while still showing enough of a code
+ * editor or browser window to tell one minute from another, which is the
+ * only judgement the cut UI asks of it. */
+export const PREVIEW_WIDTH = 1280;
+export const PREVIEW_HEIGHT = 720;
+
+/** Scale-with-pillarbox filter for a tier. */
+export function scaleFilter(quality: SegmentQuality = "publish"): string {
+ const [w, h] =
+ quality === "preview" ? [PREVIEW_WIDTH, PREVIEW_HEIGHT] : [1920, 1080];
+ return `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2`;
+}
+
+/** Shared video filter: scale to 1920x1080 with pillarboxing. */
+export const SCALE_FILTER = scaleFilter("publish");
+
+/** Output framerate of the compiled timelapse. Every capture unit (one
+ * recorded minute) becomes exactly one second of output at this rate. */
+export const SEGMENT_FPS = 30;
+
+/** How many segment builds run concurrently. Each build is a small
+ * single-threaded ffmpeg (see -threads 1 below), so the parallelism
+ * lives here rather than inside x264. */
+export const SEGMENT_CONCURRENCY = 8;
+
+/** Timeout for one segment build (demux + 30-frame encode). */
+export const SEGMENT_TIMEOUT_MS = 120_000;
+
+/** Timeout for final assembly. The happy path is a stream-copy remux
+ * (seconds even for 12h sessions); the budget covers the re-encode
+ * fallback too. */
+export const ASSEMBLE_TIMEOUT_MS = 1_800_000;
+
+/** GOP pinning shared by every encode that produces (part of) a compiled
+ * timelapse: a closed GOP of exactly one segment (1 second) starting on an
+ * IDR frame, scene-cut keyframes disabled. This grid is ALSO what makes
+ * compiled videos losslessly cuttable at second boundaries by the edit
+ * feature's stream-copy cut — every encode path (segments, assembly
+ * fallback, edited-video re-encode fallback) must keep it. */
+export const SEGMENT_GOP_ARGS = [
+ "-g", String(SEGMENT_FPS),
+ "-keyint_min", String(SEGMENT_FPS),
+ "-sc_threshold", "0",
+ "-x264-params", "open-gop=0",
+];
+
+/**
+ * Pinned x264 parameters for a segment encode. Segments within one build
+ * must be bit-compatible so final assembly can stream-copy concatenate them:
+ * fixed profile/level, the pinned GOP grid above, single-threaded (the
+ * parallelism is at the segment level). Changing any of these breaks
+ * copy-concat — keep in lockstep with the assembly fallback and the
+ * mixed-session compile test.
+ *
+ * `publish` tier: CRF 18 = visually lossless. The compile step must not be a
+ * quality event — the clip bitrate is the only intended quality dial. (The
+ * legacy pipeline used CRF 28, which added a visible second generation of
+ * loss on top of already-compressed clips.) Costs ~2.5-3x the output size of
+ * CRF 28; timelapses are short, so absolute sizes stay modest.
+ *
+ * `preview` tier: as cheap as stays useful for choosing cuts, because the
+ * file is deleted at publish and no viewer ever sees it. Measured per unit
+ * through buildSegment on a 10-core box, 6fpm clip, against 431ms/55KB for
+ * the publish tier:
+ *
+ * 720p ultrafast crf30 72 ms 58 KB
+ * 720p superfast crf30 83 ms 29 KB <- chosen
+ * 720p veryfast crf30 103 ms 25 KB
+ *
+ * `superfast` rather than `ultrafast`: 15% slower for HALF the bytes, and the
+ * preview is not just encoded — the worker uploads it and the editor streams
+ * it back, so its size is part of the latency this tier exists to reduce.
+ * ultrafast's output is actually LARGER than the 1080p publish tier's, which
+ * would have made the editor slower to load in exchange for the faster
+ * encode. Net: ~5x faster to build and half the size to move.
+ */
+export function segmentEncodeArgs(
+ quality: SegmentQuality = "publish",
+ opts: { singleThreaded?: boolean } = {},
+): string[] {
+ const { singleThreaded = true } = opts;
+ const tier =
+ quality === "preview"
+ ? ["-preset", "superfast", "-crf", "30"]
+ : ["-preset", "fast", "-crf", "18"];
+ return [
+ "-c:v", "libx264",
+ "-profile:v", "high",
+ "-level:v", "4.0",
+ ...tier,
+ "-pix_fmt", "yuv420p",
+ ...SEGMENT_GOP_ARGS,
+ // Segment builds are single-threaded because the parallelism lives at the
+ // segment level (SEGMENT_CONCURRENCY). Whole-file encodes — the assembly
+ // fallback, the cut re-encode — are one process at a time and should use
+ // the box.
+ ...(singleThreaded ? ["-threads", "1"] : []),
+ ];
+}
+
+/** Publish-tier segment parameters. See segmentEncodeArgs. */
+export const SEGMENT_ENCODE_ARGS = segmentEncodeArgs("publish");
+
+/** Count the video frames in a file with ffprobe. */
+export async function probeFrameCount(filePath: string): Promise {
+ const { stdout } = await execFileAsync(
+ "ffprobe",
+ [
+ "-v", "error",
+ "-count_packets",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=nb_read_packets",
+ "-of", "csv=p=0",
+ filePath,
+ ],
+ { timeout: 30_000 },
+ );
+ return parseInt(stdout.trim(), 10);
+}
+
+/** Assert a built segment holds exactly SEGMENT_FPS frames. Strict — a
+ * short segment would silently desync every later minute of the video. */
+async function verifySegmentFrameCount(filePath: string): Promise {
+ const frames = await probeFrameCount(filePath);
+ if (frames !== SEGMENT_FPS) {
+ throw new Error(`segment has ${frames} frames, expected ${SEGMENT_FPS}`);
+ }
+}
+
+/**
+ * Normalize one capture unit into a 1-second, 30fps MPEG-TS segment with
+ * the pinned encoder parameters.
+ *
+ * - jpeg unit: the still is held for the full second — identical to the
+ * legacy one-frame-per-minute output.
+ * - webm/mp4 clip: transcoded in ONE ffmpeg pass — decode, retime the
+ * REAL frame count evenly across the second (clips are VFR; the
+ * client's claimed frameCount is never trusted, ffprobe counts), scale,
+ * encode. No intermediate JPEG round-trip: that cost an extra encode+
+ * decode generation (visible softness on top of the clip's own
+ * compression) and an extra process per unit.
+ *
+ * Returns the segment path; throws if the unit is undecodable.
+ */
+export async function buildSegment(
+ tmpDir: string,
+ index: number,
+ unitPath: string,
+ format: string,
+ quality: SegmentQuality = "publish",
+): Promise {
+ const segmentPath = path.join(
+ tmpDir,
+ `segment_${String(index).padStart(5, "0")}.ts`,
+ );
+ const encodeArgs = segmentEncodeArgs(quality);
+ const scale = scaleFilter(quality);
+
+ if (format === "jpeg") {
+ // -framerate 1 over one still = exactly one second of input; fps
+ // duplicates it onto the 30fps grid, -frames:v hard-caps the length.
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-framerate", "1",
+ "-i", unitPath,
+ "-vf", `${scale},fps=${SEGMENT_FPS}`,
+ "-frames:v", String(SEGMENT_FPS),
+ ...encodeArgs,
+ "-f", "mpegts",
+ "-y",
+ segmentPath,
+ ],
+ { timeout: SEGMENT_TIMEOUT_MS },
+ );
+ } else {
+ const frames = await probeFrameCount(unitPath);
+ if (!Number.isFinite(frames) || frames < 1) {
+ throw new Error("clip contained no decodable frames");
+ }
+ // setpts spreads the N decoded frames evenly across [0, 1s); fps
+ // resamples onto the 30fps grid; tpad clone-extends the last frame so
+ // PTS rounding can never come up a frame short; -frames:v caps at
+ // exactly one segment.
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-i", unitPath,
+ "-vf",
+ `setpts=N/(${frames}*TB),${scale},fps=${SEGMENT_FPS},tpad=stop_mode=clone:stop=-1`,
+ "-frames:v", String(SEGMENT_FPS),
+ ...encodeArgs,
+ "-f", "mpegts",
+ "-y",
+ segmentPath,
+ ],
+ { timeout: SEGMENT_TIMEOUT_MS },
+ );
+ }
+
+ // Frame-count verification costs an ffprobe per unit (~22ms measured), and
+ // it exists because a short segment silently desyncs every later minute of
+ // the PUBLISHED video. The preview is a scrubbing aid that gets deleted, so
+ // a one-frame drift in it is invisible and not worth the process — the
+ // publish tier is still checked strictly.
+ if (quality === "publish") {
+ await verifySegmentFrameCount(segmentPath);
+ }
+ return segmentPath;
+}
+
+/**
+ * The edit feature's cut step: produce a video containing only the kept
+ * ranges of a compiled original.
+ *
+ * Fast path (`aligned`): every second of the original starts on an IDR
+ * frame in a closed GOP (the pinned grid above), and one second = one
+ * capture unit. Each kept range is extracted losslessly with an input seek
+ * to its IDR (`-ss` lands exactly on the second boundary) plus an exact
+ * packet count (`-frames:v` applies to copied packets), into an MPEG-TS
+ * intermediate; the intermediates stream-copy concat into the edited MP4.
+ * 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.
+ * This path is I/O-bound (seconds even for a 12-hour session) and adds
+ * zero generation loss.
+ *
+ * Fallback (originals that predate GOP pinning, or a failed copy): one
+ * frame-exact re-encode of the whole original through a `select` filter
+ * keeping pts ∈ [start, end) per range, with the pinned parameters — so
+ * the output is itself aligned for future edits.
+ */
+export async function cutVideoToKeptRanges(
+ tmpDir: string,
+ originalPath: string,
+ keptRanges: KeptRange[],
+ aligned: boolean,
+): Promise {
+ if (keptRanges.length === 0) {
+ throw new Error("cutVideoToKeptRanges: no kept ranges");
+ }
+
+ const editedPath = path.join(tmpDir, "edited.mp4");
+ const keptUnits = keptRanges.reduce((n, r) => n + (r.end - r.start), 0);
+ const expectedFrames = keptUnits * SEGMENT_FPS;
+
+ const verify = async (label: string, toleranceFrames: number) => {
+ const stat = await fs.stat(editedPath);
+ if (stat.size === 0) throw new Error(`${label}: ffmpeg produced empty output`);
+ const frames = await probeFrameCount(editedPath);
+ if (
+ !Number.isFinite(frames) ||
+ Math.abs(frames - expectedFrames) > toleranceFrames
+ ) {
+ throw new Error(
+ `${label}: frame count mismatch: expected ${expectedFrames} (±${toleranceFrames}), got ${frames}`,
+ );
+ }
+ };
+
+ if (aligned) {
+ try {
+ // 1. Extract each kept range losslessly into a TS intermediate.
+ const rangePaths: string[] = [];
+ for (const [i, r] of keptRanges.entries()) {
+ const rangePath = path.join(tmpDir, `kept_${i}.ts`);
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-ss", String(r.start),
+ "-i", originalPath,
+ "-c", "copy",
+ "-frames:v", String((r.end - r.start) * SEGMENT_FPS),
+ "-avoid_negative_ts", "make_zero",
+ "-f", "mpegts",
+ "-y",
+ rangePath,
+ ],
+ { timeout: ASSEMBLE_TIMEOUT_MS },
+ );
+ rangePaths.push(rangePath);
+ }
+
+ // 2. Stream-copy concat the ranges (same mechanism as assembly).
+ const listPath = path.join(tmpDir, "kept_ranges.txt");
+ await fs.writeFile(
+ listPath,
+ rangePaths.map((p) => `file '${p}'`).join("\n") + "\n",
+ );
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "concat",
+ "-safe", "0",
+ "-i", listPath,
+ "-c", "copy",
+ "-movflags", "+faststart",
+ "-y",
+ editedPath,
+ ],
+ { timeout: ASSEMBLE_TIMEOUT_MS },
+ );
+ // The copy path must be frame-EXACT — that's the whole point.
+ await verify("Edited MP4 (copy)", 0);
+ return editedPath;
+ } catch (err) {
+ // The only way a cut costs quality. The copy path is bit-exact
+ // (proven in cutVideo.test.ts by comparing decoded frame hashes),
+ // so falling through here means the user's timelapse takes a
+ // generation of loss it shouldn't have. Loud, not a debug aside.
+ console.error(
+ "Stream-copy cut FAILED — falling back to a re-encode, so this " +
+ "timelapse loses a generation of quality. Investigate: the " +
+ "original was expected to be on the pinned 1s IDR grid.",
+ err,
+ );
+ }
+ }
+
+ // Frame-exact single-pass re-encode: keep frames whose pts falls in any
+ // kept [start, end) range, then retime onto a contiguous 30fps grid.
+ const keepExpr = keptRanges
+ .map((r) => `(gte(t\\,${r.start})*lt(t\\,${r.end}))`)
+ .join("+");
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-i", originalPath,
+ "-vf", `select='${keepExpr}',setpts=N/(${SEGMENT_FPS}*TB)`,
+ "-r", String(SEGMENT_FPS),
+ "-c:v", "libx264",
+ "-preset", "fast",
+ "-crf", "18",
+ "-pix_fmt", "yuv420p",
+ ...SEGMENT_GOP_ARGS,
+ "-movflags", "+faststart",
+ "-y",
+ editedPath,
+ ],
+ { timeout: ASSEMBLE_TIMEOUT_MS },
+ );
+ await verify("Edited MP4 (re-encoded)", 1);
+ return editedPath;
+}
diff --git a/packages/worker/test/cutVideo.test.ts b/packages/worker/test/cutVideo.test.ts
new file mode 100644
index 00000000..ded55c9d
--- /dev/null
+++ b/packages/worker/test/cutVideo.test.ts
@@ -0,0 +1,214 @@
+/**
+ * Integration tests for the edit feature's cut step: a compiled original
+ * (built exactly like the production pipeline — pinned 1s closed-GOP
+ * segments, stream-copy concat) cut down to kept ranges.
+ *
+ * Verifies the core promise of the edit design: cuts on second boundaries
+ * are LOSSLESS stream copies with exact frame counts, and the re-encode
+ * fallback produces the same shape for non-aligned originals.
+ *
+ * Uses real ffmpeg with synthetic inputs (no DB, no R2). Skipped when
+ * ffmpeg isn't installed — CI installs it explicitly.
+ */
+import { describe, expect, it, beforeAll } from "vitest";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import * as fs from "node:fs/promises";
+import * as os from "node:os";
+import * as path from "node:path";
+import { computeKeptRanges, type CutInterval } from "@lookout/shared";
+import {
+ buildSegment,
+ cutVideoToKeptRanges,
+ probeFrameCount,
+ SEGMENT_FPS,
+} from "../src/segments.js";
+
+const execFileAsync = promisify(execFile);
+
+async function hasFfmpeg(): Promise {
+ try {
+ await execFileAsync("ffmpeg", ["-version"], { timeout: 10_000 });
+ await execFileAsync("ffprobe", ["-version"], { timeout: 10_000 });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+const ffmpegAvailable = await hasFfmpeg();
+
+/** Per-frame checksums of the DECODED video, ignoring container timing.
+ * Identical sequences mean identical pixels, frame for frame. */
+async function frameHashes(filePath: string): Promise {
+ const { stdout } = await execFileAsync(
+ "ffmpeg",
+ ["-v", "error", "-i", filePath, "-an", "-f", "framemd5", "-"],
+ { timeout: 180_000, maxBuffer: 64 * 1024 * 1024 },
+ );
+ return stdout
+ .split("\n")
+ .filter((l) => l && !l.startsWith("#"))
+ // Columns: stream, dts, pts, duration, size, hash. Only the hash is
+ // comparable — a cut restarts timestamps at zero by design.
+ .map((l) => l.trim().split(/[,\s]+/).pop() as string)
+ .filter(Boolean);
+}
+
+const UNITS = 6;
+
+describe.skipIf(!ffmpegAvailable)("cutVideoToKeptRanges", () => {
+ let tmpDir: string;
+ let originalPath: string;
+
+ beforeAll(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "lookout-cut-"));
+
+ // Build a production-shaped original: UNITS distinct JPEG stills →
+ // 1s pinned segments → stream-copy concat.
+ const segments: string[] = [];
+ for (let i = 0; i < UNITS; i++) {
+ const jpeg = path.join(tmpDir, `unit_${i}.jpg`);
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "lavfi",
+ "-i", `testsrc2=size=640x360:rate=1:duration=1`,
+ "-frames:v", "1",
+ "-y", jpeg,
+ ],
+ { timeout: 60_000 },
+ );
+ segments.push(await buildSegment(tmpDir, i, jpeg, "jpeg"));
+ }
+ const listPath = path.join(tmpDir, "segments.txt");
+ await fs.writeFile(
+ listPath,
+ segments.map((p) => `file '${p}'`).join("\n") + "\n",
+ );
+ originalPath = path.join(tmpDir, "original.mp4");
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "concat",
+ "-safe", "0",
+ "-i", listPath,
+ "-c", "copy",
+ "-movflags", "+faststart",
+ "-y", originalPath,
+ ],
+ { timeout: 120_000 },
+ );
+ expect(await probeFrameCount(originalPath)).toBe(UNITS * SEGMENT_FPS);
+ }, 300_000);
+
+ it("losslessly cuts a middle range with stream copy", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lookout-cut-a-"));
+ // Keep units 0-1 and 4-5 (cut 2 and 3).
+ const edited = await cutVideoToKeptRanges(
+ dir,
+ originalPath,
+ [
+ { start: 0, end: 2 },
+ { start: 4, end: 6 },
+ ],
+ true,
+ );
+ expect(await probeFrameCount(edited)).toBe(4 * SEGMENT_FPS);
+ }, 120_000);
+
+ it("cuts head and tail ranges", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lookout-cut-b-"));
+ const edited = await cutVideoToKeptRanges(
+ dir,
+ originalPath,
+ [{ start: 1, end: 5 }],
+ true,
+ );
+ expect(await probeFrameCount(edited)).toBe(4 * SEGMENT_FPS);
+ }, 120_000);
+
+ it("re-encode fallback produces the same frame counts", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lookout-cut-c-"));
+ const edited = await cutVideoToKeptRanges(
+ dir,
+ originalPath,
+ [
+ { start: 0, end: 1 },
+ { start: 3, end: 6 },
+ ],
+ false, // force the re-encode path
+ );
+ const frames = await probeFrameCount(edited);
+ // Re-encode is CFR at 30fps over the same ranges — allow ±1 frame of
+ // container rounding.
+ expect(Math.abs(frames - 4 * SEGMENT_FPS)).toBeLessThanOrEqual(1);
+ }, 180_000);
+
+ it("computeKeptRanges output plugs directly into the cutter", async () => {
+ // Units captured one per minute starting at T0; cut minutes 2..4.
+ const T0 = Date.parse("2026-07-01T10:00:00.000Z");
+ const unitTimes = Array.from({ length: UNITS }, (_, i) => T0 + i * 60_000);
+ const cuts: CutInterval[] = [
+ {
+ start: new Date(T0 + 2 * 60_000).toISOString(),
+ end: new Date(T0 + 4 * 60_000).toISOString(),
+ },
+ ];
+ const kept = computeKeptRanges(unitTimes, cuts);
+ expect(kept).toEqual([
+ { start: 0, end: 2 },
+ { start: 4, end: 6 },
+ ]);
+
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lookout-cut-d-"));
+ const edited = await cutVideoToKeptRanges(dir, originalPath, kept, true);
+ expect(await probeFrameCount(edited)).toBe(4 * SEGMENT_FPS);
+ }, 120_000);
+
+ /**
+ * The quality guarantee, proven rather than asserted.
+ *
+ * `-f framemd5` hashes every DECODED frame, so if the cut is a true
+ * stream copy the kept frames decode to byte-identical pixels. Any
+ * re-encode — even a visually lossless one — changes them.
+ */
+ it("is bit-exact: kept frames decode identically to the original", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lookout-cut-lossless-"));
+ const kept = [
+ { start: 0, end: 2 },
+ { start: 4, end: 6 },
+ ];
+ const edited = await cutVideoToKeptRanges(dir, originalPath, kept, true);
+
+ const originalHashes = await frameHashes(originalPath);
+ const editedHashes = await frameHashes(edited);
+
+ // The frames those ranges cover, taken straight from the source.
+ const expected = kept.flatMap((r) =>
+ originalHashes.slice(r.start * SEGMENT_FPS, r.end * SEGMENT_FPS),
+ );
+
+ expect(editedHashes).toHaveLength(expected.length);
+ expect(editedHashes).toEqual(expected);
+ }, 180_000);
+
+ it("shows the fallback re-encode is NOT bit-exact, so the copy path matters", async () => {
+ // Guards the claim above from rotting: if someone makes the copy path
+ // silently re-encode, the test above would still pass against a
+ // similarly re-encoded expectation unless we know the two differ.
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lookout-cut-lossy-"));
+ const kept = [{ start: 0, end: 2 }];
+ const reencoded = await cutVideoToKeptRanges(dir, originalPath, kept, false);
+
+ const originalHashes = await frameHashes(originalPath);
+ const lossyHashes = await frameHashes(reencoded);
+ expect(lossyHashes).not.toEqual(originalHashes.slice(0, 2 * SEGMENT_FPS));
+ }, 180_000);
+
+ it("refuses an empty kept list", async () => {
+ await expect(
+ cutVideoToKeptRanges(tmpDir, originalPath, [], true),
+ ).rejects.toThrow(/no kept ranges/);
+ });
+});
diff --git a/packages/worker/test/seedUnit.test.ts b/packages/worker/test/seedUnit.test.ts
new file mode 100644
index 00000000..830ce766
--- /dev/null
+++ b/packages/worker/test/seedUnit.test.ts
@@ -0,0 +1,57 @@
+/**
+ * The seed capture must not become a video second.
+ *
+ * A session's first capture opens the recording rather than closing a
+ * recorded minute: it credits 0 tracked seconds in both tracking modes, and
+ * in clips mode its clip spans only the ~8s before the opening cut. Giving
+ * it an equal one-second segment made the video one second longer than the
+ * tracked minute count AND played the head of every timelapse at ~8x while
+ * the rest ran at 60x.
+ *
+ * Pure row math — no ffmpeg, no DB.
+ */
+import { describe, expect, it } from "vitest";
+import { dropSeedUnit } from "../src/segments.js";
+
+/** Minimal stand-in for the compiler's `DISTINCT ON (minute_bucket)` rows,
+ * which arrive ordered by bucket ascending. */
+const buckets = (n: number) =>
+ Array.from({ length: n }, (_, i) => ({ id: `u${i}`, minute_bucket: i }));
+
+describe("seed unit exclusion", () => {
+ it("drops the first unit so video seconds equal tracked minutes", () => {
+ // The reported case: 2 captures, 60s apart. Bucket mode reports
+ // (2 - 1) * 60 = 60s tracked, so the video must be 1 second, not 2.
+ const kept = dropSeedUnit(buckets(2));
+ expect(kept).toHaveLength(1);
+ expect(kept[0].id).toBe("u1");
+ });
+
+ it("agrees with tracked minutes across session lengths", () => {
+ for (const captures of [2, 3, 10, 61, 720]) {
+ const trackedMinutes = (captures - 1) * 60 / 60;
+ expect(dropSeedUnit(buckets(captures))).toHaveLength(trackedMinutes);
+ }
+ });
+
+ it("keeps the only unit of a single-capture session", () => {
+ // Tracked time is legitimately 0 here, but a zero-length video is worse
+ // than an imprecise one — and an empty segment list fails the compile.
+ expect(dropSeedUnit(buckets(1))).toHaveLength(1);
+ });
+
+ it("is a no-op on an empty list", () => {
+ expect(dropSeedUnit([])).toEqual([]);
+ });
+
+ it("preserves order and identity of the surviving units", () => {
+ // Array index == video second == the map the edit feature cuts against,
+ // so the surviving rows must stay in bucket order.
+ expect(dropSeedUnit(buckets(5)).map((r) => r.id)).toEqual([
+ "u1",
+ "u2",
+ "u3",
+ "u4",
+ ]);
+ });
+});
diff --git a/packages/worker/test/segments.test.ts b/packages/worker/test/segments.test.ts
new file mode 100644
index 00000000..372fda0b
--- /dev/null
+++ b/packages/worker/test/segments.test.ts
@@ -0,0 +1,245 @@
+/**
+ * Integration tests for the segment pipeline — the contract that makes the
+ * compiler's stream-copy assembly safe:
+ *
+ * every capture unit (legacy JPEG still, VP8 webm clip, H.264 mp4 clip,
+ * any resolution) → exactly ONE second of 30fps video with pinned,
+ * bit-compatible encoder parameters, concatenable with `-c copy`.
+ *
+ * Uses real ffmpeg with synthetic inputs (no DB, no R2). Skipped when
+ * ffmpeg isn't installed — CI installs it explicitly.
+ */
+import { describe, expect, it, beforeAll } from "vitest";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import * as fs from "node:fs/promises";
+import * as os from "node:os";
+import * as path from "node:path";
+import {
+ buildSegment,
+ probeFrameCount,
+ SEGMENT_FPS,
+ PREVIEW_WIDTH,
+ PREVIEW_HEIGHT,
+} from "../src/segments.js";
+
+const execFileAsync = promisify(execFile);
+
+async function hasFfmpeg(): Promise {
+ try {
+ await execFileAsync("ffmpeg", ["-version"], { timeout: 10_000 });
+ await execFileAsync("ffprobe", ["-version"], { timeout: 10_000 });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+const ffmpegAvailable = await hasFfmpeg();
+
+async function probeResolution(
+ filePath: string,
+): Promise<{ width: number; height: number }> {
+ const { stdout } = await execFileAsync(
+ "ffprobe",
+ [
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=width,height",
+ "-of", "csv=p=0",
+ filePath,
+ ],
+ { timeout: 30_000 },
+ );
+ // ffprobe lists the stream twice for MPEG-TS ("1280,720\n\n1280,720"), so
+ // take the first non-empty line rather than splitting the whole output.
+ const line = stdout
+ .split("\n")
+ .map((l) => l.trim())
+ .find((l) => l.length > 0)!;
+ const [width, height] = line.split(",").map(Number);
+ return { width, height };
+}
+
+async function probeDurationSeconds(filePath: string): Promise {
+ const { stdout } = await execFileAsync(
+ "ffprobe",
+ [
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "csv=p=0",
+ filePath,
+ ],
+ { timeout: 30_000 },
+ );
+ return parseFloat(stdout.trim());
+}
+
+describe.skipIf(!ffmpegAvailable)("segment pipeline", () => {
+ let tmpDir: string;
+ let jpegPath: string;
+ let webmPath: string;
+ let mp4Path: string;
+
+ beforeAll(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "lookout-segments-"));
+
+ // Legacy unit: a single JPEG still (odd size on purpose — the scale
+ // filter must normalize it).
+ jpegPath = path.join(tmpDir, "unit_jpeg.jpg");
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "lavfi",
+ "-i", "testsrc2=size=1280x801:rate=1:duration=1",
+ "-frames:v", "1",
+ "-y", jpegPath,
+ ],
+ { timeout: 60_000 },
+ );
+
+ // Clip unit: VP8/WebM. A deliberately odd 20 frames — nothing in the
+ // pipeline may assume the nominal count, since clips are VFR and the
+ // real count comes from demuxing.
+ webmPath = path.join(tmpDir, "unit_clip.webm");
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "lavfi",
+ "-i", "testsrc2=size=1668x1080:rate=1/3:duration=60",
+ "-c:v", "libvpx",
+ "-b:v", "133k",
+ "-y", webmPath,
+ ],
+ { timeout: 120_000 },
+ );
+
+ // Clip unit: H.264/MP4 at a DIFFERENT resolution — Safari's format,
+ // and simulates a mid-session display change. 12 frames over 60s.
+ mp4Path = path.join(tmpDir, "unit_clip.mp4");
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "lavfi",
+ "-i", "testsrc2=size=1920x1080:rate=1/5:duration=60",
+ "-c:v", "libx264",
+ "-pix_fmt", "yuv420p",
+ "-y", mp4Path,
+ ],
+ { timeout: 120_000 },
+ );
+ }, 300_000);
+
+ it("normalizes a JPEG still to exactly one second of 30fps video", async () => {
+ const seg = await buildSegment(tmpDir, 0, jpegPath, "jpeg");
+ expect(await probeFrameCount(seg)).toBe(SEGMENT_FPS);
+ }, 120_000);
+
+ it("normalizes a VP8 webm clip to exactly one second of 30fps video", async () => {
+ const seg = await buildSegment(tmpDir, 1, webmPath, "webm");
+ expect(await probeFrameCount(seg)).toBe(SEGMENT_FPS);
+ }, 120_000);
+
+ it("normalizes an H.264 mp4 clip to exactly one second of 30fps video", async () => {
+ const seg = await buildSegment(tmpDir, 2, mp4Path, "mp4");
+ expect(await probeFrameCount(seg)).toBe(SEGMENT_FPS);
+ }, 120_000);
+
+ it("stream-copy concatenates a mixed session into a coherent MP4", async () => {
+ // Same order a real mixed session could produce: clip, jpeg fallback,
+ // clip in another format/resolution.
+ const segments = [
+ await buildSegment(tmpDir, 10, webmPath, "webm"),
+ await buildSegment(tmpDir, 11, jpegPath, "jpeg"),
+ await buildSegment(tmpDir, 12, mp4Path, "mp4"),
+ ];
+ const listPath = path.join(tmpDir, "segments.txt");
+ await fs.writeFile(
+ listPath,
+ segments.map((p) => `file '${p}'`).join("\n") + "\n",
+ );
+ const outPath = path.join(tmpDir, "timelapse.mp4");
+ await execFileAsync(
+ "ffmpeg",
+ [
+ "-f", "concat",
+ "-safe", "0",
+ "-i", listPath,
+ "-c", "copy",
+ "-movflags", "+faststart",
+ "-y", outPath,
+ ],
+ { timeout: 120_000 },
+ );
+
+ // 3 units → exactly 3 seconds, 90 frames, one decodable H.264 stream.
+ expect(await probeFrameCount(outPath)).toBe(3 * SEGMENT_FPS);
+ expect(await probeDurationSeconds(outPath)).toBeCloseTo(3, 1);
+
+ // The copied stream must actually DECODE end to end (a bad splice can
+ // still carry a plausible frame count).
+ const { stderr } = await execFileAsync(
+ "ffmpeg",
+ ["-v", "error", "-i", outPath, "-f", "null", "-"],
+ { timeout: 120_000 },
+ );
+ expect(stderr.trim()).toBe("");
+ }, 300_000);
+
+ it("rejects an undecodable clip instead of emitting a bad segment", async () => {
+ const garbagePath = path.join(tmpDir, "garbage.webm");
+ await fs.writeFile(garbagePath, Buffer.from("not a webm file at all"));
+ await expect(buildSegment(tmpDir, 99, garbagePath, "webm")).rejects.toThrow();
+ }, 120_000);
+
+ /**
+ * The two-tier contract. The preview tier exists only to open the editor
+ * quickly and is deleted at publish, so it may be small and cheap — but it
+ * must still be one second on the same 30fps grid, because the editor maps
+ * video seconds to capture units.
+ */
+ describe("preview tier", () => {
+ it("keeps the 1-second grid while being smaller and cheaper", async () => {
+ const publishSeg = await buildSegment(tmpDir, 200, mp4Path, "mp4", "publish");
+ const previewSeg = await buildSegment(tmpDir, 201, mp4Path, "mp4", "preview");
+
+ // Same timeline shape — this is what the cut UI depends on.
+ expect(await probeFrameCount(previewSeg)).toBe(SEGMENT_FPS);
+ expect(await probeFrameCount(publishSeg)).toBe(SEGMENT_FPS);
+
+ // Reduced resolution is where the speed comes from.
+ expect(await probeResolution(previewSeg)).toEqual({
+ width: PREVIEW_WIDTH,
+ height: PREVIEW_HEIGHT,
+ });
+ expect(await probeResolution(publishSeg)).toEqual({
+ width: 1920,
+ height: 1080,
+ });
+
+ // The preview must also be cheaper to MOVE, not just to encode: the
+ // worker uploads it and the editor streams it back. This is what rules
+ // out the very fastest presets, whose output is bigger than the 1080p
+ // publish tier's — see segmentEncodeArgs.
+ const previewBytes = (await fs.stat(previewSeg)).size;
+ const publishBytes = (await fs.stat(publishSeg)).size;
+ expect(previewBytes).toBeLessThan(publishBytes);
+ }, 300_000);
+
+ it("still decodes cleanly, so the editor can scrub it", async () => {
+ const seg = await buildSegment(tmpDir, 202, mp4Path, "mp4", "preview");
+ const { stderr } = await execFileAsync(
+ "ffmpeg",
+ ["-v", "error", "-i", seg, "-f", "null", "-"],
+ { timeout: 120_000 },
+ );
+ expect(stderr.trim()).toBe("");
+ }, 120_000);
+ });
+});
+
+describe.skipIf(ffmpegAvailable)("segment pipeline (skipped)", () => {
+ it("skipped because ffmpeg/ffprobe are not installed", () => {
+ console.warn("ffmpeg not found — segment pipeline tests were skipped");
+ });
+});