From 6fd71a225beda6760b1204299226b6a0d9022525 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 24 Jul 2026 13:05:01 +0000
Subject: [PATCH 01/65] perf(desktop): cut per-tick CPU/RAM in the Rust capture
pipeline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Share one reqwest::Client (OnceLock) across the upload pipeline, sleep
recovery, and exit-pause instead of building a new client (fresh pool +
TLS config + handshake) on every 60s tick. Timeouts are unchanged, now
applied per-request.
- Stop round-tripping frames through base64: capture returns raw JPEG
bytes, base64 is encoded exactly once for the JS preview, and the
upload body is bytes::Bytes so retry clones are refcount bumps instead
of full-buffer copies. Previously every tick encoded, decoded, and
re-cloned the frame.
- Run capture_and_upload's screenshot on spawn_blocking like the capture
loop already does, keeping capture + JPEG encode off async workers.
- Use Triangle instead of Lanczos3 when scaling multi-source stitches,
matching the filter the (far more common) single-source path already
uses; use into_rgba8() moves instead of to_rgba8() full-frame clones.
- Tray ticker: only touch the native tray when the formatted title
actually changes (it has minute granularity, the ticker runs at 1s),
and drop the per-second "tray-timer-tick" emit nothing listens to.
- Scope the macOS App Nap / idle-sleep assertion to active recordings
(tray-timer lifetime) instead of the whole process. Recording behavior
is unchanged — captures are still never throttled, including while
paused mid-session — but an idle Lookout no longer prevents the Mac
from ever sleeping.
- Remove dead capture.rs wrappers (take_screenshot_raw,
take_stitched_screenshots) with no callers.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg
---
clients/desktop/src-tauri/Cargo.lock | 2 +
clients/desktop/src-tauri/Cargo.toml | 5 +
clients/desktop/src-tauri/src/capture.rs | 94 +++--------
clients/desktop/src-tauri/src/lib.rs | 206 +++++++++++++++++------
4 files changed, 181 insertions(+), 126 deletions(-)
diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock
index c5ca9fe0..0b9dcf3a 100644
--- a/clients/desktop/src-tauri/Cargo.lock
+++ b/clients/desktop/src-tauri/Cargo.lock
@@ -3156,10 +3156,12 @@ version = "0.3.5"
dependencies = [
"ashpd",
"base64 0.22.1",
+ "bytes",
"gstreamer",
"gstreamer-app",
"gstreamer-video",
"image",
+ "objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml
index 3eb3e5d1..011454d6 100644
--- a/clients/desktop/src-tauri/Cargo.toml
+++ b/clients/desktop/src-tauri/Cargo.toml
@@ -33,6 +33,10 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
tokio = { version = "1", features = ["full"] }
base64 = "0.22"
+# Refcounted byte buffers for the upload pipeline — retrying an R2 PUT clones
+# the request body, and `Bytes` makes that clone a refcount bump instead of a
+# full JPEG copy. Already in the dependency tree via tokio/reqwest.
+bytes = "1"
tauri-plugin-deep-link = "2.4.7"
tauri-plugin-http = "2.5.7"
tauri-plugin-macos-permissions = "2.3.0"
@@ -53,6 +57,7 @@ sentry = "0.38"
tokio = { version = "1", features = ["test-util"] }
[target.'cfg(target_os = "macos")'.dependencies]
+objc2 = "0.6"
objc2-core-foundation = "0.3.2"
objc2-core-graphics = "0.3.2"
objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString"] }
diff --git a/clients/desktop/src-tauri/src/capture.rs b/clients/desktop/src-tauri/src/capture.rs
index d52bce0f..0644381c 100644
--- a/clients/desktop/src-tauri/src/capture.rs
+++ b/clients/desktop/src-tauri/src/capture.rs
@@ -200,7 +200,9 @@ fn capture_to_dynamic_image_with_blacklist(
if let CaptureSource::Monitor { id } = source {
if !blacklisted_apps.is_empty() {
if let Some(bounds) = get_monitor_screen_bounds(*id) {
- let mut rgba = dynamic.to_rgba8();
+ // into_rgba8 is a move (captures are always RGBA8) — avoids
+ // cloning a full-resolution frame just to draw on it.
+ let mut rgba = dynamic.into_rgba8();
let w = rgba.width();
let h = rgba.height();
redact_blacklisted_regions(&mut rgba, bounds, blacklisted_apps, w, h);
@@ -235,23 +237,6 @@ pub struct RawCaptureResult {
pub height: u32,
}
-pub fn take_screenshot_raw(
- source: CaptureSource,
- max_width: u32,
- max_height: u32,
- jpeg_quality: u8,
- pipewire_fds: &std::collections::HashMap,
-) -> Result {
- take_screenshot_raw_with_blacklist(
- source,
- max_width,
- max_height,
- jpeg_quality,
- pipewire_fds,
- &[],
- )
-}
-
pub fn take_screenshot_raw_with_blacklist(
source: CaptureSource,
max_width: u32,
@@ -300,24 +285,6 @@ pub fn take_screenshot(
max_height: u32,
jpeg_quality: u8,
pipewire_fds: &std::collections::HashMap,
-) -> Result {
- take_screenshot_with_blacklist(
- source,
- max_width,
- max_height,
- jpeg_quality,
- pipewire_fds,
- &[],
- )
-}
-
-pub fn take_screenshot_with_blacklist(
- source: CaptureSource,
- max_width: u32,
- max_height: u32,
- jpeg_quality: u8,
- pipewire_fds: &std::collections::HashMap,
- blacklisted_apps: &[String],
) -> Result {
let raw = take_screenshot_raw_with_blacklist(
source,
@@ -325,7 +292,7 @@ pub fn take_screenshot_with_blacklist(
max_height,
jpeg_quality,
pipewire_fds,
- blacklisted_apps,
+ &[],
)?;
let size_bytes = raw.data.len();
@@ -340,37 +307,23 @@ pub fn take_screenshot_with_blacklist(
})
}
-pub fn take_stitched_screenshots(
- sources: &[CaptureSource],
- max_width: u32,
- max_height: u32,
- jpeg_quality: u8,
- pipewire_fds: &std::collections::HashMap,
-) -> Result {
- take_stitched_screenshots_with_blacklist(
- sources,
- max_width,
- max_height,
- jpeg_quality,
- pipewire_fds,
- &[],
- )
-}
-
-pub fn take_stitched_screenshots_with_blacklist(
+/// Capture one or more sources side-by-side and encode as a single JPEG.
+/// Returns raw JPEG bytes — callers that need base64 (e.g. for the preview
+/// event) encode it themselves, so the upload path never has to decode.
+pub fn take_stitched_screenshots_raw_with_blacklist(
sources: &[CaptureSource],
max_width: u32,
max_height: u32,
jpeg_quality: u8,
pipewire_fds: &std::collections::HashMap,
blacklisted_apps: &[String],
-) -> Result {
+) -> Result {
if sources.is_empty() {
return Err("No sources provided".to_string());
}
if sources.len() == 1 {
- return take_screenshot_with_blacklist(
+ return take_screenshot_raw_with_blacklist(
sources[0].clone(),
max_width,
max_height,
@@ -409,7 +362,9 @@ pub fn take_stitched_screenshots_with_blacklist(
if h != target_h && h > 0 {
let scale = target_h as f64 / h as f64;
let new_w = (w as f64 * scale).round() as u32;
- let scaled = img.resize_exact(new_w, target_h, image::imageops::FilterType::Lanczos3);
+ // Triangle matches the single-source path — Lanczos3 here cost
+ // several times the CPU for a difference invisible at 60s/frame.
+ let scaled = img.resize_exact(new_w, target_h, image::imageops::FilterType::Triangle);
total_w += scaled.width();
scaled_images.push(scaled);
} else {
@@ -419,11 +374,13 @@ pub fn take_stitched_screenshots_with_blacklist(
}
let mut stitched = image::RgbaImage::new(total_w, target_h);
- let mut current_x = 0;
+ let mut current_x: i64 = 0;
for img in scaled_images {
- let rgba = img.to_rgba8();
- image::imageops::overlay(&mut stitched, &rgba, current_x as i64, 0);
- current_x += img.width() as i64;
+ let w = img.width() as i64;
+ // into_rgba8 is a move for RGBA8 frames — no full-frame clone.
+ let rgba = img.into_rgba8();
+ image::imageops::overlay(&mut stitched, &rgba, current_x, 0);
+ current_x += w;
}
let mut dynamic = DynamicImage::ImageRgba8(stitched);
@@ -441,7 +398,7 @@ pub fn take_stitched_screenshots_with_blacklist(
);
let new_w = (w as f64 * scale).round() as u32;
let new_h = (h as f64 * scale).round() as u32;
- dynamic = dynamic.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3);
+ dynamic = dynamic.resize_exact(new_w, new_h, image::imageops::FilterType::Triangle);
}
let (final_w, final_h) = (dynamic.width(), dynamic.height());
@@ -454,17 +411,10 @@ pub fn take_stitched_screenshots_with_blacklist(
.encode_image(&rgb)
.map_err(|e| format!("JPEG encoding failed: {e}"))?;
- let jpeg_bytes = jpeg_buf.into_inner();
- let size_bytes = jpeg_bytes.len();
-
- use base64::Engine;
- let base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_bytes);
-
- Ok(CaptureResult {
- base64,
+ Ok(RawCaptureResult {
+ data: jpeg_buf.into_inner(),
width: final_w,
height: final_h,
- size_bytes,
})
}
diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs
index 39d1986e..758cb1a1 100644
--- a/clients/desktop/src-tauri/src/lib.rs
+++ b/clients/desktop/src-tauri/src/lib.rs
@@ -6,6 +6,56 @@ mod tray;
#[cfg(target_os = "windows")]
mod windows_permissions;
+/// Scoped App Nap / idle-system-sleep suppression (macOS).
+///
+/// The assertion must be held while a session is recording (or paused
+/// mid-session) so macOS never throttles the capture cadence or lets the
+/// machine idle-sleep out from under an active recording. It must NOT be
+/// held for the whole process lifetime — that kept the user's Mac from ever
+/// idle-sleeping just because Lookout sat open on the gallery.
+#[cfg(target_os = "macos")]
+mod power {
+ use objc2::rc::Retained;
+ use objc2::runtime::{NSObjectProtocol, ProtocolObject};
+ use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};
+ use std::sync::Mutex;
+
+ struct ActivityToken(Retained>);
+ // SAFETY: the token is an opaque handle whose only use is being handed
+ // back to `NSProcessInfo::endActivity`, which is documented thread-safe.
+ unsafe impl Send for ActivityToken {}
+
+ static ACTIVITY: Mutex
) : (
<>
+ {/* 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
+
diff --git a/clients/react/src/hooks/clipRecorder.ts b/clients/react/src/hooks/clipRecorder.ts
new file mode 100644
index 00000000..30ce2d08
--- /dev/null
+++ b/clients/react/src/hooks/clipRecorder.ts
@@ -0,0 +1,284 @@
+import {
+ MAX_WIDTH,
+ MAX_HEIGHT,
+ JPEG_QUALITY,
+ CLIP_VIDEO_BITS_PER_SECOND,
+ type CaptureFormat,
+} from "@lookout/shared";
+
+/** One finalized per-minute clip, ready for the upload pipeline. */
+export interface ClipCaptureResult {
+ blob: Blob;
+ format: Exclude;
+ width: number;
+ height: number;
+ /** Frames drawn into the clip. Informational — the server/worker derive
+ * the real count by demuxing. */
+ frameCount: number;
+ /** Client-clock ms timestamp stamped at cut time — the clip's capture
+ * moment for credit-mode purposes (one clip = one capture unit). */
+ capturedAtMs: number;
+ /** JPEG snapshot of the clip's last frame, for the UI preview only. */
+ previewBlob: Blob | null;
+}
+
+interface MimeCandidate {
+ mime: string;
+ format: Exclude;
+}
+
+/** Preference order: VP9 (best compression) → VP8 → generic WebM →
+ * MP4/H.264 (Safari — its MediaRecorder does not do WebM). */
+const MIME_CANDIDATES: MimeCandidate[] = [
+ { mime: "video/webm;codecs=vp9", format: "webm" },
+ { mime: "video/webm;codecs=vp8", format: "webm" },
+ { mime: "video/webm", format: "webm" },
+ { mime: "video/mp4", format: "mp4" },
+];
+
+function pickMimeCandidate(): MimeCandidate | null {
+ if (typeof MediaRecorder === "undefined") return null;
+ for (const c of MIME_CANDIDATES) {
+ try {
+ if (MediaRecorder.isTypeSupported(c.mime)) return c;
+ } catch {
+ // isTypeSupported can throw on exotic UAs — treat as unsupported
+ }
+ }
+ return null;
+}
+
+/** Per-recorder display knobs. Cadence and bitrate are deliberately NOT
+ * options: the frame interval is server-authoritative (constructor arg,
+ * from the session response) and the bitrate is the shared constant. */
+export interface ClipRecorderOptions {
+ maxWidth?: number;
+ maxHeight?: number;
+ jpegQuality?: number;
+}
+
+/**
+ * Records the shared screen into per-minute video clips.
+ *
+ * Owns an offscreen canvas fed from the caller's `
- {/* 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. */}
) : (
@@ -558,9 +566,9 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
1}
+ live={windowFocused && !isCamera}
/>
))
)}
diff --git a/clients/desktop/src/hooks/useNativeCapture.ts b/clients/desktop/src/hooks/useNativeCapture.ts
index 63e75789..2e1b9664 100644
--- a/clients/desktop/src/hooks/useNativeCapture.ts
+++ b/clients/desktop/src/hooks/useNativeCapture.ts
@@ -285,6 +285,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/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;
+}
From f97789800c61e3ccd627b1151f776d2940b092b8 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Sat, 25 Jul 2026 20:36:26 +0800
Subject: [PATCH 11/65] fix(worker): visually-lossless segment encode (CRF 28
-> 18)
The compile step should not be a quality event: with clips already
bitrate-capped at the client, re-encoding at CRF 28 added a visible
second generation of loss. CRF 18 makes the segment encode perceptually
transparent, leaving the clip bitrate as the only quality dial.
~2.5-3x larger output files; timelapses are short so absolute sizes
stay modest.
---
packages/worker/src/compile.ts | 4 +++-
packages/worker/src/segments.ts | 8 +++++++-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/packages/worker/src/compile.ts b/packages/worker/src/compile.ts
index 85325102..d77a0339 100644
--- a/packages/worker/src/compile.ts
+++ b/packages/worker/src/compile.ts
@@ -322,7 +322,9 @@ export async function compileTimelapse(sessionId: string): Promise<{
"-i", concatListPath,
"-c:v", "libx264",
"-preset", "fast",
- "-crf", "28",
+ // Match the segment encoder's visually-lossless setting — this
+ // fallback must not be a quality downgrade either.
+ "-crf", "18",
"-pix_fmt", "yuv420p",
"-r", String(SEGMENT_FPS),
"-movflags", "+faststart",
diff --git a/packages/worker/src/segments.ts b/packages/worker/src/segments.ts
index 9bc314a7..29c60d90 100644
--- a/packages/worker/src/segments.ts
+++ b/packages/worker/src/segments.ts
@@ -42,7 +42,13 @@ export const SEGMENT_ENCODE_ARGS = [
"-profile:v", "high",
"-level:v", "4.0",
"-preset", "fast",
- "-crf", "28",
+ // 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.
+ "-crf", "18",
"-pix_fmt", "yuv420p",
"-g", String(SEGMENT_FPS),
"-keyint_min", String(SEGMENT_FPS),
From 1343bf346c7982206ab9db5c8b336d43a73cc4c9 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Sat, 25 Jul 2026 20:52:03 +0800
Subject: [PATCH 12/65] fix(clips): 15 fpm globally, 800kbps budget, pinned
encoder GOP
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Real-world 133k-era clips measured ~400KB/min and visibly soft — the
per-frame bit budget is what buys text legibility, so:
- Frame cadence 20/min -> 15/min GLOBALLY (CLIP_FRAME_INTERVAL_MS 4000,
server-authoritative; all clients follow). Slightly less smoothness,
~33% more bits per frame.
- Clip bitrate 400k -> 800kbps (~400KB available per 4s frame =
JPEG-q85-class keyframes at 1080p); server cap 4MB -> 8MB. VBR ceiling
only — static content undershoots heavily.
- Pin encoder behavior explicitly: one IDR per clip + no B-frames +
~1fps rate-control hint on VideoToolbox; MF_MT_MAX_KEYFRAME_SPACING on
Media Foundation. Regression test asserts exactly one keyframe per
clip.
- Docs/tests updated to the new numbers.
---
clients/desktop/src-tauri/src/clips.rs | 82 ++++++++++++++++---
clients/desktop/src-tauri/src/lib.rs | 8 +-
docs/integration.md | 8 +-
packages/server/API.md | 10 +--
.../server/test/clips.integration.test.ts | 2 +-
packages/shared/src/constants.ts | 39 +++++----
packages/shared/src/types.ts | 2 +-
7 files changed, 108 insertions(+), 43 deletions(-)
diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs
index 6d64b308..8b35fb17 100644
--- a/clients/desktop/src-tauri/src/clips.rs
+++ b/clients/desktop/src-tauri/src/clips.rs
@@ -3,7 +3,7 @@
//! with clips enabled.
//!
//! One `ClipRecorder` lives per upload interval: the capture loop pushes a
-//! frame every `frameIntervalMs` (server-authoritative, 3s = 20/min), and
+//! frame every `frameIntervalMs` (server-authoritative, 4s = 15/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:
//!
@@ -20,11 +20,11 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
/// Encoder bitrate cap (bits/second). Matches the web client's
-/// CLIP_VIDEO_BITS_PER_SECOND — ~3 MB per 60s clip worst case, far less on
-/// static screens (VBR undershoots easy content). The server rejects clips
-/// over 4 MB. 133 kbps was tried first and produced visibly soft H.264 at
-/// 1080p.
-pub const CLIP_BITS_PER_SECOND: u32 = 400_000;
+/// CLIP_VIDEO_BITS_PER_SECOND. Sized for text legibility: ~300 KB per 3s
+/// frame allows JPEG-q85-class keyframes at 1080p. VBR ceiling, not a
+/// floor — static screens undershoot heavily. The server rejects clips
+/// over 8 MB. 133k/400k were tried first and produced soft H.264.
+pub const CLIP_BITS_PER_SECOND: u32 = 800_000;
/// A finished clip ready for upload.
pub struct FinishedClip {
@@ -213,6 +213,34 @@ mod tests {
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");
}
@@ -255,9 +283,11 @@ mod platform {
use objc2::runtime::{AnyObject, ProtocolObject};
use objc2_av_foundation::{
AVAssetWriter, AVAssetWriterInput, AVAssetWriterInputPixelBufferAdaptor,
- AVAssetWriterStatus, AVFileTypeMPEG4, AVMediaTypeVideo, AVVideoAverageBitRateKey,
- AVVideoCodecKey, AVVideoCodecTypeH264, AVVideoCompressionPropertiesKey,
- AVVideoHeightKey, AVVideoWidthKey,
+ AVAssetWriterStatus, AVFileTypeMPEG4, AVMediaTypeVideo,
+ AVVideoAllowFrameReorderingKey, AVVideoAverageBitRateKey, AVVideoCodecKey,
+ AVVideoCodecTypeH264, AVVideoCompressionPropertiesKey,
+ AVVideoExpectedSourceFrameRateKey, AVVideoHeightKey,
+ AVVideoMaxKeyFrameIntervalKey, AVVideoWidthKey,
};
use objc2_core_media::CMTime;
use objc2_core_video::{
@@ -300,6 +330,12 @@ mod platform {
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}"))?;
@@ -312,6 +348,27 @@ mod platform {
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.
+ compression.setObject_forKey(
+ NSNumber::new_u32(1).as_ref(),
+ ProtocolObject::from_ref(key_expected_fps),
+ );
let settings: Retained> =
NSMutableDictionary::new();
@@ -473,7 +530,8 @@ mod platform {
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_SUBTYPE, MF_VERSION, MFMediaType_Video,
+ MF_MT_MAJOR_TYPE, MF_MT_MAX_KEYFRAME_SPACING, MF_MT_SUBTYPE, MF_VERSION,
+ MFMediaType_Video,
};
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
@@ -543,6 +601,10 @@ mod platform {
out_type
.SetUINT64(&MF_MT_FRAME_RATE, pack_u64(1, 1))
.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}"))?;
diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs
index fd52b9c0..c4e6544d 100644
--- a/clients/desktop/src-tauri/src/lib.rs
+++ b/clients/desktop/src-tauri/src/lib.rs
@@ -2075,12 +2075,12 @@ const CAPTURE_INTERVAL_SECS: u64 = 60;
/// probably slept (or the WebView was throttled hard).
const SLEEP_THRESHOLD_SECS: u64 = CAPTURE_INTERVAL_SECS * 2 + 30; // 150s
/// Fallback frame cadence when the server doesn't advertise one (pre-clips
-/// servers): every 3s = 20 frames/min. When the server sends
+/// servers): every 4s = 15 frames/min. 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 = 3_000;
+const DEFAULT_FRAME_INTERVAL_MS: u64 = 4_000;
/// Format seconds into the same tray title format as the JS side:
/// >0h: "{h}h {m}m", 0m: "< 1m", else: "{m}m"
@@ -2555,8 +2555,8 @@ async fn capture_loop_task(
'outer: loop {
// ── Wait until next_fire, collecting frames along the way ──
- // Frames run at the clip cadence (20/min) through the SAME
- // redaction-aware capture path as uploads. In clips mode every
+ // Frames run at the clip cadence (server-set, 15/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
diff --git a/docs/integration.md b/docs/integration.md
index dcc13231..1891d59b 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -62,14 +62,14 @@ 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` — opt this session into [clips](#clips-20-frames-per-minute) (~20 frames/min video capture instead of 1 JPEG/min → 20× smoother timelapses). Default `false`; immutable after creation.
+- `clips` — opt this session into [clips](#clips-15-frames-per-minute) (~15 frames/min video capture instead of 1 JPEG/min → 15× smoother timelapses). Default `false`; immutable after creation.
-### Clips (20 frames per minute)
+### Clips (15 frames per minute)
Sessions created with `"clips": true` record **clips**: instead of one JPEG per
minute, the recording client uploads one ~60s video file per minute containing
-~20 frames captured 3s apart. The compiled timelapse has the same length but is
-20× smoother, with motion from the very first second.
+~15 frames captured 4s apart. The compiled timelapse has the same length but is
+15× smoother, with motion from the very first second.
What this means for your program:
diff --git a/packages/server/API.md b/packages/server/API.md
index 2586da52..d80e87a8 100644
--- a/packages/server/API.md
+++ b/packages/server/API.md
@@ -124,7 +124,7 @@ Pre-0.2.1 the bucket count caused timer jump-back when two captures arrived in t
## Clips
-By default, each capture unit is one JPEG per minute. Sessions created with `"clips": true` on the [internal create endpoint](#create-session) instead accept **clips**: per-minute video files (~20 frames captured 3s apart, WebM from Chromium/Firefox MediaRecorder, MP4 from Safari) that compile into a 20×-smoother timelapse.
+By default, each capture unit is one JPEG per minute. Sessions created with `"clips": true` on the [internal create endpoint](#create-session) instead accept **clips**: per-minute video files (~15 frames captured 4s apart, WebM from Chromium/Firefox MediaRecorder, MP4 from Safari and desktop) that compile into a 15×-smoother timelapse.
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.
@@ -133,7 +133,7 @@ A clip is still **one capture unit** — one `upload-url` request, one R2 PUT, o
- **Session-level, immutable opt-in.** `clips_enabled` is set at creation and 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 3000 = 20 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).
+- **Server-authoritative cadence.** `frameIntervalMs` (default 4000 = 15 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.
@@ -190,7 +190,7 @@ Returns the current state of a session.
"thumbnailUrl": "https://...",
"videoUrl": "https://...",
"clipsEnabled": false,
- "frameIntervalMs": 3000,
+ "frameIntervalMs": 4000,
"metadata": {}
}
```
@@ -267,7 +267,7 @@ Generates a presigned PUT URL for uploading a screenshot to R2. Activates pendin
"trackingMode": "credit",
"format": "jpeg",
"clipsEnabled": false,
- "frameIntervalMs": 3000
+ "frameIntervalMs": 4000
}
```
@@ -667,7 +667,7 @@ Creates a new session in `pending` state.
|-------|------|----------|-------------|
| `name` | string | no | Session name (1-255 chars) |
| `metadata` | object | no | Arbitrary JSON metadata to attach to the session (max 50 properties) |
-| `clips` | boolean | no | Allow [clip uploads](#clips) (~20 frames/min video) for this session. Default `false` = legacy 1 JPEG/min. **Immutable after creation.** |
+| `clips` | boolean | no | Allow [clip uploads](#clips) (~15 frames/min video) for this session. Default `false` = legacy 1 JPEG/min. **Immutable after creation.** |
**Response `201 Created`:**
```json
diff --git a/packages/server/test/clips.integration.test.ts b/packages/server/test/clips.integration.test.ts
index 4e907d90..4cc44528 100644
--- a/packages/server/test/clips.integration.test.ts
+++ b/packages/server/test/clips.integration.test.ts
@@ -236,7 +236,7 @@ describe("per-format confirm validation", () => {
const up = await getUploadUrl(s.token, { capturedAt: nowIso(), format: "webm" });
(globalThis as any).__r2HeadObjectOverride = {
ContentType: "video/webm",
- ContentLength: 5 * 1024 * 1024,
+ ContentLength: 9 * 1024 * 1024,
};
const confirm = await confirmUpload(s.token, up.body.screenshotId, {
frameCount: 20,
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 45a70e38..8c553cd5 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -63,9 +63,9 @@ export const CREDIT_PER_CAPTURE_S = 60;
/** 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 ~20 frames captured
+ * "webm"/"mp4" are per-minute video clips holding ~15 frames captured
* seconds apart (webm from Chromium/Firefox MediaRecorder; mp4 from
- * Safari MediaRecorder and the future desktop hardware encoder). The
+ * 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`, set at
@@ -83,33 +83,36 @@ export const CAPTURE_FORMAT_CONTENT_TYPES: Record = {
};
/** How often a clip-recording client grabs a frame into the current
- * clip. 3000ms = 20 frames per SCREENSHOT_INTERVAL_MS. The cadence is
+ * clip. 4000ms = 15 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.
- * Default: 3000 (3 seconds) */
-export const CLIP_FRAME_INTERVAL_MS = 3_000;
+ * that rate, and no client exposes an override. 15/min trades a hair
+ * of smoothness for ~33% more bitrate budget per frame — text
+ * legibility wins.
+ * Default: 4000 (4 seconds) */
+export const CLIP_FRAME_INTERVAL_MS = 4_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: 20 */
-export const FRAMES_PER_CLIP = 20;
+ * Default: 15 */
+export const FRAMES_PER_CLIP = 15;
/** Client-side encoder bitrate cap for clips (bits/second).
- * 400 kbps ≈ 3 MB per 60s clip worst case; static screen content lands
- * far below (VBR undershoots easy content). 133 kbps was tried first
- * and produced visibly soft H.264 at 1080p — hardware encoders need
- * more budget than VP9 for the same quality, and the compiled
- * timelapse re-encodes this once more, so source softness compounds.
- * Default: 400000 */
-export const CLIP_VIDEO_BITS_PER_SECOND = 400_000;
+ * Sized for TEXT LEGIBILITY: at 15 frames/min, 800 kbps allows ~400 KB
+ * per 4s frame — JPEG-q85-class keyframes at 1080p, the bar the legacy
+ * single-screenshot pipeline set. This is a VBR ceiling, not a floor:
+ * static screen content undershoots it heavily (measured 133 kbps-era
+ * clips landed at ~400 KB/min total). 133k and 400k were tried first
+ * and produced visibly soft H.264.
+ * Default: 800000 */
+export const CLIP_VIDEO_BITS_PER_SECOND = 800_000;
/** Max clip file size in bytes, validated server-side via HeadObject
- * after upload. Sized above the bitrate budget (400 kbps × 60s ≈ 3 MB)
+ * after upload. Sized above the bitrate budget (800 kbps × 60s ≈ 6 MB)
* to absorb encoder overshoot and container overhead.
- * Default: 4194304 (4 MB) */
-export const MAX_CLIP_BYTES = 4 * 1024 * 1024;
+ * Default: 8388608 (8 MB) */
+export const MAX_CLIP_BYTES = 8 * 1024 * 1024;
// ──────────────────────────────────────────────────────────
// Auto-timeout thresholds
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 4e2ad9cc..6d4c20e8 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -61,7 +61,7 @@ export interface CreateSessionRequest {
name?: string;
metadata?: Record;
/** Allow this session to receive clip uploads (per-minute videos of
- * ~20 frames) instead of one JPEG per minute. Default false.
+ * ~15 frames) instead of one JPEG per minute. Default false.
* Immutable after creation. */
clips?: boolean;
}
From a2d3980e7039cf20889ef3bcc7e1e8eb7b6d0270 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Sun, 26 Jul 2026 01:50:33 +0800
Subject: [PATCH 13/65] perf(desktop): upload clips concurrently with frame
capture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Serially awaiting the clip finalize+upload (~4-5s) stalled frame
collection at every tick — a hole in the recording after each cut that
compounds to minutes of missing screen time per hour.
The tick now cuts the clip and spawns the upload as a background task;
frame capture for the next clip resumes immediately. The wait loop
gains a third select arm that applies the confirm when it lands
(tray sync, nextExpectedAt refinement, error/termination recovery),
with a provisional interval-based target until then. Strictly one
upload in flight: the next tick settles the previous upload before
cutting, preserving capturedAt monotonicity and rate-limit
assumptions. A pending upload at cancel/stop detaches and finishes in
the background so the final minute still lands.
---
clients/desktop/src-tauri/src/lib.rs | 232 ++++++++++++++++---------
clients/react/src/hooks/useGallery.ts | 70 +++++---
packages/server/src/routes/sessions.ts | 40 ++++-
3 files changed, 227 insertions(+), 115 deletions(-)
diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs
index c4e6544d..08839c4c 100644
--- a/clients/desktop/src-tauri/src/lib.rs
+++ b/clients/desktop/src-tauri/src/lib.rs
@@ -2511,6 +2511,58 @@ async fn capture_loop_task(
Ok(true)
}
+ /// 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 = {
@@ -2553,6 +2605,16 @@ async fn capture_loop_task(
let opening_frame_dur = Duration::from_millis((frame_interval_ms / 3).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, 15/min) through the
@@ -2572,12 +2634,37 @@ async fn capture_loop_task(
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 {
@@ -2686,6 +2773,21 @@ async fn capture_loop_task(
}
}
+ // 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;
+ }
+ }
+
// 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.
@@ -2745,90 +2847,60 @@ async fn capture_loop_task(
None
};
- // 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 upload_result = match clip {
- Some(c) => {
- match upload_and_confirm(
- UploadPayload::mp4(c, jpeg_base64.clone()),
- captured_at.as_deref(),
- &config,
- &app,
- )
- .await
- {
- Ok(r) => Ok(r),
- Err(e) => {
- eprintln!(
- "[capture-loop] clip upload failed ({e}) — retrying tick as JPEG"
- );
- upload_and_confirm(
- UploadPayload::jpeg(
- jpeg_bytes.clone(),
- jpeg_base64.clone(),
- jpeg_w,
- jpeg_h,
- ),
- captured_at.as_deref(),
- &config,
- &app,
- )
- .await
+ // 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(
- UploadPayload::jpeg(jpeg_bytes, jpeg_base64, jpeg_w, jpeg_h),
- captured_at.as_deref(),
- &config,
- &app,
- )
- .await
- }
- };
-
- match upload_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).
- 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(_) => {}
+ 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}");
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/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts
index cbbb1df4..179f8316 100644
--- a/packages/server/src/routes/sessions.ts
+++ b/packages/server/src/routes/sessions.ts
@@ -1331,14 +1331,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;
+ }
},
);
From 808ba0affd2e06ccf3762558a0cc1e895dbd88ad Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Sun, 26 Jul 2026 17:05:59 +0800
Subject: [PATCH 14/65] fix(ui): label capture units 'captures', not
'screenshots'
screenshotCount counts capture units (one per recorded minute). On
clips sessions each unit is a ~15-frame clip, so 'screenshots' was
misleading; 'captures' is accurate for both modes.
---
clients/desktop/src/components/DesktopRecorder.tsx | 13 +++++++------
clients/react/src/components/StatusBar.tsx | 4 +++-
2 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/clients/desktop/src/components/DesktopRecorder.tsx b/clients/desktop/src/components/DesktopRecorder.tsx
index 7abb46bd..c9df2c25 100644
--- a/clients/desktop/src/components/DesktopRecorder.tsx
+++ b/clients/desktop/src/components/DesktopRecorder.tsx
@@ -87,7 +87,7 @@ function RecorderPreviewItem({
color: colors.badge.overlayText, background: colors.badge.overlayBg,
padding: "2px 6px", borderRadius: radii.sm,
}}>
- {showingLive ? "Live preview" : captureUrl ? "Latest capture" : "Live preview"}
+ {showingLive ? "Preview" : captureUrl ? "Latest capture" : "Preview"}
)}
@@ -97,9 +97,8 @@ 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`;
}
@@ -364,7 +363,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:
@@ -515,7 +514,9 @@ 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"}
@@ -558,7 +559,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
color: colors.badge.overlayText, background: colors.badge.overlayBg,
padding: "2px 6px", borderRadius: radii.sm,
}}>
- {windowFocused && !isCamera ? "Live preview" : "Latest capture"}
+ {windowFocused && !isCamera ? "Preview" : "Latest capture"}
) : (
diff --git a/clients/react/src/components/StatusBar.tsx b/clients/react/src/components/StatusBar.tsx
index 5319f07f..9277a1cc 100644
--- a/clients/react/src/components/StatusBar.tsx
+++ b/clients/react/src/components/StatusBar.tsx
@@ -29,7 +29,9 @@ export function StatusBar({ displaySeconds, screenshotCount, uploads }: StatusBa
{formatTime(displaySeconds)}
- {screenshotCount} {screenshotCount === 1 ? "screenshot" : "screenshots"}
+ {/* One capture unit per recorded minute — a JPEG screenshot on
+ legacy sessions, a ~15-frame clip on clips sessions. */}
+ {screenshotCount} {screenshotCount === 1 ? "capture" : "captures"}
{uploads.pending > 0 && (
{uploads.pending} uploading...
)}
From 3bbf3ca5856e987204970399bd2ceb6049a686ea Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Sun, 26 Jul 2026 18:05:43 +0800
Subject: [PATCH 15/65] feat: native add-menu popup, program icons, session
redirect URLs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- desktop: Raycast-style SwiftUI popup on the gallery + button (borderless
NSPanel, menu material, fling-in spring) via a new Swift FFI bridge
- desktop: native SwiftUI menu-bar item with rolling-digit time (numericText)
- server: programs.icon_url end-to-end — registry, admin API + page, and
/api/programs; icons render in the + menu and AddSessionPage
- server: sessions.redirect_url — creators can send users somewhere when
their timelapse completes; desktop opens it on completion
- react: Gallery onAdd passes the + button rect for popup anchoring
---
clients/desktop/src-tauri/Cargo.lock | 1 +
clients/desktop/src-tauri/Cargo.toml | 3 +
clients/desktop/src-tauri/build.rs | 9 +-
clients/desktop/src-tauri/src/lib.rs | 16 +-
clients/desktop/src-tauri/src/native_menu.rs | 108 +++
clients/desktop/src-tauri/src/native_tray.rs | 60 ++
clients/desktop/src-tauri/src/tray.rs | 57 +-
.../swift/lookout-tray/Package.swift | 13 +
.../swift/lookout-tray/Sources/AddMenu.swift | 353 +++++++++
.../swift/lookout-tray/Sources/Tray.swift | 189 +++++
clients/desktop/src/App.tsx | 81 +-
.../desktop/src/components/AddSessionPage.tsx | 14 +
.../react/src/components/CameraPreview.tsx | 2 +-
clients/react/src/components/Gallery.tsx | 24 +-
.../react/src/components/SessionDetail.tsx | 25 +
clients/react/src/index.ts | 2 +-
docs/integration.md | 29 +
packages/server/API.md | 8 +
packages/server/drizzle/0016_redirect_url.sql | 1 +
.../server/drizzle/0017_program_icon_url.sql | 1 +
.../server/drizzle/meta/0016_snapshot.json | 691 +++++++++++++++++
.../server/drizzle/meta/0017_snapshot.json | 697 ++++++++++++++++++
packages/server/drizzle/meta/_journal.json | 14 +
packages/server/src/db/schema.ts | 8 +
packages/server/src/routes/admin.ts | 50 +-
packages/server/src/routes/adminPage.ts | 28 +
packages/server/src/routes/internal.ts | 18 +-
packages/server/src/routes/programs.ts | 1 +
packages/server/src/routes/sessions.ts | 4 +
.../server/test/sessions.integration.test.ts | 75 ++
packages/shared/src/types.ts | 10 +
31 files changed, 2558 insertions(+), 34 deletions(-)
create mode 100644 clients/desktop/src-tauri/src/native_menu.rs
create mode 100644 clients/desktop/src-tauri/src/native_tray.rs
create mode 100644 clients/desktop/src-tauri/swift/lookout-tray/Package.swift
create mode 100644 clients/desktop/src-tauri/swift/lookout-tray/Sources/AddMenu.swift
create mode 100644 clients/desktop/src-tauri/swift/lookout-tray/Sources/Tray.swift
create mode 100644 packages/server/drizzle/0016_redirect_url.sql
create mode 100644 packages/server/drizzle/0017_program_icon_url.sql
create mode 100644 packages/server/drizzle/meta/0016_snapshot.json
create mode 100644 packages/server/drizzle/meta/0017_snapshot.json
diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock
index 36366453..f1758442 100644
--- a/clients/desktop/src-tauri/Cargo.lock
+++ b/clients/desktop/src-tauri/Cargo.lock
@@ -3188,6 +3188,7 @@ dependencies = [
"sentry",
"serde",
"serde_json",
+ "swift-rs",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml
index 1ff754e6..91f545f4 100644
--- a/clients/desktop/src-tauri/Cargo.toml
+++ b/clients/desktop/src-tauri/Cargo.toml
@@ -21,6 +21,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
+# Compiles swift/lookout-tray (SwiftUI menu-bar item with numericText digit
+# animation) and links it into the macOS binary; no-op on other targets.
+swift-rs = { version = "1.0.7", features = ["build"] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "devtools", "tray-icon", "image-png"] }
diff --git a/clients/desktop/src-tauri/build.rs b/clients/desktop/src-tauri/build.rs
index 105301eb..d18e12df 100644
--- a/clients/desktop/src-tauri/build.rs
+++ b/clients/desktop/src-tauri/build.rs
@@ -1,7 +1,14 @@
fn main() {
// Link CoreGraphics on macOS for screen capture permission APIs
#[cfg(target_os = "macos")]
- println!("cargo:rustc-link-lib=framework=CoreGraphics");
+ {
+ println!("cargo:rustc-link-lib=framework=CoreGraphics");
+ // Native menu-bar item: compiles swift/lookout-tray (SwiftUI with the
+ // numericText digit-roll animation) and links it into the binary.
+ swift_rs::SwiftLinker::new("10.15")
+ .with_package("lookout-tray", "./swift/lookout-tray")
+ .link();
+ }
tauri_build::build()
}
diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs
index 08839c4c..34b2c481 100644
--- a/clients/desktop/src-tauri/src/lib.rs
+++ b/clients/desktop/src-tauri/src/lib.rs
@@ -1,6 +1,9 @@
mod capture;
mod clips;
mod crop;
+mod native_menu;
+#[cfg(target_os = "macos")]
+mod native_tray;
mod pipewire;
mod screencast;
mod tray;
@@ -2083,15 +2086,13 @@ const SLEEP_THRESHOLD_SECS: u64 = CAPTURE_INTERVAL_SECS * 2 + 30; // 150s
const DEFAULT_FRAME_INTERVAL_MS: u64 = 4_000;
/// Format seconds into the same tray title format as the JS side:
-/// >0h: "{h}h {m}m", 0m: "< 1m", else: "{m}m"
+/// >0h: "{h}h {m}m", else: "{m}m"
fn format_tray_time(total_seconds: i64) -> String {
let total = total_seconds.max(0) as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
if h > 0 {
format!("{h}h {m}m")
- } else if m == 0 {
- "< 1m".to_string()
} else {
format!("{m}m")
}
@@ -2140,6 +2141,14 @@ async fn tray_timer_task(
let time_text = format_tray_time(display_seconds);
if was_paused || last_title.as_deref() != Some(time_text.as_str()) {
+ #[cfg(target_os = "macos")]
+ {
+ let _ = &app;
+ // None = keep the current pause state; the Swift side
+ // renders the tooltip and the numericText digit roll.
+ let _ = crate::native_tray::update(&time_text, None);
+ }
+ #[cfg(not(target_os = "macos"))]
if let Some(tray) = app.tray_by_id("timelapse_tray") {
let _ = tray.set_title(Some(&time_text));
// Windows doesn't render tray titles — the hover tooltip is
@@ -3312,6 +3321,7 @@ pub fn run() {
disable_vibrancy,
is_wayland,
open_external_url,
+ native_menu::show_add_menu,
request_screencast,
add_screencast,
set_blacklisted_apps,
diff --git a/clients/desktop/src-tauri/src/native_menu.rs b/clients/desktop/src-tauri/src/native_menu.rs
new file mode 100644
index 00000000..8b134288
--- /dev/null
+++ b/clients/desktop/src-tauri/src/native_menu.rs
@@ -0,0 +1,108 @@
+//! Raycast-style popup menu for the gallery's "+" button, rendered by Swift
+//! (swift/lookout-tray/Sources/AddMenu.swift) as a borderless NSPanel with
+//! SwiftUI content. The frontend invokes `show_add_menu` with the items and
+//! the button's rect (CSS px, viewport-relative — the webview spans the whole
+//! window on macOS, so those are window coordinates); the command resolves to
+//! the chosen item's id, or None when the menu is dismissed.
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AddMenuEntry {
+ pub id: Option,
+ pub label: Option,
+ pub symbol: Option,
+ /// Remote image shown instead of `symbol`, which stays the fallback.
+ #[serde(rename = "iconUrl")]
+ pub icon_url: Option,
+ #[serde(default)]
+ pub separator: bool,
+}
+
+#[derive(Debug, Clone, Copy, Deserialize)]
+pub struct AddMenuAnchor {
+ pub x: f64,
+ pub y: f64,
+ pub width: f64,
+ pub height: f64,
+}
+
+#[cfg(target_os = "macos")]
+mod imp {
+ use super::{AddMenuAnchor, AddMenuEntry};
+ use std::ffi::{c_char, c_void, CStr, CString};
+ use std::sync::Mutex;
+ use tokio::sync::oneshot;
+
+ extern "C" {
+ fn lookout_add_menu_show(
+ items_json: *const c_char,
+ ns_window: *mut c_void,
+ x: f64,
+ y: f64,
+ w: f64,
+ h: f64,
+ cb: extern "C" fn(*const c_char),
+ );
+ }
+
+ /// Only one menu can be open; replacing the sender cancels the previous
+ /// command's await (its receiver resolves to None).
+ static PENDING: Mutex
+ );
+}
+
+export function AddMenuPopup({ items, anchor, onSelect }: AddMenuPopupProps) {
+ const ref = useRef(null);
+ const [selection, setSelection] = useState(null);
+ // Drops below the anchor by default; flipped above when there's no room.
+ const [flipped, setFlipped] = useState(false);
+
+ // Keep the latest onSelect without re-binding the listeners below.
+ const onSelectRef = useRef(onSelect);
+ onSelectRef.current = onSelect;
+
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const below = anchor.y + anchor.height + GAP;
+ setFlipped(below + el.offsetHeight > window.innerHeight - EDGE);
+ }, [anchor, items]);
+
+ // Click-away and window blur dismiss, like the NSPanel's resignKey.
+ useEffect(() => {
+ const onPointerDown = (e: PointerEvent) => {
+ const el = ref.current;
+ if (el && el.contains(e.target as Node)) return;
+ // The + button itself toggles the menu in its click handler; closing
+ // here too would make that click immediately reopen it.
+ if (
+ e.clientX >= anchor.x && e.clientX <= anchor.x + anchor.width &&
+ e.clientY >= anchor.y && e.clientY <= anchor.y + anchor.height
+ ) return;
+ onSelectRef.current(null);
+ };
+ const onBlur = () => onSelectRef.current(null);
+ document.addEventListener("pointerdown", onPointerDown, true);
+ window.addEventListener("blur", onBlur);
+ return () => {
+ document.removeEventListener("pointerdown", onPointerDown, true);
+ window.removeEventListener("blur", onBlur);
+ };
+ }, [anchor]);
+
+ // Escape / arrows / enter, mirroring the Swift key monitor.
+ useEffect(() => {
+ const selectable = items
+ .map((item, i) => ({ item, i }))
+ .filter(({ item }) => !item.separator)
+ .map(({ i }) => i);
+ const onKeyDown = (e: KeyboardEvent) => {
+ switch (e.key) {
+ case "Escape":
+ e.preventDefault();
+ onSelectRef.current(null);
+ break;
+ case "ArrowDown":
+ case "ArrowUp": {
+ e.preventDefault();
+ if (selectable.length === 0) return;
+ const delta = e.key === "ArrowDown" ? 1 : -1;
+ setSelection((current) => {
+ const pos = current === null ? -1 : selectable.indexOf(current);
+ if (pos === -1) return delta > 0 ? selectable[0] : selectable[selectable.length - 1];
+ return selectable[(pos + delta + selectable.length) % selectable.length];
+ });
+ break;
+ }
+ case "Enter":
+ e.preventDefault();
+ setSelection((current) => {
+ if (current !== null && !items[current].separator) {
+ onSelectRef.current(items[current].id ?? null);
+ }
+ return current;
+ });
+ break;
+ }
+ };
+ document.addEventListener("keydown", onKeyDown, true);
+ return () => document.removeEventListener("keydown", onKeyDown, true);
+ }, [items]);
+
+ const right = Math.max(EDGE, window.innerWidth - (anchor.x + anchor.width));
+
+ return (
+
+ {items.map((item, i) =>
+ item.separator ? (
+
+ ) : (
+ setSelection(i)}
+ onLeave={() => setSelection((s) => (s === i ? null : s))}
+ onActivate={() => onSelect(item.id ?? null)}
+ />
+ ),
+ )}
+
+ );
+}
From 3bacb7720795e3cc617da0fb74655b6ba3dfd364 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Sun, 26 Jul 2026 23:22:49 +0800
Subject: [PATCH 19/65] feat: user-editable cuts for compiled timelapses
(#edit)
An edit is a cut list of absolute wall-clock intervals stored on the
session; one shared membership rule (ts in [start, end)) drives all three
outputs consistently: the published video, /timings (cut captures excluded
by default, so Hackatime forwarders honor edits with no changes), and
trackedSeconds (reported as raw - cutSeconds; raw kept as
uncutTrackedSeconds; cuts can only shrink time).
- shared: cuts.ts (CutInterval, normalize, membership, kept ranges,
cut-seconds math), new API types, constants
- server: GET /units, PUT /cuts, POST /compile (5-recompile budget,
instant un-cut no-op), timings filtering + includeCut, post-cut tracked
time on all session responses, original-video purge 7 days after the
last edit (privacy backstop)
- worker: compile records video_units (video second i <-> wall clock) and
always writes original.mp4; cut-compiles slice the original losslessly
(IDR-aligned -ss + exact -frames:v copy per kept range -- concat
inpoint/outpoint leaks ~2 B-frame-dts frames per boundary, caught by the
frame-exact test) with a pinned-GOP re-encode fallback; assembly
fallback now GOP-pinned too
- react SDK: TimelapseEditor (region drag/handles/seek/snap, playback
skips cuts, scrubbing passes through with removal overlay, filmstrip,
gap markers), api client methods, Edit affordances in SessionDetail,
LookoutRecorder (editing prop) and hosted web Result (?edit=false)
- desktop: dedicated resizable 960x720 editor window (main window is a
fixed 480x640), lookout-edited event refresh, capability additions
- tests: shared cut math, editor math round-trips, real-ffmpeg lossless
cut verification, endpoint integration suite; docs for API.md,
integration.md, react API.md
---
.../src-tauri/capabilities/default.json | 19 +-
clients/desktop/src/App.tsx | 33 +-
.../desktop/src/components/EditorWindow.tsx | 89 ++
clients/react/API.md | 56 +-
clients/react/src/api/client.ts | 31 +
.../react/src/components/LookoutRecorder.tsx | 63 +-
.../react/src/components/SessionDetail.tsx | 40 +-
.../react/src/components/TimelapseEditor.tsx | 856 ++++++++++++++++++
clients/react/src/hooks/editorMath.test.ts | 136 +++
clients/react/src/hooks/editorMath.ts | 135 +++
clients/react/src/hooks/useHashRouter.ts | 9 +
clients/react/src/index.ts | 12 +
clients/web/src/components/Result.tsx | 79 +-
docs/edit-feature-plan.md | 305 +++++++
docs/integration.md | 31 +
packages/server/API.md | 95 +-
.../server/drizzle/0018_session_edits.sql | 7 +
packages/server/drizzle/meta/_journal.json | 7 +
packages/server/src/db/schema.ts | 34 +
packages/server/src/lib/timeouts.ts | 54 ++
packages/server/src/routes/internal.ts | 20 +-
packages/server/src/routes/sessions.ts | 405 ++++++++-
packages/server/test/cuts.unit.test.ts | 144 +++
.../server/test/edits.integration.test.ts | 252 ++++++
packages/shared/src/cuts.ts | 212 +++++
packages/shared/src/index.ts | 1 +
packages/shared/src/types.ts | 71 ++
packages/worker/src/compile.ts | 353 +++++++-
packages/worker/src/schema.ts | 20 +
packages/worker/src/segments.ts | 152 +++-
packages/worker/test/cutVideo.test.ts | 157 ++++
31 files changed, 3800 insertions(+), 78 deletions(-)
create mode 100644 clients/desktop/src/components/EditorWindow.tsx
create mode 100644 clients/react/src/components/TimelapseEditor.tsx
create mode 100644 clients/react/src/hooks/editorMath.test.ts
create mode 100644 clients/react/src/hooks/editorMath.ts
create mode 100644 docs/edit-feature-plan.md
create mode 100644 packages/server/drizzle/0018_session_edits.sql
create mode 100644 packages/server/test/cuts.unit.test.ts
create mode 100644 packages/server/test/edits.integration.test.ts
create mode 100644 packages/shared/src/cuts.ts
create mode 100644 packages/worker/test/cutVideo.test.ts
diff --git a/clients/desktop/src-tauri/capabilities/default.json b/clients/desktop/src-tauri/capabilities/default.json
index 5fa24256..e278e544 100644
--- a/clients/desktop/src-tauri/capabilities/default.json
+++ b/clients/desktop/src-tauri/capabilities/default.json
@@ -2,7 +2,11 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the Lookout desktop app",
- "windows": ["main", "tray"],
+ "windows": [
+ "main",
+ "tray",
+ "editor-*"
+ ],
"permissions": [
"core:default",
"liquid-glass:default",
@@ -18,12 +22,19 @@
"macos-permissions:default",
"updater:default",
"notification:default",
+ "core:webview:allow-create-webview-window",
+ "core:window:allow-close",
+ "core:window:allow-set-focus",
{
"identifier": "http:default",
"allow": [
- { "url": "http://localhost:*" },
- { "url": "https://**" }
+ {
+ "url": "http://localhost:*"
+ },
+ {
+ "url": "https://**"
+ }
]
}
]
-}
+}
\ No newline at end of file
diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx
index f3dc04a0..5c46a7b4 100644
--- a/clients/desktop/src/App.tsx
+++ b/clients/desktop/src/App.tsx
@@ -25,6 +25,7 @@ import { RecordPage } from "./components/RecordPage.js";
import { AddSessionPage } from "./components/AddSessionPage.js";
import { SettingsPage } from "./components/SettingsPage.js";
import { TrayApp } from "./components/TrayApp.js";
+import { EditorWindow, openEditorWindow, EDITED_EVENT } from "./components/EditorWindow.js";
import { useBlacklistedApps } from "./hooks/useBlacklistedApps.js";
import { useAppUpdate } from "./hooks/useAppUpdate.js";
import { useAnnouncement } from "./hooks/useAnnouncement.js";
@@ -72,6 +73,13 @@ export function App() {
if (isTray) {
return ;
}
+ // Dedicated editor window (see EditorWindow.tsx). Branches before
+ // MainWindowApp so it skips the permission gates, vibrancy, deep-link
+ // handlers, and the rest of the main-window machinery.
+ const editorMatch = window.location.hash.match(/^#\/?editor\?token=([0-9a-fA-F]{64})/);
+ if (editorMatch) {
+ return ;
+ }
return ;
}
@@ -114,6 +122,22 @@ function MainWindowApp() {
tokens: tokenStore.getAllTokenValues(),
});
+ // Bumped when an editor window applies cuts — remounts the open
+ // SessionDetail so it re-fetches (picks up the compiling → complete flip
+ // and the recompiled video) and refreshes gallery thumbnails.
+ const [editNonce, setEditNonce] = useState(0);
+ const galleryRefreshRef = React.useRef(gallery.refresh);
+ galleryRefreshRef.current = gallery.refresh;
+ useEffect(() => {
+ let unlisten: (() => void) | undefined;
+ listen(EDITED_EVENT, () => {
+ console.log("[app] editor window applied cuts — refreshing");
+ setEditNonce((n) => n + 1);
+ galleryRefreshRef.current();
+ }).then((fn) => { unlisten = fn; });
+ return () => { if (unlisten) unlisten(); };
+ }, []);
+
// Initialize blacklisted apps sync from localStorage to Rust backend
useBlacklistedApps();
@@ -368,14 +392,14 @@ function MainWindowApp() {
// So we just rely on standard browser matchMedia to get the universal native standard.
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const getSystemTheme = () => mediaQuery.matches ? "dark" : "light";
-
+
const applyTheme = () => {
const theme = getSystemTheme();
updateTheme(theme);
// Force the Tauri window GTK decorations to match the media query since winit is confused
getCurrentWindow().setTheme(theme).catch(() => {});
};
-
+
applyTheme();
const listener = () => applyTheme();
@@ -424,7 +448,7 @@ function MainWindowApp() {
const prevRootBg = root?.style.background ?? "";
let effectsApplied = false;
-
+
const isLinux = navigator.userAgent.toLowerCase().includes("linux");
if (!isLinux) {
invoke("enable_vibrancy")
@@ -521,9 +545,10 @@ function MainWindowApp() {
case "session":
return (
{ void openEditorWindow(route.token); }}
onComplete={({ redirectUrl }) => {
// Redirect hook: the session's creator asked us to send the
// user somewhere once their timelapse is ready.
diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx
new file mode 100644
index 00000000..f8d76de1
--- /dev/null
+++ b/clients/desktop/src/components/EditorWindow.tsx
@@ -0,0 +1,89 @@
+import { emit } from "@tauri-apps/api/event";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
+import { TimelapseEditor, colors, fontSize, fontWeight, spacing } from "@lookout/react";
+import { getApiBase } from "../serverConfig.js";
+
+/** Event the editor window emits after applying cuts, so the main window
+ * can refresh the session detail + gallery. Payload: { token }. */
+export const EDITED_EVENT = "lookout-edited";
+
+/**
+ * Open (or focus) the dedicated editor window for a session. The main
+ * window is a fixed 480×640 — far too small to scrub a multi-hour
+ * timeline with any precision — so editing gets its own resizable window.
+ */
+export async function openEditorWindow(token: string): Promise {
+ const label = `editor-${token.slice(0, 8)}`;
+ const existing = await WebviewWindow.getByLabel(label);
+ if (existing) {
+ await existing.setFocus().catch(() => {});
+ return;
+ }
+ const win = new WebviewWindow(label, {
+ url: `${window.location.pathname}#/editor?token=${token}`,
+ title: "Edit timelapse",
+ width: 960,
+ height: 720,
+ minWidth: 720,
+ minHeight: 560,
+ resizable: true,
+ center: true,
+ });
+ win.once("tauri://error", (e) => {
+ console.error("[editor] failed to open editor window:", e);
+ });
+}
+
+/** The editor window's root view (route `#/editor?token=…`). */
+export function EditorWindow({ token }: { token: string }) {
+ return (
+
+
+ Edit timelapse
+
+ drag the strip to remove minutes — the tracked time updates with it
+
+
+ {
+ // Tell the main window, then close. Fire-and-forget on purpose:
+ // even if the emit fails, closing is correct — the main window
+ // shows the recompiled video on its next fetch.
+ void emit(EDITED_EVENT, { token })
+ .catch((e) => console.error("[editor] emit failed:", e))
+ .finally(() => getCurrentWindow().close().catch(() => {}));
+ }}
+ onCancel={() => {
+ void getCurrentWindow().close().catch(() => {});
+ }}
+ />
+
+ );
+}
diff --git a/clients/react/API.md b/clients/react/API.md
index 9d45cf9d..cb81f09c 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -468,7 +468,13 @@ Drop-in recorder widget. Handles the full lifecycle: capture, upload, pause/resu
```
-No props — reads everything from context.
+**Props (`LookoutRecorderProps`):**
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `editing` | `boolean?` | Offer the cut editor on completed timelapses (default `true`). Pass `false` to hide the affordance. |
+
+Everything else is read from context.
**Renders based on status:**
- `loading` — spinner
@@ -700,6 +706,51 @@ Full session detail view with video player, stats, and compilation polling. Stan
| `onBack` | `() => void?` | Back button handler |
| `onArchive` | `() => void?` | Archive button handler |
+When the session is `complete` and still editable, the header shows an **Edit**
+button that swaps the view for a ``; applying edits returns to
+the detail view and polls the cut-compile back to `complete`.
+
+---
+
+### ``
+
+Post-compile cut editor. Previews the **uncut original** video (1 second of
+video = 1 capture unit = 1 real-world minute), lets the user drag out cut
+regions on a filmstrip timeline, and applies them via a fast server-side
+cut-compile (usually a lossless stream copy). Standalone (no provider needed).
+
+```tsx
+ refetchStatus()}
+ onCancel={() => setEditing(false)}
+/>
+```
+
+**Props (`TimelapseEditorProps`):**
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `token` | `string` | Session token |
+| `apiBaseUrl` | `string` | Server API base URL |
+| `onApplied` | `() => void?` | Cuts were saved and the cut-compile started — return to your detail view and poll `/status` |
+| `onCancel` | `() => void?` | User backed out without applying |
+
+**Interactions:**
+- **Drag on the filmstrip** creates a cut region in one gesture (edges snap to
+ whole minutes); **plain click seeks**; the **ruler lane scrubs**.
+- Regions are first-class objects: drag to move, edge handles to resize
+ (the preview follows the dragged edge, showing the boundary frame),
+ click to select, Delete/Backspace to remove.
+- **Space** plays/pauses. Playback **skips cut regions** (previewing the
+ published result); scrubbing passes through them with a "will be removed"
+ overlay so edges can be judged.
+- Footer shows server-authoritative "kept / removed" durations; recording
+ pauses appear as dashed gap markers on the strip.
+- Shows "n edits remaining" as the per-session recompile budget runs low,
+ and a not-editable state once the original video has been purged.
+
---
## Callbacks
@@ -781,6 +832,9 @@ const session = await client.getSession();
| `rename` | `(name: string) => Promise` | Rename the timelapse |
| `getStatus` | `() => Promise` | Poll compilation status |
| `getVideo` | `() => Promise` | Get video URL |
+| `getUnits` | `() => Promise` | Editor metadata: unit map, cuts, presigned original-video URL |
+| `setCuts` | `(cuts: CutInterval[]) => Promise` | Replace the session's cut list (`[]` clears) |
+| `applyCuts` | `() => Promise` | Apply the cut list to the published video (cut-compile) |
---
diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts
index 3c576b6d..aa78a66d 100644
--- a/clients/react/src/api/client.ts
+++ b/clients/react/src/api/client.ts
@@ -10,6 +10,10 @@ import type {
RenameSessionResponse,
StatusResponse,
VideoResponse,
+ UnitsResponse,
+ SetCutsResponse,
+ ApplyCutsResponse,
+ CutInterval,
} from "@lookout/shared";
import type { TokenProvider } from "../types.js";
@@ -34,6 +38,16 @@ export interface LookoutClient {
rename(name: string): Promise;
getStatus(): Promise;
getVideo(): Promise;
+ /** Editor metadata: the compiled original's unit map (video second i ↔
+ * wall clock), current cut list, and a token-gated presigned URL for the
+ * UNCUT original video. */
+ getUnits(): Promise;
+ /** Replace the session's cut list (full replace; [] clears all edits).
+ * Returns the normalized list plus a server-authoritative preview. */
+ setCuts(cuts: CutInterval[]): Promise;
+ /** Apply the current cut list to the published video (a cut-compile —
+ * usually a lossless stream copy, seconds not minutes). */
+ applyCuts(): Promise;
}
export class HttpError extends Error {
@@ -184,5 +198,22 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient
async getVideo() {
return fetchJson(await sessionUrl("/video"));
},
+
+ async getUnits() {
+ return fetchJson(await sessionUrl("/units"));
+ },
+
+ async setCuts(cuts) {
+ return fetchJson(await sessionUrl("/cuts"), {
+ method: "PUT",
+ body: JSON.stringify({ cuts }),
+ });
+ },
+
+ async applyCuts() {
+ return fetchJson(await sessionUrl("/compile"), {
+ method: "POST",
+ });
+ },
};
}
diff --git a/clients/react/src/components/LookoutRecorder.tsx b/clients/react/src/components/LookoutRecorder.tsx
index 10bbde11..44a773b6 100644
--- a/clients/react/src/components/LookoutRecorder.tsx
+++ b/clients/react/src/components/LookoutRecorder.tsx
@@ -1,6 +1,8 @@
-import React from "react";
+import React, { useEffect, useState } from "react";
import { useLookout } from "../hooks/useLookout.js";
+import { useLookoutContext } from "../LookoutProvider.js";
import { StatusBar } from "./StatusBar.js";
+import { TimelapseEditor } from "./TimelapseEditor.js";
import { ScreenPreview } from "./ScreenPreview.js";
import { CameraPreview } from "./CameraPreview.js";
import { CameraSelector } from "./CameraSelector.js";
@@ -22,8 +24,41 @@ import { colors, fontSize, fontWeight, spacing } from "../ui/theme.js";
*
* Must be used within a ``.
*/
-export function LookoutRecorder() {
+export interface LookoutRecorderProps {
+ /** Offer the cut editor on completed timelapses (default true). Programs
+ * embedding the recorder can pass false to hide the affordance. */
+ editing?: boolean;
+}
+
+export function LookoutRecorder({ editing = true }: LookoutRecorderProps = {}) {
const { state, actions } = useLookout();
+ const { client, config } = useLookoutContext();
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [resolvedToken, setResolvedToken] = useState(null);
+ const [sessionEditable, setSessionEditable] = useState(false);
+
+ // The editor needs a concrete token string and the session's editability.
+ // Resolve them once the recording reaches "complete".
+ useEffect(() => {
+ if (state.status !== "complete" || !editing) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const [token, status] = await Promise.all([
+ client.resolveToken(),
+ client.getStatus(),
+ ]);
+ if (cancelled) return;
+ setResolvedToken(token);
+ setSessionEditable(status.editable === true);
+ } catch {
+ // Editability probe is best-effort — the video still plays.
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [state.status, editing, client]);
if (state.status === "loading") {
return (
@@ -62,12 +97,36 @@ export function LookoutRecorder() {
state.status === "complete" ||
state.status === "failed"
) {
+ if (editorOpen && resolvedToken) {
+ return (
+
+ setEditorOpen(false)}
+ onApplied={() => {
+ // The session flips complete → compiling → complete on the
+ // server; the ResultView refetches the video on next mount.
+ setEditorOpen(false);
+ setSessionEditable(false);
+ }}
+ />
+
+ );
+ }
return (
+ {state.status === "complete" && editing && sessionEditable && resolvedToken && (
+
+ setEditorOpen(true)}>
+ Edit timelapse
+
+
+ )}
);
}
diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx
index 5c554d22..2d414f3a 100644
--- a/clients/react/src/components/SessionDetail.tsx
+++ b/clients/react/src/components/SessionDetail.tsx
@@ -5,6 +5,7 @@ import { formatTrackedTime } from "../hooks/useSessionTimer.js";
import { Button } from "../ui/Button.js";
import { ErrorDisplay } from "../ui/ErrorDisplay.js";
import { ProcessingState } from "./ProcessingState.js";
+import { TimelapseEditor } from "./TimelapseEditor.js";
import { SessionDetailSkeleton } from "../ui/Skeleton.js";
import { Card } from "../ui/Card.js";
import { Badge } from "../ui/Badge.js";
@@ -20,6 +21,10 @@ export interface SessionDetailProps {
* NOT fired when opening a session that is already complete. Carries the
* session's redirect-hook URL, if one was set at creation. */
onComplete?: (info: { redirectUrl: string | null }) => void;
+ /** Override for the Edit button. When provided, clicking Edit calls this
+ * instead of opening the inline editor — e.g. the desktop app opens a
+ * dedicated resizable editor window (the main window is a fixed 480px). */
+ onEdit?: () => void;
}
export function SessionDetail({
@@ -28,6 +33,7 @@ export function SessionDetail({
onBack,
onArchive,
onComplete,
+ onEdit,
}: SessionDetailProps) {
const [sessionInfo, setSessionInfo] = useState<{ name: string; createdAt: string } | null>(null);
const [isRenaming, setIsRenaming] = useState(false);
@@ -63,6 +69,7 @@ export function SessionDetail({
const [status, setStatus] = useState(null);
const [videoUrl, setVideoUrl] = useState(null);
const [error, setError] = useState(null);
+ const [editing, setEditing] = useState(false);
// Completion detection for the redirect hook: only a live transition from
// an in-flight state counts — a session opened when already "complete"
@@ -152,6 +159,16 @@ export function SessionDetail({
)}
+ setEditing(false)}
+ onApplied={() => {
+ // Back to the detail view; the status poll below picks up
+ // "compiling" and flips to the re-published video. Reset the
+ // cached video URL so the edited MP4 is re-fetched.
+ setEditing(false);
+ setVideoUrl(null);
+ setStatus((prev) =>
+ prev ? { ...prev, status: "compiling" } : prev,
+ );
+ fetchStatus();
+ }}
+ />
+
+ )}
+
+ {status && !editing && (
<>
{/* Video area */}
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
new file mode 100644
index 00000000..66269a48
--- /dev/null
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -0,0 +1,856 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { AnimatePresence, motion } from "motion/react";
+import type { UnitsResponse } from "@lookout/shared";
+import { createLookoutClient, type LookoutClient } from "../api/client.js";
+import {
+ cutsToRegions,
+ cutUnitCount,
+ formatUnitsDuration,
+ gapIndices,
+ normalizeRegions,
+ regionAtTime,
+ regionsToCuts,
+ unitAtTime,
+ unitClockLabel,
+ type UnitRegion,
+} from "../hooks/editorMath.js";
+import { Button } from "../ui/Button.js";
+import { Spinner } from "../ui/Spinner.js";
+import { ErrorDisplay } from "../ui/ErrorDisplay.js";
+import { colors, fontSize, fontWeight, radii, spacing } from "../ui/theme.js";
+
+export interface TimelapseEditorProps {
+ token: string;
+ apiBaseUrl: string;
+ /** Cuts were saved and the cut-compile started (or applied instantly).
+ * The caller should return to its detail view and poll status. */
+ onApplied?: () => void;
+ onCancel?: () => void;
+}
+
+const TIMELINE_HEIGHT = 64;
+const RULER_HEIGHT = 22;
+const FILMSTRIP_SAMPLES = 60;
+const CUT_FILL = "rgba(239, 68, 68, 0.38)";
+const CUT_BORDER = "#ef4444";
+
+type DragState =
+ | { kind: "maybe"; downUnitF: number }
+ | { kind: "scrub" }
+ | {
+ kind: "region";
+ index: number;
+ mode: "move" | "start" | "end";
+ grabOffset: number;
+ anchorUnit: number;
+ }
+ | null;
+
+/**
+ * Post-compile cut editor. Previews the UNCUT original video (1 second of
+ * video = 1 capture unit = 1 real-world minute), lets the user drag out cut
+ * regions on the timeline, and applies them via a fast server-side
+ * cut-compile. Regions are first-class objects: draggable edges, selection,
+ * delete key. A plain click on the timeline seeks; playback skips cut
+ * regions so previewing shows the published result, while scrubbing passes
+ * through them (dimmed) so cut edges can be judged.
+ */
+export function TimelapseEditor({
+ token,
+ apiBaseUrl,
+ onApplied,
+ onCancel,
+}: TimelapseEditorProps) {
+ const client = useMemo(
+ () => createLookoutClient({ baseUrl: apiBaseUrl, token }),
+ [apiBaseUrl, token],
+ );
+
+ const [data, setData] = useState(null);
+ const [loadError, setLoadError] = useState(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 ────────────────────────────────────────────────────
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await client.getUnits();
+ if (cancelled) return;
+ if (!res.editable || !res.originalVideoUrl) {
+ setLoadError(
+ res.editableReason === "recompiles_exhausted"
+ ? "This timelapse has reached its edit limit."
+ : "This timelapse can no longer be edited.",
+ );
+ return;
+ }
+ setData(res);
+ setRegions(cutsToRegions(res.cuts, res.units));
+ } catch (err) {
+ if (!cancelled)
+ setLoadError(err instanceof Error ? err.message : String(err));
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [client]);
+
+ // ── 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) {
+ // Cut runs to the end — nothing kept after it.
+ v.pause();
+ v.currentTime = region.startUnit;
+ } else {
+ v.currentTime = region.endUnit;
+ }
+ };
+ v.addEventListener("timeupdate", onTimeUpdate);
+ return () => v.removeEventListener("timeupdate", onTimeUpdate);
+ }, [unitCount, data?.originalVideoUrl]);
+
+ // ── Filmstrip: sample frames from an offscreen copy of the video.
+ // Best-effort — if the CDN response isn't CORS-readable the canvas
+ // taints and we quietly fall back to a plain timeline.
+ useEffect(() => {
+ const src = data?.originalVideoUrl;
+ if (!src || unitCount === 0) return;
+ let cancelled = false;
+ (async () => {
+ const v = document.createElement("video");
+ v.crossOrigin = "anonymous";
+ v.muted = true;
+ v.preload = "auto";
+ v.src = src;
+ try {
+ await new Promise((resolve, reject) => {
+ v.onloadedmetadata = () => resolve();
+ v.onerror = () => reject(new Error("video load failed"));
+ });
+ const canvas = document.createElement("canvas");
+ const thumbH = TIMELINE_HEIGHT;
+ const thumbW = Math.round(
+ (v.videoWidth / Math.max(1, v.videoHeight)) * thumbH,
+ );
+ canvas.width = thumbW;
+ canvas.height = thumbH;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const count = Math.min(FILMSTRIP_SAMPLES, unitCount);
+ const thumbs: string[] = [];
+ for (let i = 0; i < count; i++) {
+ if (cancelled) return;
+ // Sample the middle of each strip cell, +0.5 to land inside a
+ // second (unit) rather than on the boundary between two.
+ const t = Math.min(
+ v.duration - 0.05,
+ (i / count) * unitCount + 0.5,
+ );
+ await new Promise((resolve) => {
+ v.onseeked = () => resolve();
+ v.currentTime = t;
+ });
+ ctx.drawImage(v, 0, 0, thumbW, thumbH);
+ thumbs.push(canvas.toDataURL("image/jpeg", 0.5));
+ // Stream partial strips in so the timeline fills as we go.
+ if (i % 6 === 5) setFilmstrip([...thumbs]);
+ }
+ if (!cancelled) setFilmstrip(thumbs);
+ } catch {
+ // Tainted canvas / load failure → no thumbnails, timeline still works.
+ } finally {
+ v.removeAttribute("src");
+ v.load();
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [data?.originalVideoUrl, unitCount]);
+
+ // ── 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, // index of the region just added
+ mode: unitF >= drag.downUnitF ? "end" : "start",
+ grabOffset: 0,
+ anchorUnit: Math.floor(drag.downUnitF),
+ };
+ return;
+ }
+
+ // Region move/resize. Edges snap to whole units by construction.
+ 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)));
+ // The preview rides the dragged edge: show the first REMOVED unit.
+ seekTo(r.startUnit + 0.02);
+ } else {
+ const anchor = drag.anchorUnit;
+ const rounded = Math.round(unitF);
+ if (rounded <= anchor) {
+ // Dragged back across the anchor — grow leftward instead.
+ 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));
+ // Show the first KEPT unit after the cut — the frame the splice
+ // will land on.
+ 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: seek. (Deselect any selected region.)
+ seekTo(drag.downUnitF);
+ setSelected(null);
+ return;
+ }
+ if (drag.kind === "region") {
+ setRegions((prev) => {
+ const next = normalizeRegions(prev);
+ setSelected(null);
+ return next;
+ });
+ }
+ }, [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],
+ );
+
+ // ── Keyboard: space = play/pause, delete = remove selection ─
+ // 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.key === " " || e.key === "k") {
+ e.preventDefault();
+ togglePlay();
+ } 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();
+ seekTo((videoRef.current?.currentTime ?? 0) + (e.key === "ArrowLeft" ? -1 : 1));
+ }
+ };
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selected, seekTo]);
+
+ const togglePlay = useCallback(() => {
+ const v = videoRef.current;
+ if (!v) return;
+ if (v.paused) {
+ // Never start playback inside a cut — jump to the next kept unit.
+ const region = regionAtTime(v.currentTime, regionsRef.current);
+ if (region && region.endUnit < unitCount) v.currentTime = region.endUnit;
+ void v.play();
+ } else {
+ v.pause();
+ }
+ }, [unitCount]);
+
+ // ── Save ────────────────────────────────────────────────────
+ const initialRegions = useMemo(
+ () => (data ? cutsToRegions(data.cuts, data.units) : []),
+ [data],
+ );
+ const isDirty = useMemo(
+ () => JSON.stringify(normalizeRegions(regions)) !== JSON.stringify(initialRegions),
+ [regions, initialRegions],
+ );
+
+ const save = useCallback(async () => {
+ if (!data) return;
+ setSaving(true);
+ setSaveError(null);
+ try {
+ const cuts = regionsToCuts(normalizeRegions(regionsRef.current), data.units);
+ await client.setCuts(cuts);
+ await client.applyCuts();
+ onApplied?.();
+ } catch (err) {
+ setSaveError(err instanceof Error ? err.message : String(err));
+ setSaving(false);
+ }
+ }, [client, data, onApplied]);
+
+ // ── Derived display values ──────────────────────────────────
+ const normalized = useMemo(() => normalizeRegions(regions), [regions]);
+ const removedUnits = cutUnitCount(normalized);
+ const keptUnits = unitCount - removedUnits;
+ const allCut = unitCount > 0 && keptUnits === 0;
+ const gaps = useMemo(() => (data ? gapIndices(data.units) : []), [data]);
+ const currentUnit = unitAtTime(time, Math.max(1, unitCount));
+ const inCutNow = regionAtTime(time, normalized) !== null;
+
+ const pct = (u: number) => `${(u / Math.max(1, unitCount)) * 100}%`;
+
+ // ── Render ──────────────────────────────────────────────────
+ if (loadError) {
+ return (
+
+
+ {onCancel && (
+
+
+ ← Back
+
+
+ )}
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+ Loading editor…
+
+ );
+ }
+
+ return (
+
+ {/* Preview */}
+
+ setPlaying(true)}
+ onPause={() => setPlaying(false)}
+ style={{ width: "100%", display: "block", cursor: "pointer" }}
+ />
+ {/* Play affordance when paused (clicking the video toggles) */}
+
+ {!playing && (
+
+
+
+
+
+ )}
+
+ {/* Removed-minute overlay while scrubbing inside a cut */}
+
+ {inCutNow && (
+
+
+ This minute will be removed
+
+
+ )}
+
+ {/* Wall-clock of the frame under the playhead */}
+ {unitCount > 0 && (
+
diff --git a/clients/desktop/src/components/RecordPage.tsx b/clients/desktop/src/components/RecordPage.tsx
index 65380b7b..239960ac 100644
--- a/clients/desktop/src/components/RecordPage.tsx
+++ b/clients/desktop/src/components/RecordPage.tsx
@@ -14,6 +14,7 @@ import type { CaptureSource } from "../hooks/useNativeCapture.js";
import { SourcePicker } from "./SourcePicker.js";
import { DesktopRecorder } from "./DesktopRecorder.js";
import { NamingModal } from "./NamingModal.js";
+import { openEditorWindow } from "./EditorWindow.js";
import { PageLayout, cardButtonStyle } from "./PageLayout.js";
import { getApiBase } from "../serverConfig.js";
@@ -70,9 +71,11 @@ export function RecordPage({ token, onBack, onViewSession }: RecordPageProps) {
setIsPrompting(true);
}, []);
- const handleConfirmStop = useCallback(async (name: string | null) => {
+ 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`, {
@@ -85,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);
}, []);
@@ -229,6 +257,7 @@ export function RecordPage({ token, onBack, onViewSession }: RecordPageProps) {
)}
diff --git a/clients/react/API.md b/clients/react/API.md
index cb81f09c..cfe31c53 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -183,7 +183,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 +334,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 |
@@ -472,10 +472,15 @@ Drop-in recorder widget. Handles the full lifecycle: capture, upload, pause/resu
| Prop | Type | Description |
|------|------|-------------|
-| `editing` | `boolean?` | Offer the cut editor on completed timelapses (default `true`). Pass `false` to hide the affordance. |
+| `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
- `no-token` — "no session token" message
@@ -705,26 +710,48 @@ 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).
+
+---
+
+### ``
-When the session is `complete` and still editable, the header shows an **Edit**
-button that swaps the view for a ``; applying edits returns to
-the detail view and polls the cut-compile back to `complete`.
+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 |
---
### ``
-Post-compile cut editor. Previews the **uncut original** video (1 second of
-video = 1 capture unit = 1 real-world minute), lets the user drag out cut
-regions on a filmstrip timeline, and applies them via a fast server-side
-cut-compile (usually a lossless stream copy). Standalone (no provider needed).
+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()}
- onCancel={() => setEditing(false)}
/>
```
@@ -734,8 +761,11 @@ cut-compile (usually a lossless stream copy). Standalone (no provider needed).
|------|------|-------------|
| `token` | `string` | Session token |
| `apiBaseUrl` | `string` | Server API base URL |
-| `onApplied` | `() => void?` | Cuts were saved and the cut-compile started — return to your detail view and poll `/status` |
-| `onCancel` | `() => void?` | User backed out without applying |
+| `onApplied` | `() => void?` | The timelapse was published — return to your detail view and poll `/status` |
+| `onCancel` | `() => void?` | Optional "not now". The session stays held and publishes itself when the hold expires, so nothing is lost. Omit where publishing should be an explicit choice |
+
+It polls while the preview is still compiling, and shows a countdown to the
+hold's auto-publish (getting louder in the last two minutes).
**Interactions:**
- **Drag on the filmstrip** creates a cut region in one gesture (edges snap to
@@ -828,13 +858,13 @@ 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 original-video URL |
-| `setCuts` | `(cuts: CutInterval[]) => Promise` | Replace the session's cut list (`[]` clears) |
-| `applyCuts` | `() => Promise` | Apply the cut list to the published video (cut-compile) |
+| `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 |
---
diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts
index aa78a66d..f145532f 100644
--- a/clients/react/src/api/client.ts
+++ b/clients/react/src/api/client.ts
@@ -34,7 +34,12 @@ export interface LookoutClient {
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;
@@ -178,9 +183,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 }) } : {}),
});
},
diff --git a/clients/react/src/components/LookoutRecorder.tsx b/clients/react/src/components/LookoutRecorder.tsx
index 44a773b6..5e98dcaa 100644
--- a/clients/react/src/components/LookoutRecorder.tsx
+++ b/clients/react/src/components/LookoutRecorder.tsx
@@ -3,6 +3,7 @@ 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";
@@ -25,40 +26,54 @@ import { colors, fontSize, fontWeight, spacing } from "../ui/theme.js";
* Must be used within a ``.
*/
export interface LookoutRecorderProps {
- /** Offer the cut editor on completed timelapses (default true). Programs
- * embedding the recorder can pass false to hide the affordance. */
+ /** 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 [editorOpen, setEditorOpen] = useState(false);
const [resolvedToken, setResolvedToken] = useState(null);
- const [sessionEditable, setSessionEditable] = useState(false);
+ // 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 and the session's editability.
- // Resolve them once the recording reaches "complete".
+ // 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 (state.status !== "complete" || !editing) return;
+ if (!editing) return;
let cancelled = false;
- (async () => {
- try {
- const [token, status] = await Promise.all([
- client.resolveToken(),
- client.getStatus(),
- ]);
- if (cancelled) return;
- setResolvedToken(token);
- setSessionEditable(status.editable === true);
- } catch {
- // Editability probe is best-effort — the video still plays.
- }
- })();
+ client
+ .resolveToken()
+ .then((t) => {
+ if (!cancelled) setResolvedToken(t);
+ })
+ .catch(() => {
+ // Best-effort: without a token we simply don't offer editing.
+ });
return () => {
cancelled = true;
};
- }, [state.status, editing, client]);
+ }, [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 (
@@ -97,19 +112,16 @@ export function LookoutRecorder({ editing = true }: LookoutRecorderProps = {}) {
state.status === "complete" ||
state.status === "failed"
) {
- if (editorOpen && resolvedToken) {
+ // "Edit & save": the session is held unpublished while the user cuts
+ // it. No cancel affordance here — publishing is the way out (and the
+ // hold publishes on its own if they abandon the tab).
+ if (editorOpen && resolvedToken && state.status !== "failed") {
return (
setEditorOpen(false)}
- onApplied={() => {
- // The session flips complete → compiling → complete on the
- // server; the ResultView refetches the video on next mount.
- setEditorOpen(false);
- setSessionEditable(false);
- }}
+ onApplied={() => setEditorOpen(false)}
/>
);
@@ -120,13 +132,6 @@ export function LookoutRecorder({ editing = true }: LookoutRecorderProps = {}) {
status={state.status}
trackedSeconds={state.trackedSeconds}
/>
- {state.status === "complete" && editing && sessionEditable && resolvedToken && (
-
+ {editable ? "Ready to review" : "Preparing your timelapse…"}
+
+
+ {editable
+ ? "Cut out anything you don't want to share, then save. Nothing is published until you do."
+ : "Your recording is compiling. You'll be able to trim it in a moment."}
+ {" "}
+ {secondsLeft > 0 && (
+
+ {secondsLeft < 120
+ ? `Publishing automatically in ${secondsLeft}s.`
+ : `Publishes automatically in ${minutesLeft} min.`}
+
+ )}
+
- {status?.status === "complete" && status.editable && !editing && (
- (onEdit ? onEdit() : setEditing(true))}
- style={cardButtonStyle}
- >
- Edit
-
- )}
{onArchive && (
Archive
@@ -190,30 +263,52 @@ export function SessionDetail({
apiBaseUrl={apiBaseUrl}
onCancel={() => setEditing(false)}
onApplied={() => {
- // Back to the detail view; the status poll below picks up
- // "compiling" and flips to the re-published video. Reset the
- // cached video URL so the edited MP4 is re-fetched.
+ // 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);
- setStatus((prev) =>
- prev ? { ...prev, status: "compiling" } : prev,
- );
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 && status.editHoldUntil && (
+ (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 && !editing && (
<>
- {/* 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. */}
+ {!status.editHoldUntil && (
+
+
+
+ )}
{/* Session name + date */}
{sessionInfo && (
diff --git a/clients/react/src/components/StopChoiceModal.tsx b/clients/react/src/components/StopChoiceModal.tsx
new file mode 100644
index 00000000..892813c3
--- /dev/null
+++ b/clients/react/src/components/StopChoiceModal.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useRef, useState } from "react";
+import { motion } from "motion/react";
+import { Button } from "../ui/Button.js";
+import { Card } from "../ui/Card.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."}
+
+ );
+}
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index 66269a48..9a352bc0 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -28,9 +28,12 @@ import { colors, fontSize, fontWeight, radii, spacing } from "../ui/theme.js";
export interface TimelapseEditorProps {
token: string;
apiBaseUrl: string;
- /** Cuts were saved and the cut-compile started (or applied instantly).
+ /** The timelapse was published — with cuts baked in, or without them.
* The caller should return to its detail view and poll status. */
onApplied?: () => void;
+ /** Optional "not now" escape. The timelapse stays held and publishes
+ * itself when the hold expires, so this never loses anything. Omit it
+ * in flows where publishing must be an explicit choice. */
onCancel?: () => void;
}
@@ -53,13 +56,16 @@ type DragState =
| null;
/**
- * Post-compile cut editor. Previews the UNCUT original video (1 second of
- * video = 1 capture unit = 1 real-world minute), lets the user drag out cut
- * regions on the timeline, and applies them via a fast server-side
- * cut-compile. Regions are first-class objects: draggable edges, selection,
- * delete key. A plain click on the timeline seeks; playback skips cut
- * regions so previewing shows the published result, while scrubbing passes
- * through them (dimmed) so cut edges can be judged.
+ * 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.
+ *
+ * Regions are first-class objects: draggable edges, selection, delete key.
+ * A plain click on the timeline seeks; playback skips cut regions so
+ * previewing shows the published result, while scrubbing passes through
+ * them (dimmed) so cut edges can be judged.
*/
export function TimelapseEditor({
token,
@@ -93,32 +99,71 @@ export function TimelapseEditor({
const unitCount = units.length;
// ── Load ────────────────────────────────────────────────────
+ // The preview video is built by the compile that ran at stop; while it's
+ // still building, /units reports the hold with editable=false. Poll until
+ // it's ready rather than sending the user away.
useEffect(() => {
let cancelled = false;
- (async () => {
+ let timer: ReturnType | undefined;
+
+ const load = async () => {
try {
const res = await client.getUnits();
if (cancelled) return;
- if (!res.editable || !res.originalVideoUrl) {
- setLoadError(
- res.editableReason === "recompiles_exhausted"
- ? "This timelapse has reached its edit limit."
- : "This timelapse can no longer be edited.",
- );
+ if (res.editable && res.originalVideoUrl) {
+ setData(res);
+ setRegions(cutsToRegions(res.cuts, res.units));
return;
}
- setData(res);
- setRegions(cutsToRegions(res.cuts, res.units));
+ if (res.editableReason === "no_original" && res.editHoldUntil) {
+ // Still compiling inside the hold — check back shortly.
+ timer = setTimeout(load, 2000);
+ return;
+ }
+ setLoadError(
+ res.editableReason === "published"
+ ? "This timelapse has already been published, so it can't be edited."
+ : res.editableReason === "recompiles_exhausted"
+ ? "This timelapse has reached its edit limit."
+ : "This timelapse isn't available for editing.",
+ );
} catch (err) {
if (!cancelled)
setLoadError(err instanceof Error ? err.message : String(err));
}
- })();
+ };
+
+ void load();
return () => {
cancelled = true;
+ if (timer) clearTimeout(timer);
};
}, [client]);
+ // ── Hold countdown ──────────────────────────────────────────
+ // The session publishes itself when the hold expires. Surface the
+ // deadline (and bail out gracefully once it passes) instead of letting
+ // a Save silently 409.
+ const holdUntilMs = data?.editHoldUntil ? Date.parse(data.editHoldUntil) : null;
+ const [holdSecondsLeft, setHoldSecondsLeft] = useState(null);
+ useEffect(() => {
+ if (holdUntilMs === null) return;
+ const tick = () => {
+ const left = Math.max(0, Math.round((holdUntilMs - Date.now()) / 1000));
+ setHoldSecondsLeft(left);
+ if (left === 0) {
+ // Auto-published underneath us — the timelapse is safe, just no
+ // longer editable.
+ setLoadError(
+ "The edit window closed, so your timelapse was published as recorded.",
+ );
+ }
+ };
+ tick();
+ const id = setInterval(tick, 1000);
+ return () => clearInterval(id);
+ }, [holdUntilMs]);
+
// ── Playhead tracking (rAF for a smooth 60fps playhead) ─────
useEffect(() => {
const tick = () => {
@@ -401,22 +446,16 @@ export function TimelapseEditor({
}
}, [unitCount]);
- // ── Save ────────────────────────────────────────────────────
- const initialRegions = useMemo(
- () => (data ? cutsToRegions(data.cuts, data.units) : []),
- [data],
- );
- const isDirty = useMemo(
- () => JSON.stringify(normalizeRegions(regions)) !== JSON.stringify(initialRegions),
- [regions, initialRegions],
- );
-
+ // ── Publish ─────────────────────────────────────────────────
const save = useCallback(async () => {
if (!data) return;
setSaving(true);
setSaveError(null);
try {
const cuts = regionsToCuts(normalizeRegions(regionsRef.current), data.units);
+ // Always write the list — an empty one is a meaningful "publish it
+ // as recorded" — then publish. The server bakes cuts in before the
+ // session ever reaches `complete`.
await client.setCuts(cuts);
await client.applyCuts();
onApplied?.();
@@ -466,7 +505,7 @@ export function TimelapseEditor({
fontSize: fontSize.md,
}}
>
- Loading editor…
+ Preparing your timelapse…
- {data.recompilesRemaining} edit
- {data.recompilesRemaining === 1 ? "" : "s"} remaining for this
- timelapse.
+ {/* The hold is the promise that nothing gets lost — say so plainly,
+ and get louder as it runs out. */}
+ {holdSecondsLeft !== null && (
+
+ {holdSecondsLeft < 120
+ ? `Publishing automatically in ${holdSecondsLeft}s — save now to keep your cuts.`
+ : `Not published yet. If you close this, it publishes as recorded in ${Math.round(
+ holdSecondsLeft / 60,
+ )} min.`}
)}
diff --git a/clients/react/src/hooks/useLookout.ts b/clients/react/src/hooks/useLookout.ts
index a977a2dc..ba2c5e3e 100644
--- a/clients/react/src/hooks/useLookout.ts
+++ b/clients/react/src/hooks/useLookout.ts
@@ -360,7 +360,7 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
}
}, [session.resume]);
- const stop = useCallback(async (options?: { name?: string }) => {
+ const stop = useCallback(async (options?: { name?: string; edit?: boolean }) => {
if (stopInFlightRef.current) return;
stopInFlightRef.current = true;
if (intervalRef.current) {
@@ -372,7 +372,7 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
capturingRef.current = false;
capture.stopSharing();
try {
- await session.stop(options?.name);
+ await session.stop(options?.name, { edit: options?.edit });
callbacksRef.current.onStop?.({
trackedSeconds: session.trackedSeconds,
totalActiveSeconds: session.totalActiveSeconds,
diff --git a/clients/react/src/hooks/useSession.ts b/clients/react/src/hooks/useSession.ts
index c6230374..105ef4ea 100644
--- a/clients/react/src/hooks/useSession.ts
+++ b/clients/react/src/hooks/useSession.ts
@@ -142,7 +142,7 @@ export function useSession() {
}
}, [client, syncStatus]);
- const stop = useCallback(async (name?: string) => {
+ const stop = useCallback(async (name?: string, opts?: { edit?: boolean }) => {
// Optionally name the timelapse before stopping (non-fatal if it fails)
if (name) {
try {
@@ -156,7 +156,7 @@ export function useSession() {
const RETRY_DELAYS = [1000, 2000, 4000];
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
- const data = await client.stop();
+ const data = await client.stop(opts);
setState((s) => ({
...s,
status: data.status,
diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts
index ab9cfc3f..5d950e35 100644
--- a/clients/react/src/index.ts
+++ b/clients/react/src/index.ts
@@ -9,6 +9,8 @@ export type { LookoutRecorderProps } from "./components/LookoutRecorder.js";
// Cut editor
export { TimelapseEditor } from "./components/TimelapseEditor.js";
export type { TimelapseEditorProps } from "./components/TimelapseEditor.js";
+export { StopChoiceModal } from "./components/StopChoiceModal.js";
+export type { StopChoiceModalProps } from "./components/StopChoiceModal.js";
export {
regionsToCuts,
cutsToRegions,
diff --git a/clients/react/src/types.ts b/clients/react/src/types.ts
index b0e910ba..e522e1a2 100644
--- a/clients/react/src/types.ts
+++ b/clients/react/src/types.ts
@@ -209,8 +209,12 @@ export interface LookoutActions {
pause: () => Promise;
/** Resume a paused session. */
resume: () => Promise;
- /** Stop the session (triggers compilation). Optionally name the timelapse before stopping. */
- stop: (options?: { name?: string }) => Promise;
+ /** Stop the session (triggers compilation). Optionally name the timelapse
+ * before stopping. Pass `edit: true` to hold the timelapse unpublished
+ * after it compiles so the user can cut it first — programs only ever
+ * see `complete` with the edits already applied. The hold auto-publishes
+ * if the user walks away. */
+ stop: (options?: { name?: string; edit?: boolean }) => Promise;
/** Select a camera device by ID. Only effective when captureMode is "camera". */
selectCamera: (deviceId: string) => void;
/** Start camera preview without recording. Acquires the stream so the UI can show a live video. */
diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx
index 5f413afe..6e9965b6 100644
--- a/clients/web/src/App.tsx
+++ b/clients/web/src/App.tsx
@@ -61,7 +61,14 @@ export function App() {
>
← Gallery
-
+ {/* `?edit=false` on the recorder link lets an embedding
+ program keep stopping a single click. */}
+
);
diff --git a/clients/web/src/components/Result.tsx b/clients/web/src/components/Result.tsx
index 781c7692..832206cc 100644
--- a/clients/web/src/components/Result.tsx
+++ b/clients/web/src/components/Result.tsx
@@ -1,7 +1,7 @@
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect } from "react";
import { api } from "../api/client.js";
import { formatTime } from "@lookout/react";
-import { VideoPlayer, TimelapseEditor } from "@lookout/react";
+import { VideoPlayer } from "@lookout/react";
import type { SessionStatus } from "@lookout/shared";
interface ResultProps {
@@ -9,30 +9,16 @@ interface ResultProps {
trackedSeconds: number;
}
-/** Hosted recorder result view. Also owns the post-compile cut editor:
- * the edit affordance shows on completed sessions unless the embedding
- * program disabled it with `?edit=false` on the recorder URL. */
-export function Result({ status: statusProp, trackedSeconds }: ResultProps) {
+export function Result({ status, trackedSeconds }: ResultProps) {
const [videoUrl, setVideoUrl] = useState(null);
const [error, setError] = useState(null);
- const [editing, setEditing] = useState(false);
- const [editable, setEditable] = useState(false);
- // After an edit is applied the parent's polling has already stopped, so
- // this view tracks the cut-compile itself: null = mirror the prop.
- const [localStatus, setLocalStatus] = useState(null);
- const pollRef = useRef>();
-
- const status = localStatus ?? statusProp;
-
- const editingAllowed =
- new URLSearchParams(window.location.search).get("edit") !== "false";
useEffect(() => {
if (status === "complete") {
api
.getVideo()
.then((data) => {
- if (data.videoUrl && !data.videoUrl.startsWith("https://") && !data.videoUrl.startsWith("/")) {
+ if (data.videoUrl && !data.videoUrl.startsWith("https://")) {
throw new Error("Invalid video URL: must be HTTPS.");
}
setVideoUrl(data.videoUrl);
@@ -40,47 +26,8 @@ export function Result({ status: statusProp, trackedSeconds }: ResultProps) {
.catch((err) =>
setError(err instanceof Error ? err.message : "Failed to load video"),
);
- if (editingAllowed) {
- api
- .getStatus()
- .then((s) => setEditable((s as { editable?: boolean }).editable === true))
- .catch(() => {});
- }
}
- }, [status, editingAllowed]);
-
- useEffect(() => () => clearInterval(pollRef.current), []);
-
- const onApplied = () => {
- setEditing(false);
- setEditable(false);
- setVideoUrl(null);
- setLocalStatus("compiling");
- pollRef.current = setInterval(async () => {
- try {
- const s = await api.getStatus();
- if (s.status === "complete" || s.status === "failed") {
- clearInterval(pollRef.current);
- setLocalStatus(s.status);
- }
- } catch {
- // Ignore poll errors — next tick retries.
- }
- }, 3000);
- };
-
- if (editing) {
- return (
-
- );
- }
+ }, [status]);
if (status === "stopped" || status === "compiling") {
return (
@@ -121,11 +68,6 @@ export function Result({ status: statusProp, trackedSeconds }: ResultProps) {
)}
- {editingAllowed && editable && (
- setEditing(true)}>
- Edit timelapse
-
- )}
);
}
@@ -157,17 +99,6 @@ const styles: Record = {
background: "#000",
overflow: "hidden",
},
- editButton: {
- marginTop: 16,
- padding: "8px 16px",
- fontSize: 13,
- fontWeight: 600,
- color: "#ccc",
- background: "transparent",
- border: "1px solid #444",
- borderRadius: 8,
- cursor: "pointer",
- },
spinner: {
width: 40,
height: 40,
diff --git a/docs/edit-feature-plan.md b/docs/edit-feature-plan.md
index 1b37cdfa..91a76b44 100644
--- a/docs/edit-feature-plan.md
+++ b/docs/edit-feature-plan.md
@@ -1,26 +1,40 @@
-# Edit feature (cuts) — implementation plan v2
+# Edit feature (cuts) — implementation plan v3
-Status: proposal. Covers server, worker, and all three clients.
+Status: implemented. Covers server, worker, and all three clients.
## Model
+Editing lives **inside the stop flow**, before the session is ever
+published. Stopping offers three choices:
+
```
-record ── stop ──> compile (UNCHANGED — this is the "pre-compile")
- └─> complete: original.mp4 published
- │
- │ editor previews the compiled video
- │ PUT /cuts { cuts: [{start, end}, …] } (wall-clock intervals)
- │ POST /compile
- v
- cut-compile (fast: lossless stream-copy of kept ranges)
- └─> complete: edited.mp4 published, original kept for re-edits
+recording ──Stop──> ┌ Keep recording ─────────────────────> (back to recording)
+ ├ Stop & save ───> compile ───────────> complete
+ └ Edit & save ──> stop {edit:true}
+ │ compile runs, but the session is HELD
+ │ (status stays "stopped", video unpublished)
+ │
+ ├ user cuts → publish ──> compiling ──> complete
+ ├ "publish as recorded" ───────────────> complete
+ └ hold expires (30 min) ───────────────> complete
```
-- The **existing compile runs exactly as today** the moment the user stops.
- Nothing about stop, the compile pipeline, old clients, or program
- integrations changes. Its output doubles as the editor's preview.
-- **Editing is optional and happens after `complete`.** The editor scrubs
- the compiled video; a second, much cheaper compile applies the cuts.
+**Why not edit after `complete`?** Because `complete` is the signal
+programs act on — forwarding heartbeats to Hackatime, accepting a
+submission, firing the redirect hook. Editing a published session would
+mutate numbers someone already consumed. So a session reaches `complete`
+exactly once, with its cuts already applied, and post-publication editing
+does not exist (`editable: false`, `PUT /cuts` 409s).
+
+Consequences that fall out of this:
+
+- **Programs need no changes at all.** The observable lifecycle is still
+ `stopped → compiling → complete`; an edit just means longer in `stopped`.
+- **The hold can delay publication, never cancel it.** A background job
+ publishes the timelapse as recorded when the hold expires, so an
+ abandoned edit still yields a video.
+- **Cut footage is deleted immediately** after an edited publish, instead
+ of lingering for a 7-day re-edit window.
- An edit is a **cut list of absolute wall-clock intervals** stored on the
session — `[{ "start": ISO-8601, "end": ISO-8601 }]` ("start a → end a,
start b → end b"). Never "offset + duration": Lookout is heartbeat-based,
@@ -63,6 +77,9 @@ tested once.
| `GET /timings` | Timestamps inside cuts excluded from the default array; intervals surfaced as `cuts` |
| `trackedSeconds` | Reported as `raw − cutSeconds` everywhere; raw preserved as `uncutTrackedSeconds` |
+All three are settled before the session publishes, so no consumer ever
+observes them changing.
+
`trackedSeconds` shrinking with cuts is deliberate: `/timings` and
`trackedSeconds` must tell the same story ("verified time, minus what the
user removed"), and cutting can only *reduce* the number, so there's no fraud
@@ -80,9 +97,10 @@ subtraction happens in the one read-side dispatcher
| `cuts` | `jsonb` | Normalized cut list. `null`/`[]` = no edits |
| `cut_seconds` | `integer` | Credited seconds removed; recomputed on every cuts write and at cut-compile |
| `video_units` | `jsonb` | Ordered array of the units actually included in `original.mp4` (`[{capturedAt, screenshotId}]`), written by compile. THE video-second ↔ wall-clock map — sampled rows alone can't provide it because compile skips undecodable units |
-| `original_video_r2_key` | `text` | The uncut compiled video. On first compile this equals `video_r2_key`; after an edited compile, `video_r2_key` points at `edited.mp4` while this keeps pointing at the original |
-| `video_copy_aligned` | `boolean` | True when assembly used the stream-copy path (GOP grid guaranteed). False → cut-compile must use its re-encode fallback |
-| `recompile_count` | `integer not null default 0` | User-initiated cut-compiles, capped |
+| `original_video_r2_key` | `text` | The uncut compiled video — the editor's preview source. Nulled (and the object deleted) as soon as an edited publish lands |
+| `video_copy_aligned` | `boolean` | True when assembly used the stream-copy path (GOP grid guaranteed). False → the cut must use its re-encode fallback |
+| `recompile_count` | `integer not null default 0` | User-initiated publishes-with-cuts, capped |
+| `edit_hold_until` | `timestamptz` | While set and in the future, the compiled video stays unpublished (`status` `stopped`, `video_r2_key` null) so the owner can cut it. Cleared by the publish call or the expiry job |
No `screenshots` changes — cut membership is computed from the interval
list, never denormalized onto rows.
@@ -100,38 +118,42 @@ list, never denormalized onto rows.
## API changes (`packages/server/src/routes/sessions.ts`)
-Token-authenticated, rate-limited like their neighbors. **No change to
-`/stop`.**
-
-1. **`GET /api/sessions/:token/units`** — editor metadata (no presigning, no
- R2 access): `{ units: , cuts, editable, originalVideoUrl }`.
- `editable` is false when `original.mp4` has been purged or
- `recompile_count` is exhausted. `originalVideoUrl` is a token-free
- presigned GET (1 h) for `original_video_r2_key` — the editor's preview
- source. It must NOT be the public `/api/media/...` URL: after an edit,
- cut content exists only in the original, which stays reachable through
- the secret token only.
-2. **`PUT /api/sessions/:token/cuts`** — replace the whole list (idempotent;
- no patch semantics). Allowed in `complete`; 409 while `compiling`.
- `[]` clears edits.
-3. **`POST /api/sessions/:token/compile`** — apply the current cut list:
- - Guards: status `complete`, `original_video_r2_key` present,
- `recompile_count < MAX_USER_RECOMPILES (5)`, per-token rate limit.
- - Special case, no job needed: if `cuts` is empty and an `edited.mp4`
- exists, repoint `video_r2_key` back to the original, delete
- `edited.mp4`, regenerate nothing (thumbnail = original's, kept).
- "Undo all edits" is instant.
- - Otherwise flip status → `compiling` (worker's claim already accepts
- re-entry), increment `recompile_count`, enqueue `COMPILE_JOB`.
-4. **`GET /api/sessions/:token/timings`** — returns
+Token-authenticated, rate-limited like their neighbors.
+
+1. **`POST /api/sessions/:token/stop`** — accepts an optional body
+ `{ edit: true }`. Absent (or no body at all) → today's behavior
+ byte-for-byte, so shipped clients are untouched. Present, and the
+ session has captures → sets `edit_hold_until = now + 30 min` and
+ returns it. The compile is enqueued either way.
+2. **`GET /api/sessions/:token/units`** — editor metadata:
+ `{ units: , cuts, editable, editableReason, editHoldUntil,
+ originalVideoUrl, recompilesRemaining }`. `originalVideoUrl` is a
+ presigned GET (1 h) for the unpublished original. It must NOT be the
+ public `/api/media/...` URL — that is null until the session publishes,
+ and afterwards serves the cut version only.
+3. **`PUT /api/sessions/:token/cuts`** — replace the whole list (idempotent;
+ `[]` clears). **Only during an active hold**; the write is guarded on
+ `status = 'stopped' AND edit_hold_until > now()` so a session that
+ published mid-edit can never be mutated.
+4. **`POST /api/sessions/:token/compile`** — publish, baking in the cuts:
+ - No cuts → publish the built original directly, no worker round-trip
+ (`instant: true`). This is "Save as recorded".
+ - With cuts → claim `stopped → compiling`, clear the hold (so the expiry
+ job can't race), increment `recompile_count`, enqueue `COMPILE_JOB`.
+ - Already `complete` → `200 instant` (the expiry job won the race; the
+ timelapse is out either way). Already `compiling` → `202`.
+5. **`GET /api/sessions/:token/timings`** — returns
`{ count, timestamps: [kept only], cuts, cutCount }`; cut captures are
excluded **by default** so existing Hackatime forwarders respect edits
automatically. `?includeCut=true` adds `cutTimestamps`.
-5. **`GET /api/sessions/:token`**, **`/status`**, **`/batch`**, internal
+6. **`GET /api/sessions/:token`**, **`/status`**, **`/batch`**, internal
session endpoint — add `cuts`, `cutSeconds`, `uncutTrackedSeconds`,
- `editable`. `trackedSeconds` becomes post-cut everywhere via the
- dispatcher. `/status` keeps working unchanged for old clients during a
- cut-compile (`compiling` → `complete` — states they already handle).
+ `editable`, `editHoldUntil`. `trackedSeconds` becomes post-cut
+ everywhere via the dispatcher.
+7. **Hold expiry** (`lib/timeouts.ts`, on the existing every-minute cron):
+ publish any `stopped` session whose `edit_hold_until` has passed, via
+ the shared `publishHeldSession` helper. This is what makes offering the
+ edit step safe — the hold delays publication, never cancels it.
## Worker changes
@@ -141,49 +163,55 @@ on what exists:
**A. Original build (unchanged pipeline + bookkeeping).** Runs when
`original_video_r2_key` is absent — i.e., every first compile. Identical
sampling → segment build → stream-copy assembly, plus:
-- Write output to `timelapses/{id}/original.mp4`; set both
- `original_video_r2_key` and `video_r2_key` to it.
+- Write output to `timelapses/{id}/original.mp4`; set
+ `original_video_r2_key`.
- Record `video_units` (the units whose segments actually made it in, in
order) and `video_copy_aligned` (true on the copy path).
+- **Publish, or hold.** Re-read `edit_hold_until` at the end of the build
+ (it may have lapsed during the minutes it ran). Hold active → leave the
+ session `stopped` with `video_r2_key` null: everything is built, nothing
+ is published. No hold → publish exactly as before.
- **Fix the assembly re-encode fallback to pin the GOP** (reuse
`SEGMENT_ENCODE_ARGS`' `-g/-keyint_min/-sc_threshold/open-gop`): today the
fallback emits default x264 keyframes (~every 250 frames, scene-cut on),
which would break lossless cutting. Cheap and correct regardless of this
feature.
-**B. Cut apply.** Runs when `original_video_r2_key` exists and `cuts` is
-non-empty:
+**B. Cut apply + publish.** Runs when `original_video_r2_key` and
+`video_units` exist — i.e. the user published a held session with cuts:
- Compute kept video-second ranges: map each `video_units[i]` through the
- membership rule → contiguous kept index runs → `[inpoint, outpoint)` pairs.
-- `video_copy_aligned = true`: concat-demuxer file listing `original.mp4`
- once per kept range with `inpoint`/`outpoint`, `-c copy`, remux with
- `+faststart` → `timelapses/{id}/edited.mp4`. Lossless, seconds.
-- `video_copy_aligned = false` (legacy videos assembled via the old
- fallback): same ranges but re-encode with the pinned args (one CRF-18
- generation — acceptable, rare).
-- Verify frame count = kept units × 30 (existing `verifyVideo`).
-- Regenerate the thumbnail from `edited.mp4` (first frame may have been cut).
+ membership rule → contiguous kept index runs.
+- `video_copy_aligned = true`: per kept range, an input seek to its IDR
+ (`-ss`) plus an exact copied packet count (`-frames:v n×30`) into a TS
+ intermediate, then stream-copy concat → `timelapses/{id}/edited.mp4`.
+ Lossless, seconds. (NOT concat `inpoint`/`outpoint`: outpoint is
+ dts-based and B-frame dts offsets leak ~2 frames of the cut region past
+ each boundary — caught by the worker's frame-exact test.)
+- `video_copy_aligned = false`: one frame-exact re-encode of the original
+ through a `select` filter with the pinned args (rare; CRF 18).
+- Verify frame count = kept units × 30, exactly on the copy path.
+- Regenerate the thumbnail from `edited.mp4` (the first minute may be cut).
- Point `video_r2_key` at `edited.mp4`, persist authoritative
- `cut_seconds`, status `complete`.
+ `cut_seconds`, clear the hold, status `complete` — **then** delete the
+ uncut original and null its key. Ordering matters: deleting first would
+ leave a crash pointing the session at bytes that no longer exist.
Notes:
- The cut path **never downloads capture units** — it needs only
- `original.mp4`. Editing therefore works even after the 7-day screenshot
- purge, for as long as the original is retained.
-- Keep compile step 7 (unsampled cleanup) untouched; sampled unit files
- still age out via the existing retention job. They're no longer needed for
- editing at all.
+ `original.mp4`, so it is unaffected by screenshot retention.
+- Publishing *without* cuts never reaches the worker at all: the server
+ repoints `video_r2_key` at the already-built original
+ (`lib/publish.ts#publishHeldSession`), which is also what the hold-expiry
+ job calls. One helper, one atomic guard, so a user's publish and the
+ expiry job racing each other publish exactly once.
### Retention & privacy for cut content
-Cut minutes vanish from the published video but live on in `original.mp4`
-(token-gated). Extend the daily retention job: for sessions whose cuts are
-non-empty and whose edit window has closed (`EDIT_WINDOW_DAYS = 7` after the
-last cut-compile), delete `original.mp4` and null
-`original_video_r2_key` — the cut content is then truly gone and `editable`
-goes false. Uncut sessions keep their single video file forever, as today.
-(Public media redirect caches `video.mp4` for 30 min — an edited video may
-serve the stale original that long. Documented, acceptable.)
+Cut minutes vanish from the published video, so the uncut original is
+deleted **immediately** after an edited publish — not kept for a re-edit
+window. `EDIT_WINDOW_DAYS = 7` remains only as a retention backstop in the
+daily job, for originals orphaned by a crashed publish. Uncut sessions keep
+their single video file forever, as today.
## Clients — one editor, three surfaces
@@ -214,33 +242,51 @@ serve the stale original that long. Documented, acceptable.)
- Region ↔ interval serialization: selected units `[i..j]` →
`start = video_units[i].capturedAt`,
`end = video_units[j].capturedAt + 60 s`; server normalizes.
- - Footer: "`` after cuts" (from the PUT response), Cancel, Save &
- apply (`PUT /cuts` → `POST /compile` → existing `ProcessingState` until
- `complete`).
-- Wiring: `SessionDetail` and `ResultView` gain an "Edit" button when
- `complete && editable`. `LookoutRecorder` post-stop flow flows into the
- same button once compile finishes — no recording-path changes at all.
+ - Footer: kept/removed durations (server-authoritative), optional "Not
+ now", and a primary button that reads **"Save & publish"** with cuts or
+ **"Publish as recorded"** without — publishing is the way out, not an
+ optional extra step.
+ - Polls `/units` while the preview is still compiling, and counts down to
+ the hold's auto-publish (louder in the last two minutes) so the user
+ always knows the timelapse is safe but not yet out.
+- **``**: the stop confirmation — keep recording / stop &
+ save / edit & save. This is where editing is offered; there is no
+ post-publication entry point.
+- Wiring: `LookoutRecorder` routes every Stop button through the modal and
+ renders the editor inline after an `edit` stop. `SessionDetail` shows a
+ **review panel** for any session with a live `editHoldUntil` (Edit & save
+ / Publish as recorded / countdown), and suppresses the compile spinner
+ there — "processing" under "ready to review" would contradict itself.
- Pure helpers with tests (style of `computeBestTracked.ts`): unit↔interval
mapping, kept-range computation, gap detection.
### Desktop (`clients/desktop`)
+- `NamingModal` (already the stop confirmation) gains **Edit & Save**
+ alongside Save & Stop and Resume — the three choices the user asked for,
+ in the place they already exist.
- The main window is a fixed 480×640 — too small for precise timeline
- scrubbing — so the Edit button (via `SessionDetail`'s `onEdit` override)
- opens a **dedicated resizable 960×720 editor window** (`EditorWindow.tsx`,
- route `#/editor?token=…`, Tauri `WebviewWindow` labeled `editor-*`).
- Applying cuts emits a `lookout-edited` event; the main window remounts the
- open `SessionDetail` and refreshes the gallery.
-- The post-stop RecordPage routes to `SessionDetail` on completion, which
- carries the Edit affordance; the `redirectUrl` hook keeps firing on first
- `complete` exactly as documented.
+ scrubbing — so editing opens a **dedicated resizable 960×720 window**
+ (`EditorWindow.tsx`, route `#/editor?token=…`, Tauri `WebviewWindow`
+ labeled `editor-*`). Publishing emits `lookout-edited`; the main window
+ remounts the open `SessionDetail` and refreshes the gallery.
+- **While that window is open the main window steps aside**, showing only
+ an icon and "Edit your timelapse in the edit window." (click to bring it
+ to the front). Two live views of one session would just compete for
+ attention. The main window learns the editor opened from an event and
+ then *polls* for the window's existence — the poll is what guarantees it
+ can never get stuck behind the placeholder if the editor is force-quit.
+- Both stop paths (`RecordPage` and `DesktopRecorder`) send
+ `{ edit: true }` and open the editor window; `SessionDetail`'s `onEdit`
+ override reopens it from the review panel.
- No Rust changes — capture/tray/upload untouched (window creation +
close permissions added to the default capability).
### Web (`clients/web`)
-- `Result.tsx`: Edit button → ``. `?edit=false` recorder
- URL param lets an embedding program hide it.
+- The hosted recorder renders the SDK's ``, so it inherits
+ the stop modal and editor. `?edit=false` on the recorder URL maps to
+ `editing={false}` for programs that want one-click stops.
## Docs
@@ -254,36 +300,47 @@ serve the stale original that long. Documented, acceptable.)
## Tests
-- **Shared**: membership + normalization/merge pure-function suite.
-- **Server integration**: PUT cuts validation matrix (merge, clamp, all-cut
- rejection, cap, 409 while compiling); compile endpoint transitions,
- instant un-cut repoint, recompile cap; timings filtering (+`includeCut`);
- trackedSeconds subtraction in `GET`/`status`/`batch`; units endpoint auth
- + purged behavior; original purge job.
-- **Worker** (extend the `clips.integration.test.ts` harness): original
- build writes `video_units`/aligned flag; cut-compile produces exact
- kept-units × 30 frames via stream copy; misaligned original takes the
- re-encode path; thumbnail regenerated; fallback assembly now pins GOP.
-- **React**: mapping/gap/kept-range unit tests; editor interaction tests.
+- **Shared** (`packages/server/test/cuts.unit.test.ts`): membership,
+ normalization/merge/clamp, kept ranges, cut-seconds in both tracking
+ modes.
+- **Server integration** (`packages/server/test/edits.integration.test.ts`):
+ stop with/without `{edit}` (including the no-captures case); `/units`
+ across every editability state; PUT cuts validation matrix and the
+ "published sessions are immutable" guarantee; publish semantics (instant
+ without cuts, worker handoff with cuts, idempotent against the expiry
+ job, 202 while publishing, 409 once lapsed); timings filtering
+ (+`includeCut`); trackedSeconds subtraction in `GET`/`status`/`batch`.
+- **Worker** (`packages/worker/test/cutVideo.test.ts`, real ffmpeg): cuts
+ built from a production-shaped original are frame-exact via stream copy
+ (zero tolerance), head/tail ranges, the re-encode fallback, and
+ `computeKeptRanges` output feeding the cutter directly.
+- **React** (`editorMath.test.ts`): region↔interval round-trips including
+ across pause gaps, normalization, gap detection.
- **Desktop legacy** (`legacy_client.rs`): stop path byte-identical.
## Rollout order
+Server and worker must both be deployed before any client sends
+`{ edit: true }` — a hold set by the server but ignored by an old worker
+would leave a session `stopped` until the expiry job publishes it (safe,
+but a 30-minute wait). Deploy order:
+
1. `@lookout/shared`: `CutInterval`, membership/normalize helpers,
- constants (`MAX_CUT_INTERVALS`, `MAX_USER_RECOMPILES`, `EDIT_WINDOW_DAYS`).
-2. Worker: GOP-pin the assembly fallback; write
- `video_units`/`original_video_r2_key`/`video_copy_aligned` on compile.
- (Deployable alone; migration ships here.)
-3. Server: units/cuts/compile endpoints + timings & response fields +
- original-purge job. (Deployable alone — inert until a client writes cuts.)
-4. Worker: cut-apply path.
-5. `@lookout/react`: api client + `` + wiring.
+ constants (`MAX_CUT_INTERVALS`, `MAX_USER_RECOMPILES`,
+ `EDIT_HOLD_MINUTES`, `EDIT_WINDOW_DAYS`).
+2. Migrations `0018_session_edits` + `0019_edit_hold`.
+3. Worker: GOP-pinned assembly fallback, `video_units` bookkeeping,
+ hold-aware publish, cut-apply path. (Inert — nothing sets a hold yet.)
+4. Server: stop `{edit}`, units/cuts/publish endpoints, timings + response
+ fields, hold-expiry job. (Inert until a client opts in.)
+5. `@lookout/react`: api client, ``, ``,
+ review panel.
6. Web + desktop surfaces; desktop release.
7. Docs; announce to program authors.
-Videos compiled before step 2 lack `video_units`/alignment info —
-`editable: false` for them (backfill is possible from sampled rows but not
-worth it; sessions age out of relevance in days).
+Sessions compiled before step 3 have no `video_units` — they simply never
+offer editing, and old clients never request a hold, so both keep working
+unchanged.
## Edge cases ledger
@@ -294,12 +351,16 @@ worth it; sessions age out of relevance in days).
- **Build-failure holes**: `video_units` records what's actually in the
video, so the mapping stays exact even when compile skipped undecodable
units.
-- **Concurrent PUT cuts vs compile**: PUT checks status in its transaction,
- 409 while `compiling`; compile claim is already atomic.
-- **Re-edit loops**: each cut-compile starts from `original.mp4`, so edits
- never compound quality loss and un-cutting any region always works within
- the window.
-- **Failed cut-compile**: pg-boss retries; final failure marks `failed` as
- today — recovery via internal recompile re-enters half A/B dispatch
- idempotently.
+- **Hold expires mid-build**: the build re-reads the hold at the end and
+ publishes normally if it lapsed; the expiry job skips sessions with no
+ original yet (and clears their hold so the next build publishes).
+- **User publishes while the expiry job fires**: both go through
+ `publishHeldSession`'s atomic guard, so exactly one wins; the loser's
+ endpoint returns `200 instant` because the timelapse is out either way.
+- **PUT cuts racing publication**: the write is guarded on
+ `status='stopped' AND edit_hold_until > now()`, so a published session's
+ numbers can never move.
+- **Failed cut publish**: pg-boss retries; final failure marks `failed` as
+ today. Admin recompile re-enters half A (the original was deleted), which
+ rebuilds from capture units and re-applies the same cut list.
- **`videoWebmUrl` legacy field**: unchanged (static please-update video).
diff --git a/docs/integration.md b/docs/integration.md
index 0767f34e..e8d39281 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -447,34 +447,39 @@ Notes:
## Edits and cuts
-Users can **edit** a completed timelapse in the official clients: they select
-wall-clock stretches of the recording to remove ("cuts"), and Lookout removes
-those minutes from the published video, the `/timings` heartbeats, and
-`trackedSeconds` — consistently, from one stored cut list of
-`{start, end}` intervals.
+When a user stops a recording, the official clients offer three choices:
+keep recording, save as recorded, or **review and cut first**. If they
+choose to edit, they mark wall-clock stretches to remove and Lookout drops
+those minutes from the video, the `/timings` heartbeats, and
+`trackedSeconds` — all from one stored list of `{start, end}` intervals.
What this means for your program:
-- **Nothing in your integration changes.** `trackedSeconds` and `/timings`
- already reflect the user's edits. If you forward `/timings` to Hackatime as
- described above, cut minutes are simply absent from the heartbeat array.
-- **Cuts only ever shrink the numbers.** A user cannot gain time by editing —
- removing footage removes its credit. The pre-edit value is available as
- `uncutTrackedSeconds`, and the intervals themselves as `cuts`, on
- `GET /api/sessions/:token` (and `?includeCut=true` on `/timings` returns
- the removed timestamps) if you want to audit or display them.
-- **Re-editing is time-boxed.** Users can revise or clear their cuts for up
- to 7 days after their last edit; then the uncut original video is deleted
- (the cut content is meant to be gone — think "I accidentally recorded my
- bank tab") and the edit becomes final.
-- **Opting out of the UI:** the hosted web recorder hides the edit button
- when the session URL carries `?edit=false`; ``
- does the same in the React SDK. The API itself stays available to the
- token holder either way.
-- Forward heartbeats **after** the user is done editing when possible (e.g.
- when you accept a submission) — if you forwarded earlier and the user then
- cuts, re-fetch `/timings` and reconcile, since Hackatime won't know about
- the removal.
+- **Nothing in your integration changes, and nothing you read ever changes
+ underneath you.** Editing happens *before* the session reaches
+ `complete`: a session being edited stays `stopped`, and only flips to
+ `complete` once the user's cuts are baked in. So the first time you see a
+ finished session, its video, `trackedSeconds`, and `/timings` are final.
+ There is no post-publication editing.
+- **The lifecycle you observe is unchanged.** `stopped → compiling →
+ complete` (or `stopped → complete`), exactly as before — an edit just
+ means the session sits in `stopped` a little longer. The redirect hook
+ still fires when the session completes, which is now also the moment the
+ edits are in.
+- **An abandoned edit can't strand a timelapse.** The hold expires after 30
+ minutes and the session publishes as recorded. It can delay publication,
+ never cancel it. If you poll, treat a long `stopped` exactly as you
+ always have.
+- **Cuts only ever shrink the numbers.** A user cannot gain time by
+ editing — removing footage removes its credit. The pre-edit value is
+ available as `uncutTrackedSeconds` and the intervals as `cuts` on
+ `GET /api/sessions/:token`; `?includeCut=true` on `/timings` returns the
+ removed timestamps, if you want to audit or display them.
+- **Cut footage is deleted immediately** once the edited timelapse
+ publishes — the point of a cut is usually "I didn't mean to record that."
+- **Opting out of the review step:** add `?edit=false` to the hosted
+ recorder URL, or pass `` in the React
+ SDK. Stopping is then a single click, as before.
## Client telemetry
diff --git a/packages/server/API.md b/packages/server/API.md
index e7a44b18..055b939a 100644
--- a/packages/server/API.md
+++ b/packages/server/API.md
@@ -417,22 +417,35 @@ Stops a session and enqueues video compilation if screenshots exist.
|------|------|-------------|
| `token` | string | 64-char hex session token |
+**Body (optional):**
+```json
+{ "edit": true }
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `edit` | boolean | Hold the timelapse unpublished after it compiles so the owner can cut it first. See [Edits (Cuts)](#edits-cuts). Omit for today's behavior. |
+
**Response `200 OK`:**
```json
{
"status": "stopped",
"trackedSeconds": 123,
- "totalActiveSeconds": 300
+ "totalActiveSeconds": 300,
+ "editHoldUntil": "2026-07-26T14:35:00.000Z"
}
```
+`editHoldUntil` is present only when the stop requested `edit: true` and the session actually has captures to edit.
+
**Errors:**
- `404` — Session not found
- `409` — Session already in terminal state
**Notes:**
-- Marks session `complete` immediately if no screenshots exist (skips compilation)
+- Marks session `failed` immediately if no screenshots exist (skips compilation), regardless of `edit`
- Accumulates any remaining active time
+- Only send `edit: true` from a client that can render the editor. The hold auto-publishes when it expires, so an abandoned edit still yields a timelapse — but a client that requests a hold and then never shows an editor makes the user wait for no reason.
---
@@ -539,7 +552,7 @@ Returns the ISO-8601 capture timestamps of **every confirmed screenshot** in the
### Edits (Cuts)
-A completed timelapse can be **edited** by its owner: an edit is a **cut list** of absolute wall-clock intervals of the session that are removed from every output —
+When a user stops a recording they can review it before it goes out. An edit is a **cut list** of absolute wall-clock intervals of the session that are removed from every output —
```json
[
@@ -558,9 +571,26 @@ Cuts are intervals (not video offsets) because Lookout is heartbeat-based — th
**Membership rule:** a capture is cut iff its timestamp ∈ `[start, end)` of any interval. Granularity is effectively whole minutes (one capture unit ≈ one minute ≈ one second of video).
-**Mechanics:** the first compile always produces the **uncut original** video and records its unit map. Applying cuts is a *cut-compile*: a lossless stream-copy of the kept ranges of the original (seconds, even for 12-hour sessions; no quality loss; works after the 7-day screenshot purge). The original stays reachable only through the session token; **7 days after the last cut-compile** the retention job deletes the original of edited sessions, so the cut content is truly gone and editing freezes. Cutting can only *reduce* tracked time — there is no fraud surface.
+#### Editing happens before publication, never after
+
+`complete` is the status programs act on — forwarding heartbeats to Hackatime, accepting a submission, firing the redirect hook. So a session reaches `complete` **exactly once, with its cuts already applied**. There is no post-publication editing: the data a program reads is final the first time it sees it.
-Session responses (`GET /:token`, `/status`, internal) carry `cuts`, `cutSeconds`, `uncutTrackedSeconds`, and `editable`.
+That is what the **edit hold** is for. `POST /stop` with `{"edit": true}` marks the session; it compiles as usual, but the worker leaves it `stopped` with `videoUrl` still null instead of publishing. During the hold the owner previews the built video, sets a cut list, and publishes. The lifecycle programs observe is unchanged — `stopped → compiling → complete`, or `stopped → complete` when there was nothing to cut.
+
+```
+stop {edit:true} ─> stopped (hold, compiling internally)
+ ├─ PUT /cuts … then POST /compile ─> compiling ─> complete
+ ├─ POST /compile with no cuts ────────────────────> complete
+ └─ hold expires (30 min) ────────────────────────> complete
+```
+
+The hold can only **delay** publication, never cancel it: if the user closes the app mid-edit, a background job publishes the timelapse as recorded when the hold expires. A stop without `{"edit": true}` behaves exactly as it always has, so existing clients are unaffected.
+
+**Mechanics:** the compile always produces the **uncut original** and records its unit map. Publishing with cuts is a lossless stream-copy of the kept ranges (seconds, even for 12-hour sessions, no quality loss), after which the uncut original is **deleted immediately** — cut content does not outlive the publish. Publishing without cuts just points the session at the original, with no worker round-trip.
+
+Cutting can only *reduce* tracked time — there is no fraud surface.
+
+Session responses (`GET /:token`, `/status`, internal) carry `cuts`, `cutSeconds`, `uncutTrackedSeconds`, `editable`, and `editHoldUntil`.
#### Get Editor Units
@@ -576,14 +606,16 @@ Editor metadata. Rate limit: 10 req/min per token.
"units": [ { "capturedAt": "2026-07-26T14:00:12.000Z", "screenshotId": "…" } ],
"cuts": [],
"editable": true,
+ "editHoldUntil": "2026-07-26T14:35:00.000Z",
"originalVideoUrl": "https://…presigned, ~1h…",
"recompilesRemaining": 5
}
```
- `units` — the capture units of the compiled **original** video, in output order. Array index = video second = real-world minute: the exact video-time ↔ wall-clock map.
-- `originalVideoUrl` — presigned GET for the UNCUT original (the editor's preview source). Deliberately not the public media URL: after an edit, cut content exists only here, gated by the secret token. `null` when not editable.
-- `editable` / `editableReason` — `false` with `"not_complete"`, `"no_original"` (compiled before edit support, or original purged), or `"recompiles_exhausted"`.
+- `originalVideoUrl` — presigned GET for the unpublished original (the editor's preview source). Deliberately not the public media URL, which is null until the session publishes. `null` when not editable.
+- `editable` / `editableReason` — `false` with `"no_original"` (the preview is still compiling — poll, since `editHoldUntil` is set), `"not_ready"` (no hold, or it lapsed), `"published"` (already `complete`, so editing is over), or `"recompiles_exhausted"`.
+- `editHoldUntil` — when the session auto-publishes; `null` when no hold is active.
#### Set Cut List
@@ -592,7 +624,7 @@ PUT /api/sessions/:token/cuts
Body: { "cuts": [{ "start": ISO-8601, "end": ISO-8601 }, …] }
```
-Replaces the whole cut list (idempotent; `[]` clears all edits). Only valid while `complete` and editable. The server normalizes (sorts, merges overlaps, clamps to the session envelope, caps at 120 intervals) and rejects a list that would remove **every** unit. Takes effect in `/timings` and `trackedSeconds` immediately; the published video updates on the next compile call. Rate limit: 20 req/min per token.
+Replaces the whole cut list (idempotent; `[]` clears all edits). **Only valid during an active edit hold** — a published session is immutable. The server normalizes (sorts, merges overlaps, clamps to the session envelope, caps at 120 intervals) and rejects a list that would remove **every** unit. The cuts are baked into the video by the publish call below. Rate limit: 20 req/min per token.
**Response `200 OK`:**
```json
@@ -607,16 +639,21 @@ Replaces the whole cut list (idempotent; `[]` clears all edits). Only valid whil
**Errors:** `400` invalid/entire-timelapse cut list · `409` compiling or not editable · `429` rate limit.
-#### Apply Cuts (Cut-Compile)
+#### Publish (End the Hold)
```
POST /api/sessions/:token/compile
```
-Applies the current cut list to the published video. Flips the session `complete → compiling → complete` (poll [`/status`](#poll-compilation-status)); usually completes in seconds. Clearing all cuts when the original is already published returns `{ "instant": true }` with no compile. Each non-instant call burns one of **5** recompiles per session. Rate limit: 5 req/min per token.
+Ends the edit hold and publishes the timelapse with the current cut list baked in.
+
+- **With cuts:** `stopped → compiling → complete` (poll [`/status`](#poll-compilation-status)); the worker stream-copies the kept ranges, usually in seconds, then deletes the uncut original. Burns one of **5** publishes per session.
+- **Without cuts:** returns `{ "instant": true, "status": "complete" }` immediately — the built original is simply published, no worker involved.
+
+Rate limit: 5 req/min per token.
**Response `200 OK`:** `{ "status": "compiling" | "complete", "instant": boolean, "recompilesRemaining": number }`
-**Errors:** `202` already compiling (safe to retry/poll) · `409` not editable or budget exhausted · `429` rate limit.
+**Errors:** `202` publish already running (safe to retry/poll) · `409` hold lapsed or not editable · `429` rate limit. Calling it on an already-published session is a no-op `200` with `instant: true`, so a client racing the expiry job never sees a spurious failure.
---
diff --git a/packages/server/drizzle/0019_edit_hold.sql b/packages/server/drizzle/0019_edit_hold.sql
new file mode 100644
index 00000000..19b0ec6e
--- /dev/null
+++ b/packages/server/drizzle/0019_edit_hold.sql
@@ -0,0 +1 @@
+ALTER TABLE "sessions" ADD COLUMN "edit_hold_until" timestamp with time zone;
diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json
index 534f9f2f..1e68f5d9 100644
--- a/packages/server/drizzle/meta/_journal.json
+++ b/packages/server/drizzle/meta/_journal.json
@@ -134,6 +134,13 @@
"when": 1785077227872,
"tag": "0018_session_edits",
"breakpoints": true
+ },
+ {
+ "idx": 19,
+ "version": "7",
+ "when": 1785080224937,
+ "tag": "0019_edit_hold",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts
index 550a842e..425beeee 100644
--- a/packages/server/src/db/schema.ts
+++ b/packages/server/src/db/schema.ts
@@ -168,10 +168,17 @@ export const sessions = pgTable(
// User-initiated cut-compiles, capped at MAX_USER_RECOMPILES.
recompileCount: integer("recompile_count").notNull().default(0),
// When the last cut-compile finished; anchors the EDIT_WINDOW_DAYS
- // original-video retention for edited sessions.
+ // original-video retention backstop for edited sessions.
lastEditCompileAt: timestamp("last_edit_compile_at", {
withTimezone: true,
}),
+ // Edit hold: while set and in the future, a stopped session's compiled
+ // video stays UNPUBLISHED (status remains "stopped", video_r2_key null)
+ // so the owner can cut it before programs ever see `complete`. Set by
+ // POST /stop {edit: true}; cleared by the finalize call or the expiry
+ // job (which auto-publishes uncut). Editing is only possible during
+ // this hold — never after complete, because programs act on complete.
+ editHoldUntil: timestamp("edit_hold_until", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
diff --git a/packages/server/src/lib/publish.ts b/packages/server/src/lib/publish.ts
new file mode 100644
index 00000000..cc493b51
--- /dev/null
+++ b/packages/server/src/lib/publish.ts
@@ -0,0 +1,48 @@
+import { and, eq, isNotNull } from "drizzle-orm";
+import { db, schema } from "../db/index.js";
+
+/**
+ * Publish a held session by pointing it at its already-built UNCUT
+ * original — the "no edits" outcome of an edit hold.
+ *
+ * A held session finished compiling but deliberately stayed `stopped` with
+ * `video_r2_key` null so that programs never observe `complete` before the
+ * user's cuts are baked in. This flips it to `complete` with no worker
+ * round-trip (the bytes already exist in R2), which is why "Save without
+ * edits" and hold expiry are both instant.
+ *
+ * Atomic and idempotent: the guard means a racing caller (the user's
+ * finalize vs. the expiry job) publishes exactly once; the loser gets
+ * `false` and should treat the session as already published.
+ */
+export async function publishHeldSession(sessionId: string): Promise {
+ const publicDomain = process.env.R2_PUBLIC_DOMAIN || "";
+
+ const [row] = await db
+ .select({ originalVideoR2Key: schema.sessions.originalVideoR2Key })
+ .from(schema.sessions)
+ .where(eq(schema.sessions.id, sessionId));
+ if (!row?.originalVideoR2Key) return false;
+
+ const [updated] = await db
+ .update(schema.sessions)
+ .set({
+ status: "complete",
+ videoR2Key: row.originalVideoR2Key,
+ videoUrl: publicDomain
+ ? `https://${publicDomain}/${row.originalVideoR2Key}`
+ : row.originalVideoR2Key,
+ editHoldUntil: null,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(schema.sessions.id, sessionId),
+ eq(schema.sessions.status, "stopped"),
+ isNotNull(schema.sessions.originalVideoR2Key),
+ ),
+ )
+ .returning({ id: schema.sessions.id });
+
+ return Boolean(updated);
+}
diff --git a/packages/server/src/lib/timeouts.ts b/packages/server/src/lib/timeouts.ts
index 59cf1190..b424427e 100644
--- a/packages/server/src/lib/timeouts.ts
+++ b/packages/server/src/lib/timeouts.ts
@@ -10,6 +10,7 @@ import {
CLEANUP_SCREENSHOTS_JOB,
} from "./queue.js";
import { cleanupRateLimits } from "./timing.js";
+import { publishHeldSession } from "./publish.js";
import {
AUTO_PAUSE_AFTER_MINUTES,
AUTO_STOP_AFTER_MINUTES,
@@ -50,9 +51,54 @@ export async function registerTimeoutJobs() {
});
}
+/**
+ * Publish sessions whose edit hold ran out. This is the promise that makes
+ * the "Edit & Save" flow safe to offer: a user who closes the app mid-edit
+ * still gets their timelapse, uncut, within EDIT_HOLD_MINUTES — the hold
+ * can delay publication, never cancel it.
+ */
+async function publishExpiredHolds() {
+ const expired = await db
+ .select({ id: schema.sessions.id })
+ .from(schema.sessions)
+ .where(
+ and(
+ eq(schema.sessions.status, "stopped"),
+ isNotNull(schema.sessions.editHoldUntil),
+ lt(schema.sessions.editHoldUntil, new Date()),
+ ),
+ );
+
+ for (const session of expired) {
+ // The original exists once the build lands; if the compile is still
+ // running (or failed), leave the row alone — the build path publishes
+ // directly when it finds no live hold, and the stuck-compiling timeout
+ // covers genuine failures.
+ const published = await publishHeldSession(session.id);
+ if (published) {
+ console.log(`[edit-hold] auto-published ${session.id} (hold expired)`);
+ } else {
+ // No original yet: drop the hold so the next compile publishes
+ // normally instead of the session sitting in limbo.
+ await db
+ .update(schema.sessions)
+ .set({ editHoldUntil: null, updatedAt: new Date() })
+ .where(
+ and(
+ eq(schema.sessions.id, session.id),
+ eq(schema.sessions.status, "stopped"),
+ isNull(schema.sessions.originalVideoR2Key),
+ ),
+ );
+ }
+ }
+}
+
async function checkTimeouts() {
const now = new Date();
+ await publishExpiredHolds();
+
// Auto-pause: active sessions with no screenshots for AUTO_PAUSE_AFTER_MINUTES
const autoPauseThreshold = new Date(
now.getTime() - AUTO_PAUSE_AFTER_MINUTES * 60_000,
diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts
index e5ddb1ad..5d1f57e5 100644
--- a/packages/server/src/routes/sessions.ts
+++ b/packages/server/src/routes/sessions.ts
@@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto";
import { db, schema } from "../db/index.js";
import { r2Client, R2_BUCKET } from "../config/r2.js";
import { boss, COMPILE_JOB } from "../lib/queue.js";
+import { publishHeldSession } from "../lib/publish.js";
import {
computeMinuteBucket,
checkRateLimit,
@@ -31,6 +32,7 @@ import {
CAPTURE_FORMATS,
CAPTURE_FORMAT_CONTENT_TYPES,
MAX_USER_RECOMPILES,
+ EDIT_HOLD_MINUTES,
normalizeCuts,
isCutAt,
countCutUnits,
@@ -87,20 +89,39 @@ function sessionCuts(session: { cuts: unknown }): CutInterval[] {
return Array.isArray(session.cuts) ? (session.cuts as CutInterval[]) : [];
}
-/** Whether the compiled timelapse can (still) be edited, and why not. */
+/** Is the session's edit hold currently active? */
+function holdActive(session: { editHoldUntil: Date | null }): boolean {
+ return (
+ session.editHoldUntil !== null && session.editHoldUntil.getTime() > Date.now()
+ );
+}
+
+/**
+ * Whether the session is CURRENTLY editable, and why not. Editing exists
+ * only inside the stop-time edit hold — never after `complete`. `complete`
+ * is the signal programs act on (forwarding heartbeats to Hackatime,
+ * accepting submissions, firing the redirect hook), so the data they read
+ * must already be final; a post-publish edit would mutate numbers someone
+ * already consumed.
+ */
function sessionEditability(session: {
status: string;
videoUnits: unknown;
originalVideoR2Key: string | null;
recompileCount: number;
-}): { editable: boolean; reason?: "no_original" | "recompiles_exhausted" | "not_complete" } {
- if (session.status !== "complete") {
- return { editable: false, reason: "not_complete" };
+ editHoldUntil: Date | null;
+}): {
+ editable: boolean;
+ reason?: "no_original" | "recompiles_exhausted" | "not_ready" | "published";
+} {
+ if (session.status === "complete") {
+ return { editable: false, reason: "published" };
}
- // videoUnits is written by every post-edit-feature compile; its absence
- // means the video predates edit support (no unit map to cut against).
- // A null originalVideoR2Key means the retention job reclaimed the uncut
- // original of an edited session — the source material is gone.
+ if (session.status !== "stopped" || !holdActive(session)) {
+ return { editable: false, reason: "not_ready" };
+ }
+ // Hold is active but the preview build hasn't landed yet (the compile
+ // job writes videoUnits + the original when it finishes).
if (
!Array.isArray(session.videoUnits) ||
session.videoUnits.length === 0 ||
@@ -268,6 +289,9 @@ export async function sessionRoutes(app: FastifyInstance) {
cutSeconds: session.cutSeconds ?? 0,
uncutTrackedSeconds: rawTrackedSeconds,
editable: sessionEditability(session).editable,
+ editHoldUntil: holdActive(session)
+ ? session.editHoldUntil!.toISOString()
+ : undefined,
screenshotCount,
clientInfo,
ja4,
@@ -1012,11 +1036,24 @@ export async function sessionRoutes(app: FastifyInstance) {
},
);
- // Stop session
- app.post<{ Params: { token: string } }>(
+ // Stop session.
+ // Optional body { edit: true } holds the session UNPUBLISHED after its
+ // compile so the owner can cut it before programs ever observe
+ // `complete`. The hold auto-publishes after EDIT_HOLD_MINUTES. Old
+ // clients send no body and get today's behavior byte-for-byte.
+ app.post<{ Params: { token: string }; Body: { edit?: boolean } | null }>(
"/api/sessions/:token/stop",
{
- schema: { params: tokenParamSchema },
+ schema: {
+ params: tokenParamSchema,
+ body: {
+ type: ["object", "null"] as const,
+ properties: {
+ edit: { type: "boolean" as const },
+ },
+ additionalProperties: false,
+ },
+ },
},
async (request, reply) => {
// Rate limit: 10 req/min per token (actions)
@@ -1057,6 +1094,13 @@ export async function sessionRoutes(app: FastifyInstance) {
// Compute tracked seconds before stopping (screenshots may be cleaned up later)
const trackedSeconds = await getTrackedSecondsForSession(session);
+ // Edit hold: only meaningful when there will be a video to edit.
+ const screenshotCount = await getScreenshotCount(session.id);
+ const wantsEdit = request.body?.edit === true && screenshotCount > 0;
+ const editHoldUntil = wantsEdit
+ ? new Date(stopNow.getTime() + EDIT_HOLD_MINUTES * 60_000)
+ : null;
+
const [updated] = await db
.update(schema.sessions)
.set({
@@ -1064,6 +1108,7 @@ export async function sessionRoutes(app: FastifyInstance) {
stoppedAt: stopNow,
totalActiveSeconds,
trackedSeconds,
+ editHoldUntil,
updatedAt: stopNow,
})
.where(and(
@@ -1077,7 +1122,6 @@ export async function sessionRoutes(app: FastifyInstance) {
}
// Enqueue compilation
- const screenshotCount = await getScreenshotCount(session.id);
if (screenshotCount > 0) {
await boss.send(COMPILE_JOB, { sessionId: session.id });
} else {
@@ -1092,6 +1136,9 @@ export async function sessionRoutes(app: FastifyInstance) {
status: "stopped" as const,
trackedSeconds,
totalActiveSeconds,
+ ...(editHoldUntil
+ ? { editHoldUntil: editHoldUntil.toISOString() }
+ : {}),
};
},
);
@@ -1137,7 +1184,12 @@ export async function sessionRoutes(app: FastifyInstance) {
// Redirect hook — clients watching the compile open this once the
// status flips to "complete". Absent when the session has none.
redirectUrl: session.redirectUrl ?? undefined,
+ // Edit hold. `editable` flips true when the preview build lands;
+ // until then a set `editHoldUntil` means "still preparing".
editable: sessionEditability(session).editable,
+ editHoldUntil: holdActive(session)
+ ? session.editHoldUntil!.toISOString()
+ : undefined,
};
},
);
@@ -1280,6 +1332,9 @@ export async function sessionRoutes(app: FastifyInstance) {
cuts: sessionCuts(session),
editable,
...(editable ? {} : { editableReason: reason }),
+ editHoldUntil: holdActive(session)
+ ? session.editHoldUntil!.toISOString()
+ : null,
originalVideoUrl,
recompilesRemaining: Math.max(
0,
@@ -1290,9 +1345,9 @@ export async function sessionRoutes(app: FastifyInstance) {
);
// Replace the session's cut list. Idempotent full replace — no patch
- // semantics (the list is small). `[]` clears all edits. The cuts take
- // effect in /timings and trackedSeconds immediately; the published video
- // updates on the next POST /compile.
+ // semantics (the list is small). `[]` clears all edits. Only valid during
+ // an active edit hold; the cuts are baked in by POST /compile, which also
+ // publishes the session.
app.put<{
Params: { token: string };
Body: { cuts: Array<{ start: string; end: string }> };
@@ -1383,6 +1438,9 @@ export async function sessionRoutes(app: FastifyInstance) {
normalized.cuts,
);
+ // Guard on `stopped` + a live hold: the expiry job could have
+ // published this session between our read and this write, and a
+ // published session's numbers must never move.
const [updated] = await db
.update(schema.sessions)
.set({
@@ -1393,14 +1451,15 @@ export async function sessionRoutes(app: FastifyInstance) {
.where(
and(
eq(schema.sessions.id, session.id),
- eq(schema.sessions.status, "complete"),
+ eq(schema.sessions.status, "stopped"),
+ sql`${schema.sessions.editHoldUntil} > now()`,
),
)
.returning({ id: schema.sessions.id });
if (!updated) {
return reply
.code(409)
- .send({ error: "Session state changed concurrently, please retry" });
+ .send({ error: "Edit window closed — the timelapse was already published" });
}
return {
@@ -1413,10 +1472,13 @@ export async function sessionRoutes(app: FastifyInstance) {
},
);
- // Apply the current cut list to the published video (a cut-compile).
- // Usually a lossless stream-copy of the kept ranges of the original —
- // seconds, not minutes. Clearing all cuts when the original is already
- // published is a pure no-op ("instant").
+ // Publish a held session, baking in its current cut list.
+ //
+ // This ENDS the edit hold: the session goes `complete` and programs read
+ // its final numbers. With cuts, the worker slices the kept ranges out of
+ // the built original (usually a lossless stream copy — seconds) and then
+ // deletes the uncut original. With no cuts, the already-built original is
+ // published as-is with no compile job at all ("instant").
app.post<{ Params: { token: string } }>(
"/api/sessions/:token/compile",
{
@@ -1435,17 +1497,27 @@ export async function sessionRoutes(app: FastifyInstance) {
const session = await findSession(request.params.token);
if (!session) return reply.code(404).send({ error: "Session not found" });
+ const recompilesRemaining = Math.max(
+ 0,
+ MAX_USER_RECOMPILES - session.recompileCount,
+ );
+
if (session.status === "compiling") {
- // Already applying — treat as success so client retries are safe.
- return reply.code(202).send({
- status: "compiling" as const,
- instant: false,
- recompilesRemaining: Math.max(
- 0,
- MAX_USER_RECOMPILES - session.recompileCount,
- ),
- });
+ // Already publishing — treat as success so client retries are safe.
+ return reply
+ .code(202)
+ .send({ status: "compiling" as const, instant: false, recompilesRemaining });
}
+ if (session.status === "complete") {
+ // Someone (usually the hold-expiry job) published first. Idempotent
+ // from the client's point of view: the timelapse is out.
+ return {
+ status: "complete" as const,
+ instant: true,
+ recompilesRemaining,
+ };
+ }
+
const { editable, reason } = sessionEditability(session);
if (!editable) {
return reply
@@ -1454,33 +1526,39 @@ export async function sessionRoutes(app: FastifyInstance) {
}
const cuts = sessionCuts(session);
- const publishedIsOriginal =
- session.videoR2Key === session.originalVideoR2Key;
- if (cuts.length === 0 && publishedIsOriginal) {
- // Nothing to change: no cuts, original already published.
+
+ if (cuts.length === 0) {
+ // No cuts: publish the already-built original directly. No worker
+ // round-trip, so "Save without edits" is instant.
+ const published = await publishHeldSession(session.id);
+ if (!published) {
+ return reply
+ .code(409)
+ .send({ error: "Session state changed concurrently, please retry" });
+ }
return {
status: "complete" as const,
instant: true,
- recompilesRemaining: Math.max(
- 0,
- MAX_USER_RECOMPILES - session.recompileCount,
- ),
+ recompilesRemaining,
};
}
- // Atomically claim: complete → compiling, burn one recompile. The
- // worker's own claim accepts re-entry from 'compiling'.
+ // Cuts to bake in: claim stopped → compiling and hand off to the
+ // worker (whose claim accepts re-entry from 'compiling' on retry).
const [updated] = await db
.update(schema.sessions)
.set({
status: "compiling",
recompileCount: session.recompileCount + 1,
+ // Clear the hold: this session is being published now, so the
+ // expiry job must not race in behind us.
+ editHoldUntil: null,
updatedAt: new Date(),
})
.where(
and(
eq(schema.sessions.id, session.id),
- eq(schema.sessions.status, "complete"),
+ eq(schema.sessions.status, "stopped"),
),
)
.returning({ id: schema.sessions.id });
diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts
index c1785eb8..c95b5435 100644
--- a/packages/server/test/edits.integration.test.ts
+++ b/packages/server/test/edits.integration.test.ts
@@ -1,10 +1,12 @@
/**
- * Integration tests for the edit (cuts) endpoints against a real Postgres:
- * GET /units, PUT /cuts, POST /compile, the timings filtering, and the
- * post-cut trackedSeconds reporting.
+ * Integration tests for the stop-time edit flow against a real Postgres.
*
- * Requires the test docker postgres running on port 5434 (see
- * test/setup.ts for the connection string).
+ * The invariant under test: a session reaches `complete` exactly once, with
+ * the user's cuts already applied. Editing happens during the stop-time
+ * hold and is impossible afterwards, because `complete` is what programs
+ * act on (heartbeat forwarding, submissions, the redirect hook).
+ *
+ * Requires the test docker postgres on port 5434 (see test/setup.ts).
*/
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import type { FastifyInstance } from "fastify";
@@ -17,6 +19,7 @@ let app: FastifyInstance;
const T0 = new Date("2026-07-01T10:00:00.000Z");
const minute = (i: number) => new Date(T0.getTime() + i * 60_000);
const iso = (i: number) => minute(i).toISOString();
+const UNITS = 10;
beforeEach(async () => {
await db.execute(sql`TRUNCATE screenshots, sessions RESTART IDENTITY CASCADE`);
@@ -31,24 +34,28 @@ afterAll(async () => {
});
/**
- * Seed a completed, editable session: 10 capture units a minute apart,
- * credit-mode with 540 raw tracked seconds (seed capture credits 0), a
- * compiled original video, and the unit map the compile would have written.
+ * Seed a session in its edit hold: stopped, compiled (original + unit map
+ * written) but NOT published — `video_r2_key` is still null, exactly the
+ * state the worker leaves a held session in.
*/
-async function seedCompleteSession(overrides: Partial = {}) {
+async function seedHeldSession(
+ overrides: Partial = {},
+) {
const [s] = await db
.insert(schema.sessions)
.values({
name: "edit-test",
- status: "complete",
+ status: "stopped",
trackingMode: "credit",
trackedSeconds: 540,
startedAt: minute(0),
- stoppedAt: minute(10),
- videoR2Key: "timelapses/x/original.mp4",
+ stoppedAt: minute(UNITS),
+ editHoldUntil: new Date(Date.now() + 30 * 60_000),
+ videoR2Key: null,
originalVideoR2Key: "timelapses/x/original.mp4",
+ thumbnailR2Key: "timelapses/x/thumbnail.jpg",
videoCopyAligned: true,
- videoUnits: Array.from({ length: 10 }, (_, i) => ({
+ videoUnits: Array.from({ length: UNITS }, (_, i) => ({
capturedAt: iso(i),
screenshotId: `00000000-0000-0000-0000-0000000000${String(i).padStart(2, "0")}`,
})),
@@ -57,7 +64,7 @@ async function seedCompleteSession(overrides: Partial ({
+ Array.from({ length: UNITS }, (_, i) => ({
sessionId: s.id,
r2Key: `screenshots/${s.id}/${i}.jpg`,
requestedAt: minute(i),
@@ -72,6 +79,33 @@ async function seedCompleteSession(overrides: Partial
+ db.query.sessions.findFirst({ where: eq(schema.sessions.id, id) });
+
async function putCuts(token: string, cuts: unknown) {
const r = await app.inject({
method: "PUT",
@@ -81,84 +115,133 @@ async function putCuts(token: string, cuts: unknown) {
return { status: r.statusCode, body: r.json() };
}
-describe("GET /api/sessions/:token/units", () => {
- it("returns the unit map and a presigned original URL when editable", async () => {
- const s = await seedCompleteSession();
- const r = await app.inject({ method: "GET", url: `/api/sessions/${s.token}/units` });
+const getJson = async (url: string) => (await app.inject({ method: "GET", url })).json();
+
+describe("POST /stop with { edit }", () => {
+ it("sets an edit hold and still enqueues the compile", async () => {
+ const s = await seedActiveSession();
+ const r = await app.inject({
+ method: "POST",
+ url: `/api/sessions/${s.token}/stop`,
+ payload: { edit: true },
+ });
+ expect(r.statusCode).toBe(200);
+ expect(r.json().editHoldUntil).toBeTruthy();
+
+ const row = await load(s.id);
+ expect(row!.status).toBe("stopped");
+ expect(row!.editHoldUntil).not.toBeNull();
+ });
+
+ it("leaves old clients untouched — no body means no hold", async () => {
+ const s = await seedActiveSession();
+ const r = await app.inject({
+ method: "POST",
+ url: `/api/sessions/${s.token}/stop`,
+ });
+ expect(r.statusCode).toBe(200);
+ expect(r.json().editHoldUntil).toBeUndefined();
+ expect((await load(s.id))!.editHoldUntil).toBeNull();
+ });
+
+ it("does not hold a session with nothing recorded", async () => {
+ const [s] = await db
+ .insert(schema.sessions)
+ .values({ name: "empty", status: "active", startedAt: minute(0) })
+ .returning({ id: schema.sessions.id, token: schema.sessions.token });
+ const r = await app.inject({
+ method: "POST",
+ url: `/api/sessions/${s.token}/stop`,
+ payload: { edit: true },
+ });
expect(r.statusCode).toBe(200);
- const body = r.json();
+ expect(r.json().editHoldUntil).toBeUndefined();
+ // No screenshots → failed, as before.
+ expect((await load(s.id))!.status).toBe("failed");
+ });
+});
+
+describe("GET /units", () => {
+ it("is editable during the hold and exposes the unit map", async () => {
+ const s = await seedHeldSession();
+ const body = await getJson(`/api/sessions/${s.token}/units`);
expect(body.editable).toBe(true);
- expect(body.units).toHaveLength(10);
+ expect(body.units).toHaveLength(UNITS);
expect(body.units[3].capturedAt).toBe(iso(3));
expect(body.originalVideoUrl).toContain("https://");
- expect(body.cuts).toEqual([]);
+ expect(body.editHoldUntil).toBeTruthy();
});
- it("reports pre-edit-feature sessions as not editable", async () => {
- const s = await seedCompleteSession({ videoUnits: null, originalVideoR2Key: null });
- const r = await app.inject({ method: "GET", url: `/api/sessions/${s.token}/units` });
- expect(r.statusCode).toBe(200);
- expect(r.json().editable).toBe(false);
- expect(r.json().editableReason).toBe("no_original");
- expect(r.json().originalVideoUrl).toBeNull();
+ it("reports 'still building' while the compile is in flight", async () => {
+ const s = await seedHeldSession({ videoUnits: null, originalVideoR2Key: null });
+ const body = await getJson(`/api/sessions/${s.token}/units`);
+ expect(body.editable).toBe(false);
+ expect(body.editableReason).toBe("no_original");
+ // The hold is still surfaced so clients wait rather than give up.
+ expect(body.editHoldUntil).toBeTruthy();
});
- it("reports in-flight sessions as not editable", async () => {
- const s = await seedCompleteSession({ status: "active" });
- const r = await app.inject({ method: "GET", url: `/api/sessions/${s.token}/units` });
- expect(r.json().editable).toBe(false);
- expect(r.json().editableReason).toBe("not_complete");
+ it("refuses editing once the session is published", async () => {
+ const s = await seedHeldSession({
+ status: "complete",
+ editHoldUntil: null,
+ videoR2Key: "timelapses/x/original.mp4",
+ });
+ const body = await getJson(`/api/sessions/${s.token}/units`);
+ expect(body.editable).toBe(false);
+ expect(body.editableReason).toBe("published");
+ expect(body.originalVideoUrl).toBeNull();
+ });
+
+ it("refuses editing after the hold lapses", async () => {
+ const s = await seedHeldSession({
+ editHoldUntil: new Date(Date.now() - 1000),
+ });
+ const body = await getJson(`/api/sessions/${s.token}/units`);
+ expect(body.editable).toBe(false);
+ expect(body.editableReason).toBe("not_ready");
});
});
-describe("PUT /api/sessions/:token/cuts", () => {
- it("persists a normalized cut list and reports the tracked-time preview", async () => {
- const s = await seedCompleteSession();
- // Two overlapping intervals covering minutes 3..5 → merged, 2 units cut
- // (units 3 and 4), each credited 60s.
+describe("PUT /cuts", () => {
+ it("normalizes the list and previews the post-cut tracked time", async () => {
+ const s = await seedHeldSession();
+ // Two adjacent intervals covering minutes 3..5 → merged; units 3 and 4
+ // are cut, each worth 60 credited seconds.
const { status, body } = await putCuts(s.token, [
{ start: iso(3), end: iso(4) },
{ start: iso(4), end: iso(5) },
]);
expect(status).toBe(200);
expect(body.cuts).toEqual([{ start: iso(3), end: iso(5) }]);
- expect(body.unitsTotal).toBe(10);
+ expect(body.unitsTotal).toBe(UNITS);
expect(body.unitsCut).toBe(2);
expect(body.uncutTrackedSeconds).toBe(540);
expect(body.trackedSeconds).toBe(420);
- const row = await db.query.sessions.findFirst({
- where: eq(schema.sessions.id, s.id),
- });
+ const row = await load(s.id);
expect(row!.cuts).toEqual([{ start: iso(3), end: iso(5) }]);
expect(row!.cutSeconds).toBe(120);
});
- it("reflects cuts in GET /:token, /status, and /timings", async () => {
- const s = await seedCompleteSession();
+ it("flows into /timings, /:token and /batch", async () => {
+ const s = await seedHeldSession();
await putCuts(s.token, [{ start: iso(3), end: iso(5) }]);
- const session = (await app.inject({ method: "GET", url: `/api/sessions/${s.token}` })).json();
+ const session = await getJson(`/api/sessions/${s.token}`);
expect(session.trackedSeconds).toBe(420);
expect(session.uncutTrackedSeconds).toBe(540);
expect(session.cutSeconds).toBe(120);
- expect(session.cuts).toEqual([{ start: iso(3), end: iso(5) }]);
-
- const status = (await app.inject({ method: "GET", url: `/api/sessions/${s.token}/status` })).json();
- expect(status.trackedSeconds).toBe(420);
- const timings = (await app.inject({ method: "GET", url: `/api/sessions/${s.token}/timings` })).json();
+ const timings = await getJson(`/api/sessions/${s.token}/timings`);
expect(timings.count).toBe(8);
expect(timings.timestamps).not.toContain(iso(3));
expect(timings.timestamps).not.toContain(iso(4));
expect(timings.timestamps).toContain(iso(5));
expect(timings.cutCount).toBe(2);
- expect(timings.cuts).toEqual([{ start: iso(3), end: iso(5) }]);
expect(timings.cutTimestamps).toBeUndefined();
- const withCut = (
- await app.inject({ method: "GET", url: `/api/sessions/${s.token}/timings?includeCut=true` })
- ).json();
+ const withCut = await getJson(`/api/sessions/${s.token}/timings?includeCut=true`);
expect(withCut.cutTimestamps).toEqual([iso(3), iso(4)]);
const batch = (
@@ -172,81 +255,110 @@ describe("PUT /api/sessions/:token/cuts", () => {
});
it("clears edits with an empty list", async () => {
- const s = await seedCompleteSession();
+ const s = await seedHeldSession();
await putCuts(s.token, [{ start: iso(3), end: iso(5) }]);
const { status, body } = await putCuts(s.token, []);
expect(status).toBe(200);
expect(body.trackedSeconds).toBe(540);
- const row = await db.query.sessions.findFirst({ where: eq(schema.sessions.id, s.id) });
+ const row = await load(s.id);
expect(row!.cuts).toEqual([]);
expect(row!.cutSeconds).toBe(0);
});
it("rejects a list that removes the entire timelapse", async () => {
- const s = await seedCompleteSession();
+ const s = await seedHeldSession();
const { status, body } = await putCuts(s.token, [{ start: iso(0), end: iso(60) }]);
expect(status).toBe(400);
expect(body.error).toMatch(/entire timelapse/);
});
it("rejects malformed intervals", async () => {
- const s = await seedCompleteSession();
+ const s = await seedHeldSession();
expect((await putCuts(s.token, [{ start: iso(5), end: iso(3) }])).status).toBe(400);
expect((await putCuts(s.token, [{ start: "garbage", end: iso(3) }])).status).toBe(400);
});
- it("409s while compiling and on non-editable sessions", async () => {
- const compiling = await seedCompleteSession({ status: "compiling" });
- expect((await putCuts(compiling.token, [])).status).toBe(409);
+ it("cannot touch a published session", async () => {
+ const s = await seedHeldSession({
+ status: "complete",
+ editHoldUntil: null,
+ videoR2Key: "timelapses/x/original.mp4",
+ });
+ const { status, body } = await putCuts(s.token, [{ start: iso(3), end: iso(5) }]);
+ expect(status).toBe(409);
+ expect(body.error).toMatch(/published/);
+ expect((await load(s.id))!.cuts).toBeNull();
+ });
- const legacy = await seedCompleteSession({ videoUnits: null, originalVideoR2Key: null });
- expect((await putCuts(legacy.token, [])).status).toBe(409);
+ it("cannot touch a session mid-publish", async () => {
+ const s = await seedHeldSession({ status: "compiling" });
+ expect((await putCuts(s.token, [])).status).toBe(409);
});
});
-describe("POST /api/sessions/:token/compile", () => {
- it("is an instant no-op when there are no cuts and the original is published", async () => {
- const s = await seedCompleteSession();
+describe("POST /compile (publish)", () => {
+ it("publishes the original instantly when there are no cuts", async () => {
+ const s = await seedHeldSession();
const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` });
expect(r.statusCode).toBe(200);
expect(r.json()).toMatchObject({ status: "complete", instant: true });
- const row = await db.query.sessions.findFirst({ where: eq(schema.sessions.id, s.id) });
+
+ const row = await load(s.id);
expect(row!.status).toBe("complete");
+ expect(row!.videoR2Key).toBe("timelapses/x/original.mp4");
+ expect(row!.editHoldUntil).toBeNull();
+ // No worker round-trip, so no recompile is consumed.
expect(row!.recompileCount).toBe(0);
});
- it("claims complete → compiling and burns one recompile when cuts exist", async () => {
- const s = await seedCompleteSession();
+ it("hands off to the worker when cuts must be baked in", async () => {
+ const s = await seedHeldSession();
await putCuts(s.token, [{ start: iso(3), end: iso(5) }]);
const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` });
expect(r.statusCode).toBe(200);
expect(r.json()).toMatchObject({ status: "compiling", instant: false });
- const row = await db.query.sessions.findFirst({ where: eq(schema.sessions.id, s.id) });
+
+ const row = await load(s.id);
expect(row!.status).toBe("compiling");
expect(row!.recompileCount).toBe(1);
+ // Hold cleared so the expiry job can't publish the uncut original out
+ // from under the pending cut-compile.
+ expect(row!.editHoldUntil).toBeNull();
+ // Still unpublished until the worker finishes.
+ expect(row!.videoR2Key).toBeNull();
});
- it("enqueues an un-cut recompile when cuts were cleared but an edited video is published", async () => {
- const s = await seedCompleteSession({
- videoR2Key: "timelapses/x/edited.mp4",
- cuts: [],
- cutSeconds: 0,
+ it("is idempotent against the expiry job winning the race", async () => {
+ const s = await seedHeldSession({
+ status: "complete",
+ editHoldUntil: null,
+ videoR2Key: "timelapses/x/original.mp4",
});
const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` });
expect(r.statusCode).toBe(200);
- expect(r.json()).toMatchObject({ status: "compiling", instant: false });
+ expect(r.json()).toMatchObject({ status: "complete", instant: true });
});
- it("202s while already compiling (idempotent client retries)", async () => {
- const s = await seedCompleteSession({ status: "compiling" });
+ it("202s while a publish is already running", async () => {
+ const s = await seedHeldSession({ status: "compiling" });
const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` });
expect(r.statusCode).toBe(202);
});
- it("409s once the recompile budget is exhausted", async () => {
- const s = await seedCompleteSession({ recompileCount: 5 });
+ it("409s once the hold has lapsed", async () => {
+ const s = await seedHeldSession({ editHoldUntil: new Date(Date.now() - 1000) });
const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` });
expect(r.statusCode).toBe(409);
- expect(r.json().error).toMatch(/recompiles_exhausted/);
+ });
+});
+
+describe("held sessions in status reads", () => {
+ it("look unpublished, with the hold deadline attached", async () => {
+ const s = await seedHeldSession();
+ const status = await getJson(`/api/sessions/${s.token}/status`);
+ expect(status.status).toBe("stopped");
+ expect(status.videoUrl).toBeUndefined();
+ expect(status.editable).toBe(true);
+ expect(status.editHoldUntil).toBeTruthy();
});
});
diff --git a/packages/shared/src/cuts.ts b/packages/shared/src/cuts.ts
index 78bf8486..6393adde 100644
--- a/packages/shared/src/cuts.ts
+++ b/packages/shared/src/cuts.ts
@@ -27,10 +27,19 @@ export const MAX_CUT_INTERVALS = 120;
* but enqueues worker jobs — bound the loop. */
export const MAX_USER_RECOMPILES = 5;
-/** How long after the last cut-compile the uncut original video is retained
- * for further re-edits. After this the retention job deletes the original
- * of EDITED sessions (the cut content must eventually be truly gone) and
- * editing freezes. Uncut sessions keep their single video forever. */
+/**
+ * How long a session stopped with `{edit: true}` waits, unpublished, for
+ * the user to edit before it auto-publishes uncut. The clock starts at
+ * stop. Editing happens ONLY inside this hold — never after `complete`,
+ * because `complete` is the signal programs act on (forwarding heartbeats,
+ * accepting submissions, firing the redirect hook); data must be final the
+ * first time they see it.
+ */
+export const EDIT_HOLD_MINUTES = 30;
+
+/** Backstop retention for uncut originals of EDITED sessions (the worker
+ * deletes them immediately after an edited publish; this catches crashed
+ * flows). Uncut sessions keep their single video forever. */
export const EDIT_WINDOW_DAYS = 7;
/** How far outside [startedAt, stoppedAt] a cut interval may reach before
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 3d661044..8eb9b7e9 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -150,11 +150,18 @@ export interface UnitsResponse {
units: VideoUnit[];
/** Current cut list ([] = no edits). */
cuts: CutInterval[];
- /** Whether the session can (still) be edited: original video present,
- * recompile budget remaining. */
+ /** Whether the session is currently editable: an edit hold is active,
+ * the original video is built, and recompile budget remains. Editing is
+ * only possible during the hold — never after `complete`. */
editable: boolean;
/** Why `editable` is false (for UX copy); absent when editable. */
- editableReason?: "no_original" | "recompiles_exhausted" | "not_complete";
+ editableReason?:
+ | "no_original"
+ | "recompiles_exhausted"
+ | "not_ready"
+ | "published";
+ /** When the edit hold auto-publishes; null when no hold is active. */
+ editHoldUntil?: string | null;
/** Presigned GET URL (~1h) of the UNCUT original video — the editor's
* preview source. Token-gated by this endpoint; deliberately NOT the
* public media URL, which after an edit serves the cut version only.
@@ -246,10 +253,21 @@ export interface ResumeResponse {
serverTime?: string;
}
+export interface StopRequest {
+ /** Hold the session unpublished after compiling so the user can edit
+ * (cut) it before programs see `complete`. The hold auto-publishes
+ * after EDIT_HOLD_MINUTES if the user walks away. Only send this when
+ * the stopping client can render the editor. */
+ edit?: boolean;
+}
+
export interface StopResponse {
status: "stopped";
trackedSeconds: number;
totalActiveSeconds: number;
+ /** When the edit hold auto-publishes; present only when the stop
+ * requested `edit: true`. */
+ editHoldUntil?: string;
}
export interface StatusResponse {
@@ -263,8 +281,15 @@ export interface StatusResponse {
/** Redirect hook URL — clients watching the compile open this when the
* status flips to "complete". Absent when the session has none. */
redirectUrl?: string;
- /** Whether the compiled timelapse can (still) be edited. */
+ /** Whether the session is editable RIGHT NOW: an edit hold is active and
+ * its preview video has finished building. Only ever true while
+ * `stopped` — never after `complete`, which is the point at which
+ * programs consume the session's data. */
editable?: boolean;
+ /** When the edit hold auto-publishes the session (uncut). Absent when no
+ * hold is active. While this is set and `editable` is false, the
+ * preview is still compiling — show "preparing", not "done". */
+ editHoldUntil?: string;
}
export interface VideoResponse {
diff --git a/packages/worker/src/compile.ts b/packages/worker/src/compile.ts
index c45b1617..857d1b4b 100644
--- a/packages/worker/src/compile.ts
+++ b/packages/worker/src/compile.ts
@@ -465,10 +465,10 @@ export async function compileTimelapse(sessionId: string): Promise<{
// Both assembly paths land on the pinned 1s closed-GOP grid.
const videoCopyAligned = true;
- // Step 4.25: apply cuts, if any. First compiles never have cuts (they're
- // only settable on complete sessions) — this branch covers recompiles of
- // sessions that lost their original (e.g. internal recompile of a failed
- // edited compile).
+ // Step 4.25: apply cuts, if any. A first compile normally has none —
+ // cuts are authored during the edit hold, after this build hands the
+ // user a preview. This branch covers a re-run that lost its original
+ // (e.g. an internal recompile of a failed edited compile).
const unitTimesMs = videoUnits.map((u) => Date.parse(u.capturedAt));
const keptRanges = computeKeptRanges(unitTimesMs, cuts);
const hasEffectiveCuts =
@@ -534,7 +534,15 @@ export async function compileTimelapse(sessionId: string): Promise<{
)
: 0;
- // Step 6: Mark complete
+ // Step 6: Publish — or hold.
+ //
+ // A session stopped with `{edit: true}` must NOT reach `complete` yet:
+ // that status is what programs act on (forwarding heartbeats, accepting
+ // submissions, firing the redirect hook), so it may only appear once
+ // the user's cuts are baked in. Such a session goes back to `stopped`
+ // with everything built but `video_r2_key` still null — the editor
+ // opens on the original, and either the user's publish call or the
+ // hold-expiry job flips it to `complete`.
const thumbnailUrl = R2_PUBLIC_DOMAIN
? `https://${R2_PUBLIC_DOMAIN}/${thumbnailR2Key}`
: thumbnailR2Key;
@@ -543,12 +551,22 @@ export async function compileTimelapse(sessionId: string): Promise<{
? `https://${R2_PUBLIC_DOMAIN}/${publishR2Key}`
: publishR2Key;
+ // Re-read the hold: the user may have let it lapse (or the expiry job
+ // may have cleared it) during the minutes this build was running.
+ const [current] = await db
+ .select({ editHoldUntil: schema.sessions.editHoldUntil })
+ .from(schema.sessions)
+ .where(eq(schema.sessions.id, sessionId));
+ const holdActive =
+ current?.editHoldUntil != null &&
+ current.editHoldUntil.getTime() > Date.now();
+
await db
.update(schema.sessions)
.set({
- status: "complete",
- videoUrl,
- videoR2Key: publishR2Key,
+ status: holdActive ? "stopped" : "complete",
+ videoUrl: holdActive ? null : videoUrl,
+ videoR2Key: holdActive ? null : publishR2Key,
originalVideoR2Key: originalR2Key,
videoUnits,
videoCopyAligned,
@@ -560,6 +578,12 @@ export async function compileTimelapse(sessionId: string): Promise<{
})
.where(eq(schema.sessions.id, sessionId));
+ if (holdActive) {
+ console.log(
+ `Session ${sessionId} built and held for editing until ${current!.editHoldUntil!.toISOString()}`,
+ );
+ }
+
// Step 7: Cleanup unsampled screenshots from R2
const unsampled = await db
.select({ r2Key: schema.screenshots.r2Key, id: schema.screenshots.id })
@@ -599,10 +623,16 @@ export async function compileTimelapse(sessionId: string): Promise<{
}
/**
- * Half B of the compile job: the original video and its unit map already
- * exist, so apply (or clear) the session's cut list against it. Downloads a
- * single MP4, cuts it (usually a lossless stream copy), regenerates the
- * thumbnail, and republishes — no capture units involved.
+ * Half B of the compile job: bake the user's cuts into the already-built
+ * original and PUBLISH the session. Downloads a single MP4, cuts it
+ * (usually a lossless stream copy), regenerates the thumbnail, and flips
+ * the session `complete` — no capture units involved.
+ *
+ * This is the end of the edit hold, so the uncut original is deleted right
+ * after: the cut minutes are gone the moment the timelapse goes out, not
+ * seven days later. Recovering an edited session afterwards (admin
+ * recompile) falls back to Half A, which rebuilds from capture units and
+ * applies the same cut list.
*/
async function applyCutCompile(
session: typeof schema.sessions.$inferSelect,
@@ -701,9 +731,12 @@ async function applyCutCompile(
videoUrl,
videoR2Key: publishR2Key,
cutSeconds,
- // Cleared cuts stay [] (the user's choice); the window anchor still
- // advances so the retention job eventually reclaims the original of a
- // session that was ever edited.
+ // The hold ends here — the session is published and its numbers are
+ // final for every program reading them.
+ editHoldUntil: null,
+ // Cut content must not outlive the publish: once the edited video is
+ // out, the uncut original is deleted below and its key cleared.
+ ...(hasEffectiveCuts ? { originalVideoR2Key: null } : {}),
lastEditCompileAt: new Date(),
thumbnailUrl,
thumbnailR2Key,
@@ -711,5 +744,12 @@ async function applyCutCompile(
})
.where(eq(schema.sessions.id, sessionId));
+ // Delete the uncut original only AFTER the edited video is published and
+ // the row committed — if this ordering flipped, a crash in between would
+ // leave a session pointing at bytes that no longer exist.
+ if (hasEffectiveCuts) {
+ await deleteObjectQuiet(originalR2Key);
+ }
+
return { videoUrl, videoR2Key: publishR2Key, thumbnailUrl, thumbnailR2Key };
}
diff --git a/packages/worker/src/schema.ts b/packages/worker/src/schema.ts
index ab7f470e..8ff7c738 100644
--- a/packages/worker/src/schema.ts
+++ b/packages/worker/src/schema.ts
@@ -59,6 +59,7 @@ export const sessions = pgTable(
lastEditCompileAt: timestamp("last_edit_compile_at", {
withTimezone: true,
}),
+ editHoldUntil: timestamp("edit_hold_until", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
From d7dfdc7d8694a6b42b4b5b3954eecbb4d086fa5d Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 00:50:54 +0800
Subject: [PATCH 22/65] fix: editor opened into a dead end while the preview
was still compiling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Clicking 'Edit & save' failed immediately with 'this timelapse isn't
available for editing'. The worker claims the compile within a second of
the stop, flipping the session to 'compiling' — and sessionEditability
only tolerated 'stopped', so the state the editor almost always opens
into was reported as terminal.
- server: 'compiling' with a live hold is now 'preparing' (a wait, not a
failure), a failed compile reports 'failed' rather than a generic
not-ready, and /units returns expectedUnits so clients can size a
progress estimate
- server: POST /compile during 'preparing' drops the hold and returns 200
instead of 409 — 'publish as recorded' shouldn't make the user wait for
a preview they just declined; the in-flight build publishes when it
lands
- react SDK: new ProgressRing primitive; the editor and the SessionDetail
review panel poll through 'preparing' behind a live ring sized from the
session's capture count. It's a time estimate, so it eases toward but
never reaches 100% — only the video actually landing completes it
- SessionDetail no longer treats a failed compile as a hold to wait on
- regression tests cover the compiling-with-hold state, the failed state,
and publishing mid-compile
---
clients/desktop/src-tauri/src/lib.rs | 219 ++++++++++++++----
clients/desktop/src-tauri/src/tray.rs | 18 +-
.../src/components/DesktopRecorder.tsx | 88 +++++--
clients/desktop/src/components/TrayApp.tsx | 54 +++--
clients/desktop/src/hooks/useNativeCapture.ts | 11 +-
clients/react/API.md | 33 ++-
.../react/src/components/SessionDetail.tsx | 76 ++++--
.../react/src/components/TimelapseEditor.tsx | 92 ++++++--
.../react/src/hooks/useSessionTimer.test.ts | 105 ++++++++-
clients/react/src/hooks/useSessionTimer.ts | 90 ++++++-
clients/react/src/index.ts | 12 +-
clients/react/src/ui/ProgressRing.tsx | 80 +++++++
clients/react/src/ui/index.ts | 2 +
docs/edit-feature-plan.md | 14 +-
packages/server/API.md | 17 +-
packages/server/src/routes/sessions.ts | 44 +++-
.../server/test/edits.integration.test.ts | 45 +++-
packages/shared/src/types.ts | 9 +-
18 files changed, 858 insertions(+), 151 deletions(-)
create mode 100644 clients/react/src/ui/ProgressRing.tsx
diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs
index edd1f205..48191ba4 100644
--- a/clients/desktop/src-tauri/src/lib.rs
+++ b/clients/desktop/src-tauri/src/lib.rs
@@ -107,10 +107,20 @@ struct CaptureLoopHandle {
/// Shared state for the Rust-side tray title timer.
/// Uses atomics so the capture loop can update tracked_seconds
/// without acquiring a mutex on every tick.
+///
+/// This mirrors `useSessionTimerState` in @lookout/react. The menu bar,
+/// the tray popup and the main window each tick their own clock (so a
+/// throttled WebView can't stall the menu bar), which only works if all
+/// three apply the *same* rules to the same anchor: ratchet the base
+/// forward, cap interpolation at one capture interval, and drop the
+/// interpolated remainder while paused. Diverge on any of those and the
+/// menu bar visibly disagrees with the main window.
struct TrayTimerState {
/// Authoritative tracked seconds from the last server response.
+ /// Ratchets forward only — see `sync_tray_timer`.
tracked_seconds: AtomicI64,
- /// Wall-clock instant when tracking started (or last synced).
+ /// Wall-clock instant `tracked_seconds` last advanced (the
+ /// interpolation anchor).
started_at: Mutex,
/// Whether the timer is actively ticking (false = paused).
is_running: AtomicBool,
@@ -2085,6 +2095,25 @@ const SLEEP_THRESHOLD_SECS: u64 = CAPTURE_INTERVAL_SECS * 2 + 30; // 150s
/// and the JPEG preview side is only produced while the window is focused.
const DEFAULT_FRAME_INTERVAL_MS: u64 = 4_000;
+/// Max seconds the menu-bar time may run ahead of the last server-credited
+/// `tracked_seconds`. Must equal `MAX_INTERPOLATION_S` in
+/// @lookout/react's useSessionTimer — one capture interval. Without the cap
+/// the menu bar kept counting through a capture stall while the main window
+/// froze at base + 60, and the two never reconverged.
+const MAX_TRAY_INTERPOLATION_SECS: i64 = CAPTURE_INTERVAL_SECS as i64;
+
+/// The Rust mirror of `deriveDisplaySeconds` in @lookout/react. Keep the two
+/// in step: the menu bar and the main window each tick their own clock, so any
+/// difference here is directly visible as the two showing different times.
+fn tray_display_seconds(base_seconds: i64, elapsed_secs: i64, running: bool) -> i64 {
+ if !running {
+ // Paused drops the interpolated remainder rather than freezing it,
+ // matching the main window's snap-down.
+ return base_seconds;
+ }
+ base_seconds + elapsed_secs.clamp(0, MAX_TRAY_INTERPOLATION_SECS)
+}
+
/// Format seconds into the same tray title format as the JS side:
/// >0h: "{h}h {m}m", else: "{m}m"
fn format_tray_time(total_seconds: i64) -> String {
@@ -2127,40 +2156,54 @@ async fn tray_timer_task(
}
}
- if !timer_state.is_running.load(Ordering::Relaxed) {
- was_paused = true;
- continue;
- }
-
let base_seconds = timer_state.tracked_seconds.load(Ordering::Relaxed);
+ let running = timer_state.is_running.load(Ordering::Relaxed);
+
let elapsed = {
let started = timer_state.started_at.lock().unwrap();
started.elapsed().as_secs() as i64
};
- let display_seconds = base_seconds + elapsed;
+ let display_seconds = tray_display_seconds(base_seconds, elapsed, running);
let time_text = format_tray_time(display_seconds);
- if was_paused || last_title.as_deref() != Some(time_text.as_str()) {
- #[cfg(target_os = "macos")]
- {
- let _ = &app;
- // None = keep the current pause state; the Swift side
- // renders the tooltip and the numericText digit roll.
- let _ = crate::native_tray::update(&time_text, None);
- }
- #[cfg(not(target_os = "macos"))]
- if let Some(tray) = app.tray_by_id("timelapse_tray") {
- let _ = tray.set_title(Some(&time_text));
- // Windows doesn't render tray titles — the hover tooltip is
- // the only way to see the recorded time there.
- let _ = tray.set_tooltip(Some(format!("Lookout — {time_text} recorded")));
+
+ if !running {
+ // Write the frozen value once (a pause snaps the title down by
+ // the dropped remainder), then idle until resume.
+ if last_title.as_deref() != Some(time_text.as_str()) {
+ set_tray_title(&app, &time_text);
+ last_title = Some(time_text);
}
+ was_paused = true;
+ continue;
+ }
+
+ if was_paused || last_title.as_deref() != Some(time_text.as_str()) {
+ set_tray_title(&app, &time_text);
last_title = Some(time_text);
}
was_paused = false;
}
}
+/// Write the menu-bar time text (and, off macOS, the hover tooltip).
+fn set_tray_title(app: &AppHandle, time_text: &str) {
+ #[cfg(target_os = "macos")]
+ {
+ let _ = app;
+ // None = keep the current pause state; the Swift side renders the
+ // tooltip and the numericText digit roll.
+ let _ = crate::native_tray::update(time_text, None);
+ }
+ #[cfg(not(target_os = "macos"))]
+ if let Some(tray) = app.tray_by_id("timelapse_tray") {
+ let _ = tray.set_title(Some(time_text));
+ // Windows doesn't render tray titles — the hover tooltip is the
+ // only way to see the recorded time there.
+ let _ = tray.set_tooltip(Some(format!("Lookout — {time_text} recorded")));
+ }
+}
+
/// Start the tray timer (if not already running). Returns the shared state
/// so the capture loop can sync `tracked_seconds` into it.
fn start_tray_timer(app: &AppHandle, state: &AppState) -> Arc {
@@ -2217,21 +2260,37 @@ fn stop_tray_timer(state: &AppState) {
}
}
+/// Ratchet `tracked_seconds` to a new authoritative value, re-anchoring the
+/// elapsed counter **only if the value actually advanced**.
+///
+/// Both halves matter for staying in step with the main window:
+/// - Ratchet: an idempotent retry can confirm against a stale read and
+/// return a *lower* `trackedSeconds`. JS keeps the higher value, so
+/// storing the lower one here made the menu bar jump backwards and sit
+/// a minute behind until the next credit.
+/// - Anchor only on advance: a repeated reading must not restart the
+/// interpolation window, or the menu bar loses time the main window keeps.
+fn ratchet_tray_tracked_seconds(timer_state: &TrayTimerState, tracked_seconds: i64) {
+ let prev = timer_state
+ .tracked_seconds
+ .fetch_max(tracked_seconds, Ordering::Relaxed);
+ if tracked_seconds > prev {
+ let mut started = timer_state.started_at.lock().unwrap();
+ *started = StdInstant::now();
+ }
+}
+
/// Sync the tray timer to a new authoritative tracked_seconds value
-/// (typically from a capture result). Resets the elapsed counter.
+/// (typically from a capture result).
fn sync_tray_timer(state: &AppState, tracked_seconds: i64) {
let guard = state.tray_timer.lock().unwrap();
if let Some(ref handle) = *guard {
- handle
- .state
- .tracked_seconds
- .store(tracked_seconds, Ordering::Relaxed);
- let mut started = handle.state.started_at.lock().unwrap();
- *started = StdInstant::now();
+ ratchet_tray_tracked_seconds(&handle.state, tracked_seconds);
}
}
-/// Pause the tray timer (freeze the displayed time).
+/// Pause the tray timer. The next tick drops the interpolated remainder and
+/// shows the bare `tracked_seconds`, matching the main window's snap-down.
fn pause_tray_timer(state: &AppState) {
let guard = state.tray_timer.lock().unwrap();
if let Some(ref handle) = *guard {
@@ -2239,7 +2298,7 @@ fn pause_tray_timer(state: &AppState) {
}
}
-/// Resume the tray timer. Resets the elapsed counter so it continues
+/// Resume the tray timer. Re-anchors the elapsed counter so it continues
/// from the current tracked_seconds.
fn resume_tray_timer(state: &AppState) {
let guard = state.tray_timer.lock().unwrap();
@@ -3087,9 +3146,14 @@ async fn start_tray_ticker(
app: AppHandle,
) -> Result<(), String> {
let timer_state = start_tray_timer(&app, &state);
- timer_state.tracked_seconds.store(tracked_seconds, Ordering::Relaxed);
- let mut started = timer_state.started_at.lock().unwrap();
- *started = StdInstant::now();
+ // Ratchet, don't store: `start_tray_timer` returns the *existing* state
+ // if a session is already being tracked, and a re-entrant call with a
+ // stale (or zero) baseline would knock the menu bar backwards.
+ ratchet_tray_tracked_seconds(&timer_state, tracked_seconds);
+ {
+ let mut started = timer_state.started_at.lock().unwrap();
+ *started = StdInstant::now();
+ }
timer_state.is_running.store(true, Ordering::Relaxed);
Ok(())
}
@@ -3107,12 +3171,7 @@ fn resume_tray_ticker(
tracked_seconds: i64,
state: State<'_, AppState>,
) -> Result<(), String> {
- {
- let guard = state.tray_timer.lock().unwrap();
- if let Some(ref handle) = *guard {
- handle.state.tracked_seconds.store(tracked_seconds, Ordering::Relaxed);
- }
- }
+ sync_tray_timer(&state, tracked_seconds);
resume_tray_timer(&state);
Ok(())
}
@@ -3568,6 +3627,88 @@ pub fn run() {
// compat guarantee: an unupgraded user's binary in the wild keeps working.
// ──────────────────────────────────────────────────────────────────
+/// The menu-bar clock must agree with the main window's clock. Both tick
+/// independently, so they only stay together if these rules match
+/// `deriveDisplaySeconds` / `useSessionTimerState` in @lookout/react.
+#[cfg(test)]
+mod tray_timer_tests {
+ use super::{
+ format_tray_time, ratchet_tray_tracked_seconds, tray_display_seconds, TrayTimerState,
+ MAX_TRAY_INTERPOLATION_SECS,
+ };
+ use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
+ use std::sync::Mutex;
+ use std::time::Instant;
+
+ fn state(tracked: i64) -> TrayTimerState {
+ TrayTimerState {
+ tracked_seconds: AtomicI64::new(tracked),
+ started_at: Mutex::new(Instant::now()),
+ is_running: AtomicBool::new(true),
+ }
+ }
+
+ #[test]
+ fn cap_matches_the_js_side() {
+ // MAX_INTERPOLATION_S in useSessionTimer.ts is
+ // SCREENSHOT_INTERVAL_MS / 1000 = 60.
+ assert_eq!(MAX_TRAY_INTERPOLATION_SECS, 60);
+ }
+
+ #[test]
+ fn interpolates_at_wall_clock_rate() {
+ assert_eq!(tray_display_seconds(120, 0, true), 120);
+ assert_eq!(tray_display_seconds(120, 30, true), 150);
+ }
+
+ #[test]
+ fn interpolation_is_capped_at_one_interval() {
+ // Without the cap the menu bar kept counting through a capture stall
+ // while the main window froze at base + 60, and the two never
+ // reconverged — the reported "menu bar shows a different time".
+ assert_eq!(tray_display_seconds(120, 90, true), 180);
+ assert_eq!(tray_display_seconds(120, 600, true), 180);
+ }
+
+ #[test]
+ fn pause_drops_the_interpolated_remainder() {
+ // The main window snaps down to the base on pause. Freezing at the
+ // interpolated value here left the menu bar up to a minute ahead for
+ // the whole pause.
+ assert_eq!(tray_display_seconds(120, 45, false), 120);
+ assert_eq!(format_tray_time(tray_display_seconds(299, 59, false)), "4m");
+ }
+
+ #[test]
+ fn ratchet_ignores_a_stale_lower_reading() {
+ // An idempotent retry can confirm against a stale read and return a
+ // lower trackedSeconds. JS keeps the higher value; storing the lower
+ // one here made the menu bar jump backwards and sit behind.
+ let s = state(120);
+ ratchet_tray_tracked_seconds(&s, 60);
+ assert_eq!(s.tracked_seconds.load(Ordering::Relaxed), 120);
+ ratchet_tray_tracked_seconds(&s, 180);
+ assert_eq!(s.tracked_seconds.load(Ordering::Relaxed), 180);
+ }
+
+ #[test]
+ fn ratchet_re_anchors_only_on_advance() {
+ let s = state(120);
+ let before = *s.started_at.lock().unwrap();
+
+ // A repeated reading must not restart the interpolation window, or
+ // the menu bar loses time the main window is still counting.
+ ratchet_tray_tracked_seconds(&s, 120);
+ assert_eq!(*s.started_at.lock().unwrap(), before);
+ ratchet_tray_tracked_seconds(&s, 60);
+ assert_eq!(*s.started_at.lock().unwrap(), before);
+
+ // A real advance re-anchors.
+ ratchet_tray_tracked_seconds(&s, 180);
+ assert!(*s.started_at.lock().unwrap() > before);
+ }
+}
+
#[cfg(test)]
mod compat_tests {
use super::{
diff --git a/clients/desktop/src-tauri/src/tray.rs b/clients/desktop/src-tauri/src/tray.rs
index 3e7f3297..54877c31 100644
--- a/clients/desktop/src-tauri/src/tray.rs
+++ b/clients/desktop/src-tauri/src/tray.rs
@@ -6,13 +6,23 @@ use tauri::image::Image;
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder};
+/// State handed to the tray popup window, which ticks its own clock so it
+/// stays live while the main WebView is throttled.
+///
+/// It carries the interpolation *anchor*, not a display value: the popup
+/// re-derives the ticking time with the same rules as the main window
+/// (see `useSessionTimerState` in @lookout/react). Passing the main
+/// window's already-interpolated `displaySeconds` here meant the popup
+/// extrapolated on top of an extrapolation and drifted ahead of it.
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrayState {
- pub display_seconds: u32,
+ /// Ratcheted server-authoritative tracked seconds.
+ pub base_seconds: u32,
pub screenshot_count: u32,
pub control_mode: String,
- pub updated_at: u64,
+ /// `Date.now()` (ms) when `base_seconds` last advanced.
+ pub anchor_at: u64,
}
impl Default for TrayState {
@@ -23,10 +33,10 @@ impl Default for TrayState {
.unwrap()
.as_millis() as u64;
Self {
- display_seconds: 0,
+ base_seconds: 0,
screenshot_count: 0,
control_mode: "recording".to_string(),
- updated_at: now,
+ anchor_at: now,
}
}
}
diff --git a/clients/desktop/src/components/DesktopRecorder.tsx b/clients/desktop/src/components/DesktopRecorder.tsx
index fb5f5fc8..6f3d8739 100644
--- a/clients/desktop/src/components/DesktopRecorder.tsx
+++ b/clients/desktop/src/components/DesktopRecorder.tsx
@@ -3,7 +3,8 @@ import { invoke } from "../logger.js";
import { listen, emit } from "@tauri-apps/api/event";
import {
useSession,
- useSessionTimer,
+ useSessionTimerState,
+ computeBestTrackedSeconds,
formatTime,
Button,
ErrorDisplay,
@@ -129,6 +130,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
source,
isCamera ? cameraFrameCapture : undefined,
handleSessionTerminated,
+ session.trackedSeconds,
);
// Native OS notifications: alert the user when a session pauses, errors,
@@ -164,10 +166,23 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
};
}, [capture.isCapturing, capture.lastCaptureAt]);
- const displaySeconds = useSessionTimer(
- capture.trackedSeconds || session.trackedSeconds,
- capture.isCapturing,
- );
+ // The single authoritative baseline for every surface that shows the time
+ // (main window, menu-bar title, tray popup). Both inputs are server-derived;
+ // `max` rather than `||` so a capture-local value that hasn't caught up with
+ // the session poll yet can't drag the baseline down.
+ const bestTrackedSeconds = computeBestTrackedSeconds({
+ sessionTrackedSeconds: session.trackedSeconds,
+ uploaderTrackedSeconds: capture.trackedSeconds,
+ });
+
+ const timer = useSessionTimerState(bestTrackedSeconds, capture.isCapturing);
+ const displaySeconds = timer.displaySeconds;
+
+ // Read the baseline from a ref inside async handlers: `session.resume()`
+ // awaits a round trip that itself refreshes `session.trackedSeconds`, so the
+ // value captured at render time is stale by the time we seed the ticker.
+ const bestTrackedRef = useRef(bestTrackedSeconds);
+ bestTrackedRef.current = bestTrackedSeconds;
const [pauseLoading, setPauseLoading] = useState(false);
const [resumeLoading, setResumeLoading] = useState(false);
@@ -218,7 +233,11 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
await startCameraAndWait();
// Camera sources use JS-side capture loop, so we need to
// explicitly start the Rust tray ticker for the menu bar time.
- invoke("start_tray_ticker", { trackedSeconds: 0 }).catch(console.error);
+ // Seed it with the session's existing time — hardcoding 0 made the
+ // menu bar restart from "0m" when recording an already-started session.
+ invoke("start_tray_ticker", {
+ trackedSeconds: session.trackedSeconds,
+ }).catch(console.error);
}
capture.startCapturing();
})();
@@ -308,7 +327,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
console.log(`[session] restarting camera stream for device ${cameraDeviceId}`);
await startCameraAndWait();
}
- invoke("resume_tray_ticker", { trackedSeconds: capture.trackedSeconds }).catch(console.error);
+ invoke("resume_tray_ticker", { trackedSeconds: bestTrackedRef.current }).catch(console.error);
await capture.startCapturing();
console.log("[session] resumed");
setResumeLoading(false);
@@ -333,7 +352,7 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
if (isCamera) {
await startCameraAndWait();
}
- invoke("resume_tray_ticker", { trackedSeconds: capture.trackedSeconds }).catch(console.error);
+ invoke("resume_tray_ticker", { trackedSeconds: bestTrackedRef.current }).catch(console.error);
await capture.startCapturing();
setResumeLoading(false);
}, [capture, session, isCamera, cameraDeviceId, camera, startCameraAndWait]);
@@ -357,11 +376,27 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
const screenshotCount = session.screenshotCount + capture.screenshotCount;
- // Keep a ref of the latest state. `updatedAt` matters: the tray window
- // extrapolates the ticking clock from it, so omitting it (as the tray-ready
- // fallback used to) made the tray compute NaN until the next sync.
- const trayStateRef = useRef({ displaySeconds, screenshotCount, controlMode, updatedAt: Date.now() });
- trayStateRef.current = { displaySeconds, screenshotCount, controlMode, updatedAt: Date.now() };
+ // Keep a ref of the latest state for the tray popup.
+ //
+ // This carries the interpolation ANCHOR (`baseSeconds` + `anchorAt`), never
+ // `displaySeconds`. The popup ticks its own clock, so handing it an
+ // already-interpolated value made it extrapolate on top of an
+ // extrapolation — it drifted ahead of the main window and never came back.
+ // `anchorAt` must likewise be when the base last advanced, not `Date.now()`
+ // at push time: re-stamping it on every push restarted the popup's
+ // interpolation window and lost the seconds the main window kept.
+ const trayStateRef = useRef({
+ baseSeconds: timer.baseSeconds,
+ screenshotCount,
+ controlMode,
+ anchorAt: timer.anchorAt,
+ });
+ trayStateRef.current = {
+ baseSeconds: timer.baseSeconds,
+ screenshotCount,
+ controlMode,
+ anchorAt: timer.anchorAt,
+ };
// Listen for tray requesting initial state (fallback)
useEffect(() => {
@@ -402,25 +437,32 @@ export function DesktopRecorder({ token, source, onChangeSource: _onChangeSource
// Tray-window state sync — event-driven, NOT per-second.
//
- // The tray window ticks its own clock by extrapolating from `updatedAt`,
- // and the Rust ticker owns the menu-bar title. So the only things worth
- // pushing over IPC are the ones the tray can't derive locally: screenshot
- // count changes, pause/resume, and server time corrections. Syncing every
+ // The tray window ticks its own clock from the anchor we push, and the Rust
+ // ticker owns the menu-bar title. So the only things worth pushing over IPC
+ // are the ones the tray can't derive locally: screenshot count changes,
+ // pause/resume, and a new anchor when the server credits time. Syncing every
// second (3 IPC calls + a broadcast event) was pure overhead that also
// drowned the debug log in [ipc] noise.
useEffect(() => {
const state = trayStateRef.current;
invoke("set_tray_state", { state }).catch(console.error);
emit("tray-state", state).catch(console.error);
- }, [screenshotCount, controlMode, capture.trackedSeconds]);
+ }, [screenshotCount, controlMode, timer.baseSeconds, timer.anchorAt]);
- // Sync tracked seconds to the Rust tray timer when the server corrects it
- // (arrives with each confirmed capture, ~once a minute).
+ // Sync the baseline to the Rust tray timer whenever it advances. Rust
+ // ratchets and re-anchors on its own, so pushing the same value twice is a
+ // no-op there.
+ //
+ // Uses `bestTrackedSeconds`, not `capture.trackedSeconds`: the latter is
+ // hook-local state that starts at 0, so resuming a session that already had
+ // recorded time (app reopened on a paused session) seeded the menu bar with
+ // 0 and it showed "0m" next to a main window reading 25:00 until the first
+ // confirm landed.
useEffect(() => {
- if (capture.trackedSeconds > 0) {
- invoke("sync_tray_tracked_seconds", { trackedSeconds: capture.trackedSeconds }).catch(console.error);
+ if (bestTrackedSeconds > 0) {
+ invoke("sync_tray_tracked_seconds", { trackedSeconds: bestTrackedSeconds }).catch(console.error);
}
- }, [capture.trackedSeconds]);
+ }, [bestTrackedSeconds]);
// Hide tray on unmount or session end
useEffect(() => {
diff --git a/clients/desktop/src/components/TrayApp.tsx b/clients/desktop/src/components/TrayApp.tsx
index 766cfe11..5c670ff4 100644
--- a/clients/desktop/src/components/TrayApp.tsx
+++ b/clients/desktop/src/components/TrayApp.tsx
@@ -2,9 +2,34 @@ import { useState, useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "../logger.js";
import { isGlassSupported, setLiquidGlassEffect, GlassMaterialVariant } from "tauri-plugin-liquid-glass-api";
-import { Button, colors, spacing, fontSize, fontWeight } from "@lookout/react";
+import { Button, colors, spacing, fontSize, fontWeight, deriveDisplaySeconds } from "@lookout/react";
import NumberFlow from "@number-flow/react";
+interface TrayState {
+ /** Ratcheted server-authoritative tracked seconds (the anchor value). */
+ baseSeconds: number;
+ screenshotCount: number;
+ controlMode: "recording" | "paused";
+ /** `Date.now()` when `baseSeconds` last advanced. */
+ anchorAt: number;
+}
+
+/**
+ * Re-derive the ticking clock from the anchor the main window pushed, using
+ * the *same* shared function the main window's own timer uses. Rolling a
+ * local version of this is how the popup ended up showing a different time
+ * from the main app: it extrapolated without a cap, from an already
+ * interpolated value.
+ */
+function deriveLiveSeconds(state: TrayState, now: number): number {
+ return deriveDisplaySeconds(
+ state.baseSeconds,
+ state.anchorAt,
+ state.controlMode === "recording",
+ now,
+ );
+}
+
function TrayTimer({ totalSeconds }: { totalSeconds: number }) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
@@ -31,11 +56,11 @@ function TrayTimer({ totalSeconds }: { totalSeconds: number }) {
}
export function TrayApp() {
- const [state, setState] = useState({
- displaySeconds: 0,
+ const [state, setState] = useState({
+ baseSeconds: 0,
screenshotCount: 0,
- controlMode: "recording" as "recording" | "paused",
- updatedAt: Date.now(),
+ controlMode: "recording",
+ anchorAt: Date.now(),
});
const [liveSeconds, setLiveSeconds] = useState(0);
@@ -91,20 +116,13 @@ export function TrayApp() {
// Derive the live real-time seconds ticking up
useEffect(() => {
- // Immediate sync
- let current = state.displaySeconds;
- if (state.controlMode === "recording") {
- const elapsed = Math.floor((Date.now() - state.updatedAt) / 1000);
- current += Math.max(0, elapsed);
- }
- setLiveSeconds(current);
+ setLiveSeconds(deriveLiveSeconds(state, Date.now()));
if (state.controlMode !== "recording") return;
// Tick locally so we don't depend on the asleep main window
const interval = setInterval(() => {
- const elapsed = Math.floor((Date.now() - state.updatedAt) / 1000);
- setLiveSeconds(state.displaySeconds + Math.max(0, elapsed));
+ setLiveSeconds(deriveLiveSeconds(state, Date.now()));
}, 1000);
return () => clearInterval(interval);
@@ -116,7 +134,7 @@ export function TrayApp() {
const syncState = async () => {
try {
- const backendState = await invoke("get_tray_state");
+ const backendState = await invoke("get_tray_state");
setState(backendState);
} catch (e) {
console.error("Failed to sync tray state from backend", e);
@@ -125,12 +143,12 @@ export function TrayApp() {
const setup = async () => {
// Listen for regular state updates
- unlistenState = await listen("tray-state", (event) => {
+ unlistenState = await listen("tray-state", (event) => {
setState(event.payload);
});
-
+
// When the tray window is opened, explicitly request the latest state
- unlistenOpened = await listen("tray-opened", syncState);
+ unlistenOpened = await listen("tray-opened", syncState);
// Request initial state on first mount
syncState();
diff --git a/clients/desktop/src/hooks/useNativeCapture.ts b/clients/desktop/src/hooks/useNativeCapture.ts
index 2e1b9664..e237e38b 100644
--- a/clients/desktop/src/hooks/useNativeCapture.ts
+++ b/clients/desktop/src/hooks/useNativeCapture.ts
@@ -55,6 +55,11 @@ export function useNativeCapture(
cameraCapture?: CameraFrameCapture,
/** Called when the capture loop discovers the server moved the session to a terminal state. */
onSessionTerminated?: (status: string) => void,
+ /** The session's server-known tracked seconds, used to seed a freshly
+ * started Rust tray timer. Needed because `trackedSeconds` below is
+ * hook-local state that starts at 0 — on a session that already has
+ * recorded time it can't seed anything until the first confirm lands. */
+ sessionTrackedSeconds = 0,
) {
const [isCapturing, setIsCapturing] = useState(false);
const [trackedSeconds, setTrackedSeconds] = useState(0);
@@ -71,9 +76,11 @@ export function useNativeCapture(
// check if the user intentionally stopped (avoids auto-resume race).
const capturingRef = useRef(false);
- // Latest tracked seconds, for seeding a freshly started Rust tray timer.
+ // Best known tracked seconds, for seeding a freshly started Rust tray timer.
+ // Both inputs are server-derived; take the higher one so neither a
+ // not-yet-confirmed capture nor a lagging session poll seeds a low baseline.
const trackedSecondsRef = useRef(0);
- trackedSecondsRef.current = trackedSeconds;
+ trackedSecondsRef.current = Math.max(trackedSeconds, sessionTrackedSeconds);
// Track blob URL for cleanup
const blobUrlRef = useRef(null);
diff --git a/clients/react/API.md b/clients/react/API.md
index cfe31c53..bbcc3333 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -367,6 +367,33 @@ const displaySeconds = useSessionTimer(trackedSeconds, isActive);
---
+### `useSessionTimerState(serverTrackedSeconds, isActive)`
+
+Same timer, but returns the interpolation **anchor** alongside the display value. Use this when another surface has to tick its own copy of the clock — the desktop app's menu-bar title (Rust) and tray popup window both do, so they stay live while the main WebView is throttled.
+
+**Returns:** `SessionTimerState`
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `displaySeconds` | `number` | What to render |
+| `baseSeconds` | `number` | The ratcheted server-authoritative value the display is anchored to |
+| `anchorAt` | `number` | `Date.now()` when `baseSeconds` last advanced |
+
+### `deriveDisplaySeconds(baseSeconds, anchorAt, isActive, now)`
+
+The pure function behind the hook, exported so independently-ticking surfaces derive the clock identically instead of reimplementing it:
+
+```ts
+const seconds = deriveDisplaySeconds(baseSeconds, anchorAt, isRecording, Date.now());
+```
+
+Any surface ticking its own clock must go through this (or mirror it exactly — see `tray_display_seconds` in the desktop crate). Two rules are easy to get wrong and both produce a visibly wrong clock:
+
+- **Interpolate from `baseSeconds`, never from `displaySeconds`.** The latter already contains the interpolated remainder, so extrapolating from it double-counts and the surface drifts ahead of the main window.
+- **Pass through `anchorAt` unchanged.** It marks when the base last advanced; re-stamping it to "now" on each push restarts the interpolation window and loses time the main window is still counting.
+
+---
+
### `useTokenStore()`
Manages session tokens in `localStorage` with cross-tab sync. No provider required.
@@ -764,7 +791,10 @@ baked in (a lossless server-side stream copy) or without them. Standalone
| `onApplied` | `() => void?` | The timelapse was published — return to your detail view and poll `/status` |
| `onCancel` | `() => void?` | Optional "not now". The session stays held and publishes itself when the hold expires, so nothing is lost. Omit where publishing should be an explicit choice |
-It polls while the preview is still compiling, and shows a countdown to the
+The editor normally opens **before** the preview video exists — the compile
+starts at stop and takes tens of seconds — so it polls through that state
+and shows a `` sized from the session's capture count, then
+swaps to the timeline when the video lands. It also counts down to the
hold's auto-publish (getting louder in the last two minutes).
**Interactions:**
@@ -876,6 +906,7 @@ The SDK exports styled UI primitives used by its components. All use inline styl
|--------|-------------|
| `Button` | Styled button with variants: `primary`, `secondary`, `success`, `warning`, `danger`, `ghost` and sizes: `sm`, `md`, `lg` |
| `Spinner` | Loading spinner with sizes: `sm`, `md`, `lg` |
+| `ProgressRing` | Determinate circular progress (`progress` 0–1, optional centre `label`) — for waits long enough that a spinner under-informs |
| `Badge` | Status badge with variants: `default`, `overlay` |
| `Card` | Styled card container |
| `ErrorDisplay` | Error message display with variants: `inline`, `banner`, `page` |
diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx
index ee538be5..7cbec1a8 100644
--- a/clients/react/src/components/SessionDetail.tsx
+++ b/clients/react/src/components/SessionDetail.tsx
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "motion/react";
import type { StatusResponse, VideoResponse, SessionResponse } from "@lookout/shared";
import { formatTrackedTime } from "../hooks/useSessionTimer.js";
import { Button } from "../ui/Button.js";
+import { ProgressRing } from "../ui/ProgressRing.js";
import { ErrorDisplay } from "../ui/ErrorDisplay.js";
import { ProcessingState } from "./ProcessingState.js";
import { TimelapseEditor } from "./TimelapseEditor.js";
@@ -43,34 +44,54 @@ function HoldPanel({
return () => clearInterval(id);
}, [holdUntil]);
+ // Same asymptotic estimate the editor uses (the worker reports no real
+ // progress); it eases toward 100% and the `editable` flip is what
+ // actually ends it.
+ const [buildProgress, setBuildProgress] = useState(0);
+ useEffect(() => {
+ if (editable) return;
+ const startedAt = Date.now();
+ const tick = () =>
+ setBuildProgress(1 - Math.exp(-2.2 * ((Date.now() - startedAt) / 30_000)));
+ tick();
+ const id = setInterval(tick, 250);
+ return () => clearInterval(id);
+ }, [editable]);
+
const minutesLeft = Math.max(1, Math.round(secondsLeft / 60));
return (
-
- {editable ? "Ready to review" : "Preparing your timelapse…"}
-
-
- {editable
- ? "Cut out anything you don't want to share, then save. Nothing is published until you do."
- : "Your recording is compiling. You'll be able to trim it in a moment."}
- {" "}
- {secondsLeft > 0 && (
-
- {secondsLeft < 120
- ? `Publishing automatically in ${secondsLeft}s.`
- : `Publishes automatically in ${minutesLeft} min.`}
-
+
+ {!editable && (
+
)}
+
+
+ {editable ? "Ready to review" : "Preparing your timelapse…"}
+
+
+ {editable
+ ? "Cut out anything you don't want to share, then save. Nothing is published until you do."
+ : "Your recording is compiling. You'll be able to trim it in a moment."}
+ {" "}
+ {secondsLeft > 0 && (
+
+ {secondsLeft < 120
+ ? `Publishing automatically in ${secondsLeft}s.`
+ : `Publishes automatically in ${minutesLeft} min.`}
+
+ )}
+
+
-
+
Edit & save
@@ -226,6 +247,11 @@ export function SessionDetail({
return () => clearInterval(interval);
}, [status?.status, fetchStatus]);
+ // A live edit hold owns the view: the review panel replaces the compile
+ // spinner. A failed compile is not a hold worth waiting on, even if the
+ // deadline hasn't passed yet — show the normal failure state instead.
+ const inHold = Boolean(status?.editHoldUntil) && status?.status !== "failed";
+
const cardButtonStyle: React.CSSProperties = {
background: colors.bg.surface,
border: `1px solid ${colors.border.default}`,
@@ -277,10 +303,10 @@ export function SessionDetail({
{/* Edit hold: the recording is compiled but deliberately not
published yet, so this is the user's one chance to cut it. */}
- {status && !editing && status.editHoldUntil && (
+ {status && !editing && inHold && (
(onEdit ? onEdit() : setEditing(true))}
onPublish={async () => {
try {
@@ -300,7 +326,7 @@ export function SessionDetail({
{/* Video area. Suppressed during an edit hold: the session reads
as "stopped", but showing a compile spinner under a panel that
says "ready to review" would contradict it. */}
- {!status.editHoldUntil && (
+ {!inHold && (
(null);
const [loadError, setLoadError] = useState(null);
+ /** Non-null while the preview video is still compiling. */
+ const [preparing, setPreparing] = useState<{ expectedUnits: number } | null>(null);
+ const [buildProgress, setBuildProgress] = useState(0);
const [regions, setRegions] = useState([]);
const [selected, setSelected] = useState(null);
const [time, setTime] = useState(0);
@@ -99,9 +108,10 @@ export function TimelapseEditor({
const unitCount = units.length;
// ── Load ────────────────────────────────────────────────────
- // The preview video is built by the compile that ran at stop; while it's
- // still building, /units reports the hold with editable=false. Poll until
- // it's ready rather than sending the user away.
+ // The preview video is built by the compile that ran at stop, so the
+ // editor almost always opens BEFORE it exists: `/units` reports
+ // `preparing` for the whole build. Poll through that instead of
+ // showing an error — this is the normal path, not a failure.
useEffect(() => {
let cancelled = false;
let timer: ReturnType | undefined;
@@ -111,21 +121,24 @@ export function TimelapseEditor({
const res = await client.getUnits();
if (cancelled) return;
if (res.editable && res.originalVideoUrl) {
+ setPreparing(null);
setData(res);
setRegions(cutsToRegions(res.cuts, res.units));
return;
}
- if (res.editableReason === "no_original" && res.editHoldUntil) {
- // Still compiling inside the hold — check back shortly.
- timer = setTimeout(load, 2000);
+ if (res.editableReason === "preparing" || res.editableReason === "no_original") {
+ setPreparing({ expectedUnits: res.expectedUnits ?? 0 });
+ timer = setTimeout(load, 1500);
return;
}
setLoadError(
res.editableReason === "published"
? "This timelapse has already been published, so it can't be edited."
- : res.editableReason === "recompiles_exhausted"
- ? "This timelapse has reached its edit limit."
- : "This timelapse isn't available for editing.",
+ : res.editableReason === "failed"
+ ? "This timelapse couldn't be compiled, so there's nothing to edit."
+ : res.editableReason === "recompiles_exhausted"
+ ? "This timelapse has reached its edit limit."
+ : "This timelapse isn't available for editing.",
);
} catch (err) {
if (!cancelled)
@@ -140,6 +153,25 @@ export function TimelapseEditor({
};
}, [client]);
+ // ── Build progress ──────────────────────────────────────────
+ // The worker doesn't report progress, so this is a time estimate scaled
+ // by how much footage there is to compile. It eases toward — and stops
+ // short of — 100%, and only completes when the real thing does; a ring
+ // that sat at 100% while the user waited would be worse than none.
+ useEffect(() => {
+ if (!preparing) return;
+ const startedAt = Date.now();
+ const estimateMs = COMPILE_BASE_MS + preparing.expectedUnits * COMPILE_MS_PER_UNIT;
+ const tick = () => {
+ const elapsed = Date.now() - startedAt;
+ // Asymptotic: fast at first, never quite arriving.
+ setBuildProgress(1 - Math.exp(-2.2 * (elapsed / estimateMs)));
+ };
+ tick();
+ const id = setInterval(tick, 200);
+ return () => clearInterval(id);
+ }, [preparing]);
+
// ── Hold countdown ──────────────────────────────────────────
// The session publishes itself when the hold expires. Surface the
// deadline (and bail out gracefully once it passes) instead of letting
@@ -497,15 +529,47 @@ export function TimelapseEditor({
+ {preparing && preparing.expectedUnits > 0
+ ? `Stitching ${preparing.expectedUnits} minute${
+ preparing.expectedUnits === 1 ? "" : "s"
+ } of footage. Nothing is published until you save.`
+ : "Nothing is published until you save."}
+
+
);
}
diff --git a/clients/react/src/hooks/useSessionTimer.test.ts b/clients/react/src/hooks/useSessionTimer.test.ts
index 8d7c4b8b..76ccb676 100644
--- a/clients/react/src/hooks/useSessionTimer.test.ts
+++ b/clients/react/src/hooks/useSessionTimer.test.ts
@@ -16,7 +16,11 @@
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
-import { useSessionTimer } from "./useSessionTimer.js";
+import {
+ useSessionTimer,
+ useSessionTimerState,
+ deriveDisplaySeconds,
+} from "./useSessionTimer.js";
// The hook uses `Date.now()` for elapsed-time math and
// `requestAnimationFrame` for the per-second tick. We control both via
@@ -272,3 +276,102 @@ describe("useSessionTimer — latency / delay scenarios", () => {
expect(result.current).toBe(60); // capped at one interval, not 90
});
});
+
+/**
+ * The desktop app renders this clock on three surfaces that each tick
+ * independently: the main window, the menu-bar title (Rust), and the tray
+ * popup window. The two that don't run this hook are handed `baseSeconds` +
+ * `anchorAt` and re-derive from them, so these tests pin the contract those
+ * surfaces are written against. Break one of these and the menu bar starts
+ * showing a different time from the main app.
+ */
+describe("deriveDisplaySeconds — shared cross-surface derivation", () => {
+ const ANCHOR = 1_000_000;
+
+ it("interpolates at wall-clock rate from the anchor", () => {
+ expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR)).toBe(120);
+ expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR + 30_000)).toBe(150);
+ });
+
+ it("caps interpolation at one capture interval", () => {
+ // The menu bar used to keep counting here while the main window froze
+ // at +60, and the two never reconverged.
+ expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR + 90_000)).toBe(180);
+ expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR + 600_000)).toBe(180);
+ });
+
+ it("drops the interpolated remainder when not active", () => {
+ // Pausing mid-interval snaps DOWN to the base on every surface. The
+ // menu bar used to freeze at the interpolated value instead, leaving it
+ // up to a minute ahead for the whole pause.
+ expect(deriveDisplaySeconds(120, ANCHOR, false, ANCHOR + 30_000)).toBe(120);
+ });
+
+ it("never goes backward if the anchor is in the future", () => {
+ expect(deriveDisplaySeconds(120, ANCHOR, true, ANCHOR - 5_000)).toBe(120);
+ });
+
+ it("tracks the hook's own display value across a whole interval", () => {
+ // The surfaces tick on independent 1s timers, so at any given instant
+ // they may be up to one tick apart from the main window — invisible at
+ // the menu bar's minute granularity. What must never happen is the
+ // unbounded, non-converging drift this test would catch as `step` grows.
+ const { result } = renderHook(({ s, a }) => useSessionTimerState(s, a), {
+ initialProps: { s: 120, a: true },
+ });
+ for (const step of [0, 1_000, 30_000, 59_000, 90_000, 600_000]) {
+ tickClock(step);
+ const surface = deriveDisplaySeconds(
+ result.current.baseSeconds,
+ result.current.anchorAt,
+ true,
+ Date.now(),
+ );
+ expect(Math.abs(surface - result.current.displaySeconds)).toBeLessThanOrEqual(1);
+ }
+ });
+});
+
+describe("useSessionTimerState — anchor exposed to other surfaces", () => {
+ it("exposes the ratcheted base, not the interpolated display", () => {
+ const { result } = renderHook(({ s, a }) => useSessionTimerState(s, a), {
+ initialProps: { s: 120, a: true },
+ });
+ tickClock(30_000);
+ expect(result.current.displaySeconds).toBe(150);
+ // Surfaces must interpolate from 120, never from 150 — extrapolating
+ // from an already-interpolated value double-counts the remainder.
+ expect(result.current.baseSeconds).toBe(120);
+ });
+
+ it("holds the base on a stale lower reading, so surfaces don't jump back", () => {
+ const { result, rerender } = renderHook(
+ ({ s, a }) => useSessionTimerState(s, a),
+ { initialProps: { s: 120, a: true } },
+ );
+ rerender({ s: 60, a: true });
+ expect(result.current.baseSeconds).toBe(120);
+ });
+
+ it("re-anchors only when the base actually advances", () => {
+ const { result, rerender } = renderHook(
+ ({ s, a }) => useSessionTimerState(s, a),
+ { initialProps: { s: 120, a: true } },
+ );
+ const firstAnchor = result.current.anchorAt;
+
+ // A repeated (or lower) reading must not restart the interpolation
+ // window — that silently discarded up to a minute the other surfaces
+ // were still counting.
+ tickClock(20_000);
+ rerender({ s: 120, a: true });
+ expect(result.current.anchorAt).toBe(firstAnchor);
+ expect(result.current.displaySeconds).toBe(140);
+
+ // A real advance re-anchors and restarts interpolation from there.
+ rerender({ s: 180, a: true });
+ expect(result.current.anchorAt).toBeGreaterThan(firstAnchor);
+ expect(result.current.baseSeconds).toBe(180);
+ expect(result.current.displaySeconds).toBe(180);
+ });
+});
diff --git a/clients/react/src/hooks/useSessionTimer.ts b/clients/react/src/hooks/useSessionTimer.ts
index 84d4ba1d..48825843 100644
--- a/clients/react/src/hooks/useSessionTimer.ts
+++ b/clients/react/src/hooks/useSessionTimer.ts
@@ -6,7 +6,52 @@ import { SCREENSHOT_INTERVAL_MS } from "@lookout/shared";
* the display jumps to the new server value (== frozen value) and
* unfreezes smoothly. If captures stall, the freeze stays put so the
* user sees something is wrong instead of an inflated count. */
-const MAX_INTERPOLATION_S = Math.floor(SCREENSHOT_INTERVAL_MS / 1000);
+export const MAX_INTERPOLATION_S = Math.floor(SCREENSHOT_INTERVAL_MS / 1000);
+
+/**
+ * The timer's anchor state, for surfaces that tick their own clock
+ * instead of consuming `displaySeconds` (the desktop menu-bar ticker in
+ * Rust, and the tray popup window).
+ *
+ * Those surfaces MUST reproduce the same three rules or they drift out
+ * of sync with the main window — which is exactly the "menu bar shows a
+ * different time" bug:
+ *
+ * 1. display = `baseSeconds` + min(MAX_INTERPOLATION_S, now - `anchorAt`)
+ * 2. while not active (paused/stopped), display = `baseSeconds` — the
+ * interpolated remainder is dropped, not frozen
+ * 3. `baseSeconds` ratchets forward only, and `anchorAt` resets only
+ * when it actually advances
+ *
+ * Never re-interpolate from `displaySeconds`: it already contains the
+ * interpolated remainder, so extrapolating from it double-counts.
+ */
+export interface SessionTimerState {
+ /** What to render. */
+ displaySeconds: number;
+ /** Ratcheted server-authoritative value the display is anchored to. */
+ baseSeconds: number;
+ /** `Date.now()` when `baseSeconds` last advanced. */
+ anchorAt: number;
+}
+
+/**
+ * The one implementation of rules 1 and 2 above. Every JS surface that
+ * renders the recording clock goes through this — the main window via
+ * `useSessionTimerState`, the desktop tray popup from the anchor it
+ * receives over IPC. (The Rust menu-bar ticker mirrors it in
+ * `tray_timer_task`; keep the two in step.)
+ */
+export function deriveDisplaySeconds(
+ baseSeconds: number,
+ anchorAt: number,
+ isActive: boolean,
+ now: number,
+): number {
+ if (!isActive) return baseSeconds;
+ const elapsed = Math.floor((now - anchorAt) / 1000);
+ return baseSeconds + Math.min(MAX_INTERPOLATION_S, Math.max(0, elapsed));
+}
/**
* Display timer for the recording session.
@@ -26,10 +71,10 @@ const MAX_INTERPOLATION_S = Math.floor(SCREENSHOT_INTERVAL_MS / 1000);
* ratchets up and `lastSyncRef` resets — display jumps to the new
* value and the next interpolation cycle starts from there.
*/
-export function useSessionTimer(
+export function useSessionTimerState(
serverTrackedSeconds: number,
isActive: boolean,
-): number {
+): SessionTimerState {
const [displaySeconds, setDisplaySeconds] = useState(serverTrackedSeconds);
const lastSyncRef = useRef(Date.now());
const baseRef = useRef(serverTrackedSeconds);
@@ -57,15 +102,17 @@ export function useSessionTimer(
lastSyncRef.current = Date.now();
let raf: number;
- let lastRenderedSecond = -1;
+ let lastRendered = -1;
const tick = () => {
- const elapsed = Math.min(
- MAX_INTERPOLATION_S,
- Math.floor((Date.now() - lastSyncRef.current) / 1000),
+ const next = deriveDisplaySeconds(
+ baseRef.current,
+ lastSyncRef.current,
+ true,
+ Date.now(),
);
- if (elapsed !== lastRenderedSecond) {
- lastRenderedSecond = elapsed;
- setDisplaySeconds(baseRef.current + elapsed);
+ if (next !== lastRendered) {
+ lastRendered = next;
+ setDisplaySeconds(next);
}
raf = requestAnimationFrame(tick);
};
@@ -78,9 +125,28 @@ export function useSessionTimer(
// keep the inflated baseRef). Server credits after resume re-anchor
// baseRef via the sync effect above.
};
- }, [isActive, serverTrackedSeconds]);
+ // `serverTrackedSeconds` is deliberately NOT a dep. The tick reads
+ // baseRef/lastSyncRef live, and the sync effect above already
+ // re-anchors on advance. Including it here re-ran this effect on
+ // every server response and reset `lastSyncRef` even when the value
+ // did NOT advance (a repeated or lower reading), silently discarding
+ // up to a minute of interpolation that the other surfaces kept.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isActive]);
+
+ return {
+ displaySeconds,
+ baseSeconds: baseRef.current,
+ anchorAt: lastSyncRef.current,
+ };
+}
- return displaySeconds;
+/** Convenience wrapper: just the number to render. */
+export function useSessionTimer(
+ serverTrackedSeconds: number,
+ isActive: boolean,
+): number {
+ return useSessionTimerState(serverTrackedSeconds, isActive).displaySeconds;
}
/** Format seconds as H:MM:SS or M:SS (for live timer display). */
diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts
index 5d950e35..156bbde2 100644
--- a/clients/react/src/index.ts
+++ b/clients/react/src/index.ts
@@ -53,7 +53,17 @@ export type { UploadPayload } from "./hooks/useUploader.js";
export { ClipRecorder } from "./hooks/clipRecorder.js";
export type { ClipCaptureResult, ClipRecorderOptions } from "./hooks/clipRecorder.js";
export { useSession } from "./hooks/useSession.js";
-export { useSessionTimer, formatTime, formatTrackedTime } from "./hooks/useSessionTimer.js";
+export {
+ useSessionTimer,
+ useSessionTimerState,
+ deriveDisplaySeconds,
+ MAX_INTERPOLATION_S,
+ formatTime,
+ formatTrackedTime,
+} from "./hooks/useSessionTimer.js";
+export type { SessionTimerState } from "./hooks/useSessionTimer.js";
+export { computeBestTrackedSeconds } from "./hooks/computeBestTracked.js";
+export type { BestTrackedInputs } from "./hooks/computeBestTracked.js";
// Gallery hooks
export { useTokenStore } from "./hooks/useTokenStore.js";
diff --git a/clients/react/src/ui/ProgressRing.tsx b/clients/react/src/ui/ProgressRing.tsx
new file mode 100644
index 00000000..36ab4aa2
--- /dev/null
+++ b/clients/react/src/ui/ProgressRing.tsx
@@ -0,0 +1,80 @@
+import { colors, fontSize, fontWeight } from "./theme.js";
+
+export interface ProgressRingProps {
+ /** 0–1. Values outside are clamped. */
+ progress: number;
+ size?: number;
+ strokeWidth?: number;
+ /** Centre label. Omit for a bare ring. */
+ label?: string;
+ color?: string;
+}
+
+/**
+ * Determinate circular progress. Used where a spinner would under-inform —
+ * a long wait the user is standing in front of, like a timelapse compile.
+ */
+export function ProgressRing({
+ progress,
+ size = 72,
+ strokeWidth = 5,
+ label,
+ color,
+}: ProgressRingProps) {
+ const clamped = Math.max(0, Math.min(1, progress));
+ const radius = (size - strokeWidth) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const offset = circumference * (1 - clamped);
+
+ return (
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+ );
+}
diff --git a/clients/react/src/ui/index.ts b/clients/react/src/ui/index.ts
index 6a8c2e8b..a006ae2d 100644
--- a/clients/react/src/ui/index.ts
+++ b/clients/react/src/ui/index.ts
@@ -2,6 +2,8 @@ export { Button } from "./Button.js";
export type { ButtonProps } from "./Button.js";
export { Spinner } from "./Spinner.js";
export type { SpinnerProps } from "./Spinner.js";
+export { ProgressRing } from "./ProgressRing.js";
+export type { ProgressRingProps } from "./ProgressRing.js";
export { Badge } from "./Badge.js";
export type { BadgeProps } from "./Badge.js";
export { ErrorDisplay } from "./ErrorDisplay.js";
diff --git a/docs/edit-feature-plan.md b/docs/edit-feature-plan.md
index 91a76b44..8e89f2c3 100644
--- a/docs/edit-feature-plan.md
+++ b/docs/edit-feature-plan.md
@@ -246,9 +246,17 @@ their single video file forever, as today.
now", and a primary button that reads **"Save & publish"** with cuts or
**"Publish as recorded"** without — publishing is the way out, not an
optional extra step.
- - Polls `/units` while the preview is still compiling, and counts down to
- the hold's auto-publish (louder in the last two minutes) so the user
- always knows the timelapse is safe but not yet out.
+ - **The editor opens before the preview exists** — the compile starts at
+ stop and runs for tens of seconds — so `preparing` is the normal
+ opening state, not an error. It polls through it behind a
+ `` sized from `expectedUnits` (compile time scales with
+ unit count), then swaps to the timeline. It also counts down to the
+ hold's auto-publish (louder in the last two minutes) so the user always
+ knows the timelapse is safe but not yet out.
+ - The ring is a time estimate, not worker-reported progress: it eases
+ asymptotically toward 100% and only completes when `/units` actually
+ reports the video ready, so it can never sit at 100% while the user
+ waits.
- **``**: the stop confirmation — keep recording / stop &
save / edit & save. This is where editing is offered; there is no
post-publication entry point.
diff --git a/packages/server/API.md b/packages/server/API.md
index 055b939a..00acd139 100644
--- a/packages/server/API.md
+++ b/packages/server/API.md
@@ -607,15 +607,27 @@ Editor metadata. Rate limit: 10 req/min per token.
"cuts": [],
"editable": true,
"editHoldUntil": "2026-07-26T14:35:00.000Z",
+ "expectedUnits": 47,
"originalVideoUrl": "https://…presigned, ~1h…",
"recompilesRemaining": 5
}
```
-- `units` — the capture units of the compiled **original** video, in output order. Array index = video second = real-world minute: the exact video-time ↔ wall-clock map.
+- `units` — the capture units of the compiled **original** video, in output order. Array index = video second = real-world minute: the exact video-time ↔ wall-clock map. Empty until the preview finishes building.
- `originalVideoUrl` — presigned GET for the unpublished original (the editor's preview source). Deliberately not the public media URL, which is null until the session publishes. `null` when not editable.
-- `editable` / `editableReason` — `false` with `"no_original"` (the preview is still compiling — poll, since `editHoldUntil` is set), `"not_ready"` (no hold, or it lapsed), `"published"` (already `complete`, so editing is over), or `"recompiles_exhausted"`.
+- `editable` / `editableReason` — `false` with one of:
+
+ | Reason | Meaning | What a client should do |
+ |--------|---------|-------------------------|
+ | `preparing` | Hold is active; the preview video is still compiling (the session reads `stopped` or `compiling`) | **Poll** — this is the normal state right after a stop, not an error. Show progress |
+ | `no_original` | Hold active but no original recorded | Poll; same as above |
+ | `not_ready` | No hold, or it lapsed | Editing isn't on offer |
+ | `published` | Already `complete` | Editing is over — by design |
+ | `failed` | The compile failed | Show the failure; there's nothing to edit |
+ | `recompiles_exhausted` | Publish budget spent | Editing is over |
+
- `editHoldUntil` — when the session auto-publishes; `null` when no hold is active.
+- `expectedUnits` — confirmed captures ≈ units the finished video will hold. Lets a client waiting on the build size a progress estimate (compile time scales with unit count).
#### Set Cut List
@@ -649,6 +661,7 @@ Ends the edit hold and publishes the timelapse with the current cut list baked i
- **With cuts:** `stopped → compiling → complete` (poll [`/status`](#poll-compilation-status)); the worker stream-copies the kept ranges, usually in seconds, then deletes the uncut original. Burns one of **5** publishes per session.
- **Without cuts:** returns `{ "instant": true, "status": "complete" }` immediately — the built original is simply published, no worker involved.
+- **Before the preview finishes building** (`editableReason: "preparing"`): drops the hold and returns `200` with `instant: false`. The in-flight compile publishes normally when it lands, so "publish as recorded" works without waiting for a preview the user just declined.
Rate limit: 5 req/min per token.
diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts
index 5d1f57e5..05127725 100644
--- a/packages/server/src/routes/sessions.ts
+++ b/packages/server/src/routes/sessions.ts
@@ -112,22 +112,39 @@ function sessionEditability(session: {
editHoldUntil: Date | null;
}): {
editable: boolean;
- reason?: "no_original" | "recompiles_exhausted" | "not_ready" | "published";
+ reason?:
+ | "preparing"
+ | "no_original"
+ | "recompiles_exhausted"
+ | "not_ready"
+ | "failed"
+ | "published";
} {
if (session.status === "complete") {
return { editable: false, reason: "published" };
}
- if (session.status !== "stopped" || !holdActive(session)) {
+ if (session.status === "failed") {
+ return { editable: false, reason: "failed" };
+ }
+ if (!holdActive(session)) {
+ return { editable: false, reason: "not_ready" };
+ }
+ // A held session is "compiling" for most of the wait — the worker claims
+ // the job within a second of the stop and only returns the session to
+ // "stopped" once the preview is built. Both states are legitimate
+ // waiting room; anything else means the recording isn't finished.
+ if (session.status !== "stopped" && session.status !== "compiling") {
return { editable: false, reason: "not_ready" };
}
// Hold is active but the preview build hasn't landed yet (the compile
// job writes videoUnits + the original when it finishes).
if (
+ session.status === "compiling" ||
!Array.isArray(session.videoUnits) ||
session.videoUnits.length === 0 ||
!session.originalVideoR2Key
) {
- return { editable: false, reason: "no_original" };
+ return { editable: false, reason: "preparing" };
}
if (session.recompileCount >= MAX_USER_RECOMPILES) {
return { editable: false, reason: "recompiles_exhausted" };
@@ -1335,6 +1352,10 @@ export async function sessionRoutes(app: FastifyInstance) {
editHoldUntil: holdActive(session)
? session.editHoldUntil!.toISOString()
: null,
+ // Roughly how many units the finished video will hold. Lets a
+ // client waiting on the build size its progress estimate — compile
+ // time scales with unit count.
+ expectedUnits: await getScreenshotCount(session.id),
originalVideoUrl,
recompilesRemaining: Math.max(
0,
@@ -1519,6 +1540,23 @@ export async function sessionRoutes(app: FastifyInstance) {
}
const { editable, reason } = sessionEditability(session);
+
+ // "Publish as recorded" while the preview is still building: just
+ // drop the hold. The build re-reads it when it finishes and
+ // publishes normally, so the user never has to wait for a preview
+ // they said they don't want.
+ if (!editable && reason === "preparing") {
+ await db
+ .update(schema.sessions)
+ .set({ editHoldUntil: null, updatedAt: new Date() })
+ .where(eq(schema.sessions.id, session.id));
+ return {
+ status: session.status as "stopped" | "compiling",
+ instant: false,
+ recompilesRemaining,
+ };
+ }
+
if (!editable) {
return reply
.code(409)
diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts
index c95b5435..0e7d6588 100644
--- a/packages/server/test/edits.integration.test.ts
+++ b/packages/server/test/edits.integration.test.ts
@@ -172,15 +172,40 @@ describe("GET /units", () => {
expect(body.editHoldUntil).toBeTruthy();
});
- it("reports 'still building' while the compile is in flight", async () => {
+ it("reports 'preparing' while the compile is in flight", async () => {
const s = await seedHeldSession({ videoUnits: null, originalVideoR2Key: null });
const body = await getJson(`/api/sessions/${s.token}/units`);
expect(body.editable).toBe(false);
- expect(body.editableReason).toBe("no_original");
+ expect(body.editableReason).toBe("preparing");
// The hold is still surfaced so clients wait rather than give up.
expect(body.editHoldUntil).toBeTruthy();
});
+ it("reports 'preparing' — not a hard failure — once the worker claims the job", async () => {
+ // Regression: the worker flips a held session to `compiling` within a
+ // second of the stop, which is the state the editor almost always
+ // opens into. Reporting it as un-editable made "Edit & save" fail
+ // immediately for every user.
+ const s = await seedHeldSession({
+ status: "compiling",
+ videoUnits: null,
+ originalVideoR2Key: null,
+ });
+ const body = await getJson(`/api/sessions/${s.token}/units`);
+ expect(body.editable).toBe(false);
+ expect(body.editableReason).toBe("preparing");
+ expect(body.editHoldUntil).toBeTruthy();
+ // The client needs this to size its progress estimate.
+ expect(body.expectedUnits).toBe(UNITS);
+ });
+
+ it("reports a failed compile as failed, not as something to wait for", async () => {
+ const s = await seedHeldSession({ status: "failed" });
+ const body = await getJson(`/api/sessions/${s.token}/units`);
+ expect(body.editable).toBe(false);
+ expect(body.editableReason).toBe("failed");
+ });
+
it("refuses editing once the session is published", async () => {
const s = await seedHeldSession({
status: "complete",
@@ -350,6 +375,22 @@ describe("POST /compile (publish)", () => {
const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` });
expect(r.statusCode).toBe(409);
});
+
+ it("lets the user publish mid-compile by dropping the hold", async () => {
+ // "I don't want to edit after all" must work even while the preview is
+ // still building: clearing the hold makes the in-flight build publish
+ // when it finishes, instead of making the user wait for a preview they
+ // just declined.
+ const s = await seedHeldSession({
+ status: "compiling",
+ videoUnits: null,
+ originalVideoR2Key: null,
+ });
+ const r = await app.inject({ method: "POST", url: `/api/sessions/${s.token}/compile` });
+ expect(r.statusCode).toBe(200);
+ expect(r.json().instant).toBe(false);
+ expect((await load(s.id))!.editHoldUntil).toBeNull();
+ });
});
describe("held sessions in status reads", () => {
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 8eb9b7e9..58b7c30c 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -154,14 +154,21 @@ export interface UnitsResponse {
* the original video is built, and recompile budget remains. Editing is
* only possible during the hold — never after `complete`. */
editable: boolean;
- /** Why `editable` is false (for UX copy); absent when editable. */
+ /** Why `editable` is false (for UX copy); absent when editable.
+ * `"preparing"` means the hold is active and the preview video is still
+ * compiling — keep polling, it will become editable. */
editableReason?:
+ | "preparing"
| "no_original"
| "recompiles_exhausted"
| "not_ready"
+ | "failed"
| "published";
/** When the edit hold auto-publishes; null when no hold is active. */
editHoldUntil?: string | null;
+ /** Confirmed captures in the session ≈ units the finished video will
+ * hold. Lets a client waiting on the build size a progress estimate. */
+ expectedUnits?: number;
/** Presigned GET URL (~1h) of the UNCUT original video — the editor's
* preview source. Token-gated by this endpoint; deliberately NOT the
* public media URL, which after an edit serves the cut version only.
From 8d2114a453e25b3f82e5050cb1d034f99f496212 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 01:08:11 +0800
Subject: [PATCH 23/65] fix(editor): thumbnails silently failed when the
preview video lacked CORS headers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The filmstrip and hover scrubber read pixels through a canvas, so the
offscreen was loaded with crossOrigin="anonymous". That is
all-or-nothing: a presigned URL served without CORS headers doesn't taint
the canvas, it fails the load outright — so a bucket without a GET CORS
rule produced no track thumbnails and no scrubber preview at all, with
nothing in the UI to say why.
Now the frame source is resolved in two steps: probe the URL with CORS,
and if that fails, pull the bytes through the app's fetch (Tauri's HTTP
plugin on desktop, which isn't subject to browser CORS) and use a blob:
URL, which is same-origin by definition. Both paths log which one was
taken, and only a failure of both degrades the timeline to a plain track.
Also splits the hover scrubber onto its own decoder so it no longer
competes with the filmstrip pass for currentTime, and gives the preview
card a bare time-chip fallback so hovering still says where you are.
---
.../desktop/src/components/EditorWindow.tsx | 172 +++-
.../react/src/components/TimelapseEditor.tsx | 894 +++++++++++-------
clients/react/src/components/editorStyles.ts | 73 ++
clients/react/src/ui/theme.ts | 31 +
4 files changed, 805 insertions(+), 365 deletions(-)
create mode 100644 clients/react/src/components/editorStyles.ts
diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx
index 0ab27912..88439b86 100644
--- a/clients/desktop/src/components/EditorWindow.tsx
+++ b/clients/desktop/src/components/EditorWindow.tsx
@@ -3,6 +3,7 @@ import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { TimelapseEditor, colors, fontSize, fontWeight, spacing } from "@lookout/react";
+import { invoke } from "../logger.js";
import { getApiBase } from "../serverConfig.js";
/** Event the editor window emits after applying cuts, so the main window
@@ -46,15 +47,23 @@ export async function openEditorWindow(token: string): Promise {
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: 960,
- height: 720,
- minWidth: 720,
- minHeight: 560,
+ 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,
+ ...(isMacOS ? { titleBarStyle: "overlay" as const, hiddenTitle: true } : {}),
});
win.once("tauri://error", (e) => {
console.error("[editor] failed to open editor window:", e);
@@ -171,55 +180,154 @@ export function useEditorWindowOpen(): string | null {
return token;
}
-/** The editor window's root view (route `#/editor?token=…`). */
+/**
+ * The editor window's root view (route `#/editor?token=…`).
+ *
+ * An app shell, not a page: a draggable title strip, 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.
+ */
export function EditorWindow({ token }: { token: string }) {
+ const isMacOS = navigator.userAgent.includes("Mac");
+ const [sessionName, setSessionName] = useState(null);
+
+ // 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) {
+ setSessionName(d.name);
+ void getCurrentWindow().setTitle(d.name);
+ }
+ })
+ .catch(() => {
+ // Name is decoration — the editor works without it.
+ });
+ }, [token]);
+
+ const close = () => void getCurrentWindow().close().catch(() => {});
+
return (
+ {/* Title strip. Draggable, and on macOS it clears the traffic
+ lights so the label never collides with them. */}
- Edit timelapse
- drag the strip to remove minutes — the tracked time updates with it
+ Edit timelapse
+ {sessionName && (
+
+ {sessionName}
+
+ )}
- {
- // Tell the main window, then close. Fire-and-forget on purpose:
- // even if the emit fails, closing is correct — the main window
- // shows the recompiled video on its next fetch.
- void emit(EDITED_EVENT, { token })
- .catch((e) => console.error("[editor] emit failed:", e))
- .finally(() => getCurrentWindow().close().catch(() => {}));
- }}
- onCancel={() => {
- void getCurrentWindow().close().catch(() => {});
+
+ {/* Body owns the rest. min-height:0 is what lets the stage inside
+ letterboxe down instead of clipping the dock off the bottom. */}
+
+ >
+ {
+ // Tell the main window, then close. Fire-and-forget on purpose:
+ // even if the emit fails, closing is correct — the main window
+ // shows the published video on its next fetch.
+ void emit(EDITED_EVENT, { token })
+ .catch((e) => console.error("[editor] emit failed:", e))
+ .finally(close);
+ }}
+ onCancel={close}
+ />
+
);
}
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index 96075377..27669f45 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -20,6 +20,7 @@ import {
unitClockLabel,
type UnitRegion,
} from "../hooks/editorMath.js";
+import { injectEditorStyles } from "./editorStyles.js";
import { Button } from "../ui/Button.js";
import { Spinner } from "../ui/Spinner.js";
import { ProgressRing } from "../ui/ProgressRing.js";
@@ -32,22 +33,23 @@ export interface TimelapseEditorProps {
/** The timelapse was published — with cuts baked in, or without them.
* The caller should return to its detail view and poll status. */
onApplied?: () => void;
- /** Optional "not now" escape. The timelapse stays held and publishes
+ /** Optional "not now" escape. The session stays held and publishes
* itself when the hold expires, so this never loses anything. Omit it
* in flows where publishing must be an explicit choice. */
onCancel?: () => void;
}
-const TIMELINE_HEIGHT = 64;
-const RULER_HEIGHT = 22;
-const FILMSTRIP_SAMPLES = 60;
+const STRIP_HEIGHT = 56;
+const RULER_HEIGHT = 18;
+const FILMSTRIP_SAMPLES = 48;
+/** Scrubber preview card. 16:9 keeps it honest about the source footage. */
+const PREVIEW_W = 168;
+const PREVIEW_H = 96;
/** Fixed cost of a compile: claim, sampling query, assembly, upload. */
const COMPILE_BASE_MS = 6_000;
/** Marginal cost per capture unit (download + 1s segment encode, across
* the worker's 8-way pool). Only used to size the progress estimate. */
const COMPILE_MS_PER_UNIT = 350;
-const CUT_FILL = "rgba(239, 68, 68, 0.38)";
-const CUT_BORDER = "#ef4444";
type DragState =
| { kind: "maybe"; downUnitF: number }
@@ -62,16 +64,16 @@ type DragState =
| null;
/**
- * The "Edit & Save" step of stopping a recording. The session is compiled
+ * 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.
*
- * Regions are first-class objects: draggable edges, selection, delete key.
- * A plain click on the timeline seeks; playback skips cut regions so
- * previewing shows the published result, while scrubbing passes through
- * them (dimmed) so cut edges can be judged.
+ * 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,
@@ -84,6 +86,8 @@ export function TimelapseEditor({
[apiBaseUrl, token],
);
+ useEffect(() => injectEditorStyles(), []);
+
const [data, setData] = useState(null);
const [loadError, setLoadError] = useState(null);
/** Non-null while the preview video is still compiling. */
@@ -96,9 +100,15 @@ export function TimelapseEditor({
const [filmstrip, setFilmstrip] = useState([]);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
+ /** Scrubber preview: the unit under the pointer, or null when away. */
+ const [hoverUnit, setHoverUnit] = useState(null);
const videoRef = useRef(null);
const timelineRef = useRef(null);
+ const previewCanvasRef = useRef(null);
+ const scrubVideoRef = useRef(null);
+ const scrubBusyRef = useRef(false);
+ const scrubWantRef = useRef(null);
const dragRef = useRef(null);
const regionsRef = useRef(regions);
regionsRef.current = regions;
@@ -164,7 +174,6 @@ export function TimelapseEditor({
const estimateMs = COMPILE_BASE_MS + preparing.expectedUnits * COMPILE_MS_PER_UNIT;
const tick = () => {
const elapsed = Date.now() - startedAt;
- // Asymptotic: fast at first, never quite arriving.
setBuildProgress(1 - Math.exp(-2.2 * (elapsed / estimateMs)));
};
tick();
@@ -173,9 +182,6 @@ export function TimelapseEditor({
}, [preparing]);
// ── Hold countdown ──────────────────────────────────────────
- // The session publishes itself when the hold expires. Surface the
- // deadline (and bail out gracefully once it passes) instead of letting
- // a Save silently 409.
const holdUntilMs = data?.editHoldUntil ? Date.parse(data.editHoldUntil) : null;
const [holdSecondsLeft, setHoldSecondsLeft] = useState(null);
useEffect(() => {
@@ -184,8 +190,6 @@ export function TimelapseEditor({
const left = Math.max(0, Math.round((holdUntilMs - Date.now()) / 1000));
setHoldSecondsLeft(left);
if (left === 0) {
- // Auto-published underneath us — the timelapse is safe, just no
- // longer editable.
setLoadError(
"The edit window closed, so your timelapse was published as recorded.",
);
@@ -216,7 +220,6 @@ export function TimelapseEditor({
const region = regionAtTime(v.currentTime, regionsRef.current);
if (!region) return;
if (region.endUnit >= unitCount) {
- // Cut runs to the end — nothing kept after it.
v.pause();
v.currentTime = region.startUnit;
} else {
@@ -227,29 +230,107 @@ export function TimelapseEditor({
return () => v.removeEventListener("timeupdate", onTimeUpdate);
}, [unitCount, data?.originalVideoUrl]);
- // ── Filmstrip: sample frames from an offscreen copy of the video.
- // Best-effort — if the CDN response isn't CORS-readable the canvas
- // taints and we quietly fall back to a plain timeline.
+ // ── 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 || unitCount === 0) return;
+ 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]);
+
+ // Persistent decoder for the hover scrubber. Kept separate from the
+ // filmstrip pass below so the two never fight over `currentTime`.
+ useEffect(() => {
+ if (!frameSrc) return;
+ const v = document.createElement("video");
+ if (!frameSrc.startsWith("blob:")) v.crossOrigin = "anonymous";
+ v.muted = true;
+ v.preload = "auto";
+ v.src = frameSrc;
+ scrubVideoRef.current = v;
+ return () => {
+ scrubVideoRef.current = null;
+ v.removeAttribute("src");
+ v.load();
+ };
+ }, [frameSrc]);
+
+ // Filmstrip: sample evenly across the video, streaming partial strips in
+ // so the timeline fills progressively instead of blocking on the whole set.
+ useEffect(() => {
+ if (!frameSrc || unitCount === 0) return;
let cancelled = false;
+
+ const v = document.createElement("video");
+ if (!frameSrc.startsWith("blob:")) v.crossOrigin = "anonymous";
+ v.muted = true;
+ v.preload = "auto";
+ v.src = frameSrc;
+
(async () => {
- const v = document.createElement("video");
- v.crossOrigin = "anonymous";
- v.muted = true;
- v.preload = "auto";
- v.src = src;
try {
await new Promise((resolve, reject) => {
v.onloadedmetadata = () => resolve();
- v.onerror = () => reject(new Error("video load failed"));
+ v.onerror = () => reject(new Error("filmstrip video load failed"));
});
const canvas = document.createElement("canvas");
- const thumbH = TIMELINE_HEIGHT;
- const thumbW = Math.round(
- (v.videoWidth / Math.max(1, v.videoHeight)) * thumbH,
- );
+ const thumbH = STRIP_HEIGHT;
+ const thumbW = Math.round((v.videoWidth / Math.max(1, v.videoHeight)) * thumbH);
canvas.width = thumbW;
canvas.height = thumbH;
const ctx = canvas.getContext("2d");
@@ -259,33 +340,69 @@ export function TimelapseEditor({
const thumbs: string[] = [];
for (let i = 0; i < count; i++) {
if (cancelled) return;
- // Sample the middle of each strip cell, +0.5 to land inside a
- // second (unit) rather than on the boundary between two.
- const t = Math.min(
- v.duration - 0.05,
- (i / count) * unitCount + 0.5,
- );
+ const t = Math.min(v.duration - 0.05, (i / count) * unitCount + 0.5);
await new Promise((resolve) => {
v.onseeked = () => resolve();
v.currentTime = t;
});
ctx.drawImage(v, 0, 0, thumbW, thumbH);
- thumbs.push(canvas.toDataURL("image/jpeg", 0.5));
- // Stream partial strips in so the timeline fills as we go.
- if (i % 6 === 5) setFilmstrip([...thumbs]);
+ thumbs.push(canvas.toDataURL("image/jpeg", 0.6));
+ if (i % 4 === 3) setFilmstrip([...thumbs]);
}
if (!cancelled) setFilmstrip(thumbs);
- } catch {
- // Tainted canvas / load failure → no thumbnails, timeline still works.
+ } catch (err) {
+ console.error("[editor] filmstrip generation failed:", err);
} finally {
v.removeAttribute("src");
v.load();
}
})();
+
return () => {
cancelled = true;
};
- }, [data?.originalVideoUrl, unitCount]);
+ }, [frameSrc, unitCount]);
+
+ /**
+ * Draw the hovered frame into the preview card — the iOS-scrubber move.
+ * Seeks are coalesced: one in flight at a time, with the latest request
+ * kept as the target, so dragging across an hour of footage stays
+ * responsive instead of queueing hundreds of seeks.
+ */
+ const requestScrubFrame = useCallback((unitF: number) => {
+ const v = scrubVideoRef.current;
+ const canvas = previewCanvasRef.current;
+ if (!v || !canvas || !v.duration) return;
+ scrubWantRef.current = unitF;
+ if (scrubBusyRef.current) return;
+
+ const pump = () => {
+ const want = scrubWantRef.current;
+ if (want === null || !scrubVideoRef.current) {
+ scrubBusyRef.current = false;
+ return;
+ }
+ scrubWantRef.current = null;
+ scrubBusyRef.current = true;
+ const target = Math.max(0, Math.min(v.duration - 0.05, want + 0.5));
+ const onSeeked = () => {
+ v.removeEventListener("seeked", onSeeked);
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ try {
+ ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
+ } catch {
+ // Tainted — leave the card blank rather than throwing.
+ }
+ }
+ if (scrubWantRef.current !== null) pump();
+ else scrubBusyRef.current = false;
+ };
+ v.addEventListener("seeked", onSeeked);
+ v.currentTime = target;
+ };
+ pump();
+ }, []);
// ── Pointer plumbing ────────────────────────────────────────
const unitFromEvent = useCallback(
@@ -299,11 +416,14 @@ export function TimelapseEditor({
[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 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;
@@ -331,9 +451,15 @@ export function TimelapseEditor({
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
+ const unitF = unitFromEvent(e);
const drag = dragRef.current;
+
+ // The scrubber preview follows the pointer whether or not a drag is
+ // in progress — while dragging a cut edge it IS the boundary frame.
+ setHoverUnit(unitF);
+ requestScrubFrame(unitF);
+
if (!drag) return;
- const unitF = unitFromEvent(e);
if (drag.kind === "scrub") {
seekTo(unitF);
@@ -353,7 +479,7 @@ export function TimelapseEditor({
});
dragRef.current = {
kind: "region",
- index: regionsRef.current.length, // index of the region just added
+ index: regionsRef.current.length,
mode: unitF >= drag.downUnitF ? "end" : "start",
grabOffset: 0,
anchorUnit: Math.floor(drag.downUnitF),
@@ -361,7 +487,6 @@ export function TimelapseEditor({
return;
}
- // Region move/resize. Edges snap to whole units by construction.
setRegions((prev) => {
const next = prev.map((r) => ({ ...r }));
const r = next[drag.index];
@@ -374,20 +499,16 @@ export function TimelapseEditor({
r.endUnit = start + width;
} else if (drag.mode === "start") {
r.startUnit = Math.max(0, Math.min(r.endUnit - 1, Math.round(unitF)));
- // The preview rides the dragged edge: show the first REMOVED unit.
seekTo(r.startUnit + 0.02);
} else {
const anchor = drag.anchorUnit;
const rounded = Math.round(unitF);
if (rounded <= anchor) {
- // Dragged back across the anchor — grow leftward instead.
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));
- // Show the first KEPT unit after the cut — the frame the splice
- // will land on.
seekTo(Math.min(unitCount - 0.05, r.endUnit + 0.02));
}
}
@@ -395,7 +516,7 @@ export function TimelapseEditor({
});
setSelected(drag.index);
},
- [seekTo, unitCount, unitFromEvent],
+ [requestScrubFrame, seekTo, unitCount, unitFromEvent],
);
const onPointerUp = useCallback(() => {
@@ -403,17 +524,13 @@ export function TimelapseEditor({
dragRef.current = null;
if (!drag) return;
if (drag.kind === "maybe") {
- // A plain click: seek. (Deselect any selected region.)
seekTo(drag.downUnitF);
setSelected(null);
return;
}
if (drag.kind === "region") {
- setRegions((prev) => {
- const next = normalizeRegions(prev);
- setSelected(null);
- return next;
- });
+ setRegions((prev) => normalizeRegions(prev));
+ setSelected(null);
}
}, [seekTo]);
@@ -434,7 +551,30 @@ export function TimelapseEditor({
[beginDrag, saving, unitFromEvent],
);
- // ── Keyboard: space = play/pause, delete = remove selection ─
+ 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
@@ -443,9 +583,13 @@ export function TimelapseEditor({
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) {
@@ -457,26 +601,16 @@ export function TimelapseEditor({
setSelected(null);
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
- seekTo((videoRef.current?.currentTime ?? 0) + (e.key === "ArrowLeft" ? -1 : 1));
+ 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);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selected, seekTo]);
-
- const togglePlay = useCallback(() => {
- const v = videoRef.current;
- if (!v) return;
- if (v.paused) {
- // Never start playback inside a cut — jump to the next kept unit.
- const region = regionAtTime(v.currentTime, regionsRef.current);
- if (region && region.endUnit < unitCount) v.currentTime = region.endUnit;
- void v.play();
- } else {
- v.pause();
- }
- }, [unitCount]);
+ }, [selected, seekTo, togglePlay, cutHere]);
// ── Publish ─────────────────────────────────────────────────
const save = useCallback(async () => {
@@ -485,9 +619,6 @@ export function TimelapseEditor({
setSaveError(null);
try {
const cuts = regionsToCuts(normalizeRegions(regionsRef.current), data.units);
- // Always write the list — an empty one is a meaningful "publish it
- // as recorded" — then publish. The server bakes cuts in before the
- // session ever reaches `complete`.
await client.setCuts(cuts);
await client.applyCuts();
onApplied?.();
@@ -505,18 +636,17 @@ export function TimelapseEditor({
const gaps = useMemo(() => (data ? gapIndices(data.units) : []), [data]);
const currentUnit = unitAtTime(time, Math.max(1, unitCount));
const inCutNow = regionAtTime(time, normalized) !== null;
-
const pct = (u: number) => `${(u / Math.max(1, unitCount)) * 100}%`;
// ── Render ──────────────────────────────────────────────────
if (loadError) {
return (
-
+
{onCancel && (
- ← Back
+ Close
)}
@@ -528,13 +658,15 @@ export function TimelapseEditor({
return (
{preparing ? (
@@ -548,19 +680,21 @@ export function TimelapseEditor({
- Preparing your timelapse…
+ Preparing your timelapse
+ {/* ── Stage: the only row that flexes ──────────────────── */}
setPlaying(true)}
onPause={() => setPlaying(false)}
- style={{ width: "100%", display: "block", cursor: "pointer" }}
+ // 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",
+ }}
/>
- {/* Play affordance when paused (clicking the video toggles) */}
+
{!playing && (
)}
- {/* Removed-minute overlay while scrubbing inside a cut */}
+
{inCutNow && (
- This minute will be removed
+ Will be removed
)}
- {/* Wall-clock of the frame under the playhead */}
- {unitCount > 0 && (
+
+ {holdSecondsLeft !== null && holdSecondsLeft < 120
+ ? `Publishing automatically in ${holdSecondsLeft}s`
+ : "Drag the strip to cut · Space to preview · X to cut a minute"}
+
+
+
+
-
{selected !== null && (
-
-
- {saveError && (
-
- )}
- {/* The hold is the promise that nothing gets lost — say so plainly,
- and get louder as it runs out. */}
- {holdSecondsLeft !== null && (
-
- {holdSecondsLeft < 120
- ? `Publishing automatically in ${holdSecondsLeft}s — save now to keep your cuts.`
- : `Not published yet. If you close this, it publishes as recorded in ${Math.round(
- holdSecondsLeft / 60,
- )} min.`}
-
- )}
+ {saveError && (
+
+ )}
+
);
}
diff --git a/clients/react/src/components/editorStyles.ts b/clients/react/src/components/editorStyles.ts
new file mode 100644
index 00000000..1608b922
--- /dev/null
+++ b/clients/react/src/components/editorStyles.ts
@@ -0,0 +1,73 @@
+// 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 #3b82f6;
+ }
+
+ .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); }
+
+ .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 #3b82f6;
+ }
+
+ .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 {
+ transition-duration: 1ms;
+ }
+ .lk-ed-fade-in { animation-duration: 1ms; }
+ .lk-ed-handle:hover .lk-ed-grip { transform: none; }
+ }
+ `;
+ document.head.appendChild(style);
+}
diff --git a/clients/react/src/ui/theme.ts b/clients/react/src/ui/theme.ts
index d0ef70ea..dd3b52b2 100644
--- a/clients/react/src/ui/theme.ts
+++ b/clients/react/src/ui/theme.ts
@@ -36,6 +36,15 @@ 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-track: rgba(255, 255, 255, 0.06);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
@@ -69,6 +78,12 @@ 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-track: rgba(0, 0, 0, 0.06);
}
}
:root[data-theme="light"] {
@@ -102,6 +117,12 @@ 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-track: rgba(0, 0, 0, 0.06);
}`;
document.head.appendChild(style);
}
@@ -112,6 +133,16 @@ 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)" },
+ /** 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)",
+ },
skeleton: { bg: "var(--color-skeleton-bg)", shimmer: "var(--color-skeleton-shimmer)" },
badge: {
primaryBg: "var(--color-badge-primary-bg)",
From e966b16539d7df8e6335014562630c5734e01daf Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 01:15:26 +0800
Subject: [PATCH 24/65] fix(editor): filmstrip tiles whole frames sized by the
track, at device resolution
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two separate defects behind 'why is the preview so low quality'.
Resolution: every thumbnail canvas was sized in CSS pixels, so on a 2x
display the browser scaled a half-resolution bitmap up. Backing stores are
now device-pixel sized (capped at 2x), drawn with high-quality smoothing,
and the strip JPEG went 0.6 -> 0.82.
Geometry: the strip packed one tile per capture unit into equal flex
cells, so a 48-minute session squeezed each frame into ~19px and cropped
it with background-size:cover — a smear, not a filmstrip. Tiles are now
whole uncropped frames at the video's own aspect ratio, and the count
comes from the track instead of the recording:
tileW = STRIP_HEIGHT x (videoW / videoH) ~= 100px at 16:9
tiles = ceil(trackW / tileW) ~= 9 across a 900px track
tile i samples t = clamp(((i + 0.5) x tileW) / trackW) x duration
The last tile overruns the track and is clipped, as a real filmstrip is.
Regenerates on resize (debounced 220ms, since each pass is a run of
decoder seeks). The hover card's height now derives from the source
aspect ratio rather than an assumed 16:9.
---
.../react/src/components/TimelapseEditor.tsx | 210 +++++++++++++-----
1 file changed, 153 insertions(+), 57 deletions(-)
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index 27669f45..ea26f82c 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -41,10 +41,19 @@ export interface TimelapseEditorProps {
const STRIP_HEIGHT = 56;
const RULER_HEIGHT = 18;
-const FILMSTRIP_SAMPLES = 48;
-/** Scrubber preview card. 16:9 keeps it honest about the source footage. */
-const PREVIEW_W = 168;
-const PREVIEW_H = 96;
+/** 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;
+/** Scrubber preview card, in CSS px. Height is derived from the video's
+ * own aspect ratio at render time, not assumed. */
+const PREVIEW_W = 192;
+/** 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);
/** Fixed cost of a compile: claim, sampling query, assembly, upload. */
const COMPILE_BASE_MS = 6_000;
/** Marginal cost per capture unit (download + 1s segment encode, across
@@ -310,58 +319,109 @@ export function TimelapseEditor({
};
}, [frameSrc]);
- // Filmstrip: sample evenly across the video, streaming partial strips in
- // so the timeline fills progressively instead of blocking on the whole set.
+ // 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(() => {
- if (!frameSrc || unitCount === 0) return;
- let cancelled = false;
-
- const v = document.createElement("video");
- if (!frameSrc.startsWith("blob:")) v.crossOrigin = "anonymous";
- v.muted = true;
- v.preload = "auto";
- v.src = frameSrc;
-
- (async () => {
- try {
- await new Promise((resolve, reject) => {
- v.onloadedmetadata = () => resolve();
- v.onerror = () => reject(new Error("filmstrip video load failed"));
- });
- const canvas = document.createElement("canvas");
- const thumbH = STRIP_HEIGHT;
- const thumbW = Math.round((v.videoWidth / Math.max(1, v.videoHeight)) * thumbH);
- canvas.width = thumbW;
- canvas.height = thumbH;
- const ctx = canvas.getContext("2d");
- if (!ctx) return;
+ 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]);
- const count = Math.min(FILMSTRIP_SAMPLES, unitCount);
- const thumbs: string[] = [];
- for (let i = 0; i < count; i++) {
- if (cancelled) return;
- const t = Math.min(v.duration - 0.05, (i / count) * unitCount + 0.5);
- await new Promise((resolve) => {
- v.onseeked = () => resolve();
- v.currentTime = t;
+ /**
+ * 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"));
});
- ctx.drawImage(v, 0, 0, thumbW, thumbH);
- thumbs.push(canvas.toDataURL("image/jpeg", 0.6));
- if (i % 4 === 3) setFilmstrip([...thumbs]);
+ 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();
}
- if (!cancelled) setFilmstrip(thumbs);
- } catch (err) {
- console.error("[editor] filmstrip generation failed:", err);
- } finally {
- v.removeAttribute("src");
- v.load();
- }
- })();
+ })();
+ }, 220);
return () => {
cancelled = true;
+ clearTimeout(timer);
+ if (v) {
+ v.removeAttribute("src");
+ v.load();
+ }
};
- }, [frameSrc, unitCount]);
+ }, [frameSrc, unitCount, stripWidth]);
/**
* Draw the hovered frame into the preview card — the iOS-scrubber move.
@@ -373,6 +433,21 @@ export function TimelapseEditor({
const v = scrubVideoRef.current;
const canvas = previewCanvasRef.current;
if (!v || !canvas || !v.duration) return;
+
+ // Size the backing store to device pixels once the video's real
+ // dimensions are known. Skipping this is what makes a preview look
+ // soft on a retina display: CSS scales a half-resolution bitmap up.
+ if (v.videoWidth) {
+ const dpr = pixelRatio();
+ const aspect = v.videoWidth / Math.max(1, v.videoHeight);
+ const wantW = Math.round(PREVIEW_W * dpr);
+ const wantH = Math.round((PREVIEW_W / aspect) * dpr);
+ if (canvas.width !== wantW || canvas.height !== wantH) {
+ canvas.width = wantW;
+ canvas.height = wantH;
+ }
+ }
+
scrubWantRef.current = unitF;
if (scrubBusyRef.current) return;
@@ -390,6 +465,7 @@ export function TimelapseEditor({
const ctx = canvas.getContext("2d");
if (ctx) {
try {
+ ctx.imageSmoothingQuality = "high";
ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
} catch {
// Tainted — leave the card blank rather than throwing.
@@ -924,9 +1000,14 @@ export function TimelapseEditor({
{frameSrc && (
)}
-
+ {/* 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) => (
-
))}
From 10aa9a79030b7c611733cd319028d61df20f077e Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 01:24:45 +0800
Subject: [PATCH 25/65] feat(editor): the edit hold is a lease the editor
renews, not a countdown
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A fixed 30-minute hold was wrong in both directions: it could cut off
someone carefully trimming a long recording, and it left an abandoned
session unpublished for half an hour. Neither is a real property of
'is the user still editing?'.
Now whatever surface represents active editing renews a short lease:
POST /:token/editing every 30s, server holds 120s past the last renewal.
Editing takes exactly as long as it takes, and closing the window or
quitting publishes the timelapse about two minutes later. An absolute
ceiling measured from the stop (EDIT_HOLD_MAX_MINUTES) bounds an editor
left open overnight so a program is never waiting forever.
- shared: EDIT_LEASE_SECONDS / EDIT_HEARTBEAT_SECONDS /
EDIT_HOLD_MAX_MINUTES replace EDIT_HOLD_MINUTES
- server: POST /:token/editing renews (and renews through a term that
lapsed seconds ago, so a network stall doesn't end an edit — the
status guard is what keeps that from resurrecting a published
session); stop opens one lease term; the expiry job also fires on the
ceiling
- react SDK: useEditLease hook, held by BOTH the editor and the
SessionDetail review panel — sitting on the review panel is still
deciding, and must not publish underneath the reader
- countdown UI removed everywhere; the copy now just says nothing is
published until you save, and that closing Lookout publishes as
recorded
- lease tests: renewal extends, lapsed-but-unprocessed renews, published
sessions report held:false and stay published, ceiling stops renewal
---
clients/react/API.md | 22 +++++-
clients/react/src/api/client.ts | 11 +++
.../react/src/components/SessionDetail.tsx | 43 +++++------
.../react/src/components/TimelapseEditor.tsx | 33 ++++-----
clients/react/src/hooks/useEditLease.ts | 50 +++++++++++++
clients/react/src/index.ts | 1 +
docs/edit-feature-plan.md | 49 ++++++++----
docs/integration.md | 10 ++-
packages/server/API.md | 24 ++++--
packages/server/src/lib/timeouts.ts | 19 +++--
packages/server/src/routes/sessions.ts | 74 ++++++++++++++++++-
.../server/test/edits.integration.test.ts | 63 +++++++++++++++-
packages/shared/src/cuts.ts | 37 ++++++++--
packages/shared/src/types.ts | 17 ++++-
14 files changed, 361 insertions(+), 92 deletions(-)
create mode 100644 clients/react/src/hooks/useEditLease.ts
diff --git a/clients/react/API.md b/clients/react/API.md
index bbcc3333..9aa8703c 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -747,6 +747,19 @@ 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.
@@ -794,8 +807,12 @@ baked in (a lossless server-side stream copy) or without them. Standalone
The editor normally opens **before** the preview video exists — the compile
starts at stop and takes tens of seconds — so it polls through that state
and shows a `` sized from the session's capture count, then
-swaps to the timeline when the video lands. It also counts down to the
-hold's auto-publish (getting louder in the last two minutes).
+swaps to the timeline when the video lands.
+
+While mounted it 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
@@ -895,6 +912,7 @@ const session = await client.getSession();
| `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" |
---
diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts
index f145532f..d0a619b4 100644
--- a/clients/react/src/api/client.ts
+++ b/clients/react/src/api/client.ts
@@ -13,6 +13,7 @@ import type {
UnitsResponse,
SetCutsResponse,
ApplyCutsResponse,
+ EditHeartbeatResponse,
CutInterval,
} from "@lookout/shared";
import type { TokenProvider } from "../types.js";
@@ -53,6 +54,10 @@ export interface LookoutClient {
/** 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 {
@@ -223,5 +228,11 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient
method: "POST",
});
},
+
+ async heartbeatEditing() {
+ return fetchJson(await sessionUrl("/editing"), {
+ method: "POST",
+ });
+ },
};
}
diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx
index 7cbec1a8..2fedfc0d 100644
--- a/clients/react/src/components/SessionDetail.tsx
+++ b/clients/react/src/components/SessionDetail.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react";
+import { useState, useEffect, useLayoutEffect, 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";
@@ -7,6 +7,8 @@ import { ProgressRing } from "../ui/ProgressRing.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 { SessionDetailSkeleton } from "../ui/Skeleton.js";
import { Card } from "../ui/Card.js";
import { Badge } from "../ui/Badge.js";
@@ -20,29 +22,20 @@ import { statusConfig, colors, spacing, fontSize, fontWeight, radii } from "../u
*/
function HoldPanel({
editable,
- holdUntil,
+ client,
onEdit,
onPublish,
}: {
editable: boolean;
- holdUntil: string;
+ client: LookoutClient;
onEdit: () => void;
onPublish: () => void | Promise;
}) {
- const [secondsLeft, setSecondsLeft] = useState(() =>
- Math.max(0, Math.round((Date.parse(holdUntil) - Date.now()) / 1000)),
- );
const [publishing, setPublishing] = useState(false);
- useEffect(() => {
- const id = setInterval(
- () =>
- setSecondsLeft(
- Math.max(0, Math.round((Date.parse(holdUntil) - Date.now()) / 1000)),
- ),
- 1000,
- );
- return () => clearInterval(id);
- }, [holdUntil]);
+ // 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);
// Same asymptotic estimate the editor uses (the worker reports no real
// progress); it eases toward 100% and the `editable` flip is what
@@ -58,8 +51,6 @@ function HoldPanel({
return () => clearInterval(id);
}, [editable]);
- const minutesLeft = Math.max(1, Math.round(secondsLeft / 60));
-
return (
@@ -81,13 +72,9 @@ function HoldPanel({
? "Cut out anything you don't want to share, then save. Nothing is published until you do."
: "Your recording is compiling. You'll be able to trim it in a moment."}
{" "}
- {secondsLeft > 0 && (
-
- {secondsLeft < 120
- ? `Publishing automatically in ${secondsLeft}s.`
- : `Publishes automatically in ${minutesLeft} min.`}
-
- )}
+
+ If you close Lookout, it publishes as recorded.
+
@@ -174,6 +161,10 @@ export function SessionDetail({
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"
@@ -306,7 +297,7 @@ export function SessionDetail({
{status && !editing && inHold && (
(onEdit ? onEdit() : setEditing(true))}
onPublish={async () => {
try {
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index ea26f82c..565ae491 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -20,6 +20,7 @@ import {
unitClockLabel,
type UnitRegion,
} from "../hooks/editorMath.js";
+import { useEditLease } from "../hooks/useEditLease.js";
import { injectEditorStyles } from "./editorStyles.js";
import { Button } from "../ui/Button.js";
import { Spinner } from "../ui/Spinner.js";
@@ -190,24 +191,18 @@ export function TimelapseEditor({
return () => clearInterval(id);
}, [preparing]);
- // ── Hold countdown ──────────────────────────────────────────
- const holdUntilMs = data?.editHoldUntil ? Date.parse(data.editHoldUntil) : null;
- const [holdSecondsLeft, setHoldSecondsLeft] = useState(null);
+ // ── 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 (holdUntilMs === null) return;
- const tick = () => {
- const left = Math.max(0, Math.round((holdUntilMs - Date.now()) / 1000));
- setHoldSecondsLeft(left);
- if (left === 0) {
- setLoadError(
- "The edit window closed, so your timelapse was published as recorded.",
- );
- }
- };
- tick();
- const id = setInterval(tick, 1000);
- return () => clearInterval(id);
- }, [holdUntilMs]);
+ 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(() => {
@@ -1231,9 +1226,7 @@ export function TimelapseEditor({
)}
- {holdSecondsLeft !== null && holdSecondsLeft < 120
- ? `Publishing automatically in ${holdSecondsLeft}s`
- : "Drag the strip to cut · Space to preview · X to cut a minute"}
+ Drag the strip to cut · Space to preview · X to cut a minute
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/index.ts b/clients/react/src/index.ts
index 156bbde2..f075522f 100644
--- a/clients/react/src/index.ts
+++ b/clients/react/src/index.ts
@@ -11,6 +11,7 @@ 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,
diff --git a/docs/edit-feature-plan.md b/docs/edit-feature-plan.md
index 8e89f2c3..c6e2f9b0 100644
--- a/docs/edit-feature-plan.md
+++ b/docs/edit-feature-plan.md
@@ -16,9 +16,18 @@ recording ──Stop──> ┌ Keep recording ───────────
│
├ user cuts → publish ──> compiling ──> complete
├ "publish as recorded" ───────────────> complete
- └ hold expires (30 min) ───────────────> 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
@@ -31,7 +40,7 @@ Consequences that fall out of this:
- **Programs need no changes at all.** The observable lifecycle is still
`stopped → compiling → complete`; an edit just means longer in `stopped`.
- **The hold can delay publication, never cancel it.** A background job
- publishes the timelapse as recorded when the hold expires, so an
+ 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.
@@ -100,7 +109,7 @@ subtraction happens in the one read-side dispatcher
| `original_video_r2_key` | `text` | The uncut compiled video — the editor's preview source. Nulled (and the object deleted) as soon as an edited publish lands |
| `video_copy_aligned` | `boolean` | True when assembly used the stream-copy path (GOP grid guaranteed). False → the cut must use its re-encode fallback |
| `recompile_count` | `integer not null default 0` | User-initiated publishes-with-cuts, capped |
-| `edit_hold_until` | `timestamptz` | While set and in the future, the compiled video stays unpublished (`status` `stopped`, `video_r2_key` null) so the owner can cut it. Cleared by the publish call or the expiry job |
+| `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.
@@ -123,8 +132,8 @@ Token-authenticated, rate-limited like their neighbors.
1. **`POST /api/sessions/:token/stop`** — accepts an optional body
`{ edit: true }`. Absent (or no body at all) → today's behavior
byte-for-byte, so shipped clients are untouched. Present, and the
- session has captures → sets `edit_hold_until = now + 30 min` and
- returns it. The compile is enqueued either way.
+ 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
@@ -150,10 +159,17 @@ Token-authenticated, rate-limited like their neighbors.
session endpoint — add `cuts`, `cutSeconds`, `uncutTrackedSeconds`,
`editable`, `editHoldUntil`. `trackedSeconds` becomes post-cut
everywhere via the dispatcher.
-7. **Hold expiry** (`lib/timeouts.ts`, on the existing every-minute cron):
- publish any `stopped` session whose `edit_hold_until` has passed, via
- the shared `publishHeldSession` helper. This is what makes offering the
- edit step safe — the hold delays publication, never cancels it.
+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
@@ -250,9 +266,9 @@ their single video file forever, as today.
stop and runs for tens of seconds — so `preparing` is the normal
opening state, not an error. It polls through it behind a
`` sized from `expectedUnits` (compile time scales with
- unit count), then swaps to the timeline. It also counts down to the
- hold's auto-publish (louder in the last two minutes) so the user always
- knows the timelapse is safe but not yet out.
+ 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
@@ -263,8 +279,10 @@ their single video file forever, as today.
- Wiring: `LookoutRecorder` routes every Stop button through the modal and
renders the editor inline after an `edit` stop. `SessionDetail` shows a
**review panel** for any session with a live `editHoldUntil` (Edit & save
- / Publish as recorded / countdown), and suppresses the compile spinner
- there — "processing" under "ready to review" would contradict itself.
+ / 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.
@@ -335,7 +353,8 @@ but a 30-minute wait). Deploy order:
1. `@lookout/shared`: `CutInterval`, membership/normalize helpers,
constants (`MAX_CUT_INTERVALS`, `MAX_USER_RECOMPILES`,
- `EDIT_HOLD_MINUTES`, `EDIT_WINDOW_DAYS`).
+ `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.)
diff --git a/docs/integration.md b/docs/integration.md
index e8d39281..8c9d9b2e 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -466,10 +466,12 @@ What this means for your program:
means the session sits in `stopped` a little longer. The redirect hook
still fires when the session completes, which is now also the moment the
edits are in.
-- **An abandoned edit can't strand a timelapse.** The hold expires after 30
- minutes and the session publishes as recorded. It can delay publication,
- never cancel it. If you poll, treat a long `stopped` exactly as you
- always have.
+- **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
diff --git a/packages/server/API.md b/packages/server/API.md
index 00acd139..aa93f2df 100644
--- a/packages/server/API.md
+++ b/packages/server/API.md
@@ -424,7 +424,7 @@ Stops a session and enqueues video compilation if screenshots exist.
| Field | Type | Description |
|-------|------|-------------|
-| `edit` | boolean | Hold the timelapse unpublished after it compiles so the owner can cut it first. See [Edits (Cuts)](#edits-cuts). Omit for today's behavior. |
+| `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
@@ -436,7 +436,7 @@ Stops a session and enqueues video compilation if screenshots exist.
}
```
-`editHoldUntil` is present only when the stop requested `edit: true` and the session actually has captures to edit.
+`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
@@ -445,7 +445,7 @@ Stops a session and enqueues video compilation if screenshots exist.
**Notes:**
- Marks session `failed` immediately if no screenshots exist (skips compilation), regardless of `edit`
- Accumulates any remaining active time
-- Only send `edit: true` from a client that can render the editor. The hold auto-publishes when it expires, so an abandoned edit still yields a timelapse — but a client that requests a hold and then never shows an editor makes the user wait for no reason.
+- 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.
---
@@ -581,10 +581,24 @@ That is what the **edit hold** is for. `POST /stop` with `{"edit": true}` marks
stop {edit:true} ─> stopped (hold, compiling internally)
├─ PUT /cuts … then POST /compile ─> compiling ─> complete
├─ POST /compile with no cuts ────────────────────> complete
- └─ hold expires (30 min) ────────────────────────> complete
+ └─ lease lapses (~2 min unrenewed) ──────────────> complete
```
-The hold can only **delay** publication, never cancel it: if the user closes the app mid-edit, a background job publishes the timelapse as recorded when the hold expires. A stop without `{"edit": true}` behaves exactly as it always has, so existing clients are unaffected.
+**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.
diff --git a/packages/server/src/lib/timeouts.ts b/packages/server/src/lib/timeouts.ts
index b424427e..64e64370 100644
--- a/packages/server/src/lib/timeouts.ts
+++ b/packages/server/src/lib/timeouts.ts
@@ -19,6 +19,7 @@ import {
MAX_COMPILE_ATTEMPTS,
SCREENSHOT_RETENTION_DAYS,
EDIT_WINDOW_DAYS,
+ EDIT_HOLD_MAX_MINUTES,
} from "@lookout/shared";
/**
@@ -52,12 +53,19 @@ export async function registerTimeoutJobs() {
}
/**
- * Publish sessions whose edit hold ran out. This is the promise that makes
- * the "Edit & Save" flow safe to offer: a user who closes the app mid-edit
- * still gets their timelapse, uncut, within EDIT_HOLD_MINUTES — the hold
- * can delay publication, never cancel it.
+ * 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)
@@ -65,7 +73,8 @@ async function publishExpiredHolds() {
and(
eq(schema.sessions.status, "stopped"),
isNotNull(schema.sessions.editHoldUntil),
- lt(schema.sessions.editHoldUntil, new Date()),
+ sql`(${schema.sessions.editHoldUntil} < ${now}
+ OR ${schema.sessions.stoppedAt} < ${ceilingCutoff})`,
),
);
diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts
index 05127725..09e2e191 100644
--- a/packages/server/src/routes/sessions.ts
+++ b/packages/server/src/routes/sessions.ts
@@ -32,7 +32,8 @@ import {
CAPTURE_FORMATS,
CAPTURE_FORMAT_CONTENT_TYPES,
MAX_USER_RECOMPILES,
- EDIT_HOLD_MINUTES,
+ EDIT_LEASE_SECONDS,
+ EDIT_HOLD_MAX_MINUTES,
normalizeCuts,
isCutAt,
countCutUnits,
@@ -1112,10 +1113,13 @@ export async function sessionRoutes(app: FastifyInstance) {
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_HOLD_MINUTES * 60_000)
+ ? new Date(stopNow.getTime() + EDIT_LEASE_SECONDS * 1000)
: null;
const [updated] = await db
@@ -1365,6 +1369,72 @@ export async function sessionRoutes(app: FastifyInstance) {
},
);
+ // 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
diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts
index 0e7d6588..37e2ab45 100644
--- a/packages/server/test/edits.integration.test.ts
+++ b/packages/server/test/edits.integration.test.ts
@@ -11,6 +11,7 @@
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";
@@ -50,7 +51,7 @@ async function seedHeldSession(
trackedSeconds: 540,
startedAt: minute(0),
stoppedAt: minute(UNITS),
- editHoldUntil: new Date(Date.now() + 30 * 60_000),
+ editHoldUntil: new Date(Date.now() + EDIT_LEASE_SECONDS * 1000),
videoR2Key: null,
originalVideoR2Key: "timelapses/x/original.mp4",
thumbnailR2Key: "timelapses/x/thumbnail.jpg",
@@ -118,7 +119,7 @@ async function putCuts(token: string, cuts: unknown) {
const getJson = async (url: string) => (await app.inject({ method: "GET", url })).json();
describe("POST /stop with { edit }", () => {
- it("sets an edit hold and still enqueues the compile", async () => {
+ it("opens a short first lease and still enqueues the compile", async () => {
const s = await seedActiveSession();
const r = await app.inject({
method: "POST",
@@ -130,7 +131,11 @@ describe("POST /stop with { edit }", () => {
const row = await load(s.id);
expect(row!.status).toBe("stopped");
- expect(row!.editHoldUntil).not.toBeNull();
+ // 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 () => {
@@ -161,6 +166,58 @@ describe("POST /stop with { edit }", () => {
});
});
+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();
diff --git a/packages/shared/src/cuts.ts b/packages/shared/src/cuts.ts
index 6393adde..01296224 100644
--- a/packages/shared/src/cuts.ts
+++ b/packages/shared/src/cuts.ts
@@ -28,14 +28,37 @@ export const MAX_CUT_INTERVALS = 120;
export const MAX_USER_RECOMPILES = 5;
/**
- * How long a session stopped with `{edit: true}` waits, unpublished, for
- * the user to edit before it auto-publishes uncut. The clock starts at
- * stop. Editing happens ONLY inside this hold — never after `complete`,
- * because `complete` is the signal programs act on (forwarding heartbeats,
- * accepting submissions, firing the redirect hook); data must be final the
- * first time they see it.
+ * 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_MINUTES = 30;
+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
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 58b7c30c..2832d37a 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -262,12 +262,23 @@ export interface ResumeResponse {
export interface StopRequest {
/** Hold the session unpublished after compiling so the user can edit
- * (cut) it before programs see `complete`. The hold auto-publishes
- * after EDIT_HOLD_MINUTES if the user walks away. Only send this when
- * the stopping client can render the editor. */
+ * (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;
From b4cd87bcb7a2df3bdd4252120592497791ee2394 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 01:50:31 +0800
Subject: [PATCH 26/65] fix(editor): progress ring restarted at 0 on every poll
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 'preparing' poll ran setPreparing({ expectedUnits }) every 1.5s. Same
value, but a fresh object literal each time — so the state identity
changed, the progress effect that depends on it tore down and re-ran, and
its `startedAt = Date.now()` anchor reset. The ring walked 0 -> 12% -> 0
on a 1.5s cycle.
- the poll now stores a NUMBER, so an unchanged unit count is an identity
no-op and React bails out of the re-render entirely
- the start time is anchored in a ref for the whole preparing spell, so
even a genuine change in the count can't rewind it
- progress is clamped monotonic on top of that
- extracted estimateBuildProgress() as a pure function of elapsed time,
which is what makes rewinding structurally impossible, and covered it
with tests including the exact regression
- same anchoring applied to the review panel's ring, which had the latent
version of the bug
---
.../react/src/components/SessionDetail.tsx | 10 +++-
.../react/src/components/TimelapseEditor.tsx | 46 +++++++++++--------
clients/react/src/hooks/buildProgress.test.ts | 45 ++++++++++++++++++
clients/react/src/hooks/buildProgress.ts | 31 +++++++++++++
4 files changed, 111 insertions(+), 21 deletions(-)
create mode 100644 clients/react/src/hooks/buildProgress.test.ts
create mode 100644 clients/react/src/hooks/buildProgress.ts
diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx
index 2fedfc0d..cf5deafa 100644
--- a/clients/react/src/components/SessionDetail.tsx
+++ b/clients/react/src/components/SessionDetail.tsx
@@ -9,6 +9,7 @@ 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";
@@ -41,11 +42,16 @@ function HoldPanel({
// progress); it eases toward 100% and the `editable` flip is what
// actually ends it.
const [buildProgress, setBuildProgress] = useState(0);
+ const startedAtRef = useRef(null);
useEffect(() => {
if (editable) return;
- const startedAt = Date.now();
+ // 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 = () =>
- setBuildProgress(1 - Math.exp(-2.2 * ((Date.now() - startedAt) / 30_000)));
+ setBuildProgress((prev) =>
+ Math.max(prev, estimateBuildProgress(Date.now() - startedAt, 30_000)),
+ );
tick();
const id = setInterval(tick, 250);
return () => clearInterval(id);
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index 565ae491..58f47fd8 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -21,6 +21,7 @@ import {
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 { Spinner } from "../ui/Spinner.js";
@@ -55,11 +56,6 @@ const PREVIEW_W = 192;
* nothing visible here and triples the decode cost. */
const pixelRatio = () =>
Math.min(2, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
-/** Fixed cost of a compile: claim, sampling query, assembly, upload. */
-const COMPILE_BASE_MS = 6_000;
-/** Marginal cost per capture unit (download + 1s segment encode, across
- * the worker's 8-way pool). Only used to size the progress estimate. */
-const COMPILE_MS_PER_UNIT = 350;
type DragState =
| { kind: "maybe"; downUnitF: number }
@@ -100,9 +96,15 @@ export function TimelapseEditor({
const [data, setData] = useState(null);
const [loadError, setLoadError] = useState(null);
- /** Non-null while the preview video is still compiling. */
- const [preparing, setPreparing] = useState<{ expectedUnits: number } | null>(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);
+ /** 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);
@@ -141,13 +143,15 @@ export function TimelapseEditor({
const res = await client.getUnits();
if (cancelled) return;
if (res.editable && res.originalVideoUrl) {
- setPreparing(null);
+ setPreparingUnits(null);
setData(res);
setRegions(cutsToRegions(res.cuts, res.units));
return;
}
if (res.editableReason === "preparing" || res.editableReason === "no_original") {
- setPreparing({ expectedUnits: res.expectedUnits ?? 0 });
+ // Same number on every poll ⇒ React bails out, no re-render, and
+ // the progress effect below is left running undisturbed.
+ setPreparingUnits(res.expectedUnits ?? 0);
timer = setTimeout(load, 1500);
return;
}
@@ -179,17 +183,21 @@ export function TimelapseEditor({
// short of — 100%, and only completes when the real thing does; a ring
// that sat at 100% while the user waited would be worse than none.
useEffect(() => {
- if (!preparing) return;
- const startedAt = Date.now();
- const estimateMs = COMPILE_BASE_MS + preparing.expectedUnits * COMPILE_MS_PER_UNIT;
+ 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 = () => {
- const elapsed = Date.now() - startedAt;
- setBuildProgress(1 - Math.exp(-2.2 * (elapsed / estimateMs)));
+ const next = estimateBuildProgress(Date.now() - startedAt, estimateMs);
+ setBuildProgress((prev) => Math.max(prev, next));
};
tick();
const id = setInterval(tick, 200);
return () => clearInterval(id);
- }, [preparing]);
+ }, [preparingUnits]);
// ── Edit lease ──────────────────────────────────────────────
// This editor being open IS the signal that editing is in progress, so
@@ -740,7 +748,7 @@ export function TimelapseEditor({
padding: spacing.xl,
}}
>
- {preparing ? (
+ {preparingUnits !== null ? (
- {preparing && preparing.expectedUnits > 0
- ? `Stitching ${preparing.expectedUnits} minute${
- preparing.expectedUnits === 1 ? "" : "s"
+ {preparingUnits !== null && preparingUnits > 0
+ ? `Stitching ${preparingUnits} minute${
+ preparingUnits === 1 ? "" : "s"
} of footage. Nothing is published until you save.`
: "Nothing is published until you save."}
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));
+}
From 9254ccd151de037cfef1510d8ed8c9d1ef14412d Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 01:54:45 +0800
Subject: [PATCH 27/65] feat(editor): drop hover preview, ticked ruler,
grabbable playhead tag
- removes the hover-to-preview card and its second video decoder: the
filmstrip already shows the footage, and a card chasing the cursor was
noise on top of it (also frees a decoder and the coalescing machinery)
- playhead is now a rounded tag above the ruler with a stem through the
strip, per the reference. Rendered as a sibling of both lanes rather
than inside the strip, so the head isn't clipped by its overflow, and
it's grabbable: dragging the head scrubs
- ruler gains real ticks. rulerStep() picks the labelling interval from
the track width so labels never crowd and are always round numbers
(0/5/10, never 0/7/14); rulerTicks() places a major on each step and a
minor between, comparing on the rounded quotient so accumulated
half-step float drift can't miss a major
- filmstrip already regenerated on resize via ResizeObserver; the ruler
now recomputes from the same measurement, so both track the window
11 new tests cover step selection, spacing guarantees, float drift, and
empty input.
---
clients/react/API.md | 4 +-
.../react/src/components/TimelapseEditor.tsx | 276 ++++++------------
clients/react/src/components/editorStyles.ts | 10 +-
clients/react/src/hooks/editorMath.test.ts | 58 ++++
clients/react/src/hooks/editorMath.ts | 38 +++
5 files changed, 203 insertions(+), 183 deletions(-)
diff --git a/clients/react/API.md b/clients/react/API.md
index 9aa8703c..3ff1ec5e 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -809,7 +809,9 @@ 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.
-While mounted it holds the session's **edit lease** (see `useEditLease`),
+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.
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index 58f47fd8..3b5e69b8 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -16,6 +16,8 @@ import {
normalizeRegions,
regionAtTime,
regionsToCuts,
+ rulerStep,
+ rulerTicks,
unitAtTime,
unitClockLabel,
type UnitRegion,
@@ -42,14 +44,14 @@ export interface TimelapseEditorProps {
}
const STRIP_HEIGHT = 56;
-const RULER_HEIGHT = 18;
+const RULER_HEIGHT = 22;
+/** Playhead head: a rounded tag above the ruler, wide enough to grab. */
+const HEAD_W = 20;
+const HEAD_H = 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;
-/** Scrubber preview card, in CSS px. Height is derived from the video's
- * own aspect ratio at render time, not assumed. */
-const PREVIEW_W = 192;
/** 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
@@ -112,15 +114,9 @@ export function TimelapseEditor({
const [filmstrip, setFilmstrip] = useState([]);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
- /** Scrubber preview: the unit under the pointer, or null when away. */
- const [hoverUnit, setHoverUnit] = useState(null);
const videoRef = useRef(null);
const timelineRef = useRef(null);
- const previewCanvasRef = useRef(null);
- const scrubVideoRef = useRef(null);
- const scrubBusyRef = useRef(false);
- const scrubWantRef = useRef(null);
const dragRef = useRef(null);
const regionsRef = useRef(regions);
regionsRef.current = regions;
@@ -305,23 +301,6 @@ export function TimelapseEditor({
};
}, [data?.originalVideoUrl]);
- // Persistent decoder for the hover scrubber. Kept separate from the
- // filmstrip pass below so the two never fight over `currentTime`.
- useEffect(() => {
- if (!frameSrc) return;
- const v = document.createElement("video");
- if (!frameSrc.startsWith("blob:")) v.crossOrigin = "anonymous";
- v.muted = true;
- v.preload = "auto";
- v.src = frameSrc;
- scrubVideoRef.current = v;
- return () => {
- scrubVideoRef.current = null;
- v.removeAttribute("src");
- v.load();
- };
- }, [frameSrc]);
-
// 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.
@@ -426,63 +405,6 @@ export function TimelapseEditor({
};
}, [frameSrc, unitCount, stripWidth]);
- /**
- * Draw the hovered frame into the preview card — the iOS-scrubber move.
- * Seeks are coalesced: one in flight at a time, with the latest request
- * kept as the target, so dragging across an hour of footage stays
- * responsive instead of queueing hundreds of seeks.
- */
- const requestScrubFrame = useCallback((unitF: number) => {
- const v = scrubVideoRef.current;
- const canvas = previewCanvasRef.current;
- if (!v || !canvas || !v.duration) return;
-
- // Size the backing store to device pixels once the video's real
- // dimensions are known. Skipping this is what makes a preview look
- // soft on a retina display: CSS scales a half-resolution bitmap up.
- if (v.videoWidth) {
- const dpr = pixelRatio();
- const aspect = v.videoWidth / Math.max(1, v.videoHeight);
- const wantW = Math.round(PREVIEW_W * dpr);
- const wantH = Math.round((PREVIEW_W / aspect) * dpr);
- if (canvas.width !== wantW || canvas.height !== wantH) {
- canvas.width = wantW;
- canvas.height = wantH;
- }
- }
-
- scrubWantRef.current = unitF;
- if (scrubBusyRef.current) return;
-
- const pump = () => {
- const want = scrubWantRef.current;
- if (want === null || !scrubVideoRef.current) {
- scrubBusyRef.current = false;
- return;
- }
- scrubWantRef.current = null;
- scrubBusyRef.current = true;
- const target = Math.max(0, Math.min(v.duration - 0.05, want + 0.5));
- const onSeeked = () => {
- v.removeEventListener("seeked", onSeeked);
- const ctx = canvas.getContext("2d");
- if (ctx) {
- try {
- ctx.imageSmoothingQuality = "high";
- ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
- } catch {
- // Tainted — leave the card blank rather than throwing.
- }
- }
- if (scrubWantRef.current !== null) pump();
- else scrubBusyRef.current = false;
- };
- v.addEventListener("seeked", onSeeked);
- v.currentTime = target;
- };
- pump();
- }, []);
-
// ── Pointer plumbing ────────────────────────────────────────
const unitFromEvent = useCallback(
(e: { clientX: number }): number => {
@@ -530,15 +452,9 @@ export function TimelapseEditor({
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
- const unitF = unitFromEvent(e);
const drag = dragRef.current;
-
- // The scrubber preview follows the pointer whether or not a drag is
- // in progress — while dragging a cut edge it IS the boundary frame.
- setHoverUnit(unitF);
- requestScrubFrame(unitF);
-
if (!drag) return;
+ const unitF = unitFromEvent(e);
if (drag.kind === "scrub") {
seekTo(unitF);
@@ -595,7 +511,7 @@ export function TimelapseEditor({
});
setSelected(drag.index);
},
- [requestScrubFrame, seekTo, unitCount, unitFromEvent],
+ [seekTo, unitCount, unitFromEvent],
);
const onPointerUp = useCallback(() => {
@@ -713,6 +629,11 @@ export function TimelapseEditor({
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}%`;
@@ -787,9 +708,6 @@ export function TimelapseEditor({
);
}
- const previewLeftPct =
- hoverUnit === null ? 0 : (hoverUnit / Math.max(1, unitCount)) * 100;
-
return (
{
- if (!dragRef.current) setHoverUnit(null);
- }}
>
- {/* Scrubber frame preview — follows the pointer, iOS-style.
- Falls back to a bare time chip when no frame source is
- available, so hovering still tells you where you are. */}
-
- {frameSrc && (
-
- )}
+ {/* Playhead: a grabbable tag above the ruler with a stem
+ through the strip. Rendered as a sibling of both lanes (not
+ inside the strip) so the head isn't clipped by its overflow. */}
+ {unitCount > 0 && (
+ )}
- {/* Ruler lane — owns scrubbing */}
+ {/* Ruler lane — owns scrubbing. Labels sit above their tick, at
+ a step chosen so they never crowd (see rulerStep). */}
diff --git a/clients/react/src/components/editorStyles.ts b/clients/react/src/components/editorStyles.ts
index 1608b922..43a9654e 100644
--- a/clients/react/src/components/editorStyles.ts
+++ b/clients/react/src/components/editorStyles.ts
@@ -33,6 +33,12 @@ export function injectEditorStyles(): void {
.lk-ed-grip { transition: transform 140ms ${EASE_OUT_QUART}; }
.lk-ed-handle:hover .lk-ed-grip { transform: scaleX(1.6); }
+ /* The head is the grab target for scrubbing, so it acknowledges the
+ pointer — otherwise it reads as decoration painted on the ruler. */
+ .lk-ed-playhead { transition: transform 120ms ${EASE_OUT_QUART}; }
+ .lk-ed-playhead:hover { transform: scale(1.12); }
+ .lk-ed-playhead:active { transform: scale(0.96); }
+
.lk-ed-iconbtn {
display: inline-flex; align-items: center; justify-content: center;
background: transparent; cursor: pointer; padding: 0;
@@ -62,11 +68,13 @@ export function injectEditorStyles(): void {
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-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; }
+ .lk-ed-playhead:hover { transform: none; }
}
`;
document.head.appendChild(style);
diff --git a/clients/react/src/hooks/editorMath.test.ts b/clients/react/src/hooks/editorMath.test.ts
index 925fcc0f..036b2686 100644
--- a/clients/react/src/hooks/editorMath.test.ts
+++ b/clients/react/src/hooks/editorMath.test.ts
@@ -8,6 +8,8 @@ import {
unitIsCut,
regionAtTime,
gapIndices,
+ rulerStep,
+ rulerTicks,
formatUnitsDuration,
type UnitRegion,
} from "./editorMath.js";
@@ -126,6 +128,62 @@ describe("gapIndices", () => {
});
});
+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");
diff --git a/clients/react/src/hooks/editorMath.ts b/clients/react/src/hooks/editorMath.ts
index 6a000f19..1f25f22c 100644
--- a/clients/react/src/hooks/editorMath.ts
+++ b/clients/react/src/hooks/editorMath.ts
@@ -133,3 +133,41 @@ export function unitClockLabel(unit: VideoUnit): string {
minute: "2-digit",
});
}
+
+/** 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;
+}
From f396a19026ae250daa9b9ff134ce53d91d4eb40a Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 01:58:04 +0800
Subject: [PATCH 28/65] fix(editor): ruler labelled time of day, which read as
a duration
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On a 17-minute timelapse the ruler showed '1:29' to '1:45'. Those were
correct — wall-clock times, 16 minutes apart across 17 captures — but
'1:29' reads as one minute twenty-nine, so the timeline looked wrong.
Being right and being legible are different things.
The ruler now labels elapsed recorded time, which is what a position on
a timeline actually means: '0m 5m 10m 15m' under an hour, '0:00 1:05
2:00' past it. The time of day moves to the transport readout, where
there's room to say it unambiguously and label it: '5m of 17m · recorded
at 1:34 PM'. unitClockLabel switched to hour:'numeric' so locales that
use AM/PM include it.
Also slims the playhead: the previous 20x22 tag was a white blob that
covered the label behind it. Now a 9x13 pill bottom-aligned to the ruler,
so it tucks under the labels rather than over them, with a 22px
invisible hit area so the small target is still easy to grab.
---
.../react/src/components/TimelapseEditor.tsx | 58 ++++++++++++-------
clients/react/src/components/editorStyles.ts | 11 ++--
clients/react/src/hooks/editorMath.test.ts | 22 +++++++
clients/react/src/hooks/editorMath.ts | 22 ++++++-
4 files changed, 86 insertions(+), 27 deletions(-)
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index 3b5e69b8..e2c2c8db 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -16,6 +16,7 @@ import {
normalizeRegions,
regionAtTime,
regionsToCuts,
+ elapsedLabel,
rulerStep,
rulerTicks,
unitAtTime,
@@ -45,9 +46,14 @@ export interface TimelapseEditorProps {
const STRIP_HEIGHT = 56;
const RULER_HEIGHT = 22;
-/** Playhead head: a rounded tag above the ruler, wide enough to grab. */
-const HEAD_W = 20;
-const HEAD_H = 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. */
@@ -854,9 +860,12 @@ export function TimelapseEditor({
letterSpacing: "-0.01em",
}}
>
- {unitClockLabel(units[currentUnit])}
+ {elapsedLabel(currentUnit, unitCount)}
- {" · "}min {currentUnit + 1} of {unitCount}
+ {" of "}
+ {elapsedLabel(unitCount, unitCount)}
+ {" · recorded at "}
+ {unitClockLabel(units[currentUnit])}
@@ -893,9 +902,9 @@ export function TimelapseEditor({
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
- {/* Playhead: a grabbable tag above the ruler with a stem
+ {/* 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 the head isn't clipped by its overflow. */}
+ inside the strip) so it isn't clipped by its overflow. */}
{unitCount > 0 && (
+ >
+
+
{ticks.map(({ unit, major }) => {
const left = (unit / Math.max(1, unitCount)) * 100;
- const idx = Math.min(unitCount - 1, Math.floor(unit));
return (
.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;
@@ -74,7 +75,7 @@ export function injectEditorStyles(): void {
}
.lk-ed-fade-in { animation-duration: 1ms; }
.lk-ed-handle:hover .lk-ed-grip { transform: none; }
- .lk-ed-playhead:hover { transform: none; }
+ *:hover > .lk-ed-playhead { transform: none; }
}
`;
document.head.appendChild(style);
diff --git a/clients/react/src/hooks/editorMath.test.ts b/clients/react/src/hooks/editorMath.test.ts
index 036b2686..d433e7ac 100644
--- a/clients/react/src/hooks/editorMath.test.ts
+++ b/clients/react/src/hooks/editorMath.test.ts
@@ -8,6 +8,7 @@ import {
unitIsCut,
regionAtTime,
gapIndices,
+ elapsedLabel,
rulerStep,
rulerTicks,
formatUnitsDuration,
@@ -128,6 +129,27 @@ describe("gapIndices", () => {
});
});
+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.
diff --git a/clients/react/src/hooks/editorMath.ts b/clients/react/src/hooks/editorMath.ts
index 1f25f22c..14808633 100644
--- a/clients/react/src/hooks/editorMath.ts
+++ b/clients/react/src/hooks/editorMath.ts
@@ -125,15 +125,33 @@ export function formatUnitsDuration(unitCount: number): string {
return `${m}m`;
}
-/** Wall-clock label (HH:MM, local) for a unit. */
+/** 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: "2-digit",
+ 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];
From fc55288b4072c7b161536b87deab927cea2244bc Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 02:01:05 +0800
Subject: [PATCH 29/65] fix(desktop): let macOS draw the editor window title
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hand-placed 'Edit timelapse' label sat a few pixels below the traffic
lights. Matching it by eye means pinning an offset against a position that
varies by macOS version — so instead the title is now the window's own
title, drawn and centered by the OS, which cannot drift. The session name
rides along with it ('Edit — ').
The in-window strip is now just drag area clearing the controls, and only
on macOS: Windows and Linux have real decorations above the webview, so
content there starts at the top instead of under 38px of nothing.
---
.../desktop/src/components/EditorWindow.tsx | 80 ++++++-------------
1 file changed, 26 insertions(+), 54 deletions(-)
diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx
index 88439b86..433f3c1e 100644
--- a/clients/desktop/src/components/EditorWindow.tsx
+++ b/clients/desktop/src/components/EditorWindow.tsx
@@ -63,7 +63,10 @@ export async function openEditorWindow(token: string): Promise {
// Transparent + overlay titlebar is what lets the vibrancy material
// show through, matching the main window's chrome exactly.
transparent: true,
- ...(isMacOS ? { titleBarStyle: "overlay" as const, hiddenTitle: 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);
@@ -183,14 +186,19 @@ export function useEditorWindowOpen(): string | null {
/**
* The editor window's root view (route `#/editor?token=…`).
*
- * An app shell, not a page: a draggable title strip, 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.
+ * 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");
- const [sessionName, setSessionName] = useState(null);
// Vibrancy: the main window does this too. The webview must be
// transparent for the material to show, so only go transparent once the
@@ -235,10 +243,7 @@ export function EditorWindow({ token }: { token: string }) {
fetch(`${getApiBase()}/api/sessions/${token}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
- if (d?.name) {
- setSessionName(d.name);
- void getCurrentWindow().setTitle(d.name);
- }
+ if (d?.name) void getCurrentWindow().setTitle(`Edit — ${d.name}`);
})
.catch(() => {
// Name is decoration — the editor works without it.
@@ -258,50 +263,15 @@ export function EditorWindow({ token }: { token: string }) {
flexDirection: "column",
}}
>
- {/* Title strip. Draggable, and on macOS it clears the traffic
- lights so the label never collides with them. */}
-
+ style={{ flex: "0 0 auto", height: 30, cursor: "default" }}
+ />
+ )}
{/* Body owns the rest. min-height:0 is what lets the stage inside
letterboxe down instead of clipping the dock off the bottom. */}
@@ -311,7 +281,9 @@ export function EditorWindow({ token }: { token: string }) {
minHeight: 0,
display: "flex",
flexDirection: "column",
- padding: `0 ${spacing.lg}px ${spacing.lg}px`,
+ padding: isMacOS
+ ? `0 ${spacing.lg}px ${spacing.lg}px`
+ : spacing.lg,
}}
>
Date: Mon, 27 Jul 2026 02:34:48 +0800
Subject: [PATCH 30/65] fix(editor): cuts could never stay selected; closing
now finishes the edit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- BUG: onPointerUp cleared the selection after every region gesture, so a
cut could never stay selected and 'Remove cut' was unreachable. The
selection now survives, re-found by position after normalization (which
can merge regions and shift indices)
- 'Not now' is gone. The session is unpublished until someone decides, so
an exit that decides nothing just strands it until the lease lapses.
Closing the editor window IS the decision: it confirms, publishes with
whatever cuts are on screen, and closes. TimelapseEditor reports its
working cut list via onCutsChange so the window can do that
- primary action reads 'Save', not 'Save & publish'/'Publish as recorded'
- removed the 'Cut minute' button and the keyboard-hint line (the X
shortcut stays)
- kept/removed minutes and the build percentage roll their digits via
@number-flow/react, which the desktop app already used — the readouts
change continuously under a drag, and hard text swaps read as flicker
---
.../desktop/src/components/EditorWindow.tsx | 68 ++++++++--
clients/react/API.md | 6 +-
clients/react/package.json | 1 +
.../react/src/components/TimelapseEditor.tsx | 117 ++++++++----------
clients/react/src/index.ts | 2 +-
clients/react/src/ui/MinutesFlow.tsx | 40 ++++++
clients/react/src/ui/ProgressRing.tsx | 12 +-
clients/react/src/ui/index.ts | 2 +
package-lock.json | 1 +
9 files changed, 171 insertions(+), 78 deletions(-)
create mode 100644 clients/react/src/ui/MinutesFlow.tsx
diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx
index 433f3c1e..09accf63 100644
--- a/clients/desktop/src/components/EditorWindow.tsx
+++ b/clients/desktop/src/components/EditorWindow.tsx
@@ -1,7 +1,9 @@
-import { useEffect, useState } from "react";
+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";
@@ -250,7 +252,56 @@ export function EditorWindow({ token }: { token: string }) {
});
}, [token]);
- const close = () => void getCurrentWindow().close().catch(() => {});
+ // 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;
+ try {
+ await client.setCuts(cutsRef.current);
+ await client.applyCuts();
+ await emit(EDITED_EVENT, { token });
+ } 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.
+ }
+ await getCurrentWindow().close().catch(() => {});
+ }, [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 (
{
+ cutsRef.current = cuts;
+ dirtyRef.current = dirty;
+ }}
onApplied={() => {
- // Tell the main window, then close. Fire-and-forget on purpose:
- // even if the emit fails, closing is correct — the main window
- // shows the published video on its next fetch.
+ // Saved from inside the editor. Flag it so the close handler
+ // doesn't prompt to publish something already published.
+ finishedRef.current = true;
void emit(EDITED_EVENT, { token })
.catch((e) => console.error("[editor] emit failed:", e))
- .finally(close);
+ .finally(() => void getCurrentWindow().close().catch(() => {}));
}}
- onCancel={close}
/>
diff --git a/clients/react/API.md b/clients/react/API.md
index 3ff1ec5e..527eef28 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -802,7 +802,8 @@ baked in (a lossless server-side stream copy) or without them. Standalone
| `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?` | Optional "not now". The session stays held and publishes itself when the hold expires, so nothing is lost. Omit where publishing should be an explicit choice |
+| `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
@@ -926,7 +927,8 @@ The SDK exports styled UI primitives used by its components. All use inline styl
|--------|-------------|
| `Button` | Styled button with variants: `primary`, `secondary`, `success`, `warning`, `danger`, `ghost` and sizes: `sm`, `md`, `lg` |
| `Spinner` | Loading spinner with sizes: `sm`, `md`, `lg` |
-| `ProgressRing` | Determinate circular progress (`progress` 0–1, optional centre `label`) — for waits long enough that a spinner under-informs |
+| `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` |
diff --git a/clients/react/package.json b/clients/react/package.json
index 007004b3..45bc857b 100644
--- a/clients/react/package.json
+++ b/clients/react/package.json
@@ -41,6 +41,7 @@
},
"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",
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index e2c2c8db..c773c04b 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -6,12 +6,11 @@ import {
useState,
} from "react";
import { AnimatePresence, motion } from "motion/react";
-import type { UnitsResponse } from "@lookout/shared";
+import type { CutInterval, UnitsResponse } from "@lookout/shared";
import { createLookoutClient, type LookoutClient } from "../api/client.js";
import {
cutsToRegions,
cutUnitCount,
- formatUnitsDuration,
gapIndices,
normalizeRegions,
regionAtTime,
@@ -27,6 +26,7 @@ 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";
@@ -38,10 +38,14 @@ export interface TimelapseEditorProps {
/** The timelapse was published — with cuts baked in, or without them.
* The caller should return to its detail view and poll status. */
onApplied?: () => void;
- /** Optional "not now" escape. The session stays held and publishes
- * itself when the hold expires, so this never loses anything. Omit it
- * in flows where publishing must be an explicit choice. */
+ /** 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;
@@ -94,6 +98,7 @@ export function TimelapseEditor({
apiBaseUrl,
onApplied,
onCancel,
+ onCutsChange,
}: TimelapseEditorProps) {
const client = useMemo(
() => createLookoutClient({ baseUrl: apiBaseUrl, token }),
@@ -525,13 +530,26 @@ export function TimelapseEditor({
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") {
- setRegions((prev) => normalizeRegions(prev));
- setSelected(null);
+ // 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]);
@@ -644,6 +662,17 @@ export function TimelapseEditor({
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 cuts = regionsToCuts(normalized, data.units);
+ const saved = JSON.stringify(data.cuts ?? []);
+ onCutsChangeRef.current?.(cuts, JSON.stringify(cuts) !== saved);
+ }, [normalized, data]);
+
// ── Render ──────────────────────────────────────────────────
if (loadError) {
return (
@@ -676,10 +705,7 @@ export function TimelapseEditor({
}}
>
{preparingUnits !== null ? (
-
+
) : (
)}
@@ -871,28 +897,6 @@ export function TimelapseEditor({
-
-
- Cut minute
-
@@ -1196,11 +1196,6 @@ export function TimelapseEditor({
Clear all
)}
- {onCancel && (
-
- Not now
-
- )}
- {saving
- ? "Saving…"
- : removedUnits > 0
- ? "Save & publish"
- : "Publish as recorded"}
+ {saving ? "Saving…" : "Save"}
diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts
index f075522f..693391cb 100644
--- a/clients/react/src/index.ts
+++ b/clients/react/src/index.ts
@@ -96,7 +96,7 @@ 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
diff --git a/clients/react/src/ui/MinutesFlow.tsx b/clients/react/src/ui/MinutesFlow.tsx
new file mode 100644
index 00000000..d4d62c04
--- /dev/null
+++ b/clients/react/src/ui/MinutesFlow.tsx
@@ -0,0 +1,40 @@
+import NumberFlow from "@number-flow/react";
+
+export interface MinutesFlowProps {
+ /** Whole minutes. */
+ minutes: number;
+ /** Colour for the numerals and units. */
+ color?: string;
+}
+
+/**
+ * A minute count whose digits animate between values.
+ *
+ * The kept/removed readouts change continuously while a cut region is
+ * dragged, and a hard text swap on every frame reads as flicker. Rolling
+ * the digits instead makes the number feel like it's being *adjusted* by
+ * the drag, which is exactly what's happening.
+ *
+ * Splits into hours and minutes past 60 so the unit is always explicit —
+ * a bare "83" or an ambiguous "1:23" both invite the wrong reading.
+ */
+export function MinutesFlow({ minutes, color }: MinutesFlowProps) {
+ const safe = Math.max(0, Math.round(minutes));
+ const h = Math.floor(safe / 60);
+ const m = safe % 60;
+ const style = { color, fontVariantNumeric: "tabular-nums" as const };
+
+ if (h === 0) {
+ return (
+
+ m
+
+ );
+ }
+ return (
+
+ h{" "}
+ m
+
+ );
+}
diff --git a/clients/react/src/ui/ProgressRing.tsx b/clients/react/src/ui/ProgressRing.tsx
index 36ab4aa2..ea1e5040 100644
--- a/clients/react/src/ui/ProgressRing.tsx
+++ b/clients/react/src/ui/ProgressRing.tsx
@@ -1,3 +1,4 @@
+import NumberFlow from "@number-flow/react";
import { colors, fontSize, fontWeight } from "./theme.js";
export interface ProgressRingProps {
@@ -5,8 +6,9 @@ export interface ProgressRingProps {
progress: number;
size?: number;
strokeWidth?: number;
- /** Centre label. Omit for a bare ring. */
- label?: string;
+ /** Percentage shown in the centre, with rolling digits. Omit for a
+ * bare ring. */
+ showPercent?: boolean;
color?: string;
}
@@ -18,7 +20,7 @@ export function ProgressRing({
progress,
size = 72,
strokeWidth = 5,
- label,
+ showPercent = false,
color,
}: ProgressRingProps) {
const clamped = Math.max(0, Math.min(1, progress));
@@ -58,7 +60,7 @@ export function ProgressRing({
style={{ transition: "stroke-dashoffset 0.25s linear" }}
/>
- {label && (
+ {showPercent && (
- {label}
+ %
)}
diff --git a/clients/react/src/ui/index.ts b/clients/react/src/ui/index.ts
index a006ae2d..0fb9806d 100644
--- a/clients/react/src/ui/index.ts
+++ b/clients/react/src/ui/index.ts
@@ -3,6 +3,8 @@ 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";
diff --git a/package-lock.json b/package-lock.json
index d7ad3ba2..b2966a6d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -58,6 +58,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",
From 1cd6e2a5465635a09a2fb1de74c8bd33f90490c1 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 02:37:18 +0800
Subject: [PATCH 31/65] style(editor): round and hatch the removed-footage
states
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 'will be removed' ring was an inset shadow on a square box inside a
rounded, clipped stage, so its corners were sliced — it read as a
rendering glitch rather than a border. Now radiused to match the stage.
Both removed-footage surfaces (the stage overlay and the timeline
regions) also carry a 45-degree hatch: the conventional 'excluded'
texture, and a second channel beyond colour so the state doesn't rest on
red alone. Painted as backgroundImage over backgroundColor so the
existing hover rule can swap the tint underneath without dropping the
stripes. New --color-cut-stripe token, themed for light and dark.
---
.../react/src/components/TimelapseEditor.tsx | 21 +++++++++++++++++--
clients/react/src/ui/theme.ts | 4 ++++
2 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index c773c04b..c0f9ac29 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -49,6 +49,13 @@ export interface TimelapseEditorProps {
}
const STRIP_HEIGHT = 56;
+/** Diagonal hatch marking removed footage — the conventional "this is
+ * excluded" texture, and a second channel beyond colour alone. */
+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
@@ -831,8 +838,13 @@ export function TimelapseEditor({
style={{
position: "absolute",
inset: 0,
+ // Matches the stage's radius: an inset ring on a square
+ // box inside a rounded, clipped parent gets sliced at the
+ // corners and reads as a rendering glitch.
+ borderRadius: 12,
boxShadow: `inset 0 0 0 3px ${colors.editor.cutBorder}`,
- background: "rgba(220, 38, 38, 0.12)",
+ backgroundColor: "rgba(220, 38, 38, 0.10)",
+ backgroundImage: hatch(16),
pointerEvents: "none",
}}
>
@@ -1096,7 +1108,12 @@ export function TimelapseEditor({
width: pct(r.endUnit - r.startUnit),
top: 0,
bottom: 0,
- background: colors.editor.cutFill,
+ 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}`,
diff --git a/clients/react/src/ui/theme.ts b/clients/react/src/ui/theme.ts
index dd3b52b2..494bea31 100644
--- a/clients/react/src/ui/theme.ts
+++ b/clients/react/src/ui/theme.ts
@@ -44,6 +44,7 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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.22);
--color-track: rgba(255, 255, 255, 0.06);
}
@media (prefers-color-scheme: light) {
@@ -83,6 +84,7 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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.20);
--color-track: rgba(0, 0, 0, 0.06);
}
}
@@ -122,6 +124,7 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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.20);
--color-track: rgba(0, 0, 0, 0.06);
}`;
document.head.appendChild(style);
@@ -142,6 +145,7 @@ export const colors = {
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: {
From 716efbd6f5df6f4ca2039a19a09f48b4f3b55dc1 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 02:41:08 +0800
Subject: [PATCH 32/65] style(editor): hatch the timeline only, and quieter
Texture over live footage fights the thing you're judging, so the stage
overlay keeps just its tint and rounded ring. The hatch stays on the
timeline, where the question is 'which stretch' rather than 'what's in
it', and drops to ~0.12 alpha so it registers as texture instead of
competing with the thumbnails underneath.
---
clients/react/src/components/TimelapseEditor.tsx | 11 ++++++++---
clients/react/src/ui/theme.ts | 6 +++---
2 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index c0f9ac29..f1828bc3 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -49,8 +49,10 @@ export interface TimelapseEditorProps {
}
const STRIP_HEIGHT = 56;
-/** Diagonal hatch marking removed footage — the conventional "this is
- * excluded" texture, and a second channel beyond colour alone. */
+/** 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
@@ -843,8 +845,11 @@ export function TimelapseEditor({
// corners and reads as a rendering glitch.
borderRadius: 12,
boxShadow: `inset 0 0 0 3px ${colors.editor.cutBorder}`,
+ // Tint only, no hatch: the stage is already showing the
+ // frame you're judging, and texture over live footage
+ // fights it. The hatch belongs on the timeline, where the
+ // question is "which stretch", not "what's in it".
backgroundColor: "rgba(220, 38, 38, 0.10)",
- backgroundImage: hatch(16),
pointerEvents: "none",
}}
>
diff --git a/clients/react/src/ui/theme.ts b/clients/react/src/ui/theme.ts
index 494bea31..8d1b94b4 100644
--- a/clients/react/src/ui/theme.ts
+++ b/clients/react/src/ui/theme.ts
@@ -44,7 +44,7 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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.22);
+ --color-cut-stripe: rgba(248, 113, 113, 0.13);
--color-track: rgba(255, 255, 255, 0.06);
}
@media (prefers-color-scheme: light) {
@@ -84,7 +84,7 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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.20);
+ --color-cut-stripe: rgba(220, 38, 38, 0.12);
--color-track: rgba(0, 0, 0, 0.06);
}
}
@@ -124,7 +124,7 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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.20);
+ --color-cut-stripe: rgba(220, 38, 38, 0.12);
--color-track: rgba(0, 0, 0, 0.06);
}`;
document.head.appendChild(style);
From d61d29fc7b1eeca5167c215a4144b74f5cc5e859 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 02:49:07 +0800
Subject: [PATCH 33/65] fix(desktop): editor window stayed open after saving,
and skipped the redirect hook
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two problems in the post-save path.
The close was wrapped in .catch(() => {}), so if it was refused the user
was left staring at a window that said it saved and wouldn't go away,
with nothing logged. Now: focus the main window first (closing the
frontmost window otherwise drops the user behind another app), then
close, and on failure log it and fall back to destroy() — which skips the
close-requested round trip. Added core:window:allow-destroy. The
EDITED emit no longer gates the close either; it's fire-and-forget, so a
hanging notification can't strand the window.
The redirect hook was silently skipped for every edited session.
SessionDetail fires it only on a live stopped/compiling -> complete
transition, and after an edit it remounts on a session that is already
complete — by design, so re-opening an old session doesn't re-redirect.
The main window now watches the session to completion when the editor
publishes, and both paths share one de-duped fireRedirect so a session
seen finishing by both only redirects once.
---
.../src-tauri/capabilities/default.json | 1 +
clients/desktop/src/App.tsx | 63 ++++++++++++++++---
.../desktop/src/components/EditorWindow.tsx | 48 +++++++++++---
3 files changed, 97 insertions(+), 15 deletions(-)
diff --git a/clients/desktop/src-tauri/capabilities/default.json b/clients/desktop/src-tauri/capabilities/default.json
index 84da0477..569a7c8e 100644
--- a/clients/desktop/src-tauri/capabilities/default.json
+++ b/clients/desktop/src-tauri/capabilities/default.json
@@ -27,6 +27,7 @@
"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": [
diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx
index 4d108f07..7331a83b 100644
--- a/clients/desktop/src/App.tsx
+++ b/clients/desktop/src/App.tsx
@@ -139,15 +139,62 @@ function MainWindowApp() {
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;
- listen(EDITED_EVENT, () => {
- console.log("[app] editor window applied cuts — refreshing");
+ let cancelled = false;
+
+ listen<{ token: string }>(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
+ // short 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. Watch it to completion here instead.
+ const token = event.payload?.token;
+ if (!token) return;
+ const deadline = Date.now() + 3 * 60_000;
+ 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.
+ }
+ setTimeout(poll, 2500);
+ };
+ void poll();
}).then((fn) => { unlisten = fn; });
- return () => { if (unlisten) unlisten(); };
- }, []);
+
+ return () => {
+ cancelled = true;
+ if (unlisten) unlisten();
+ };
+ }, [fireRedirect]);
// Initialize blacklisted apps sync from localStorage to Rust backend
useBlacklistedApps();
@@ -566,10 +613,10 @@ function MainWindowApp() {
onEdit={() => { void openEditorWindow(route.token); }}
onComplete={({ redirectUrl }) => {
// Redirect hook: the session's creator asked us to send the
- // user somewhere once their timelapse is ready.
- if (redirectUrl) {
- invoke("open_external_url", { url: redirectUrl }).catch(() => {});
- }
+ // 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();
diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx
index 09accf63..89b0ef91 100644
--- a/clients/desktop/src/components/EditorWindow.tsx
+++ b/clients/desktop/src/components/EditorWindow.tsx
@@ -76,6 +76,36 @@ export async function openEditorWindow(token: string): Promise {
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,
@@ -269,13 +299,16 @@ export function EditorWindow({ token }: { token: string }) {
try {
await client.setCuts(cutsRef.current);
await client.applyCuts();
- await emit(EDITED_EVENT, { token });
} 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.
}
- await getCurrentWindow().close().catch(() => {});
+ // Fire-and-forget: the close must not wait on the notification.
+ emit(EDITED_EVENT, { token }).catch((e) =>
+ console.error("[editor] emit failed:", e),
+ );
+ await closeEditorWindow();
}, [client, token]);
useEffect(() => {
@@ -345,12 +378,13 @@ export function EditorWindow({ token }: { token: string }) {
dirtyRef.current = dirty;
}}
onApplied={() => {
- // Saved from inside the editor. Flag it so the close handler
- // doesn't prompt to publish something already published.
+ // Saved from inside the editor. Flag it first so the close
+ // handler doesn't prompt to publish what's already published.
finishedRef.current = true;
- void emit(EDITED_EVENT, { token })
- .catch((e) => console.error("[editor] emit failed:", e))
- .finally(() => void getCurrentWindow().close().catch(() => {}));
+ emit(EDITED_EVENT, { token }).catch((e) =>
+ console.error("[editor] emit failed:", e),
+ );
+ void closeEditorWindow();
}}
/>
From 5b9ab050ffd3338b44ddfcc3a5583811367aca06 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 03:03:53 +0800
Subject: [PATCH 34/65] fix(editor): cut intervals assumed a 60s stride, so the
server over-counted
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Selecting 2 of 3 minutes was rejected with 'Cut list would remove the
entire timelapse' while the editor read '1m kept'. Two defects, one
visible.
Root cause: regionsToCuts ended an interval at lastCutUnit + 60s. Real
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. On a 3-minute
recording that was the difference between a valid cut and 'all of it'.
The end is exclusive, so it now anchors to the next KEPT capture's actual
timestamp, which excludes that capture exactly whatever the gap. Still
capped at one interval so a cut before a long pause can't swallow it.
Second defect, and why it presented as inconsistency: the footer counted
region widths in unit space while the server counted timestamp
membership. Two implementations of one question will drift. The footer
now runs the serialized intervals through the same shared countCutUnits
the server uses, so what it says is what the server computes — the Save
button can no longer offer something the server will reject.
Nine tests, including the reported 3-minute case at 57s spacing and a
sweep of gaps from 40s to 75s.
---
.../react/src/components/TimelapseEditor.tsx | 33 ++++++---
clients/react/src/hooks/editorMath.test.ts | 68 +++++++++++++++++--
clients/react/src/hooks/editorMath.ts | 29 ++++++--
3 files changed, 110 insertions(+), 20 deletions(-)
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index f1828bc3..9b565fac 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -6,11 +6,10 @@ import {
useState,
} from "react";
import { AnimatePresence, motion } from "motion/react";
-import type { CutInterval, UnitsResponse } from "@lookout/shared";
+import { countCutUnits, type CutInterval, type UnitsResponse } from "@lookout/shared";
import { createLookoutClient, type LookoutClient } from "../api/client.js";
import {
cutsToRegions,
- cutUnitCount,
gapIndices,
normalizeRegions,
regionAtTime,
@@ -658,7 +657,23 @@ export function TimelapseEditor({
// ── Derived display values ──────────────────────────────────
const normalized = useMemo(() => normalizeRegions(regions), [regions]);
- const removedUnits = cutUnitCount(normalized);
+ // 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]);
@@ -677,10 +692,12 @@ export function TimelapseEditor({
onCutsChangeRef.current = onCutsChange;
useEffect(() => {
if (!data) return;
- const cuts = regionsToCuts(normalized, data.units);
const saved = JSON.stringify(data.cuts ?? []);
- onCutsChangeRef.current?.(cuts, JSON.stringify(cuts) !== saved);
- }, [normalized, data]);
+ onCutsChangeRef.current?.(
+ serializedCuts,
+ JSON.stringify(serializedCuts) !== saved,
+ );
+ }, [serializedCuts, data]);
// ── Render ──────────────────────────────────────────────────
if (loadError) {
@@ -741,8 +758,8 @@ export function TimelapseEditor({
{preparingUnits !== null && preparingUnits > 0
? `Stitching ${preparingUnits} minute${
preparingUnits === 1 ? "" : "s"
- } of footage. Nothing is published until you save.`
- : "Nothing is published until you save."}
+ } of footage.`
+ : "oooooooooooo"}
diff --git a/clients/react/src/hooks/editorMath.test.ts b/clients/react/src/hooks/editorMath.test.ts
index d433e7ac..50f838ee 100644
--- a/clients/react/src/hooks/editorMath.test.ts
+++ b/clients/react/src/hooks/editorMath.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import type { CutInterval, VideoUnit } from "@lookout/shared";
+import { countCutUnits, type CutInterval, type VideoUnit } from "@lookout/shared";
import {
regionsToCuts,
cutsToRegions,
@@ -60,14 +60,14 @@ describe("regionsToCuts ⇄ cutsToRegions round-trip", () => {
expect(unitIsCut(5, cutsToRegions(cuts, units))).toBe(false);
});
- it("serializes a region as [firstUnit, lastUnit + 60s)", () => {
+ 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: new Date(Date.parse(units[2].capturedAt) + 60_000).toISOString(),
- },
+ { start: units[1].capturedAt, end: units[3].capturedAt },
]);
});
@@ -77,6 +77,62 @@ describe("regionsToCuts ⇄ cutsToRegions round-trip", () => {
});
});
+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(
diff --git a/clients/react/src/hooks/editorMath.ts b/clients/react/src/hooks/editorMath.ts
index 14808633..c8623114 100644
--- a/clients/react/src/hooks/editorMath.ts
+++ b/clients/react/src/hooks/editorMath.ts
@@ -30,12 +30,29 @@ export function regionsToCuts(
): CutInterval[] {
return regions
.filter((r) => r.endUnit > r.startUnit)
- .map((r) => ({
- start: units[r.startUnit].capturedAt,
- end: new Date(
- Date.parse(units[r.endUnit - 1].capturedAt) + SCREENSHOT_INTERVAL_MS,
- ).toISOString(),
- }));
+ .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(),
+ };
+ });
}
/**
From 3e89d53260c58da1c4aaa94e042595249d320ef0 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 03:05:36 +0800
Subject: [PATCH 35/65] Revert "fix(server): allow BASE_URL's own origin
through CORS"
Widening the CORS allowlist to BASE_URL's hostname did not fix the admin
panel's 500, so the rejected origin is not the actual cause. Restore the
original hackclub.com-only allowlist rather than leave a speculative
change in place.
---
packages/server/src/index.ts | 14 +-------------
1 file changed, 1 insertion(+), 13 deletions(-)
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 59876d42..bb392eb1 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -27,20 +27,9 @@ 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, BASE_URL's own
- // host (self-hosted deployments), tauri app
+ // Allow: no origin (server-to-server), *.hackclub.com, tauri app
// Tauri uses tauri:// on macOS/Linux but http://tauri.localhost on Windows
if (
!origin ||
@@ -55,7 +44,6 @@ 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));
From e7e215d173d5f193f71dc823f7ef11e7d3092e7c Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 03:09:45 +0800
Subject: [PATCH 36/65] Revert the session rename animation rework
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The measured-pixel-width spring didn't behave better than what was
there, so restore the original: grid-overlay mirror span, motion-driven
padding/width/color animation, and the isRenamingAnim delay that keeps
the pencil hidden until the layout animation settles.
Also restores the BASE_URL CORS allowance, which was reverted in
bb012d1 by mistake — that change was never the one in question.
---
.../react/src/components/SessionDetail.tsx | 95 ++++++++-----------
packages/server/src/index.ts | 14 ++-
2 files changed, 55 insertions(+), 54 deletions(-)
diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx
index cf5deafa..00438c5d 100644
--- a/clients/react/src/components/SessionDetail.tsx
+++ b/clients/react/src/components/SessionDetail.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useLayoutEffect, useCallback, useMemo, 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";
@@ -134,35 +134,23 @@ export function SessionDetail({
}: SessionDetailProps) {
const [sessionInfo, setSessionInfo] = useState<{ name: string; createdAt: string } | null>(null);
const [isRenaming, setIsRenaming] = useState(false);
+ const [isRenamingAnim, setIsRenamingAnim] = useState(false);
const [editName, setEditName] = useState("");
const inputRef = useRef(null);
- const measureRef = useRef(null);
- const [inputWidth, setInputWidth] = useState(null);
useEffect(() => {
- if (isRenaming && inputRef.current) {
- inputRef.current.focus();
- inputRef.current.select();
+ if (isRenaming) {
+ setIsRenamingAnim(true);
+ if (inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ } else {
+ // Small delay before showing icon again to let layout animations finish
+ const t = setTimeout(() => setIsRenamingAnim(false), 600);
+ return () => clearTimeout(t);
}
}, [isRenaming]);
-
- // Springs can't interpolate values like "max(100%, 300px)", so measure the
- // text via the hidden mirror span and animate the input's width in pixels.
- // Widths account for the input's 1px borders and horizontal padding.
- useLayoutEffect(() => {
- const el = measureRef.current;
- if (!el) return;
- const textWidth = el.getBoundingClientRect().width;
- setInputWidth(isRenaming ? Math.max(textWidth + 18, 300) : textWidth + 10);
- }, [isRenaming, editName, sessionInfo?.name]);
-
- // Apply the first measured width instantly so the name doesn't animate
- // open from the input's intrinsic size on mount.
- const prevInputWidth = useRef(null);
- const isFirstWidth = prevInputWidth.current === null && inputWidth !== null;
- useEffect(() => {
- prevInputWidth.current = inputWidth;
- });
const [status, setStatus] = useState(null);
const [videoUrl, setVideoUrl] = useState(null);
const [error, setError] = useState(null);
@@ -361,17 +349,18 @@ export function SessionDetail({
if (inputRef.current) inputRef.current.blur();
}}
style={{
- margin: 0,
- minWidth: 0,
- maxWidth: "100%",
- position: "relative"
+ display: "grid",
+ alignItems: "center",
+ margin: 0
}}
>
-
{isRenaming ? editName || " " : sessionInfo.name}
-
+
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));
From 02aaa98953f50a0397f6f40e07bb3179197c56f2 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 03:10:05 +0800
Subject: [PATCH 37/65] feat: SDK playground for exercising the edit flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Most of this feature's bugs were invisible from the desktop app: the
layout only clipped at certain window shapes, and the client/server
disagreements only surfaced as a 400 on Save. This harness makes both
checkable.
- Editor tab renders TimelapseEditor in a resizable box with presets for
the shapes that previously broke (short, narrow, the desktop window's
real minimum), so clipping is reproducible instead of anecdotal
- Server truth panel polls /status and /units beside the editor, and
'Verify against server' dry-runs the current cut list through PUT /cuts
so unitsCut can be compared directly with the footer — the exact
mismatch behind 'would remove the entire timelapse'
- Detail and Record tabs cover the review panel and the stop modal
- excludes @lookout/react from Vite pre-bundling, so SDK rebuilds are
picked up on reload; the README notes that the other clients don't,
which is why SDK edits kept appearing not to apply there
---
clients/playground/README.md | 67 +++++
clients/playground/index.html | 21 ++
clients/playground/package.json | 24 ++
clients/playground/src/App.tsx | 436 ++++++++++++++++++++++++++++++
clients/playground/src/main.tsx | 4 +
clients/playground/tsconfig.json | 14 +
clients/playground/vite.config.ts | 12 +
package-lock.json | 25 +-
package.json | 3 +-
9 files changed, 604 insertions(+), 2 deletions(-)
create mode 100644 clients/playground/README.md
create mode 100644 clients/playground/index.html
create mode 100644 clients/playground/package.json
create mode 100644 clients/playground/src/App.tsx
create mode 100644 clients/playground/src/main.tsx
create mode 100644 clients/playground/tsconfig.json
create mode 100644 clients/playground/vite.config.ts
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..62c0e2d9
--- /dev/null
+++ b/clients/playground/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@lookout/playground",
+ "version": "0.3.3",
+ "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..20262948
--- /dev/null
+++ b/clients/playground/src/App.tsx
@@ -0,0 +1,436 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ LookoutProvider,
+ LookoutRecorder,
+ SessionDetail,
+ TimelapseEditor,
+ createLookoutClient,
+ 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;
+}
+
+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: "" };
+}
+
+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([]);
+
+ const apply = () => {
+ localStorage.setItem(LS_KEY, JSON.stringify(settings));
+ setApplied({ ...settings });
+ };
+
+ return (
+
+ 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).
+
+ );
+}
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/package-lock.json b/package-lock.json
index b2966a6d..1bf3d495 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"
@@ -52,6 +53,24 @@
"vite": "^6.0.0"
}
},
+ "clients/playground": {
+ "name": "@lookout/playground",
+ "version": "0.3.3",
+ "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.3.3",
@@ -2486,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
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\"",
From f046ac8ef07459cb5807592df95acc251595d753 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 03:26:56 +0800
Subject: [PATCH 38/65] fix(editor): waiting for the preview would 429 on its
own poll
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/units presigns a URL and is rate limited to 10/min. The editor polled it
every 1.5s while the preview compiled — 40/min — so any compile longer
than about 15 seconds hit the limit and the editor reported a rate-limit
error instead of showing the video. The wait broke exactly when there was
something worth waiting for.
It now waits on /status, which is the endpoint built for polling (60/min)
and already carries `editable`, and touches /units only at the edges:
once up front (for expectedUnits, which sizes the progress estimate) and
once when the status flips to ready.
Same mistake in the playground's server panel, which polled both every
2s: /status is now 3s, /units is read on load, when the status signature
changes, or on an explicit refresh, with a 6s floor between reads.
Also makes R2 upload failures name their cause. A bare 'HTTP 403' is
unactionable and each cause has a different fix, so the S3 error code is
parsed out and mapped to one: signature mismatch, skewed signing clock,
expired URL, or a CORS-stripped empty body.
---
clients/playground/src/App.tsx | 77 +++++++++++++----
clients/react/src/api/client.ts | 26 +++++-
.../react/src/components/TimelapseEditor.tsx | 86 +++++++++++++------
3 files changed, 147 insertions(+), 42 deletions(-)
diff --git a/clients/playground/src/App.tsx b/clients/playground/src/App.tsx
index 20262948..4c29456a 100644
--- a/clients/playground/src/App.tsx
+++ b/clients/playground/src/App.tsx
@@ -311,35 +311,59 @@ function ServerTruth({
});
}, [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 [s, u] = await Promise.all([
- fetch(`${settings.apiBaseUrl}/api/sessions/${settings.token}/status`).then((r) =>
- r.json(),
- ),
- fetch(`${settings.apiBaseUrl}/api/sessions/${settings.token}/units`).then((r) =>
- r.json(),
- ),
- ]);
+ 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);
- // The unit list is long and not the interesting part.
- const { units: list, originalVideoUrl, ...rest } = u as Record;
- setUnits({ ...rest, unitCount: Array.isArray(list) ? list.length : 0 });
+ // 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, 2000);
+ const id = setInterval(tick, 3000);
return () => {
cancelled = true;
clearInterval(id);
};
- }, [settings.apiBaseUrl, settings.token]);
+ }, [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.
@@ -379,8 +403,9 @@ function ServerTruth({
Server truth
- Polled every 2s. Compare editable and the tracked-time
- pair against what the editor shows.
+ /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}
}
@@ -388,7 +413,27 @@ function ServerTruth({
{JSON.stringify(status, null, 1)}
-
+
+
+ {unitsAt ? `read ${new Date(unitsAt).toLocaleTimeString()}` : "not read yet"}
+ {" · 10/min limit, so not polled"}
+
{JSON.stringify(units, null, 1)}
diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts
index d0a619b4..258ab6a8 100644
--- a/clients/react/src/api/client.ts
+++ b/clients/react/src/api/client.ts
@@ -170,8 +170,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}`,
);
}
},
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index 9b565fac..510a5d6f 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -147,45 +147,81 @@ export function TimelapseEditor({
// ── 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. Poll through that instead of
- // showing an error — this is the normal path, not a failure.
+ // `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 load = async () => {
+ 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 res = await client.getUnits();
+ const status = await client.getStatus();
if (cancelled) return;
- if (res.editable && res.originalVideoUrl) {
- setPreparingUnits(null);
- setData(res);
- setRegions(cutsToRegions(res.cuts, res.units));
+ if (status.editable) {
+ await loadUnits();
return;
}
- if (res.editableReason === "preparing" || res.editableReason === "no_original") {
- // Same number on every poll ⇒ React bails out, no re-render, and
- // the progress effect below is left running undisturbed.
- setPreparingUnits(res.expectedUnits ?? 0);
- timer = setTimeout(load, 1500);
+ if (status.status === "complete") {
+ fail("published");
+ return;
+ }
+ if (status.status === "failed") {
+ fail("failed");
return;
}
- setLoadError(
- res.editableReason === "published"
- ? "This timelapse has already been published, so it can't be edited."
- : res.editableReason === "failed"
- ? "This timelapse couldn't be compiled, so there's nothing to edit."
- : res.editableReason === "recompiles_exhausted"
- ? "This timelapse has reached its edit limit."
- : "This timelapse isn't available for editing.",
- );
} catch (err) {
- if (!cancelled)
- setLoadError(err instanceof Error ? err.message : String(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 load();
+ void (async () => {
+ try {
+ await loadUnits();
+ } catch (err) {
+ if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err));
+ }
+ })();
+
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
From f58b7b9d6ed6162c1ed371bc71da20ba2166dbe8 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 03:44:14 +0800
Subject: [PATCH 39/65] test: pin that upload time can't cost credited minutes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Credit is judged on `capturedAt` — when the frame or clip was grabbed —
against a streak anchor with a ±30s window. So the defect that would
silently cost users 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.
Both clients already stamp it at capture time (clipRecorder at cut(),
captureUtils at grab, and the Rust loop right after the frame lands and
before upload_and_confirm), so this is a guard, not a fix. It drives the
real pipeline through a mocked transport with a deliberately slow PUT and
asserts the exact millisecond reaches /upload-url.
Also pins the display timer's behaviour around slow uploads, since that
is when the interpolation cap is actually reached: it ticks smoothly in
the steady state, holds rather than inflating once a credit is overdue,
and resumes from the held value instead of jumping — the held number and
the incoming credit are the same by construction, which is why the cap is
exactly one interval.
---
clients/react/src/hooks/uploadTiming.test.tsx | 145 ++++++++++++++++++
1 file changed, 145 insertions(+)
create mode 100644 clients/react/src/hooks/uploadTiming.test.tsx
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);
+ });
+});
From e5a96538c1350753b8eb2ac59ede45afb5afe5e6 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 13:53:55 +0800
Subject: [PATCH 40/65] fix(clips): web clips encoded at max quantizer, ~20dB
below native
The 800kbps budget was sized as "800k x 60s / 15 frames = 400KB/frame".
That reading only holds for the native encoders, which get real 4s
presentation timestamps plus a ~1fps rate-control hint. MediaRecorder
ignores wall-clock frame spacing entirely: recording the same frames
4000ms apart and 125ms apart produces BYTE-IDENTICAL output, so the
"x 60 seconds" budget never existed on the web.
At 800kbps the browser encoder sits at its maximum quantizer and still
overshoots the request -- the whole 0.8-2 Mbps range is byte-identical.
That is why raising the shared constant 400k -> 800k sharpened the
desktop and did nothing at all for the web.
- Prefer H.264/MP4 over VP9/VP8; WebM stays as the Firefox fallback.
Measured at matched output size (Chromium 148, 1080p, PSNR vs source):
~115KB/frame H.264 30.9dB vs VP9 22.3dB; ~335KB/frame 38.6 vs 28.6.
8-10dB for the same bytes, and even across the clip where VP9 spends
nearly everything on the keyframe. VP8 is inert below ~10Mbps.
Same conclusion the desktop reached when it rejected libvpx.
- Split the bitrate constant: CLIP_VIDEO_BITS_PER_SECOND stays the
native rate, CLIP_WEB_VIDEO_BITS_PER_SECOND is 40Mbps. Not comparable
numbers -- they are denominated in different things.
- Guard the cap: a clip over MAX_CLIP_BYTES halves the bitrate (floor =
the native rate) and falls back to JPEG for that tick, so an engine
that does budget over real wall clock self-corrects instead of
failing every upload.
- imageSmoothingQuality=high on the canvas downscale, matching the
desktop's area-average resize.
Measured over a full 15-frame clip against the 8MB cap:
before (vp9 @ 800k) after (h264 @ 40M)
busy screen 0.89MB 23.8dB 3.24MB 43.3dB
typical screen 0.37MB 23.8dB 2.46MB 43.3dB
0.37MB matches the ~400KB/min these clips were measured at in the
field, which is what makes the rest of the table trustworthy.
Docs: clip cap was still documented as 4MB in two places.
---
clients/desktop/src-tauri/src/clips.rs | 8 ++-
clients/react/src/hooks/clipRecorder.ts | 81 ++++++++++++++++++++++---
docs/integration.md | 13 ++--
packages/server/API.md | 2 +-
packages/shared/src/constants.ts | 52 +++++++++++++++-
5 files changed, 137 insertions(+), 19 deletions(-)
diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs
index 8b35fb17..729e81fe 100644
--- a/clients/desktop/src-tauri/src/clips.rs
+++ b/clients/desktop/src-tauri/src/clips.rs
@@ -19,8 +19,12 @@ use image::DynamicImage;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
-/// Encoder bitrate cap (bits/second). Matches the web client's
-/// CLIP_VIDEO_BITS_PER_SECOND. Sized for text legibility: ~300 KB per 3s
+/// Encoder bitrate cap (bits/second). Matches the shared
+/// CLIP_VIDEO_BITS_PER_SECOND, which is the NATIVE-encoder rate: we hand
+/// these encoders real presentation timestamps, so the number really does
+/// buy a per-frame budget. Browsers need a much larger figure for the same
+/// output quality (see CLIP_WEB_VIDEO_BITS_PER_SECOND) — the two are not
+/// comparable. Sized for text legibility: ~300 KB per 3s
/// frame allows JPEG-q85-class keyframes at 1080p. VBR ceiling, not a
/// floor — static screens undershoot heavily. The server rejects clips
/// over 8 MB. 133k/400k were tried first and produced soft H.264.
diff --git a/clients/react/src/hooks/clipRecorder.ts b/clients/react/src/hooks/clipRecorder.ts
index 67df9975..e9790b19 100644
--- a/clients/react/src/hooks/clipRecorder.ts
+++ b/clients/react/src/hooks/clipRecorder.ts
@@ -2,7 +2,9 @@ import {
MAX_WIDTH,
MAX_HEIGHT,
JPEG_QUALITY,
+ CLIP_WEB_VIDEO_BITS_PER_SECOND,
CLIP_VIDEO_BITS_PER_SECOND,
+ MAX_CLIP_BYTES,
type CaptureFormat,
} from "@lookout/shared";
@@ -27,13 +29,35 @@ interface MimeCandidate {
format: Exclude;
}
-/** Preference order: VP9 (best compression) → VP8 → generic WebM →
- * MP4/H.264 (Safari — its MediaRecorder does not do WebM). */
+/** 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" },
- { mime: "video/mp4", format: "mp4" },
];
function pickMimeCandidate(): MimeCandidate | null {
@@ -88,6 +112,11 @@ export class ClipRecorder {
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;
// Opening cadence lives in its own field (cleared after the first cut),
// so it's excluded from the always-resolved options.
private opts: Required>;
@@ -157,7 +186,7 @@ export class ClipRecorder {
this.frameCount = 0;
const recorder = new MediaRecorder(stream, {
mimeType: this.mime.mime,
- videoBitsPerSecond: CLIP_VIDEO_BITS_PER_SECOND,
+ videoBitsPerSecond: this.bitrate,
});
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) this.parts.push(e.data);
@@ -187,6 +216,10 @@ export class ClipRecorder {
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 })
@@ -244,8 +277,38 @@ export class ClipRecorder {
setTimeout(() => resolve(null), 5_000);
});
- // Tear down and restart for the next minute. The opening cadence only
- // ever applies to the first clip — every later interval is full-length.
+ 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 conservative native rate.
+ let oversize = false;
+ if (blob && blob.size > MAX_CLIP_BYTES) {
+ oversize = true;
+ const reduced = Math.max(
+ CLIP_VIDEO_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;
+ }
+ }
+
+ // 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 {
@@ -255,9 +318,9 @@ export class ClipRecorder {
// back to JPEG and recording resumes when start() next succeeds.
}
- if (parts.length === 0 || frameCount === 0) return null;
- const blob = new Blob(parts, { type: this.mime.mime.split(";")[0] });
- if (blob.size === 0) return null;
+ // 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,
diff --git a/docs/integration.md b/docs/integration.md
index 8c9d9b2e..e8e4e479 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -110,12 +110,13 @@ What this means for your program:
(≥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 4 MB/min (encoder capped ~400 kbps ≈ 3
- MB/min worst case); static screen content lands far below that since the
- encoders undershoot easy content.
-- **Frame quality trade-off:** mid-clip frames are bitrate-capped and softer
- than a q0.85 JPEG on very busy screens; each clip starts with a crisp
- keyframe. For review purposes you get 20× more moments per minute.
+- **Network:** a clip is capped at 8 MB/min server-side; a typical screen
+ measures ~2.5 MB/min and even a deliberately incompressible one stays
+ around 3 MB/min, since the encoders undershoot easy content heavily.
+- **Frame quality:** clip frames are bitrate-capped rather than encoded
+ independently, but the budget is sized to hold q0.85-JPEG-class detail at
+ 1080p even on busy screens. For review purposes you get 15× more moments
+ per minute.
- Roll it out gradually if you like — the flag is per session, so you can
enable it for a fraction of new sessions and compare.
diff --git a/packages/server/API.md b/packages/server/API.md
index aa93f2df..4aded4ba 100644
--- a/packages/server/API.md
+++ b/packages/server/API.md
@@ -335,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` — Content type doesn't match the granted format (`image/jpeg` / `video/webm` / `video/mp4`), file too large (2 MB for JPEG, 4 MB for clips), 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)
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 8c553cd5..328a4423 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -98,16 +98,66 @@ export const CLIP_FRAME_INTERVAL_MS = 4_000;
* Default: 15 */
export const FRAMES_PER_CLIP = 15;
-/** Client-side encoder bitrate cap for clips (bits/second).
+/** Encoder bitrate cap for clips recorded by a NATIVE encoder (the desktop
+ * app's VideoToolbox / Media Foundation / GStreamer paths).
+ *
* Sized for TEXT LEGIBILITY: at 15 frames/min, 800 kbps allows ~400 KB
* per 4s frame — JPEG-q85-class keyframes at 1080p, the bar the legacy
* single-screenshot pipeline set. This is a VBR ceiling, not a floor:
* static screen content undershoots it heavily (measured 133 kbps-era
* clips landed at ~400 KB/min total). 133k and 400k were tried first
* and produced visibly soft H.264.
+ *
+ * This "budget spread over the clip's 60s" reading only holds for
+ * encoders we hand real presentation timestamps to. Browsers do NOT
+ * work that way — see CLIP_WEB_VIDEO_BITS_PER_SECOND.
* Default: 800000 */
export const CLIP_VIDEO_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 ~400 KB budget. Measured over a full 15-frame clip, 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 sits at 40% of the cap. (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.
From 8b3f5c5306aea0c6f65c855262ebece93111db81 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 13:54:09 +0800
Subject: [PATCH 41/65] fix(compile): the seed capture became a slow-motion
first second
A session's first capture opens the recording rather than closing a
recorded minute. It credits 0 tracked seconds in both modes -- bucket
reports (distinct buckets - 1) * 60, and in credit mode the seed is
explicitly worth 0 -- yet it was given an equal one-second segment.
Two visible consequences:
- The video ran one second longer than the tracked minute count, so the
editor labelled a 1-minute recording "2 min".
- In clips mode the opening clip is cut after 2 frame intervals (~8s)
so the session activates quickly, so it holds ~8s of wall clock where
every later clip holds 60s. Rendered as an equal second it played at
~8x against the rest of the timelapse's 60x. Measured on a real
compiled video: second 0 held 9 unique frames spanning ~8 real
seconds, second 1 held 16 spanning 60.
Excluding the seed 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 for.
The seed is deliberately not marked `sampled`, so its R2 object is
cleaned up with the other unsampled captures; the row stays, leaving
/timings and the credit history untouched. Single-unit sessions keep
their one unit: a zero-length video is worse than an imprecise one, and
an empty segment list fails the compile outright.
expectedUnits drops the seed too, so the waiting-room copy doesn't
promise a minute the finished video won't hold.
The editor needs no change -- /units returns the stored videoUnits, so
its unit count now matches trackedSeconds on its own.
Known gap: the artifact recurs after a resume, which restarts the
capture loop and cuts another short opening clip mid-video. Fixing that
needs the client to report each clip's real-time span; not done here.
---
packages/server/src/routes/sessions.ts | 7 ++-
.../server/test/edits.integration.test.ts | 7 ++-
packages/worker/src/compile.ts | 21 +++++--
packages/worker/src/segments.ts | 32 +++++++++++
packages/worker/test/seedUnit.test.ts | 57 +++++++++++++++++++
5 files changed, 115 insertions(+), 9 deletions(-)
create mode 100644 packages/worker/test/seedUnit.test.ts
diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts
index 09e2e191..2273118e 100644
--- a/packages/server/src/routes/sessions.ts
+++ b/packages/server/src/routes/sessions.ts
@@ -1358,8 +1358,11 @@ export async function sessionRoutes(app: FastifyInstance) {
: null,
// Roughly how many units the finished video will hold. Lets a
// client waiting on the build size its progress estimate — compile
- // time scales with unit count.
- expectedUnits: await getScreenshotCount(session.id),
+ // 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,
diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts
index 37e2ab45..4b756a6f 100644
--- a/packages/server/test/edits.integration.test.ts
+++ b/packages/server/test/edits.integration.test.ts
@@ -252,8 +252,11 @@ describe("GET /units", () => {
expect(body.editable).toBe(false);
expect(body.editableReason).toBe("preparing");
expect(body.editHoldUntil).toBeTruthy();
- // The client needs this to size its progress estimate.
- expect(body.expectedUnits).toBe(UNITS);
+ // 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 () => {
diff --git a/packages/worker/src/compile.ts b/packages/worker/src/compile.ts
index 857d1b4b..fb0da174 100644
--- a/packages/worker/src/compile.ts
+++ b/packages/worker/src/compile.ts
@@ -24,6 +24,7 @@ import * as schema from "./schema.js";
import {
buildSegment,
cutVideoToKeptRanges,
+ dropSeedUnit,
SEGMENT_CONCURRENCY,
SEGMENT_FPS,
SEGMENT_GOP_ARGS,
@@ -289,8 +290,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)
@@ -307,7 +318,7 @@ export async function compileTimelapse(sessionId: string): Promise<{
// 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 = sampledScreenshots.rows.length;
+ const total = unitRows.length;
const unitExt = (format: string) => (format === "jpeg" ? "jpg" : format);
const segmentPaths: (string | null)[] = new Array(total).fill(null);
let downloadFailures = 0;
@@ -317,7 +328,7 @@ export async function compileTimelapse(sessionId: string): Promise<{
const worker = async () => {
while (next < total) {
const i = next++;
- const ss = sampledScreenshots.rows[i];
+ const ss = unitRows[i];
const unitPath = path.join(tmpDir, `dl_${i}.${unitExt(ss.format)}`);
let downloadedUnit = false;
@@ -387,7 +398,7 @@ export async function compileTimelapse(sessionId: string): Promise<{
const videoUnits: VideoUnit[] = [];
for (let i = 0; i < total; i++) {
if (segmentPaths[i] === null) continue;
- const ss = sampledScreenshots.rows[i];
+ const ss = unitRows[i];
const ts = ss.captured_at ?? ss.requested_at;
videoUnits.push({
capturedAt: (ts instanceof Date ? ts : new Date(ts)).toISOString(),
diff --git a/packages/worker/src/segments.ts b/packages/worker/src/segments.ts
index 2d84ea77..d5bf2573 100644
--- a/packages/worker/src/segments.ts
+++ b/packages/worker/src/segments.ts
@@ -12,6 +12,38 @@ 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 2 frame intervals (~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;
+}
+
/** Shared video filter: scale to 1920x1080 with pillarboxing. */
export const SCALE_FILTER =
"scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2";
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",
+ ]);
+ });
+});
From 8eba5a10a94f2119a6f699434f3fb395b5e9a086 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:09:12 +0800
Subject: [PATCH 42/65] test: prove the cut is bit-exact, and make the lossy
fallback loud
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"Does cutting affect quality" deserved evidence, not an assertion. The cut
now has a test that hashes every DECODED frame of the original and of the
edited output (`-f framemd5`) and asserts the kept frames are identical,
pixel for pixel. A stream copy moves the same encoded packets, so it
passes; any re-encode, however visually lossless, would not.
A companion test asserts the re-encode fallback is NOT bit-exact. That
keeps the first test honest: if someone made the copy path silently
re-encode, a test comparing two re-encodes could still pass.
The fallback is the one way a cut can cost quality — it only runs if the
copy path throws, which shouldn't happen now that every encode pins the
GOP. It was a console.warn among other warnings; it's now an error that
says plainly that the timelapse just took a generation of loss and what
to investigate.
---
packages/worker/src/segments.ts | 11 +++++-
packages/worker/test/cutVideo.test.ts | 57 +++++++++++++++++++++++++++
2 files changed, 67 insertions(+), 1 deletion(-)
diff --git a/packages/worker/src/segments.ts b/packages/worker/src/segments.ts
index d5bf2573..41872290 100644
--- a/packages/worker/src/segments.ts
+++ b/packages/worker/src/segments.ts
@@ -293,7 +293,16 @@ export async function cutVideoToKeptRanges(
await verify("Edited MP4 (copy)", 0);
return editedPath;
} catch (err) {
- console.warn("Stream-copy cut failed, re-encoding kept ranges:", 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,
+ );
}
}
diff --git a/packages/worker/test/cutVideo.test.ts b/packages/worker/test/cutVideo.test.ts
index ff841430..ded55c9d 100644
--- a/packages/worker/test/cutVideo.test.ts
+++ b/packages/worker/test/cutVideo.test.ts
@@ -38,6 +38,23 @@ async function hasFfmpeg(): Promise {
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", () => {
@@ -149,6 +166,46 @@ describe.skipIf(!ffmpegAvailable)("cutVideoToKeptRanges", () => {
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),
From ffdb0d8be3e9586be040190ab43cd462acdb3561 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:12:52 +0800
Subject: [PATCH 43/65] fix(api): /stop must keep ignoring bodies it doesn't
recognise
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adding the `edit` flag brought a body schema with additionalProperties
false. That route previously had no body schema at all, so it accepted and
ignored anything sent to it — meaning a custom client that posts its own
field would have started getting 400s on stop, the one call it cannot
afford to fail. Relaxed to permissive, with a test.
Audited the rest of the branch's surface against main for the same class
of problem: every other change is additive (new endpoints, new response
fields, new nullable columns). The two behavioural changes only apply to
sessions a client explicitly opted into editing — trackedSeconds and
/timings subtract cuts, and both are settled before the session ever
reaches `complete`, which is the point programs consume it.
---
packages/server/src/routes/sessions.ts | 5 ++++-
packages/server/test/edits.integration.test.ts | 13 +++++++++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts
index 2273118e..fef002b8 100644
--- a/packages/server/src/routes/sessions.ts
+++ b/packages/server/src/routes/sessions.ts
@@ -1069,7 +1069,10 @@ export async function sessionRoutes(app: FastifyInstance) {
properties: {
edit: { type: "boolean" as const },
},
- additionalProperties: false,
+ // 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,
},
},
},
diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts
index 4b756a6f..c2a372a4 100644
--- a/packages/server/test/edits.integration.test.ts
+++ b/packages/server/test/edits.integration.test.ts
@@ -149,6 +149,19 @@ describe("POST /stop with { edit }", () => {
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)
From 7d3e9d3cf218bdc109d8678dd4f595602f913fb8 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:14:59 +0800
Subject: [PATCH 44/65] docs: state plainly that embedders get the edit flow
for free
The whole point of putting the flow inside the recorder was that programs
adopt nothing. The section described the behaviour but never said that
outright, and named the one case that does need work: a program driving
the headless hook with its own stop button.
---
docs/integration.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/integration.md b/docs/integration.md
index e8e4e479..bb58294e 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -454,6 +454,17 @@ 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" swaps in the editor until the user
+saves. No code change, no new version to adopt, nothing to call.
+
+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
From c05deb0ae2667a236a9a823ea7e73bf1bb8075e7 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:17:52 +0800
Subject: [PATCH 45/65] fix(sdk): the embedded editor was narrower than the
screen it replaced
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PageContainer defaults to 640px, so the inline editor got ~590px of
usable width — less than the 800 the recorder itself uses, and only about
six filmstrip frames to cut against. Widened to 960, roughly matching the
desktop editor window. The timeline is a precision surface; every 100px
of width is another whole frame.
---
clients/react/src/components/LookoutRecorder.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/clients/react/src/components/LookoutRecorder.tsx b/clients/react/src/components/LookoutRecorder.tsx
index 5e98dcaa..85593e97 100644
--- a/clients/react/src/components/LookoutRecorder.tsx
+++ b/clients/react/src/components/LookoutRecorder.tsx
@@ -117,7 +117,10 @@ export function LookoutRecorder({ editing = true }: LookoutRecorderProps = {}) {
// hold publishes on its own if they abandon the tab).
if (editorOpen && resolvedToken && state.status !== "failed") {
return (
-
+ // Wider than the recorder's own 800: the timeline is a precision
+ // surface, and every 100px is another whole filmstrip frame. The
+ // default 640 left it narrower than the screen it replaced.
+
Date: Mon, 27 Jul 2026 14:28:33 +0800
Subject: [PATCH 46/65] feat(sdk): the editor opens as a modal overlay, not
inline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Embedders put the recorder in columns and cards of arbitrary width, and
inline meant the timeline inherited that width — a precision tool you
can't be precise with. The overlay takes the viewport instead, and the
detail view keeps rendering behind it rather than being replaced.
New `Overlay` primitive, portalled to document.body. That matters for an
SDK dropped into pages we don't control: `position: fixed` resolves
against the nearest transformed or filtered ancestor, so an embedder with
a transform anywhere up the tree would otherwise get a modal pinned
inside their card. It also locks body scroll and moves focus into the
dialog. StopChoiceModal now uses it too, since it had the same exposure.
The editor overlay has no dismiss: leaving without deciding would strand
an unpublished session, so Save is the exit — and an abandoned tab is
covered by the edit lease.
---
clients/react/API.md | 9 ++
.../react/src/components/LookoutRecorder.tsx | 86 ++++++++++----
.../react/src/components/SessionDetail.tsx | 11 +-
.../react/src/components/StopChoiceModal.tsx | 33 ++----
clients/react/src/ui/Overlay.tsx | 107 ++++++++++++++++++
clients/react/src/ui/index.ts | 2 +
docs/integration.md | 6 +-
7 files changed, 201 insertions(+), 53 deletions(-)
create mode 100644 clients/react/src/ui/Overlay.tsx
diff --git a/clients/react/API.md b/clients/react/API.md
index 527eef28..62652dc9 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -810,6 +810,14 @@ 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`),
@@ -933,6 +941,7 @@ The SDK exports styled UI primitives used by its components. All use inline styl
| `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 |
diff --git a/clients/react/src/components/LookoutRecorder.tsx b/clients/react/src/components/LookoutRecorder.tsx
index 85593e97..55b91ca3 100644
--- a/clients/react/src/components/LookoutRecorder.tsx
+++ b/clients/react/src/components/LookoutRecorder.tsx
@@ -13,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";
/**
@@ -112,30 +113,71 @@ export function LookoutRecorder({ editing = true }: LookoutRecorderProps = {}) {
state.status === "complete" ||
state.status === "failed"
) {
- // "Edit & save": the session is held unpublished while the user cuts
- // it. No cancel affordance here — publishing is the way out (and the
- // hold publishes on its own if they abandon the tab).
- if (editorOpen && resolvedToken && state.status !== "failed") {
- return (
- // Wider than the recorder's own 800: the timeline is a precision
- // surface, and every 100px is another whole filmstrip frame. The
- // default 640 left it narrower than the screen it replaced.
-
- setEditorOpen(false)}
+ return (
+ <>
+
+
- );
- }
- 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)}
+ />
+
+
+ )}
+ >
);
}
diff --git a/clients/react/src/components/SessionDetail.tsx b/clients/react/src/components/SessionDetail.tsx
index 00438c5d..1a6bc8c1 100644
--- a/clients/react/src/components/SessionDetail.tsx
+++ b/clients/react/src/components/SessionDetail.tsx
@@ -4,6 +4,7 @@ import type { StatusResponse, VideoResponse, SessionResponse } from "@lookout/sh
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";
@@ -268,7 +269,10 @@ export function SessionDetail({
{!status && !error && }
{status && editing && (
-
+ // Same overlay the recorder uses, so editing looks and behaves
+ // identically wherever it's entered from.
+
+
-
+
+
)}
{/* Edit hold: the recording is compiled but deliberately not
@@ -306,7 +311,7 @@ export function SessionDetail({
/>
)}
- {status && !editing && (
+ {status && (
<>
{/* Video area. Suppressed during an edit hold: the session reads
as "stopped", but showing a compile spinner under a panel that
diff --git a/clients/react/src/components/StopChoiceModal.tsx b/clients/react/src/components/StopChoiceModal.tsx
index 892813c3..03ec3387 100644
--- a/clients/react/src/components/StopChoiceModal.tsx
+++ b/clients/react/src/components/StopChoiceModal.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
-import { motion } from "motion/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 {
@@ -42,34 +42,15 @@ export function StopChoiceModal({
const value = () => name.trim() || null;
return (
-
-
+
+
-
-
+
+
);
}
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(
+
,
+ document.body,
+ );
+}
diff --git a/clients/react/src/ui/index.ts b/clients/react/src/ui/index.ts
index 0fb9806d..cd1117ef 100644
--- a/clients/react/src/ui/index.ts
+++ b/clients/react/src/ui/index.ts
@@ -13,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/docs/integration.md b/docs/integration.md
index bb58294e..086eeac1 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -457,8 +457,10 @@ those minutes from the video, the `/timings` heartbeats, and
**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" swaps in the editor until the user
-saves. No code change, no new version to adopt, nothing to call.
+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
From 136477b38ecfc03e20fab9ed0633df1d5f0b03f8 Mon Sep 17 00:00:00 2001
From: Anson Chung <58066418+anscg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:31:47 +0800
Subject: [PATCH 47/65] feat(sdk): embedders can replace the accent colour
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`` recolours primary buttons, focus
rings, and the compile progress ring — everywhere the UI says "this is the
main action". `accentTextColor` covers the case where a light brand colour
makes white labels unreadable. `setAccentColor()` is exported for the
surfaces used without a provider (SessionDetail, TimelapseEditor).
Three decisions worth stating:
- Semantic colours are deliberately excluded. Success green, warning amber
and the red marking removed footage carry meaning, not brand; a green
"this will be deleted" would be worse than an off-brand one.
- Applied to the document root rather than a wrapper, because the stop
dialog and the editor portal to document.body and a scoped subtree
wouldn't reach them. The provider restores the previous value on
unmount so a temporarily-mounted Lookout doesn't leak its accent.
- The hover shade is derived via color-mix so embedders supply one colour,
with a literal fallback declared first — an unsupported color-mix is
dropped at parse time and the literal survives, rather than the hover
background resolving to nothing.
The playground gains an accent picker so this is checkable against the
editor and both dialogs.
---
clients/playground/src/App.tsx | 42 ++++++++++++++++-
clients/react/API.md | 28 ++++++++++++
clients/react/src/LookoutProvider.tsx | 20 +++++++++
clients/react/src/components/editorStyles.ts | 4 +-
clients/react/src/index.ts | 1 +
clients/react/src/ui/Button.tsx | 14 +++++-
clients/react/src/ui/ProgressRing.tsx | 2 +-
clients/react/src/ui/theme.ts | 47 ++++++++++++++++++++
docs/integration.md | 4 ++
9 files changed, 156 insertions(+), 6 deletions(-)
diff --git a/clients/playground/src/App.tsx b/clients/playground/src/App.tsx
index 4c29456a..a75a1275 100644
--- a/clients/playground/src/App.tsx
+++ b/clients/playground/src/App.tsx
@@ -5,6 +5,7 @@ import {
SessionDetail,
TimelapseEditor,
createLookoutClient,
+ setAccentColor,
colors,
fontSize,
fontWeight,
@@ -29,6 +30,8 @@ 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 {
@@ -38,7 +41,11 @@ function loadSettings(): Settings {
} catch {
// Fall through to defaults.
}
- return { apiBaseUrl: "https://lookout-stage.dino.icu", token: "" };
+ return {
+ apiBaseUrl: "https://lookout-stage.dino.icu",
+ token: "",
+ accent: "#3b82f6",
+ };
}
export function App() {
@@ -50,6 +57,13 @@ export function App() {
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 });
@@ -153,6 +167,32 @@ function Header({
if (e.key === "Enter") onApply();
}}
/>
+
Load
diff --git a/clients/react/API.md b/clients/react/API.md
index 62652dc9..1f43fa1e 100644
--- a/clients/react/API.md
+++ b/clients/react/API.md
@@ -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`
@@ -944,6 +946,32 @@ The SDK exports styled UI primitives used by its components. All use inline styl
| `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/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/components/editorStyles.ts b/clients/react/src/components/editorStyles.ts
index d98b9833..c6c4a4c8 100644
--- a/clients/react/src/components/editorStyles.ts
+++ b/clients/react/src/components/editorStyles.ts
@@ -19,7 +19,7 @@ export function injectEditorStyles(): void {
.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 #3b82f6;
+ box-shadow: 0 0 0 2px var(--color-bg-body), 0 0 0 4px var(--color-accent);
}
.lk-ed-region {
@@ -56,7 +56,7 @@ export function injectEditorStyles(): void {
.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 #3b82f6;
+ 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; }
diff --git a/clients/react/src/index.ts b/clients/react/src/index.ts
index 693391cb..e2f7a441 100644
--- a/clients/react/src/index.ts
+++ b/clients/react/src/index.ts
@@ -101,3 +101,4 @@ 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/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/ProgressRing.tsx b/clients/react/src/ui/ProgressRing.tsx
index ea1e5040..742caeed 100644
--- a/clients/react/src/ui/ProgressRing.tsx
+++ b/clients/react/src/ui/ProgressRing.tsx
@@ -50,7 +50,7 @@ export function ProgressRing({
cy={size / 2}
r={radius}
fill="none"
- stroke={color ?? colors.status.info}
+ stroke={color ?? colors.accent.base}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
diff --git a/clients/react/src/ui/theme.ts b/clients/react/src/ui/theme.ts
index 8d1b94b4..d39f425d 100644
--- a/clients/react/src/ui/theme.ts
+++ b/clients/react/src/ui/theme.ts
@@ -46,6 +46,14 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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"]) {
@@ -86,6 +94,10 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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"] {
@@ -126,6 +138,10 @@ if (typeof document !== "undefined" && !document.querySelector("style[data-looko
--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);
}
@@ -136,6 +152,14 @@ 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: {
@@ -178,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/docs/integration.md b/docs/integration.md
index 086eeac1..28eb3ef4 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -496,6 +496,10 @@ What this means for your program:
- **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
From 48ef2cf574cbcc3829f8d5b1bb2d7b7042d30dc8 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sat, 8 Aug 2026 23:28:52 +0800
Subject: [PATCH 48/65] chore: ignore SwiftPM build output and .env backups
The tray's SwiftPM .build directory was tracked, including a 64MB index
database, and a .env.bak sat alongside it. Both are now purged from this
branch's history; these rules stop them coming back.
---
.gitignore | 9 +++++++++
1 file changed, 9 insertions(+)
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)
From f3289a460a77048711630be31239fa09a6257c1d Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 00:03:32 +0800
Subject: [PATCH 49/65] feat(ui): surface real compile progress while a
timelapse builds
Work in progress that was sitting uncommitted: the compile_progress column
plus the gallery/editor surfaces that read it, and the clock-style tray title.
---
clients/desktop/src/App.tsx | 159 +++++++++++++-----
.../desktop/src/components/AddSessionPage.tsx | 96 ++++-------
.../desktop/src/components/EditorWindow.tsx | 25 ++-
clients/react/src/components/Gallery.tsx | 4 +
clients/react/src/components/SessionCard.tsx | 10 +-
.../react/src/components/SessionDetail.tsx | 23 ++-
.../react/src/components/TimelapseEditor.tsx | 40 ++++-
clients/react/src/ui/Card.tsx | 12 +-
.../server/drizzle/0020_compile_progress.sql | 1 +
9 files changed, 239 insertions(+), 131 deletions(-)
create mode 100644 packages/server/drizzle/0020_compile_progress.sql
diff --git a/clients/desktop/src/App.tsx b/clients/desktop/src/App.tsx
index 7331a83b..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,
@@ -44,6 +45,12 @@ 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;
@@ -156,39 +163,57 @@ function MainWindowApp() {
let unlisten: (() => void) | undefined;
let cancelled = false;
- listen<{ token: string }>(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
- // short 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. Watch it to completion here instead.
- const token = event.payload?.token;
- if (!token) return;
- const deadline = Date.now() + 3 * 60_000;
- 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;
+ 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;
}
- if (data.status === "failed") return;
+ } catch {
+ // Transient — the retry below covers it.
}
- } catch {
- // Transient — the retry below covers it.
- }
- setTimeout(poll, 2500);
- };
- void poll();
- }).then((fn) => { unlisten = fn; });
+ delay = Math.min(delay * 1.5, 15_000);
+ setTimeout(poll, delay);
+ };
+ void poll();
+ },
+ ).then((fn) => { unlisten = fn; });
return () => {
cancelled = true;
@@ -333,6 +358,60 @@ function MainWindowApp() {
[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.
@@ -550,21 +629,9 @@ 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();
- }
- }}
+ 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).
diff --git a/clients/desktop/src/components/AddSessionPage.tsx b/clients/desktop/src/components/AddSessionPage.tsx
index 8d5f8546..d183afe4 100644
--- a/clients/desktop/src/components/AddSessionPage.tsx
+++ b/clients/desktop/src/components/AddSessionPage.tsx
@@ -40,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.
@@ -122,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
+
>
}
>
diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx
index 89b0ef91..bed89c4d 100644
--- a/clients/desktop/src/components/EditorWindow.tsx
+++ b/clients/desktop/src/components/EditorWindow.tsx
@@ -296,18 +296,23 @@ export function EditorWindow({ token }: { token: string }) {
const finishAndClose = useCallback(async () => {
finishedRef.current = true;
+ let published: Awaited> | null = null;
try {
await client.setCuts(cutsRef.current);
- await client.applyCuts();
+ 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.
- emit(EDITED_EVENT, { token }).catch((e) =>
- console.error("[editor] emit failed:", e),
- );
+ // 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]);
@@ -377,13 +382,15 @@ export function EditorWindow({ token }: { token: string }) {
cutsRef.current = cuts;
dirtyRef.current = dirty;
}}
- onApplied={() => {
+ 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 }).catch((e) =>
- console.error("[editor] emit failed:", e),
- );
+ emit(EDITED_EVENT, {
+ token,
+ status: result.status,
+ redirectUrl: result.redirectUrl,
+ }).catch((e) => console.error("[editor] emit failed:", e));
void closeEditorWindow();
}}
/>
diff --git a/clients/react/src/components/Gallery.tsx b/clients/react/src/components/Gallery.tsx
index 2a78d66a..f0d47a92 100644
--- a/clients/react/src/components/Gallery.tsx
+++ b/clients/react/src/components/Gallery.tsx
@@ -21,6 +21,8 @@ export interface GalleryProps {
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?: (anchor: AddAnchor) => void;
onSettings?: () => void;
@@ -78,6 +80,7 @@ export function Gallery({
error,
onSessionClick,
onArchive,
+ onSessionContextMenu,
onRefresh,
onAdd,
onSettings,
@@ -174,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/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 1a6bc8c1..422d028d 100644
--- a/clients/react/src/components/SessionDetail.tsx
+++ b/clients/react/src/components/SessionDetail.tsx
@@ -24,11 +24,15 @@ import { statusConfig, colors, spacing, fontSize, fontWeight, radii } from "../u
*/
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;
@@ -39,20 +43,30 @@ function HoldPanel({
// publish the timelapse out from under the person reading it.
useEditLease(client, !publishing);
- // Same asymptotic estimate the editor uses (the worker reports no real
- // progress); it eases toward 100% and the `editable` flip is what
- // actually ends it.
+ // 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 = () =>
+ 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);
@@ -296,6 +310,7 @@ export function SessionDetail({
{status && !editing && inHold && (
(onEdit ? onEdit() : setEditing(true))}
onPublish={async () => {
diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx
index 510a5d6f..536a2073 100644
--- a/clients/react/src/components/TimelapseEditor.tsx
+++ b/clients/react/src/components/TimelapseEditor.tsx
@@ -6,7 +6,12 @@ import {
useState,
} from "react";
import { AnimatePresence, motion } from "motion/react";
-import { countCutUnits, type CutInterval, type UnitsResponse } from "@lookout/shared";
+import {
+ countCutUnits,
+ type ApplyCutsResponse,
+ type CutInterval,
+ type UnitsResponse,
+} from "@lookout/shared";
import { createLookoutClient, type LookoutClient } from "../api/client.js";
import {
cutsToRegions,
@@ -35,8 +40,10 @@ 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. */
- onApplied?: () => void;
+ * 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. */
@@ -123,6 +130,11 @@ export function TimelapseEditor({
* 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);
@@ -194,6 +206,7 @@ export function TimelapseEditor({
try {
const status = await client.getStatus();
if (cancelled) return;
+ if (typeof status.progress === "number") setRealProgress(status.progress);
if (status.editable) {
await loadUnits();
return;
@@ -229,10 +242,17 @@ export function TimelapseEditor({
}, [client]);
// ── Build progress ──────────────────────────────────────────
- // The worker doesn't report progress, so this is a time estimate scaled
- // by how much footage there is to compile. It eases toward — and stops
- // short of — 100%, and only completes when the real thing does; a ring
- // that sat at 100% while the user waited would be worse than none.
+ // 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;
@@ -242,6 +262,8 @@ export function TimelapseEditor({
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));
};
@@ -683,8 +705,8 @@ export function TimelapseEditor({
try {
const cuts = regionsToCuts(normalizeRegions(regionsRef.current), data.units);
await client.setCuts(cuts);
- await client.applyCuts();
- onApplied?.();
+ const result = await client.applyCuts();
+ onApplied?.(result);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
setSaving(false);
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 (
Date: Sun, 9 Aug 2026 00:03:32 +0800
Subject: [PATCH 50/65] test(server): anchor the edit-hold fixture to now, not
a fixed date
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The fixture pinned T0 to 2026-07-01 — a future date when it was written, so it
passed CI happily until the calendar caught up. The edit hold's ceiling is
measured from stoppedAt (EDIT_HOLD_MAX_MINUTES after the stop), so once that
date was more than two hours in the past every lease test failed with
held:false for reasons that had nothing to do with leases.
---
.../server/test/edits.integration.test.ts | 53 ++++++++++++++++++-
1 file changed, 51 insertions(+), 2 deletions(-)
diff --git a/packages/server/test/edits.integration.test.ts b/packages/server/test/edits.integration.test.ts
index c2a372a4..c297ca9f 100644
--- a/packages/server/test/edits.integration.test.ts
+++ b/packages/server/test/edits.integration.test.ts
@@ -17,10 +17,23 @@ import { db, schema } from "../src/db/index.js";
let app: FastifyInstance;
-const T0 = new Date("2026-07-01T10:00:00.000Z");
+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();
-const UNITS = 10;
beforeEach(async () => {
await db.execute(sql`TRUNCATE screenshots, sessions RESTART IDENTITY CASCADE`);
@@ -409,6 +422,42 @@ describe("POST /compile (publish)", () => {
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) }]);
From 6b97b0255e6ed5186d4c21e433883ae8f3eb5b71 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 00:03:46 +0800
Subject: [PATCH 51/65] perf(compile): build a throwaway preview, publish from
the capture units
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The editor used to wait on a full-quality 1080p CRF18 build, and then the
published video was a byte-copy of it — so making the preview cheaper would
have made the published timelapse worse. Split the two:
preview 720p CRF30 superfast, built only to open the editor, deleted
on publish. 431 -> 83 ms/unit measured, at half the bytes.
published re-encoded at full quality from the capture units, with cuts
applied by not encoding the removed units — so cutting half a
session makes publishing cheaper, not dearer.
superfast rather than ultrafast deliberately: 15% slower for half the size,
and the preview is uploaded and then streamed back by the editor, so its size
is part of the latency this exists to reduce. ultrafast's output was actually
larger than the 1080p publish tier.
Guards, because a preview must never reach a viewer:
- original_is_preview marks such a build; a null/false value means
publish-grade, so legacy sessions and sessions that never entered the edit
flow keep the lossless-copy path untouched.
- A preview build never publishes. If the hold lapses mid-build it hands over
to the publish path rather than shipping a low-res video.
- If the units are gone (retention purge, R2 outage), it publishes the preview
with a loud error rather than stranding the recording.
- Sampled units are now load-bearing until publish; cleanup keeps them.
---
.../drizzle/0021_original_is_preview.sql | 1 +
packages/worker/src/compile.ts | 443 ++++++++++++++++--
packages/worker/src/schema.ts | 9 +
packages/worker/src/segments.ts | 142 ++++--
packages/worker/test/segments.test.ts | 81 +++-
5 files changed, 595 insertions(+), 81 deletions(-)
create mode 100644 packages/server/drizzle/0021_original_is_preview.sql
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/worker/src/compile.ts b/packages/worker/src/compile.ts
index fb0da174..0eeb16cf 100644
--- a/packages/worker/src/compile.ts
+++ b/packages/worker/src/compile.ts
@@ -25,14 +25,62 @@ 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);
+/** 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");
@@ -43,7 +91,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!,
@@ -59,6 +112,28 @@ 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;
+/** 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(
filePath: string,
@@ -222,7 +297,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),
@@ -255,6 +333,25 @@ export async function compileTimelapse(sessionId: string): Promise<{
// ── 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<{
@@ -280,7 +377,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: "",
@@ -325,6 +422,24 @@ export async function compileTimelapse(sessionId: string): Promise<{
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++;
@@ -351,11 +466,18 @@ export async function compileTimelapse(sessionId: string): Promise<{
}
if (!downloadedUnit) {
downloadFailures++;
+ await reportUnitDone();
continue;
}
try {
- segmentPaths[i] = await buildSegment(tmpDir, i, unitPath, ss.format);
+ segmentPaths[i] = await buildSegment(
+ tmpDir,
+ i,
+ unitPath,
+ ss.format,
+ buildQuality,
+ );
} catch (err) {
buildFailures++;
console.warn(
@@ -363,6 +485,7 @@ export async function compileTimelapse(sessionId: string): Promise<{
err,
);
}
+ await reportUnitDone();
}
};
await Promise.all(
@@ -452,14 +575,11 @@ export async function compileTimelapse(sessionId: string): Promise<{
"-f", "concat",
"-safe", "0",
"-i", concatListPath,
- "-c:v", "libx264",
- "-preset", "fast",
- // Match the segment encoder's visually-lossless setting — this
- // fallback must not be a quality downgrade either.
- "-crf", "18",
- "-pix_fmt", "yuv420p",
+ // 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),
- ...SEGMENT_GOP_ARGS,
"-movflags", "+faststart",
"-y",
originalPath,
@@ -572,10 +692,63 @@ export async function compileTimelapse(sessionId: string): Promise<{
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: 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,
@@ -596,30 +769,7 @@ export async function compileTimelapse(sessionId: string): Promise<{
}
// 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),
- ),
- );
-
- for (const ss of unsampled) {
- await deleteObjectQuiet(ss.r2Key);
- }
-
- // Delete unconfirmed screenshot records
- await db
- .delete(schema.screenshots)
- .where(
- and(
- eq(schema.screenshots.sessionId, sessionId),
- eq(schema.screenshots.confirmed, false),
- ),
- );
+ await cleanUpCaptureLeftovers(sessionId);
return {
videoUrl,
@@ -645,6 +795,165 @@ export async function compileTimelapse(sessionId: string): Promise<{
* 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;
+
+ 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 {
+ const response = await r2Client.send(
+ new GetObjectCommand({ Bucket: R2_BUCKET, Key: row.r2_key }),
+ );
+ await fs.writeFile(
+ unitPath,
+ await response.Body!.transformToByteArray(),
+ );
+ } catch {
+ 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,
+ ),
+ );
+
+ 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[],
@@ -660,9 +969,6 @@ async function applyCutCompile(
const editedR2Key = `timelapses/${sessionId}/edited.mp4`;
const videoUnits = session.videoUnits as VideoUnit[];
- const originalPath = path.join(tmpDir, "original.mp4");
- await downloadObject(originalR2Key, originalPath);
-
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);
@@ -674,17 +980,64 @@ async function applyCutCompile(
);
}
- let publishPath = originalPath;
- let publishR2Key = originalR2Key;
+ let publishPath: string;
+ let publishR2Key: string;
- if (hasEffectiveCuts) {
- publishPath = await cutVideoToKeptRanges(
+ 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,
- originalPath,
keptRanges,
- session.videoCopyAligned === true,
+ videoUnits.length,
);
- publishR2Key = editedR2Key;
+ 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;
+ }
}
// Thumbnail follows the published video: a cut first minute must not leak
diff --git a/packages/worker/src/schema.ts b/packages/worker/src/schema.ts
index 8ff7c738..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,
@@ -47,6 +48,9 @@ export const sessions = pgTable(
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"),
@@ -55,6 +59,11 @@ export const sessions = pgTable(
>(),
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,
diff --git a/packages/worker/src/segments.ts b/packages/worker/src/segments.ts
index 41872290..f9b48711 100644
--- a/packages/worker/src/segments.ts
+++ b/packages/worker/src/segments.ts
@@ -26,11 +26,11 @@ const execFileAsync = promisify(execFile);
* 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 2 frame intervals (~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.
+ * 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
@@ -44,9 +44,39 @@ 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 =
- "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2";
+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. */
@@ -78,28 +108,62 @@ export const SEGMENT_GOP_ARGS = [
"-x264-params", "open-gop=0",
];
-/** Pinned x264 parameters shared by EVERY segment encode. Segments 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. */
-export const SEGMENT_ENCODE_ARGS = [
- "-c:v", "libx264",
- "-profile:v", "high",
- "-level:v", "4.0",
- "-preset", "fast",
- // 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.
- "-crf", "18",
- "-pix_fmt", "yuv420p",
- ...SEGMENT_GOP_ARGS,
- "-threads", "1",
-];
+/**
+ * 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 {
@@ -147,11 +211,14 @@ export async function buildSegment(
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
@@ -161,9 +228,9 @@ export async function buildSegment(
[
"-framerate", "1",
"-i", unitPath,
- "-vf", `${SCALE_FILTER},fps=${SEGMENT_FPS}`,
+ "-vf", `${scale},fps=${SEGMENT_FPS}`,
"-frames:v", String(SEGMENT_FPS),
- ...SEGMENT_ENCODE_ARGS,
+ ...encodeArgs,
"-f", "mpegts",
"-y",
segmentPath,
@@ -184,9 +251,9 @@ export async function buildSegment(
[
"-i", unitPath,
"-vf",
- `setpts=N/(${frames}*TB),${SCALE_FILTER},fps=${SEGMENT_FPS},tpad=stop_mode=clone:stop=-1`,
+ `setpts=N/(${frames}*TB),${scale},fps=${SEGMENT_FPS},tpad=stop_mode=clone:stop=-1`,
"-frames:v", String(SEGMENT_FPS),
- ...SEGMENT_ENCODE_ARGS,
+ ...encodeArgs,
"-f", "mpegts",
"-y",
segmentPath,
@@ -195,7 +262,14 @@ export async function buildSegment(
);
}
- await verifySegmentFrameCount(segmentPath);
+ // 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;
}
diff --git a/packages/worker/test/segments.test.ts b/packages/worker/test/segments.test.ts
index 47c56f51..372fda0b 100644
--- a/packages/worker/test/segments.test.ts
+++ b/packages/worker/test/segments.test.ts
@@ -15,7 +15,13 @@ 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 } from "../src/segments.js";
+import {
+ buildSegment,
+ probeFrameCount,
+ SEGMENT_FPS,
+ PREVIEW_WIDTH,
+ PREVIEW_HEIGHT,
+} from "../src/segments.js";
const execFileAsync = promisify(execFile);
@@ -31,6 +37,30 @@ async function hasFfmpeg(): Promise {
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",
@@ -68,7 +98,9 @@ describe.skipIf(!ffmpegAvailable)("segment pipeline", () => {
{ timeout: 60_000 },
);
- // Clip unit: VP8/WebM, 20 frames over 60s (the Chromium/Firefox shape).
+ // 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",
@@ -159,6 +191,51 @@ describe.skipIf(!ffmpegAvailable)("segment pipeline", () => {
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)", () => {
From d322f89974a2f538b1c00c0893839e2dbb6ac9d8 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 00:03:59 +0800
Subject: [PATCH 52/65] feat(clips): 6 frames a minute, on by default
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two changes to how clips are configured.
Cadence is now 10s (6 frames/min). Every knob that used to be denominated in
frames is derived from it — the per-frame byte budget, the native encoder
bitrate, the stall cap — so per-frame QUALITY is held constant when the cadence
moves, in either direction. That mattered here: native encoders are handed real
presentation timestamps, so their bitrate buys bytes per second of MEDIA time,
and a fixed 800kbps would have inflated every clip toward the server's 8MB cap
as the interval grew. The formula reproduces the hand-tuned 800kbps at the old
4s cadence exactly, which is what makes it trustworthy elsewhere.
Clips are also the default now: clips_enabled defaults true and a program opts
OUT with clips:false. Existing rows are deliberately not backfilled — a
session's capture character 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.
Smoothness scales with the cadence, so this is 6 distinct images per output
second rather than 15. Bandwidth falls with it: ~1.1 MB/min for a typical
screen, under half what 15/min cost.
---
clients/react/src/hooks/useSession.ts | 2 +-
docs/integration.md | 35 ++--
packages/server/API.md | 11 +-
.../server/drizzle/0022_clips_default_on.sql | 6 +
packages/server/drizzle/meta/_journal.json | 21 +++
packages/server/src/db/schema.ts | 38 ++++-
packages/server/src/routes/internal.ts | 11 +-
.../server/test/clips.integration.test.ts | 21 ++-
packages/shared/src/constants.ts | 152 ++++++++++++++----
packages/shared/src/types.ts | 31 +++-
10 files changed, 258 insertions(+), 70 deletions(-)
create mode 100644 packages/server/drizzle/0022_clips_default_on.sql
diff --git a/clients/react/src/hooks/useSession.ts b/clients/react/src/hooks/useSession.ts
index 105ef4ea..caca59b3 100644
--- a/clients/react/src/hooks/useSession.ts
+++ b/clients/react/src/hooks/useSession.ts
@@ -11,7 +11,7 @@ interface SessionState {
startedAt: string | null;
createdAt: string | null;
totalActiveSeconds: number;
- /** Whether this session accepts clip uploads (~20 frames/min video).
+ /** 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. */
diff --git a/docs/integration.md b/docs/integration.md
index 28eb3ef4..fe0591ce 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -46,7 +46,7 @@ API calls require the `X-API-Key` header.
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", "projectId": "proj_456"}, "clips": true}'
+ -d '{"metadata": {"userId": "user_123", "projectId": "proj_456"}}'
```
Response:
@@ -62,7 +62,7 @@ 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` — opt this session into [clips](#clips-15-frames-per-minute) (~15 frames/min video capture instead of 1 JPEG/min → 15× smoother timelapses). Default `false`; immutable after creation.
+- `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
@@ -93,12 +93,13 @@ How it behaves:
convenience, not a guaranteed callback. For server-side certainty, poll
[session status](#get-session-info) instead.
-### Clips (15 frames per minute)
+### Clips (6 frames per minute)
-Sessions created with `"clips": true` record **clips**: instead of one JPEG per
-minute, the recording client uploads one ~60s video file per minute containing
-~15 frames captured 4s apart. The compiled timelapse has the same length but is
-15× smoother, with motion from the very first second.
+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:
@@ -110,15 +111,19 @@ What this means for your program:
(≥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; a typical screen
- measures ~2.5 MB/min and even a deliberately incompressible one stays
- around 3 MB/min, since the encoders undershoot easy content heavily.
+- **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 to hold q0.85-JPEG-class detail at
- 1080p even on busy screens. For review purposes you get 15× more moments
- per minute.
-- Roll it out gradually if you like — the flag is per session, so you can
- enable it for a fraction of new sessions and compare.
+ 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
diff --git a/packages/server/API.md b/packages/server/API.md
index 4aded4ba..30b9b274 100644
--- a/packages/server/API.md
+++ b/packages/server/API.md
@@ -124,16 +124,16 @@ Pre-0.2.1 the bucket count caused timer jump-back when two captures arrived in t
## Clips
-By default, each capture unit is one JPEG per minute. Sessions created with `"clips": true` on the [internal create endpoint](#create-session) instead accept **clips**: per-minute video files (~15 frames captured 4s apart, WebM from Chromium/Firefox MediaRecorder, MP4 from Safari and desktop) that compile into a 15×-smoother timelapse.
+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-in.** `clips_enabled` is set at creation and enforced server-side on every `upload-url`. It cannot be changed later — a session's capture character never changes mid-recording.
+- **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 4000 = 15 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).
+- **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.
@@ -822,8 +822,7 @@ Creates a new session in `pending` state.
**Request Body:**
```json
{
- "metadata": {},
- "clips": false
+ "metadata": {}
}
```
@@ -831,7 +830,7 @@ Creates a new session in `pending` state.
|-------|------|----------|-------------|
| `name` | string | no | Session name (1-255 chars) |
| `metadata` | object | no | Arbitrary JSON metadata to attach to the session (max 50 properties) |
-| `clips` | boolean | no | Allow [clip uploads](#clips) (~15 frames/min video) for this session. Default `false` = legacy 1 JPEG/min. **Immutable after creation.** |
+| `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`:**
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/_journal.json b/packages/server/drizzle/meta/_journal.json
index 1e68f5d9..0499763f 100644
--- a/packages/server/drizzle/meta/_journal.json
+++ b/packages/server/drizzle/meta/_journal.json
@@ -141,6 +141,27 @@
"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/src/db/schema.ts b/packages/server/src/db/schema.ts
index 425beeee..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,
@@ -115,12 +116,18 @@ 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 (~20
- // frames/min) instead of single JPEGs. Set at creation by the program's
- // backend (internal API `clips: true`), enforced on every upload-url
- // (disallowed formats are downgraded to jpeg), and immutable thereafter —
+ // 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.
- clipsEnabled: boolean("clips_enabled").notNull().default(false),
+ //
+ // 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
@@ -138,6 +145,14 @@ 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
@@ -165,6 +180,17 @@ export const sessions = pgTable(
// 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
@@ -210,7 +236,7 @@ export const screenshots = pgTable(
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 ~20 frames). Decided per upload by the
+ // '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.
diff --git a/packages/server/src/routes/internal.ts b/packages/server/src/routes/internal.ts
index 3ea740db..94a7410f 100644
--- a/packages/server/src/routes/internal.ts
+++ b/packages/server/src/routes/internal.ts
@@ -34,9 +34,10 @@ export async function internalRoutes(app: FastifyInstance) {
properties: {
name: { type: "string" as const, minLength: 1, maxLength: 255 },
metadata: { type: "object" as const, maxProperties: 50 },
- // Opt this session into clip uploads (per-minute videos of ~20
- // frames). Default false = legacy 1 JPEG/min. Immutable after
- // creation — a session's capture character never changes.
+ // 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
@@ -59,7 +60,9 @@ export async function internalRoutes(app: FastifyInstance) {
.values({
...(name ? { name } : {}),
metadata: metadata ?? {},
- clipsEnabled: clips ?? false,
+ // 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;
diff --git a/packages/server/test/clips.integration.test.ts b/packages/server/test/clips.integration.test.ts
index 4cc44528..4d6789b4 100644
--- a/packages/server/test/clips.integration.test.ts
+++ b/packages/server/test/clips.integration.test.ts
@@ -262,7 +262,7 @@ describe("capability discovery", () => {
});
});
-describe("internal API opt-in", () => {
+describe("internal API opt-out", () => {
async function makeApiKey(): Promise {
const [row] = await db
.insert(schema.apiKeys)
@@ -271,9 +271,11 @@ describe("internal API opt-in", () => {
return row.key;
}
- it("creates clips-enabled sessions only when clips: true is passed", async () => {
+ 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",
@@ -290,13 +292,26 @@ describe("internal API opt-in", () => {
});
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 offRow = await db.query.sessions.findFirst({
+ 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.
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 328a4423..79ef578f 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -58,18 +58,19 @@ export const STREAK_WINDOW_MS = 30_000;
export const CREDIT_PER_CAPTURE_S = 60;
// ──────────────────────────────────────────────────────────
-// Clips (20 frames/minute via per-minute video uploads)
+// 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 ~15 frames captured
+ * "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`, set at
- * creation via the internal API and immutable thereafter. */
+ * 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];
@@ -83,36 +84,111 @@ export const CAPTURE_FORMAT_CONTENT_TYPES: Record = {
};
/** How often a clip-recording client grabs a frame into the current
- * clip. 4000ms = 15 frames per SCREENSHOT_INTERVAL_MS. The cadence is
+ * 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. 15/min trades a hair
- * of smoothness for ~33% more bitrate budget per frame — text
- * legibility wins.
- * Default: 4000 (4 seconds) */
-export const CLIP_FRAME_INTERVAL_MS = 4_000;
+ * 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: 15 */
-export const FRAMES_PER_CLIP = 15;
+ * Default: 6 */
+export const FRAMES_PER_CLIP = Math.round(
+ SCREENSHOT_INTERVAL_MS / CLIP_FRAME_INTERVAL_MS,
+);
-/** Encoder bitrate cap for clips recorded by a NATIVE encoder (the desktop
- * app's VideoToolbox / Media Foundation / GStreamer paths).
+/** Hard cap on frames the client records into a SINGLE clip, as a multiple
+ * of the nominal count.
*
- * Sized for TEXT LEGIBILITY: at 15 frames/min, 800 kbps allows ~400 KB
- * per 4s frame — JPEG-q85-class keyframes at 1080p, the bar the legacy
- * single-screenshot pipeline set. This is a VBR ceiling, not a floor:
- * static screen content undershoots it heavily (measured 133 kbps-era
- * clips landed at ~400 KB/min total). 133k and 400k were tried first
- * and produced visibly soft H.264.
+ * 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.
*
- * This "budget spread over the clip's 60s" reading only holds for
- * encoders we hand real presentation timestamps to. Browsers do NOT
- * work that way — see CLIP_WEB_VIDEO_BITS_PER_SECOND.
+ * 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_VIDEO_BITS_PER_SECOND = 800_000;
+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
@@ -141,17 +217,21 @@ export const CLIP_VIDEO_BITS_PER_SECOND = 800_000;
* 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 ~400 KB budget. Measured over a full 15-frame clip, against
- * the 8 MB MAX_CLIP_BYTES cap:
+ * 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 sits at 40% of the cap. (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.)
+ * 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.
@@ -282,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/types.ts b/packages/shared/src/types.ts
index 2832d37a..6ce5c523 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -61,9 +61,10 @@ export type ClientInfo = string;
export interface CreateSessionRequest {
name?: string;
metadata?: Record;
- /** Allow this session to receive clip uploads (per-minute videos of
- * ~15 frames) instead of one JPEG per minute. Default false.
- * Immutable after creation. */
+ /** 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
@@ -202,6 +203,10 @@ export interface ApplyCutsResponse {
* (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 {
@@ -211,9 +216,19 @@ 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
@@ -290,6 +305,12 @@ export interface StopResponse {
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 —
From 8a882582e64553d92291f9493cce3381b300595a Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 00:04:12 +0800
Subject: [PATCH 53/65] fix(server): a wrong system clock must not cost the
recording
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An out-of-envelope capturedAt returned 400 from upload-url, which meant a
client whose clock was more than five minutes off never received a presigned
URL and uploaded NOTHING for the entire session. A skewed clock is common,
invisible to the user, and none of their doing.
The server now adopts its own clock for those captures instead. That is
strictly safer than accepting the claim — server time is unforgeable, so a
hostile client gains nothing by sending a wild timestamp; it just gets stamped
with the moment the server saw it. It costs precision, not the recording: the
capture carries upload latency, so credit is measured a little late.
Adoption is for clocks, not for requests. Only the two envelope failures are
absorbed; non-monotonic and pre-session timestamps are still refused, and the
substituted value is re-validated against both, so replay protection survives.
The response reports capturedAtAdopted so a client can correct itself, and
ClockOffset (shared) does exactly that: it learns the offset from the server's
own timestamps, bracketing each sample so the round trip is charged to latency
rather than to the offset. A healthy clock stays inside a deadband and its
stamps pass through untouched.
Also here, both small:
- POST /compile returned 202 for any compiling session, which shadowed the
branch that drops the edit hold. 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. The discriminator is whether an original
exists: with one, the in-flight job is the publish; without, it is the
preview build and the request means 'publish as recorded'.
- R2_ENDPOINT override so the stack can run against a local S3-compatible
endpoint. Unset in production, where the account-derived host is used.
---
packages/server/src/config/r2.ts | 8 +-
packages/server/src/lib/timing.test.ts | 90 +++++++++++++++-
packages/server/src/lib/timing.ts | 73 +++++++++++++
packages/server/src/routes/sessions.ts | 74 ++++++++++---
.../server/test/sessions.integration.test.ts | 65 +++++++++--
packages/shared/src/clockOffset.test.ts | 101 ++++++++++++++++++
packages/shared/src/clockOffset.ts | 85 +++++++++++++++
packages/shared/src/index.ts | 1 +
8 files changed, 476 insertions(+), 21 deletions(-)
create mode 100644 packages/shared/src/clockOffset.test.ts
create mode 100644 packages/shared/src/clockOffset.ts
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/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/sessions.ts b/packages/server/src/routes/sessions.ts
index fef002b8..a217592c 100644
--- a/packages/server/src/routes/sessions.ts
+++ b/packages/server/src/routes/sessions.ts
@@ -16,7 +16,7 @@ import {
checkRateLimit,
checkGenericRateLimit,
creditCapture,
- validateCapturedAt,
+ adoptedCapturedAt,
} from "../lib/timing.js";
import { now } from "../lib/clock.js";
import { extractJa4 } from "../lib/ja4.js";
@@ -441,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())) {
@@ -544,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) {
@@ -565,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.
@@ -625,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,
@@ -660,6 +682,11 @@ 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,
@@ -1197,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,
@@ -1599,11 +1630,26 @@ export async function sessionRoutes(app: FastifyInstance) {
MAX_USER_RECOMPILES - session.recompileCount,
);
- if (session.status === "compiling") {
- // Already publishing — treat as success so client retries are safe.
+ // 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 });
+ .send({
+ status: "compiling" as const,
+ instant: false,
+ recompilesRemaining,
+ redirectUrl: session.redirectUrl,
+ });
}
if (session.status === "complete") {
// Someone (usually the hold-expiry job) published first. Idempotent
@@ -1612,6 +1658,7 @@ export async function sessionRoutes(app: FastifyInstance) {
status: "complete" as const,
instant: true,
recompilesRemaining,
+ redirectUrl: session.redirectUrl,
};
}
@@ -1630,6 +1677,7 @@ export async function sessionRoutes(app: FastifyInstance) {
status: session.status as "stopped" | "compiling",
instant: false,
recompilesRemaining,
+ redirectUrl: session.redirectUrl,
};
}
@@ -1654,6 +1702,7 @@ export async function sessionRoutes(app: FastifyInstance) {
status: "complete" as const,
instant: true,
recompilesRemaining,
+ redirectUrl: session.redirectUrl,
};
}
@@ -1691,6 +1740,7 @@ export async function sessionRoutes(app: FastifyInstance) {
0,
MAX_USER_RECOMPILES - (session.recompileCount + 1),
),
+ redirectUrl: session.redirectUrl,
};
},
);
diff --git a/packages/server/test/sessions.integration.test.ts b/packages/server/test/sessions.integration.test.ts
index b8f1c657..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 () => {
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/index.ts b/packages/shared/src/index.ts
index 0e41e6d4..e7a93ad7 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -2,3 +2,4 @@ export * from "./constants.js";
export * from "./types.js";
export * from "./clientInfo.js";
export * from "./cuts.js";
+export * from "./clockOffset.js";
From 4770f1d227b49c63910f5d96df68a7711000b4ea Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 00:04:25 +0800
Subject: [PATCH 54/65] fix(web): a bad network no longer costs minutes of
recording
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four compounding failures on a slow uplink, all in the browser client. The
desktop loop already had the right shape; this brings the web one in line.
1. No timeout anywhere. fetch has no default, so a half-open socket parked the
capture loop indefinitely with no error to retry on. Every step now has a
30s deadline, which fits inside the 120s presigned-URL expiry with room for
the retry budget.
2. The upload blocked the loop. Each second of latency was a second the
recorder wasn't cutting on schedule, so clips stretched to cover minutes
each — and a clip renders as ONE second of video however long it took to
record, so that footage was genuinely lost. Uploads now run concurrently,
strictly one in flight, settled at the next tick to keep them ordered.
3. The in-flight clip grew unbounded. A five-minute stall produced a clip that
blew the 8MB cap and was refused on arrival, costing the whole window. Now
capped at 3x nominal; the tail of a stalled window is dropped instead.
4. The oversize path halved the encoder bitrate permanently, so one network
incident left the rest of the session soft — a user on bad wifi ended up
with a worse timelapse than one on no wifi. The backoff no longer fires for
a clip that was merely stalled.
And the two fallbacks desktop had that web didn't:
- A failed CLIP upload is retried as a JPEG reusing the clip's own
capturedAtMs, so the minute still credits. Previously the whole minute was
lost.
- Clips latch off after 3 consecutive failures, or immediately on a typed
ClipFormatRejectedError. That error's message used to claim 'falling back to
JPEG captures' while nothing did.
---
clients/react/src/api/client.ts | 43 +++-
clients/react/src/hooks/clipFallback.test.tsx | 192 +++++++++++++++++
clients/react/src/hooks/clipRecorder.test.ts | 143 ++++++++++++
clients/react/src/hooks/clipRecorder.ts | 63 +++++-
clients/react/src/hooks/useLookout.ts | 204 +++++++++++++++---
clients/react/src/hooks/useUploader.ts | 66 +++++-
6 files changed, 659 insertions(+), 52 deletions(-)
create mode 100644 clients/react/src/hooks/clipFallback.test.tsx
create mode 100644 clients/react/src/hooks/clipRecorder.test.ts
diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts
index 258ab6a8..fec6d634 100644
--- a/clients/react/src/api/client.ts
+++ b/clients/react/src/api/client.ts
@@ -1,3 +1,4 @@
+import { UPLOAD_STEP_TIMEOUT_MS } from "@lookout/shared";
import type {
CaptureFormat,
SessionResponse,
@@ -83,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) {
@@ -90,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);
@@ -159,8 +190,18 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient
body: blob,
// 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.",
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
index e9790b19..b2074b13 100644
--- a/clients/react/src/hooks/clipRecorder.ts
+++ b/clients/react/src/hooks/clipRecorder.ts
@@ -3,8 +3,10 @@ import {
MAX_HEIGHT,
JPEG_QUALITY,
CLIP_WEB_VIDEO_BITS_PER_SECOND,
- CLIP_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";
@@ -17,6 +19,10 @@ export interface ClipCaptureResult {
/** 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;
@@ -79,11 +85,13 @@ export interface ClipRecorderOptions {
maxWidth?: number;
maxHeight?: number;
jpegQuality?: number;
- /** Faster cadence for the FIRST clip only. The opening clip is cut
- * after ~2 frame intervals (fast session activation), so at the normal
- * cadence it would hold just 2 frames — one near-still second at the
- * head of every timelapse. A denser opening cadence fixes that; after
- * the first cut the recorder reverts to `frameIntervalMs`. */
+ /** 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;
}
@@ -117,6 +125,10 @@ export class ClipRecorder {
* `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>;
@@ -139,6 +151,9 @@ export class ClipRecorder {
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,
@@ -214,6 +229,19 @@ export class ClipRecorder {
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
@@ -249,6 +277,7 @@ export class ClipRecorder {
this.drawFrame();
const capturedAtMs = Date.now();
const frameCount = this.frameCount;
+ const truncated = frameCount >= this.maxFrames;
if (this.frameTimer) {
clearInterval(this.frameTimer);
this.frameTimer = null;
@@ -288,20 +317,31 @@ export class ClipRecorder {
// 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 conservative native rate.
+ // the floor is the coarsest setting worth uploading.
let oversize = false;
if (blob && blob.size > MAX_CLIP_BYTES) {
oversize = true;
- const reduced = Math.max(
- CLIP_VIDEO_BITS_PER_SECOND,
- Math.round(this.bitrate / 2),
- );
+ // ...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}`,
+ );
}
}
@@ -328,6 +368,7 @@ export class ClipRecorder {
width,
height,
frameCount,
+ truncated,
capturedAtMs,
previewBlob,
};
diff --git a/clients/react/src/hooks/useLookout.ts b/clients/react/src/hooks/useLookout.ts
index ba2c5e3e..770b78bf 100644
--- a/clients/react/src/hooks/useLookout.ts
+++ b/clients/react/src/hooks/useLookout.ts
@@ -1,9 +1,18 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { CLIP_FRAME_INTERVAL_MS } from "@lookout/shared";
+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, type UploadPayload } 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";
@@ -132,10 +141,13 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
maxWidth: config.capture.maxWidth,
maxHeight: config.capture.maxHeight,
jpegQuality: config.capture.jpegQuality,
- // Denser cadence for the short opening clip (cut at
- // 2×frameIntervalMs) so the timelapse's first second is as
- // smooth as the rest.
- openingFrameIntervalMs: Math.max(500, Math.round(frameIntervalMs / 3)),
+ // 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) {
@@ -149,20 +161,128 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
}
clipRecorderRef.current = clipRecorder;
- // Serial capture-upload chain — matches the desktop Rust loop in
- // `clients/desktop/src-tauri/src/lib.rs::capture_loop_task`. Each
- // tick produces one capture unit (a clip cut, or a JPEG 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.
+ // 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.
+ // 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 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). 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;
+ }
+ };
+
+ const scheduleNext = (nextExpectedAt: string | null) => {
+ if (cancelled) return;
+ const target = nextExpectedAt
+ ? Date.parse(nextExpectedAt)
+ : Date.now() + config.capture.intervalMs;
+ // Defensive upper bound: never sleep longer than 2x interval.
+ // Matches desktop's same clamp — protects against malformed
+ // server timestamps.
+ const delay = Math.min(
+ config.capture.intervalMs * 2,
+ Math.max(0, target - Date.now()),
+ );
+ if (intervalRef.current !== null) clearTimeout(intervalRef.current);
+ intervalRef.current = setTimeout(tick, delay);
+ };
+
const tick = async () => {
if (cancelled) return;
- let nextExpectedAt: string | null = null;
+
+ // 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)
@@ -170,38 +290,52 @@ export function useLookout(): { state: LookoutState; actions: LookoutActions } {
// 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);
- const result = await captureUploadConfirmRef.current(payload);
- nextExpectedAt = result.nextExpectedAt;
+ // 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) {
- // Pipeline failure (network / server / 409). Schedule next tick
- // on the local fallback so the chain stays alive.
+ // Capture-side failure (canvas, encoder, no video). The upload
+ // path has its own handling.
console.warn("[lookout] capture cycle failed:", err);
}
if (cancelled) return;
- const target = nextExpectedAt
- ? Date.parse(nextExpectedAt)
- : Date.now() + config.capture.intervalMs;
- // Defensive upper bound: never sleep longer than 2x interval.
- // Matches desktop's same clamp — protects against malformed
- // server timestamps.
- const delay = Math.min(
- config.capture.intervalMs * 2,
- Math.max(0, target - Date.now()),
- );
- intervalRef.current = setTimeout(tick, delay);
+ // 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 couple of frames before the first cut so
- // the timelapse's first second shows motion, not a single still.
- intervalRef.current = setTimeout(tick, frameIntervalMs * 2);
+ // 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();
}
diff --git a/clients/react/src/hooks/useUploader.ts b/clients/react/src/hooks/useUploader.ts
index fc999d62..c213a179 100644
--- a/clients/react/src/hooks/useUploader.ts
+++ b/clients/react/src/hooks/useUploader.ts
@@ -1,5 +1,9 @@
-import { useCallback, useState } from "react";
-import { CAPTURE_FORMAT_CONTENT_TYPES, type CaptureFormat } from "@lookout/shared";
+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 { UploadState } from "../types.js";
@@ -27,6 +31,25 @@ 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 {
@@ -37,6 +60,9 @@ export interface UploadPayload {
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;
}
@@ -95,15 +121,26 @@ 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: 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 sentAt = Date.now();
const urlResponse = await retry(
() =>
client.getUploadUrl({
@@ -113,6 +150,24 @@ export function useUploader(): UploaderResult {
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 ≠
@@ -120,8 +175,9 @@ export function useUploader(): UploaderResult {
// 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 Error(
- `Server granted "${urlResponse.format ?? "jpeg"}" for a "${format}" clip — falling back to JPEG captures`,
+ throw new ClipFormatRejectedError(
+ format,
+ urlResponse.format ?? "jpeg",
);
}
From f802e88afcbf047a7bb138984f6498d668076e75 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 00:04:43 +0800
Subject: [PATCH 55/65] fix(desktop): Windows encoder bugs, and stop retrying a
dead encoder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two real bugs in the Media Foundation path, both found by reading it rather
than running it — this is the one platform CI can only type-check:
- Sample duration was pinned at 3000ms, a cadence the app no longer uses, so
every sample claimed a span that disagreed with its own timestamps. Now
derived from the real frame interval.
- MFStartup leaked a refcount on every failed init. Invisible when init
succeeds (finish() balances it), unbounded on a machine whose encoder always
fails, because the loop retried init once per frame for the whole session.
Now RAII-guarded, with ownership passed to the encoder on success.
Media Foundation also gets a two-attempt media-type negotiation: declare the
real sub-1fps cadence first, so the bitrate and the frame-rate hint agree about
what a second means, and fall back to the 1fps hint this shipped with (scaling
the bitrate to match) if the MFT refuses fractional rates. Only if both fail
does the interval fall back to a JPEG.
And a failure latch: after 3 consecutive encoder failures, clips turn off for
the run. Each failure was already survivable — the interval falls back to a
JPEG — but a machine where the encoder can never initialise was paying the full
cost of constructing and tearing down an OS encoder several times a minute for
hours. Any clip that finalises resets the count, so a transient hiccup (a
display mode change, a busy GPU) never disables clips.
Windows per-frame output remains unmeasured on real hardware; if clips come
back soft or oversize, that negotiation is where to look first.
---
clients/desktop/src-tauri/src/clips.rs | 203 ++++++++++++++++++++++---
clients/desktop/src-tauri/src/lib.rs | 125 +++++++++++----
2 files changed, 278 insertions(+), 50 deletions(-)
diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs
index 729e81fe..9088a851 100644
--- a/clients/desktop/src-tauri/src/clips.rs
+++ b/clients/desktop/src-tauri/src/clips.rs
@@ -3,7 +3,7 @@
//! with clips enabled.
//!
//! One `ClipRecorder` lives per upload interval: the capture loop pushes a
-//! frame every `frameIntervalMs` (server-authoritative, 4s = 15/min), and
+//! 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:
//!
@@ -19,16 +19,31 @@ use image::DynamicImage;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
-/// Encoder bitrate cap (bits/second). Matches the shared
-/// CLIP_VIDEO_BITS_PER_SECOND, which is the NATIVE-encoder rate: we hand
-/// these encoders real presentation timestamps, so the number really does
-/// buy a per-frame budget. Browsers need a much larger figure for the same
-/// output quality (see CLIP_WEB_VIDEO_BITS_PER_SECOND) — the two are not
-/// comparable. Sized for text legibility: ~300 KB per 3s
-/// frame allows JPEG-q85-class keyframes at 1080p. VBR ceiling, not a
-/// floor — static screens undershoot heavily. The server rejects clips
-/// over 8 MB. 133k/400k were tried first and produced soft H.264.
-pub const CLIP_BITS_PER_SECOND: u32 = 800_000;
+/// 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 {
@@ -67,7 +82,13 @@ impl ClipRecorder {
let width = (width & !1).max(2);
let height = (height & !1).max(2);
let path = clip_temp_path();
- let encoder = platform::Encoder::new(&path, width, height, CLIP_BITS_PER_SECOND)?;
+ let encoder = platform::Encoder::new(
+ &path,
+ width,
+ height,
+ clip_bits_per_second(frame_interval_ms),
+ frame_interval_ms,
+ )?;
Ok(Self {
encoder,
path,
@@ -250,6 +271,34 @@ mod tests {
}
}
+ /// 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"
+ );
+ }
+
/// A recorder with zero frames must fail, not produce an empty clip.
#[test]
fn empty_clip_errors() {
@@ -317,7 +366,13 @@ mod platform {
unsafe impl Send for Encoder {}
impl Encoder {
- pub fn new(path: &Path, width: u32, height: u32, bitrate: u32) -> Result {
+ pub fn new(
+ path: &Path,
+ width: u32,
+ height: u32,
+ bitrate: u32,
+ frame_interval_ms: u64,
+ ) -> Result {
unsafe {
let url = NSURL::fileURLWithPath(&NSString::from_str(
path.to_str().ok_or("non-utf8 temp path")?,
@@ -369,8 +424,14 @@ mod platform {
);
// 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(1).as_ref(),
+ NSNumber::new_u32(expected_fps).as_ref(),
ProtocolObject::from_ref(key_expected_fps),
);
@@ -557,24 +618,98 @@ mod platform {
((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 {
- pub fn new(path: &Path, width: u32, height: u32, bitrate: u32) -> Result {
+ /// 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,
@@ -582,9 +717,10 @@ mod platform {
)
.map_err(|e| format!("MFCreateSinkWriterFromURL failed: {e}"))?;
- // Output: H.264 at the clip bitrate. ~1 fps nominal rate —
- // frame timing is carried per-sample, the rate attribute
- // only seeds the encoder's rate control.
+ // 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
@@ -603,7 +739,10 @@ mod platform {
.SetUINT64(&MF_MT_FRAME_SIZE, pack_u64(width, height))
.map_err(|e| e.to_string())?;
out_type
- .SetUINT64(&MF_MT_FRAME_RATE, pack_u64(1, 1))
+ .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
@@ -630,7 +769,10 @@ mod platform {
.SetUINT64(&MF_MT_FRAME_SIZE, pack_u64(width, height))
.map_err(|e| e.to_string())?;
in_type
- .SetUINT64(&MF_MT_FRAME_RATE, pack_u64(1, 1))
+ .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)
@@ -640,11 +782,15 @@ mod platform {
.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,
})
}
}
@@ -687,8 +833,12 @@ mod platform {
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(3_000i64 * 10_000)
+ .SetSampleDuration((self.frame_interval_ms * 10_000) as i64)
.map_err(|e| e.to_string())?;
self.writer
@@ -738,7 +888,16 @@ mod platform {
unsafe impl Send for Encoder {}
impl Encoder {
- pub fn new(path: &Path, width: u32, height: u32, bitrate: u32) -> Result {
+ /// `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
diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs
index 48191ba4..d398440e 100644
--- a/clients/desktop/src-tauri/src/lib.rs
+++ b/clients/desktop/src-tauri/src/lib.rs
@@ -2088,12 +2088,36 @@ const CAPTURE_INTERVAL_SECS: u64 = 60;
/// probably slept (or the WebView was throttled hard).
const SLEEP_THRESHOLD_SECS: u64 = CAPTURE_INTERVAL_SECS * 2 + 30; // 150s
/// Fallback frame cadence when the server doesn't advertise one (pre-clips
-/// servers): every 4s = 15 frames/min. 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 = 4_000;
+/// 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
@@ -2114,16 +2138,17 @@ fn tray_display_seconds(base_seconds: i64, elapsed_secs: i64, running: bool) ->
base_seconds + elapsed_secs.clamp(0, MAX_TRAY_INTERPOLATION_SECS)
}
-/// Format seconds into the same tray title format as the JS side:
-/// >0h: "{h}h {m}m", else: "{m}m"
+/// 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")
+ format!("{h}:{m:02}:{s:02}")
} else {
- format!("{m}m")
+ format!("{m:02}:{s:02}")
}
}
@@ -2469,6 +2494,26 @@ async fn grab_frame(
.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.
@@ -2642,7 +2687,11 @@ async fn capture_loop_task(
Some(c) => fetch_clip_capabilities(c).await,
None => SessionClipCapabilities::default(),
};
- let clips_mode = caps.clips_enabled;
+ // 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
@@ -2655,22 +2704,22 @@ async fn capture_loop_task(
eprintln!("[capture-loop] clips enabled (frame every {frame_interval_ms}ms)");
}
- // Clips: delay the first upload by two frame intervals so the opening
- // clip carries motion instead of a single still. JPEG mode keeps the
- // legacy immediate first tick.
+ // 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 {
- 2 * frame_dur
+ Duration::from_millis(CLIP_FIRST_CUT_DELAY_MS)
} else {
Duration::ZERO
};
- // The opening window is only ~2 frame intervals long, so at the normal
- // cadence the first clip would hold just 2 frames — rendered as one
- // near-still second at the head of every timelapse. Capture the opening
- // window ~3x faster so the first clip is as visually dense as the rest;
- // after the first upload the cadence returns to the server's value.
- let opening_frame_dur = Duration::from_millis((frame_interval_ms / 3).max(500));
+ // 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
@@ -2685,7 +2734,7 @@ async fn capture_loop_task(
'outer: loop {
// ── Wait until next_fire, collecting frames along the way ──
- // Frames run at the clip cadence (server-set, 15/min) through the
+ // 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).
@@ -2763,9 +2812,15 @@ async fn capture_loop_task(
frame_interval_ms,
) {
Ok(r) => recorder = Some(r),
- Err(e) => eprintln!(
- "[capture-loop] clip encoder init failed: {e} — JPEG fallback this interval"
- ),
+ 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() {
@@ -2776,6 +2831,7 @@ async fn capture_loop_task(
if let Some(r) = recorder.take() {
r.discard();
}
+ note_clip_failure(&mut clip_encoder_failures, &mut clips_mode);
}
}
}
@@ -2889,7 +2945,8 @@ async fn capture_loop_task(
frame_interval_ms,
)
.map_err(|e| {
- eprintln!("[capture-loop] clip encoder init failed: {e}")
+ eprintln!("[capture-loop] clip encoder init failed: {e}");
+ note_clip_failure(&mut clip_encoder_failures, &mut clips_mode);
})
.ok();
}
@@ -2899,14 +2956,21 @@ async fn capture_loop_task(
if let Some(r) = recorder.take() {
r.discard();
}
+ note_clip_failure(&mut clip_encoder_failures, &mut clips_mode);
}
}
match recorder.take().map(|r| r.finish()) {
- Some(Ok(c)) => Some(c),
+ 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,
@@ -3676,7 +3740,12 @@ mod tray_timer_tests {
// interpolated value here left the menu bar up to a minute ahead for
// the whole pause.
assert_eq!(tray_display_seconds(120, 45, false), 120);
- assert_eq!(format_tray_time(tray_display_seconds(299, 59, false)), "4m");
+ // 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]
From 6e549179b1a964eb18f73a36f5eb9510fd2245eb Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 00:04:43 +0800
Subject: [PATCH 56/65] ci: exercise the desktop client in release, and run the
shared tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
cargo test/check ran the debug profile. The clip encoders are unsafe FFI
against VideoToolbox, Media Foundation and GStreamer — exactly the code whose
behaviour differs under optimisation, and this project has already been bitten
once by a misalignment crash that only appeared in a release build. Testing a
debug binary proves the wrong artifact. The Windows job is the only one that
compiles the Media Foundation encoder at all, so it should compile it the way
users get it.
Also adds the worker job (ffmpeg segment pipeline) and the shared package's
unit tests, which now cover clock-offset estimation.
---
.github/workflows/tests.yml | 21 +++++++++++++++++----
1 file changed, 17 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f55b7bed..65ba977e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -119,6 +119,10 @@ jobs:
- 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
@@ -163,9 +167,15 @@ 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
+ run: cargo test --lib --release
# ──────────────────────────────────────────────────────────────────
# Windows type-check: the Media Foundation clip encoder is Windows-only
@@ -187,9 +197,12 @@ jobs:
workspaces: "./clients/desktop/src-tauri -> target"
shared-key: rust-windows-check
- - name: cargo 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 check
+ run: cargo check --release
# ──────────────────────────────────────────────────────────────────
# Type-check the rest of the workspace (React/web clients). Catches
From dbb624f4945efe654cac4fcec082c5fd9319613b Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 00:04:50 +0800
Subject: [PATCH 57/65] 0.3.7: clips at 6fpm by default, two-tier compile,
network and clock resilience
Skips 0.3.6, which is taken by the unreleased crash-handler branch.
Workspace packages move 0.3.3 -> 0.3.7 and the desktop app 0.3.5 -> 0.3.7, so
both tracks read the same number from here on. Also adds the shared package's
test script (its units are new in this branch) and corrects the SDK doc's
stale v0.1.0 header.
---
clients/desktop/package.json | 2 +-
clients/desktop/src-tauri/Cargo.lock | 2 +-
clients/desktop/src-tauri/Cargo.toml | 2 +-
clients/desktop/src-tauri/tauri.conf.json | 15 ++++++++++++---
clients/playground/package.json | 2 +-
clients/react/API.md | 2 +-
clients/react/package.json | 2 +-
clients/web/package.json | 2 +-
package-lock.json | 14 +++++++-------
packages/server/package.json | 2 +-
packages/shared/package.json | 5 +++--
packages/worker/package.json | 2 +-
12 files changed, 31 insertions(+), 21 deletions(-)
diff --git a/clients/desktop/package.json b/clients/desktop/package.json
index 224c8d4a..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",
diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock
index f1758442..5f405532 100644
--- a/clients/desktop/src-tauri/Cargo.lock
+++ b/clients/desktop/src-tauri/Cargo.lock
@@ -3164,7 +3164,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lookout-desktop"
-version = "0.3.5"
+version = "0.3.7"
dependencies = [
"ashpd",
"base64 0.22.1",
diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml
index 91f545f4..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]
diff --git a/clients/desktop/src-tauri/tauri.conf.json b/clients/desktop/src-tauri/tauri.conf.json
index 2b225abe..efbba002 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",
@@ -38,7 +38,14 @@
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
- "targets": ["app", "dmg", "nsis", "deb", "appimage", "rpm"],
+ "targets": [
+ "app",
+ "dmg",
+ "nsis",
+ "deb",
+ "appimage",
+ "rpm"
+ ],
"macOS": {
"entitlements": "./Entitlements.plist"
},
@@ -53,7 +60,9 @@
"plugins": {
"deep-link": {
"desktop": {
- "schemes": ["lookout"]
+ "schemes": [
+ "lookout"
+ ]
}
},
"updater": {
diff --git a/clients/playground/package.json b/clients/playground/package.json
index 62c0e2d9..39113910 100644
--- a/clients/playground/package.json
+++ b/clients/playground/package.json
@@ -1,6 +1,6 @@
{
"name": "@lookout/playground",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
diff --git a/clients/react/API.md b/clients/react/API.md
index 1f43fa1e..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
diff --git a/clients/react/package.json b/clients/react/package.json
index 45bc857b..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",
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/package-lock.json b/package-lock.json
index 1bf3d495..acf031fe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,7 +21,7 @@
},
"clients/desktop": {
"name": "@lookout/desktop",
- "version": "0.3.5",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@lookout/react": "*",
@@ -55,7 +55,7 @@
},
"clients/playground": {
"name": "@lookout/playground",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@lookout/react": "*",
@@ -73,7 +73,7 @@
},
"clients/react": {
"name": "@lookout/react",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@lookout/shared": "*",
@@ -101,7 +101,7 @@
},
"clients/web": {
"name": "@lookout/web",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@lookout/react": "*",
@@ -10113,7 +10113,7 @@
},
"packages/server": {
"name": "@lookout/server",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
@@ -10137,7 +10137,7 @@
},
"packages/shared": {
"name": "@lookout/shared",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later"
},
"packages/web": {
@@ -10160,7 +10160,7 @@
},
"packages/worker": {
"name": "@lookout/worker",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
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/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/worker/package.json b/packages/worker/package.json
index e391ecf2..62cdda64 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -1,6 +1,6 @@
{
"name": "@lookout/worker",
- "version": "0.3.3",
+ "version": "0.3.7",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
From 4f8b98b7ea52c6be3ab6f980915911a7d48f7713 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 13:00:04 +0800
Subject: [PATCH 58/65] fix(security): the uncut original must not sit at a
guessable key
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
During the edit hold the original holds every minute the user is about to cut
out, and it stays readable until the publish deletes it. Its key was
`timelapses//original.mp4` — and sessionId is public, appearing in
the /api/media/:sessionId/... URLs handed out with any shared timelapse. So
anyone with 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 cut footage — straight past the token gate that
/units presigns behind.
Now `original-<128 random bits>.mp4`. That holds whether or not the bucket is
public, which is the property worth having: it doesn't depend on an ACL staying
correct. The published video keeps its predictable key, since being fetchable
is the point of it.
A recompile reuses the session's existing key rather than minting a new one, so
the old object is overwritten instead of orphaned. Nothing rebuilds this key
from the session id — every reader takes it from
sessions.original_video_r2_key, which is what made the change contained.
---
packages/worker/src/compile.ts | 31 +++++++++++++++++++++++++++++--
1 file changed, 29 insertions(+), 2 deletions(-)
diff --git a/packages/worker/src/compile.ts b/packages/worker/src/compile.ts
index 0eeb16cf..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,
@@ -37,6 +38,29 @@ import {
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 (
@@ -611,8 +635,11 @@ export async function compileTimelapse(sessionId: string): Promise<{
let publishPath = originalPath;
let publishSize = originalSize;
- let publishR2Key = `timelapses/${sessionId}/original.mp4`;
- const originalR2Key = `timelapses/${sessionId}/original.mp4`;
+ // 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);
From faad8da32ed66c2e22894d05f802ef8650ba9dd1 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 13:14:55 +0800
Subject: [PATCH 59/65] ci: install the GStreamer plugins the Linux clip
encoder needs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Desktop Rust job has been failing since clips landed — every test that
constructs a real ClipRecorder dies with 'no H.264 encoder element available'.
It installs libgstreamer1.0-dev and libgstreamer-plugins-base1.0-dev, which are
DEV headers; the elements the encoder actually builds live in four separate
runtime packages: videoconvert in -base, mp4mux in -good, h264parse in -bad,
x264enc in -ugly. None were present, so ENCODER_CANDIDATES matched nothing.
Not caused by the switch to --release; the run on d938b7e failed identically.
Worth having rather than skipping the tests, because this job is the only place
the Linux encoder path is executed at all — macOS runs it on a dev machine and
Windows is check-only. With the plugins present it goes from compiled to
tested.
---
.github/workflows/tests.yml | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 65ba977e..8a7ea53f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -152,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
From 3e6387f2a1f6a556c0c99977b9bd5a1fc260cb22 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 13:29:24 +0800
Subject: [PATCH 60/65] fix(desktop): declare the Linux runtime dependencies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
bundle.linux was empty, so the .deb and .rpm declared nothing at all — and the
Linux build needs GStreamer elements spread across several packages. The worst
of it isn't clips: pipewiresrc is how the app captures the screen at all
(pipewire.rs), and it ships in gstreamer1.0-pipewire. A user could install the
package and have capture simply not work.
Depends covers what the app cannot run without — pipewiresrc, videoconvert —
plus mp4mux and h264parse, since clips are the default capture mode now.
The H.264 encoder is Recommends rather than Depends on purpose. -ugly carries
GPL x264, and gstreamer1-plugins-ugly isn't in Fedora proper (RPMFusion), so a
hard dependency would either impose that licence on every install or make the
rpm uninstallable on a stock Fedora. apt and dnf install Recommends by default,
so the ordinary user still gets an encoder; anyone excluding it gets a working
app whose clips fall back to one JPEG a minute, which the failure latch now
handles quietly.
This is the same gap that had CI failing: dev headers were installed, runtime
plugins were not.
---
clients/desktop/src-tauri/src/clips.rs | 19 +++++++++++++++++
clients/desktop/src-tauri/tauri.conf.json | 26 ++++++++++++++++++++++-
2 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs
index 9088a851..52cad65e 100644
--- a/clients/desktop/src-tauri/src/clips.rs
+++ b/clients/desktop/src-tauri/src/clips.rs
@@ -876,6 +876,25 @@ mod platform {
/// 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 {
diff --git a/clients/desktop/src-tauri/tauri.conf.json b/clients/desktop/src-tauri/tauri.conf.json
index efbba002..6f0d80f4 100644
--- a/clients/desktop/src-tauri/tauri.conf.json
+++ b/clients/desktop/src-tauri/tauri.conf.json
@@ -55,7 +55,31 @@
"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": {
From 5658c8574e49fe0460e02258fe9589ed05d700e1 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 13:35:59 +0800
Subject: [PATCH 61/65] fix(desktop): plug a ~4.9MB-per-clip leak in the macOS
encoder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Found by stress-testing the encode cycle: RSS grew 4,894 KB for every clip
recorded. One clip a minute means ~3.5 GB over a 12-hour session.
Every Cocoa object the encoder builds — the settings dictionaries, the NSNumbers
in them, the writer, the input, the pixel-buffer adaptor, the CVPixelBuffers —
is autoreleased. The capture loop calls this from a tokio worker thread, and
unlike the main run loop a tokio thread never drains an autorelease pool, so
none of it was ever freed. Nothing was wrong with the retain/release balance;
there was simply no pool to drain into.
All three entry points now run inside an explicit autoreleasepool. Measured
over 200 cycles (~3.3 hours of recording): 4,894 KB/cycle -> 2 KB/cycle, and
baseline process RSS fell from 84 MB to 55 MB. The discard path went from 2,439
to 51 KB/cycle and is sub-linear, so that remainder is warm-up, not
accumulation.
Adds the tests that found it, plus the cost measurement:
- encode_cycle_does_not_leak / discard_path_does_not_leak — ignored stress
tests, LEAK_CYCLES tunable, budget scales with the run.
- clip_temp_files_are_always_removed — runs in CI; covers finish, discard, and
the finish-with-no-frames error path.
- encode_cost_at_1080p — 96 ms/clip worst case (13.7 ms/frame, 2 MB/clip),
i.e. 0.16% of one core at a clip a minute.
The leak tests stay manual: they assert on RSS, which is too environment-
dependent to gate CI on. The temp-file test is the one that runs every push.
---
clients/desktop/src-tauri/src/clips.rs | 217 ++++++++++++++++++++++++-
1 file changed, 210 insertions(+), 7 deletions(-)
diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs
index 52cad65e..b8064914 100644
--- a/clients/desktop/src-tauri/src/clips.rs
+++ b/clients/desktop/src-tauri/src/clips.rs
@@ -299,6 +299,200 @@ mod tests {
);
}
+ /// 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 is supposed to remove it on both finish and discard; a
+ /// session leaking one per minute would fill a small disk.
+ #[test]
+ fn clip_temp_files_are_always_removed() {
+ let count = || {
+ std::fs::read_dir(std::env::temp_dir())
+ .map(|d| {
+ d.filter_map(Result::ok)
+ .filter(|e| {
+ e.file_name().to_string_lossy().starts_with("lookout-clip-")
+ })
+ .count()
+ })
+ .unwrap_or(0)
+ };
+ let before = count();
+
+ let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+ 320, 240, image::Rgba([1, 2, 3, 255]),
+ ));
+ // finish path
+ let mut r = ClipRecorder::new(320, 240, 10_000).expect("init");
+ r.push_frame(&img).expect("push");
+ r.finish().expect("finish");
+ // discard path
+ let mut r = ClipRecorder::new(320, 240, 10_000).expect("init");
+ r.push_frame(&img).expect("push");
+ r.discard();
+ // failure path: finishing with no frames errors, and must still clean up
+ let r = ClipRecorder::new(320, 240, 10_000).expect("init");
+ assert!(r.finish().is_err());
+
+ assert_eq!(count(), before, "clip temp files were left behind");
+ }
+
/// A recorder with zero frames must fail, not produce an empty clip.
#[test]
fn empty_clip_errors() {
@@ -332,7 +526,7 @@ mod platform {
use std::ptr::NonNull;
use block2::RcBlock;
- use objc2::rc::Retained;
+ use objc2::rc::{autoreleasepool, Retained};
use objc2::runtime::{AnyObject, ProtocolObject};
use objc2_av_foundation::{
AVAssetWriter, AVAssetWriterInput, AVAssetWriterInputPixelBufferAdaptor,
@@ -373,7 +567,13 @@ mod platform {
bitrate: u32,
frame_interval_ms: u64,
) -> Result {
- unsafe {
+ // 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")?,
));
@@ -474,7 +674,7 @@ mod platform {
adaptor,
started: false,
})
- }
+ })
}
pub fn append_bgra_frame(
@@ -484,7 +684,10 @@ mod platform {
height: u32,
pts_ms: u64,
) -> Result<(), String> {
- unsafe {
+ // 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!(
@@ -548,11 +751,11 @@ mod platform {
));
}
Ok(())
- }
+ })
}
pub fn finish(self, duration_ms: u64) -> Result<(), String> {
- unsafe {
+ autoreleasepool(|_| unsafe {
if !self.started {
// Nothing was written; cancel to avoid a zero-byte file
// error from finishWriting.
@@ -578,7 +781,7 @@ mod platform {
));
}
Ok(())
- }
+ })
}
}
}
From 6f70602ca46c1ba29b39d5cc8b436082d2fef056 Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 13:42:02 +0800
Subject: [PATCH 62/65] perf(desktop): stop the Filtered Apps page blocking a
capture worker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Opening Filtered Apps was doing all its work synchronously inside an `async fn`
command. `async` alone bought nothing: the body never yields, so it occupied a
tokio worker for its whole duration — and the capture loop runs on those same
workers, so a slow app scan could delay a capture tick, not just the page.
Three things, in order of how much they cost:
- The command body now runs on the blocking pool via spawn_blocking, so it
cannot hold a capture worker regardless of how slow the machine's filesystem
or window enumeration is.
- The installed-app cache is pre-warmed in a background thread, DEFERRED five
seconds. The scan is disk-bound, and launch is the most I/O-contended moment
in the process's life (the webview is loading its own assets), so warming it
immediately would trade a faster Settings page for a slower app open. Five
seconds is long before anyone navigates there and after launch I/O settles.
A failed spawn is ignored — the cache then fills lazily, exactly as before.
- The Windows Start Menu walk used `entry.path().is_dir()`, which stats every
entry a second time; the directory enumeration already returned the
attributes. `entry.file_type()` uses them. The Start Menu has hundreds of
entries and every stat goes through whatever filter drivers and AV hooks are
installed. `file_type()` doesn't follow symlinks where `is_dir()` did, so a
directory junction still falls back to the stat rather than silently stopping
being traversed.
Note what is NOT fixed: `running_apps()` enumerates windows on every open and
is uncached, by design — it has to be live for a running app to be listed. It is
now merely off the capture workers rather than cheaper.
Windows behaviour is unverified by me; the cross-platform check job compiles it.
---
clients/desktop/src-tauri/src/lib.rs | 57 ++++++++++++++++++++++++++--
1 file changed, 54 insertions(+), 3 deletions(-)
diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs
index d398440e..97e6909d 100644
--- a/clients/desktop/src-tauri/src/lib.rs
+++ b/clients/desktop/src-tauri/src/lib.rs
@@ -733,8 +733,21 @@ fn scan_installed_apps() -> Vec {
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();
- if path.is_dir() {
+ 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));
}
@@ -899,10 +912,44 @@ fn running_apps() -> Vec<(String, Option)> {
/// List apps for the Filtered Apps page, sorted by name: every installed app
/// (scanned once per process and cached) merged with currently running apps.
/// Only real applications appear — helper/XPC processes that merely own
-/// windows (e.g. "CursorUIViewService") don't. Async so scans run off the
-/// main thread.
+/// 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()
@@ -3461,6 +3508,10 @@ 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")]
{
// NOTE: App Nap / idle-sleep suppression is scoped to active
From b86cbc6cce615a9729715af2d19f634121074c8a Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 13:47:26 +0800
Subject: [PATCH 63/65] test(desktop): fix the flaky temp-file test I added
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
clip_temp_files_are_always_removed compared a COUNT of lookout-clip-* files in
the OS temp dir before and after. That directory is shared by the whole
process, cargo runs tests in parallel, and encodes_frames_into_playable_mp4
stages two files of its own there for ffprobe — so an interleaving where the
count rises by one across the window is not just possible, it is exactly the
observed failure ("left: 1, right: 0").
My bug, not a product one: no path leaks a clip container. finish() and
discard() both remove the file unconditionally, including when finish errors on
a zero-frame clip, which is the case the test exists to cover.
Both tests now hold a TEMP_DIR mutex, so the counting window can't overlap a
creator. Poisoning is ignored deliberately — one test panicking should surface
as that test's failure, not cascade. The assertion also reports leaked
FILENAMES rather than a count, so if it ever fails for real the name says which
recorder left it.
Verified with 25 consecutive runs at --test-threads=8: no failures.
---
clients/desktop/src-tauri/src/clips.rs | 36 ++++++++++++++++++++------
1 file changed, 28 insertions(+), 8 deletions(-)
diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs
index b8064914..6e624e20 100644
--- a/clients/desktop/src-tauri/src/clips.rs
+++ b/clients/desktop/src-tauri/src/clips.rs
@@ -185,11 +185,28 @@ fn frame_to_bgra(frame: &DynamicImage, width: u32, height: u32) -> Vec {
mod tests {
use super::*;
+ /// Serialises the tests that care about the CONTENTS of the OS temp dir.
+ ///
+ /// `clip_temp_path()` writes into a directory shared by the whole process,
+ /// and cargo runs tests in parallel, so a test that counts
+ /// `lookout-clip-*` files will see another test's file mid-flight and
+ /// report a leak that isn't there. Every test that either counts those
+ /// files or creates them outside a ClipRecorder must hold this.
+ static TEMP_DIR: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ /// Take the temp-dir lock, ignoring poisoning — a panic in one test should
+ /// surface as that test's failure, not as a cascade of others.
+ fn lock_temp_dir() -> std::sync::MutexGuard<'static, ()> {
+ TEMP_DIR.lock().unwrap_or_else(|e| e.into_inner())
+ }
+
/// 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() {
+ // Stages files via clip_temp_path() for ffprobe — see TEMP_DIR.
+ let _temp_dir = lock_temp_dir();
let mut recorder = ClipRecorder::new(640, 360, 3000).expect("encoder init");
for i in 0u32..5 {
let mut img =
@@ -462,18 +479,20 @@ mod tests {
/// session leaking one per minute would fill a small disk.
#[test]
fn clip_temp_files_are_always_removed() {
- let count = || {
+ let _temp_dir = lock_temp_dir();
+ // Names, not just a count: if this ever fails for real, the filename
+ // says which recorder leaked it.
+ let names = || -> std::collections::BTreeSet {
std::fs::read_dir(std::env::temp_dir())
.map(|d| {
d.filter_map(Result::ok)
- .filter(|e| {
- e.file_name().to_string_lossy().starts_with("lookout-clip-")
- })
- .count()
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|n| n.starts_with("lookout-clip-"))
+ .collect()
})
- .unwrap_or(0)
+ .unwrap_or_default()
};
- let before = count();
+ let before = names();
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
320, 240, image::Rgba([1, 2, 3, 255]),
@@ -490,7 +509,8 @@ mod tests {
let r = ClipRecorder::new(320, 240, 10_000).expect("init");
assert!(r.finish().is_err());
- assert_eq!(count(), before, "clip temp files were left behind");
+ let leaked: Vec<_> = names().difference(&before).cloned().collect();
+ assert!(leaked.is_empty(), "clip temp files left behind: {leaked:?}");
}
/// A recorder with zero frames must fail, not produce an empty clip.
From 14aeb56f2b53dc5b9ca9f462f93d09610e123a2c Mon Sep 17 00:00:00 2001
From: Anson Chung
Date: Sun, 9 Aug 2026 13:51:51 +0800
Subject: [PATCH 64/65] test(desktop): assert temp-file cleanup per path, not
by scanning the temp dir
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
My previous attempt at this was still wrong. Locking two tests wasn't enough —
empty_clip_errors and resized_frames_are_normalized build recorders too, so
their in-flight containers still landed in the counting window and got reported
as leaks. Adding them to the lock would have worked, but the design was the
problem: a test that inspects a directory shared by the whole process is
coupled to every other test in it.
It now asks each recorder for its own path and asserts that path is gone. No
shared state, no lock, correct under any amount of parallelism, and the failure
message points at one specific recorder. 30 full-suite runs at 16 threads: no
failures.
Also fixes a real orphan the investigation turned up: if platform::Encoder::new
FAILS, the container may already exist — GStreamer's filesink opens it the
moment the pipeline goes Playing — and there is no ClipRecorder yet whose
finish()/discard() would remove it. That matters more now the capture loop
retries init before latching clips off: a machine with a broken encoder would
drip one orphan into the temp dir per attempt. ClipRecorder::new now removes the
path on the error path.
---
clients/desktop/src-tauri/src/clips.rs | 81 +++++++++++++-------------
1 file changed, 40 insertions(+), 41 deletions(-)
diff --git a/clients/desktop/src-tauri/src/clips.rs b/clients/desktop/src-tauri/src/clips.rs
index 6e624e20..032d5c04 100644
--- a/clients/desktop/src-tauri/src/clips.rs
+++ b/clients/desktop/src-tauri/src/clips.rs
@@ -82,13 +82,24 @@ impl ClipRecorder {
let width = (width & !1).max(2);
let height = (height & !1).max(2);
let path = clip_temp_path();
- let encoder = platform::Encoder::new(
+ // 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,
@@ -103,6 +114,13 @@ impl ClipRecorder {
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> {
@@ -185,28 +203,12 @@ fn frame_to_bgra(frame: &DynamicImage, width: u32, height: u32) -> Vec {
mod tests {
use super::*;
- /// Serialises the tests that care about the CONTENTS of the OS temp dir.
- ///
- /// `clip_temp_path()` writes into a directory shared by the whole process,
- /// and cargo runs tests in parallel, so a test that counts
- /// `lookout-clip-*` files will see another test's file mid-flight and
- /// report a leak that isn't there. Every test that either counts those
- /// files or creates them outside a ClipRecorder must hold this.
- static TEMP_DIR: std::sync::Mutex<()> = std::sync::Mutex::new(());
-
- /// Take the temp-dir lock, ignoring poisoning — a panic in one test should
- /// surface as that test's failure, not as a cascade of others.
- fn lock_temp_dir() -> std::sync::MutexGuard<'static, ()> {
- TEMP_DIR.lock().unwrap_or_else(|e| e.into_inner())
- }
/// 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() {
- // Stages files via clip_temp_path() for ffprobe — see TEMP_DIR.
- let _temp_dir = lock_temp_dir();
let mut recorder = ClipRecorder::new(640, 360, 3000).expect("encoder init");
for i in 0u32..5 {
let mut img =
@@ -475,42 +477,39 @@ mod tests {
}
/// Temp files must not accumulate. Each clip writes a container to the OS
- /// temp dir and is supposed to remove it on both finish and discard; a
- /// session leaking one per minute would fill a small disk.
+ /// 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 _temp_dir = lock_temp_dir();
- // Names, not just a count: if this ever fails for real, the filename
- // says which recorder leaked it.
- let names = || -> std::collections::BTreeSet {
- std::fs::read_dir(std::env::temp_dir())
- .map(|d| {
- d.filter_map(Result::ok)
- .map(|e| e.file_name().to_string_lossy().into_owned())
- .filter(|n| n.starts_with("lookout-clip-"))
- .collect()
- })
- .unwrap_or_default()
- };
- let before = names();
-
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
320, 240, image::Rgba([1, 2, 3, 255]),
));
- // finish path
+
+ // 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");
- // discard path
+ 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();
- // failure path: finishing with no frames errors, and must still clean up
+ 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());
-
- let leaked: Vec<_> = names().difference(&before).cloned().collect();
- assert!(leaked.is_empty(), "clip temp files left behind: {leaked:?}");
+ assert!(!errored.exists(), "failed finish() left {errored:?}");
}
/// A recorder with zero frames must fail, not produce an empty clip.
From 1f8d09ca2e0f34bc4a19536f96043deba38618e4 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 9 Aug 2026 06:02:00 +0000
Subject: [PATCH 65/65] chore(deps): bump actions/setup-node from 6.3.0 to
7.0.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/53b83947a5a98c8d113130e565377fae1a50d02f...820762786026740c76f36085b0efc47a31fe5020)
---
updated-dependencies:
- dependency-name: actions/setup-node
dependency-version: 7.0.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/release.yml | 2 +-
.github/workflows/tests.yml | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
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 8a7ea53f..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
@@ -101,7 +101,7 @@ jobs:
sudo apt-get install -y ffmpeg
- 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
@@ -234,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