[Draft!] Add higher fpm + ability to edit videos - #167
Draft
anscg wants to merge 45 commits into
Draft
Conversation
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg
- Replace the per-second tray-state sync (3 IPC calls + a broadcast event every second, all session long) with event-driven syncs on screenshot count, pause/resume, and server time corrections. The tray window already extrapolates its clock from updatedAt and the Rust ticker owns the menu-bar title, so nothing visible changes. - Fix the tray-ready fallback emitting state without updatedAt, which made the tray window compute NaN seconds until the next sync (now the next sync can be a minute away, so it mattered). - Seed a freshly started Rust tray timer with the last known tracked seconds so the menu-bar time doesn't briefly reset on pause -> resume. - Pause the screen-preview polling loop while the window is hidden — each preview frame is a full native capture + JPEG encode nobody can see. Resumes instantly on visibilitychange. - Skip the 5s running-apps refresh in Settings while the window is hidden (it enumerates every window on the system). - Stop logging preview-frame fetches; at 1/s per source they drowned the 200-entry debug buffer that error reports are built from. Failures are still logged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg
- Tray tooltip ("Lookout — 12m recorded"): Windows doesn't render tray
titles at all, so until now Windows users had no way to see the
recorded time from the tray without clicking it.
- Show a pause glyph in the menu-bar title while paused, wiring up the
is_paused parameter update_tray_time already received but ignored.
- Remember the last monitor selection and preselect it in the source
picker when every remembered monitor is still connected — multi-screen
users no longer re-shift-click the same setup every session. Falls
back to the primary monitor as before.
- Add-session page: re-enable program buttons 15s after launching the
browser flow. If the deep link never came back (closed tab, changed
mind), they used to stay stuck on a spinner until you left the page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RurnLWE27eznNVgysSuwbg
Claude/lookout desktop qol perf f8z7o3
Sessions can now opt in (clips_enabled, set at creation, immutable) to uploading per-minute webm/mp4 clips holding ~20 frames instead of a single JPEG — same cadence, credit math, and rate limits either way. - shared: capture format constants/types shared by all uploaders - server: clips schema + 0015 migration, format-aware upload-url and confirm validation, integration tests - worker: stitch clip segments into the compiled timelapse - react: clipRecorder hook (MediaRecorder) wired into the uploader - desktop: runtime-configurable server URL (Settings -> Advanced) with probe-before-save, persisted in localStorage; naming prompt on stop
Replace the blocking launch-time update gate with a Ghostty-style flow: check at launch and every 30 min, download in the background, and show a titlebar pill with live progress (NumberFlow digits) that becomes 'Restart to Complete Update' once the bytes are on disk. install() stays behind the click since it exits the app on Windows. Dev builds expose __updatePillDemo() to preview the pill lifecycle. Boot is no longer gated on anything: macOS permission grants are cached after the first successful check and re-verified in the background, so the pre-flight spinner flicker is gone, and [boot] timing logs trace eval -> mount -> first frame. Also rework the session rename animation (react client): spring between measured pixel widths instead of non-interpolable CSS string values, colors via CSS transitions, and drop the 600ms icon-delay workaround.
Coolify's proxy routes the domain to the container over the Docker network, so publishing host port 3000 only conflicts with other services on the box. Same as docker-compose.prod.yml otherwise.
The Settings -> Advanced custom server feature was unusable: the tauri-plugin-http capability scope only allowed lookout.hackclub.com, so every fetch to a user-configured server was rejected client-side with 'url not allowed on the configured scope'. The server URL is user-configurable by design and the webview CSP already restricts connections to https (+ localhost for dev), so mirror that here.
Self-hosted deployments serve the admin panel from BASE_URL, but the CORS allowlist only accepted *.hackclub.com — so the panel's requests were rejected by its own server. Derive the deployment's hostname from BASE_URL and allow it alongside the existing origins.
- Raise clip bitrate 133k -> 400kbps and the server cap 2MB -> 4MB: 133kbps H.264 at 1080p was visibly soft, and the compile re-encode compounded it. Static content still lands far below the cap (VBR). - Worker: transcode each clip to its segment in ONE ffmpeg pass (decode -> retime by real frame count -> scale -> x264). Removes the intermediate JPEG round-trip: one fewer lossy generation per frame and ~40% less per-clip work. tpad clone-pad guarantees exactly 30 frames per segment. - Worker: pipeline downloads into segment builds (no barrier) - early units encode while later units download. - ClipRecorder: capture the short opening clip at ~1s cadence so the timelapse's first second carries ~6 frames instead of 2 (it rendered as a near-still). Reverts to the server cadence after the first cut. - SDK preview badge: 'Latest screenshot' -> 'Latest capture'.
Desktop now records clips (~20 frames/min, format=mp4) on sessions with clips enabled, using the OS hardware encoder - no bundled codecs: - macOS: AVAssetWriter (VideoToolbox), verified against the real encoder with ffprobe-checked output - Windows: Media Foundation sink writer (hardware MFT when available); COM initialized per pool thread; new CI job cargo-checks the target - Linux: GStreamer appsrc pipeline (already a dependency), preferring VA-API encoders with a superfast x264 software fallback The capture loop fetches the session's clipsEnabled/frameIntervalMs at start. Frames are captured every 3s through the same redaction-aware path as uploads and feed both the clip and the focus-gated live preview (preview frames ship at 854x480 to keep IPC light). The opening window captures at ~1s cadence so the first clip is as dense as the rest. Any clip failure (encoder, size cap, server downgrade) falls back to the legacy one-JPEG-per-minute upload for that interval. Also: SIMD downscale via fast_image_resize (29ms -> 7ms at 5K->1080p), single-copy BGRA conversion, and a settings Advanced page probe already in tree.
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.
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.
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.
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.
- 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
AsyncImage re-fetched program icons on every menu open, flashing the fallback SF Symbol for ~0.5s. The frontend now passes icon URLs to a new prefetch_add_menu_icons command when the registry loads; Swift warms an in-memory NSImage cache the menu rows read synchronously, so icons render on the first frame. AsyncImage remains as the cold-miss fallback.
No overlay titlebar there, so top-right overlapped the gallery header controls; the pill now floats bottom-left and slides in from the bottom. Co-Authored-By: Claude <noreply@anthropic.com>
Replicates the macOS native NSPanel add menu (AddMenu.swift) where SwiftUI is not available: translucent blurred panel anchored under the gallery + button, spring fling-in from the top-right, hover/arrow-key selection, Escape or click-away to dismiss, quick fade-out exit. Program icons warm the browser HTTP cache at registry load, mirroring the Swift-side icon prefetch. Co-Authored-By: Claude <noreply@anthropic.com>
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
Stopping now offers three choices — keep recording, stop & save, or edit &
save — instead of allowing edits after the timelapse is published.
`complete` is the status programs act on (forwarding heartbeats, accepting
submissions, firing the redirect hook), so a session must reach it exactly
once with its cuts already applied; editing afterwards would rewrite data
someone already consumed.
Mechanics: POST /stop {edit:true} sets an edit hold. The compile runs as
usual but the worker leaves the session 'stopped' with video_r2_key null —
built, not published. The owner previews that video, sets cuts, and
publishes; the lifecycle programs observe is unchanged (stopped ->
compiling -> complete, or straight to complete when nothing was cut). A
hold can only delay publication, never cancel it: the timeouts job
publishes as recorded after EDIT_HOLD_MINUTES, so an abandoned edit still
yields a timelapse.
- shared: EDIT_HOLD_MINUTES, StopRequest.edit, editable/editHoldUntil
- server: migration 0019, stop {edit}, hold-gated editability (no
post-complete edits — PUT /cuts 409s on published sessions), POST
/compile publishes (instant without cuts via lib/publish.ts, worker
handoff with them), hold-expiry auto-publish sharing that same atomic
helper so a user's publish and the job race safely
- worker: held builds stay unpublished; an edited publish deletes the uncut
original immediately rather than keeping a 7-day re-edit window
- react SDK: StopChoiceModal, editor reframed as the publish step (polls
while the preview compiles, counts down the hold), SessionDetail review
panel, LookoutRecorder routes Stop through the modal
- desktop: NamingModal gains Edit & Save; while the editor window is open
the main window shows only an icon and 'Edit your timelapse in the edit
window.', recovered by polling so it can never get stuck
- web: inherits the SDK flow; ?edit=false maps to editing={false}
- tests/docs reworked around the hold; dead web Result.tsx edit code
reverted (that component is unused)
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
… CORS headers The filmstrip and hover scrubber read pixels through a canvas, so the offscreen <video> 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.
…ce resolution 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.
…down 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
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
- 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.
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.
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 — <name>').
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.
… edit - 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
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.
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.
… redirect hook
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.
…ounted 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.
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.
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.
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
/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.
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.
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.
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.
The Windows Media Foundation encoder was using CBR at default profile, producing softer H.264 than macOS VideoToolbox at the same bitrate. - Unconstrained VBR via MF_SINK_WRITER_ENCODER_CONFIG so the single IDR frame can spike above the mean and stay crisp (CBR starved it) - QualityVsSpeed=100 (encode is trivial at ~1 frame/4s) - High profile (8x8 transforms + CABAC) for sharper text - Thread sample/frame duration through append_bgra_frame instead of the hardcoded 3000ms, fixing the PTS/duration mismatch (also Linux) Co-Authored-By: Claude <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
anscg
marked this pull request as draft
July 27, 2026 17:36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
tl;dr: added higher frame per minute with uploading videos instead of screenshots per minute, and added the ability to edit timelapses before they're complete. these changes are 100% backwards compatible and doesn't have meaningful performance impact, in fact, compiling are now faster :D
this pr is very much incomplete and still need a bit of polish to make sure it doesn't break anything
how it looks on the desktop app:
combined_small.mp4
tested this on lapse too :)
3e09bac1-8a28-4226-8e7b-82a94d5657d1.mp4
also some qol: including

this selector thing with the program start url thing
the recording screen now remembers what screen you chose last time!
the filter thing no longer lags your computer
REDIRECT HOOKS! so a program, e.g. lapse, would be able to redirect user to a url after a recording is completed on desktop
among other things...
i got claude to summarise this pr and i think it did a pretty good job:
1. Clips — 15 frames per minute through the same once-a-minute pipeline
Today a session is one JPEG per minute and the compiled timelapse is a
slideshow. Clips replaces that JPEG with a ~60 s video holding ~15 frames
captured 4 s apart. Same request cadence, same credit math, same everything
downstream, because a clip is still one capture unit: one
upload-url, onePUT, one confirm per minute.
RATE_LIMIT_PER_MINUTE,MAX_SCREENSHOTS_PER_SESSIONandMAX_UPLOAD_REQUESTS_PER_SESSIONareuntouched.
The opt-in, and why it's shaped this way
POST /api/internal/sessionstakes"clips": true→sessions.clips_enabled(0015_clips.sql). Default off, set only atcreation — no route anywhere mutates it, because a session's capture
character must never change mid-recording.
The gate is enforced per upload, not per session.
upload-url?format=…is validated against
CAPTURE_FORMATS, and a clip request on a non-clipssession is silently downgraded — HTTP 200 with
format: "jpeg"and a.jpgkey, not an error:A conforming client falls back without an error round-trip, and a stale or
hostile client asking for
webmevery minute simply gets JPEG presigns.The granted format is law. The presigned PUT is signed with
CAPTURE_FORMAT_CONTENT_TYPES[format], so uploading anything else fails thesignature, and
confirmre-validates viaHeadObjectagainst the formatstored on the row (not the request) plus the right size cap — 2 MiB for JPEG,
MAX_CLIP_BYTES= 8 MiB for clips.Cadence is server-authoritative.
frameIntervalMsandclipsEnabledrideon both
GET /api/sessions/:tokenand everyupload-urlresponse, and noclient exposes an override — frame rate and bitrate decide storage cost and
text legibility for everyone, so they aren't an embedder's knob. Discovery
happens on the session fetch clients already make before recording, so the
very first minute of a clips session is already a clip.
Formats may mix inside one session and this is load-bearing, not a
tolerated accident: any clip failure (encoder error, oversize output) falls
back to a plain JPEG for that minute, and an older client pointed at a clips
session just keeps uploading JPEGs. That's what makes it safe to turn clips on
before every client has shipped.
Encoding: hardware H.264 on the desktop
clients/desktop/src-tauri/src/clips.rs(new, ~840 lines) recordsformat=mp4on the GPU:AVAssetWriter→ VideoToolbox, muxes MP4 itselfappsrc, tryingvah264enc→vaapih264enc→x264enc→openh264encFrames come from the existing capture loop through one redaction-aware path
that also feeds the focus-gated live preview, so a clip costs one grab, not
two. Frames are normalized to even dimensions for 4:2:0, with a fast RGBA→BGRA
swizzle when dimensions already match and a resize-and-pillarbox slow path for
a mid-clip display change.
Why the GOP is pinned by hand on all three platforms. Frames sit seconds
apart in media time, so any keyframe interval expressed in seconds makes the
encoder emit an all-keyframe clip — rationing the bit budget across ~20 I-frames
and producing uniformly soft ~20 KB/frame output. So: exactly one IDR per clip,
no frame reordering, and a rate-control hint that the source is ~1 fps
(
AVVideoMaxKeyFrameIntervalKey = 1200+AVVideoAllowFrameReorderingKey = false+AVVideoExpectedSourceFrameRateKey = 1;MF_MT_MAX_KEYFRAME_SPACING = 10_000+MF_MT_FRAME_RATE = 1/1;speed-preset=superfast tune=zerolatencyon x264, whose defaultmediumpreset burned 5–10× the CPU needed). An in-crate test pushes 5 synthetic frames
through the real OS encoder and asserts via
ffprobethat the output ish264, has 5 packets, and exactly 1 keyframe — the compile pipeline depends
on that.
libvpx was measured and rejected: VP8 ran ~41 ms/frame against ~1.9 ms for
VideoToolbox.
Capability discovery is fail-safe:
fetch_clip_capabilitiesGETs the session atloop start, and any failure or non-2xx falls back to
Default— clips off,bit-for-bit legacy behaviour. The server's
frameIntervalMswins, clamped to500..=30_000against a misbehaving server.Uploads run concurrently with capture (
a2d3980). Serially awaitingfinalize+upload (~4–5 s) stalled frame collection at every tick — a hole after
each cut that compounded to minutes of lost screen time per hour. The tick now
cuts the clip and spawns the upload as a background task; collection for the
next clip resumes immediately, and the wait loop gained a third
selectarmthat applies the confirm when it lands (tray sync,
nextExpectedAtrefinement, error recovery). Strictly one upload in flight, so
capturedAtmonotonicity and the rate-limit assumptions hold; a pending upload at stop
detaches and finishes in the background so the final minute still lands.
Encoding: the browser needed a completely different number
clients/react/src/hooks/clipRecorder.ts(new) records viaMediaRecorder,probing H.264/MP4 first (
avc1.640028→4d0028→42e01e→video/mp4) with VP9/VP8 WebM as the Firefox fallback. Measured at matchedoutput size on Chromium 148 at 1080p, H.264 beat VP9 by 8–10 dB PSNR for the
same bytes (~115 KB/frame: 30.9 dB vs 22.3 dB), and distributed it evenly,
where VP9 spent nearly everything on the keyframe. VP8's rate control is inert
below ~10 Mbps. Same conclusion the desktop reached about libvpx.
Then the subtle one, and the reason there are now two bitrate constants:
A native encoder gets each frame's true presentation timestamp 4 s apart plus
an explicit ~1 fps rate-control hint, so 800 kbps really does buy ~400 KB per
frame — JPEG-q85-class keyframes at 1080p, the bar the old pipeline set.
MediaRecorderignores wall-clock frame spacing entirely: recording the sameframes 4000 ms apart and 125 ms apart produces byte-identical output.
There is no "× 60 seconds" budget to spend. At 800 kbps the browser encoder
sat pinned at its maximum quantizer and still overshot the request — which
is why raising the shared constant 400k → 800k sharpened the desktop and did
nothing whatsoever for the web (
768dcf4).Measured sweep, worst-case dense-text content, PSNR vs source:
Over a full 15-frame clip against the cap: busy screen 0.89 MB / 23.8 dB →
3.24 MB / 43.3 dB; typical screen 0.37 MB → 2.46 MB. Even incompressible
content sits at ~40 % of the cap, and if a clip ever does exceed it
ClipRecorderhalves its bitrate (floored at the native rate), warns, andreturns
nullso the tick falls back to a JPEG — a server rejection wouldcost the whole minute, and an engine with different rate-control semantics
self-corrects instead of failing every upload.
Other details worth knowing:
captureStream(0)with explicitrequestFrame()(falling back to auto-capture on older Firefox), canvas fixedto even dimensions for the clip's life,
imageSmoothingQuality = "high"before every
drawImagebecause downscaled 1080p UI text aliases badly, andcut()races the encoder'sonstopagainst a 10 s timeout so a wedgedencoder can't stall the capture loop.
Cost and performance
The question everyone asks first, so: clips are close to free.
clips on. The encode is on the GPU, the frame grab is the one the preview
already needed, and the upload is off the capture path.
compression is doing the work — 15 frames of a mostly-static screen 4 s apart
are nearly identical, so you pay for roughly one good keyframe plus deltas
rather than 15 independent JPEGs. Static content undershoots the VBR ceiling
heavily; the 8 MB/min cap is for the pathological case, not the normal one.
fast_image_resize(29 ms → 7 ms for 5K→1080p), redaction applied afterthe downscale, and
Bytesbuffers so an upload retry is a refcount bumpinstead of a full frame copy.
Compiling clips into a timelapse
packages/worker/src/segments.ts(new, 333 lines, deliberately free of DB/R2imports so the ffmpeg contracts are unit-testable) normalizes every capture
unit — JPEG still, VP8 WebM, H.264 MP4, any resolution — into exactly one
second of 30 fps H.264 in an MPEG-TS segment, then stream-copy concatenates
them.
The old pipeline downloaded every JPEG, waited on a barrier, renumbered
survivors into a contiguous sequence, and ran one whole-session x264
encode at CRF 28. That's gone. Now:
setpts=N/(frames*TB)spreads the Nreal demuxed frames evenly across
[0, 1 s), thenfps=30, thentpad=stop_mode=clone:stop=-1clone-extends the last frame so PTS roundingcan never come up short, then
-frames:v 30.fa22053removed theintermediate JPEG round-trip the first draft had — one fewer lossy
generation per frame and ~40 % less per-clip work.
frame_countis never trusted —ffprobe -count_packetscounts. Clips are VFR and a static screen legitimately emitsfewer frames than nominal, so
FRAMES_PER_CLIP = 15is informational only.verifySegmentFrameCountis then strict equality, because a shortsegment would silently desync every later minute of the video.
units encode while later ones are still downloading, with
SEGMENT_CONCURRENCY = 8and-threads 1per ffmpeg (parallelism lives inthe pool, not in x264).
f977898): the compile step must not be a qualityevent — the clip bitrate is the only intended quality dial, and CRF 28 added
a visible second generation of loss on top of already-compressed clips.
whole encode; wall clock now scales with
units / SEGMENT_CONCURRENCY.The mixed case needs no special handling at all — because normalization is
per-unit and uniform, a session can interleave legacy JPEGs, VP8 WebM and
H.264 MP4 at different resolutions and the assembly is still
-c copy.There's a test for exactly that ordering.
And the piece everything else hangs off: every segment is encoded with a
closed GOP of exactly 30 frames starting on an IDR —
-g 30 -keyint_min 30 -sc_threshold 0 -x264-params open-gop=0, plus pinned-profile:v high -level:v 4.0 -pix_fmt yuv420pso segments staybit-compatible for copy-concat. Three separate encode paths (segment build,
assembly fallback, cut re-encode fallback) must keep those args in lockstep;
the code says so and so does the mixed-session test.
Seed-capture fix (
d46a5e7,segments.ts#dropSeedUnit). A session's firstcapture opens the recording rather than closing a recorded minute, and
credits 0 tracked seconds in both tracking modes — yet it was getting 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"), and in clips mode the opening clip is deliberately cut after ~8 s so
the session activates quickly, so that second played at ~8× against the rest
of the video's 60×. Measured on a real compile: second 0 held 9 unique frames
spanning ~8 real seconds, second 1 held 16 spanning 60 — a slow-motion lurch
at the head of every timelapse. The seed is now excluded, giving one uniform
rule: a capture earns video time exactly when it earns tracked time. The
timelapse still opens on motion because the first shown unit is a full
~15-frame clip. A single-unit session keeps its one unit (a zero-length video
is worse than an imprecise one), and the seed's DB row is preserved — only its
R2 object is cleaned up with the other unsampled captures — so
/timingsandthe credit history are untouched.
What program authors have to do
Nothing.
trackedSeconds,screenshotCount,/timings(still one timestampper minute, so Hackatime forwarding is unchanged),
videoUrland everyresponse shape are identical between clips and non-clips sessions. The flag is
per session, so it can be enabled for a fraction of new sessions and compared.
Documented in
docs/integration.md.2. Edits — cutting footage, before anything downstream sees it
Full design in
docs/edit-feature-plan.md.Editing lives inside the stop flow, never after
complete.completeisthe 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
completeexactlyonce, with its cuts already applied, and post-publication editing does not
exist (
editable: false,PUT /cuts→ 409).The hold is a lease, not a countdown. Any surface representing active
editing renews it every
EDIT_HEARTBEAT_SECONDS(30 s) viaPOST /:token/editing; the server holds the sessionEDIT_LEASE_SECONDS(120 s) past the last renewal, bounded by
EDIT_HOLD_MAX_MINUTES(120) fromthe stop. 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, and renewal deliberately works
through a term that lapsed seconds ago (a network stall shouldn't end an
edit) — safe only because the write is guarded on
status IN ('stopped','compiling'), so a published session can never bepulled back.
A lapsed lease publishes.
lib/timeouts.ts#publishExpiredHoldsruns on theexisting every-minute cron and publishes anything whose lease expired or that
passed the ceiling. The hold can delay publication, never cancel it — which is
what makes offering the edit step safe at all.
Cuts are lossless, and there's a test that proves it
The clips invariant pays off here. One capture unit = one real-world minute =
exactly one second of output, and every second boundary is an IDR frame in a
closed GOP. So:
capturedAtis known.sessions.video_unitsis that map, and it's built from the segment list,not the sampled rows, so build-failure holes can never desync it.
-ss) plus anexact copied packet count (
-frames:v n×30) into a TS intermediate, thenthe ranges stream-copy concat. Not the concat demuxer's
inpoint/outpoint—outpointis dts-based and B-frame dts offsets leak~2 frames of the cut region past each boundary.
added generations of loss. It never downloads capture units either, so it
works fine after the 7-day screenshot purge.
"Does cutting affect quality" deserved evidence, not an assertion (
46a1011).The worker test hashes every decoded frame of the original and of the
edited output with
ffmpeg -f framemd5(keeping only the hash column, since acut restarts timestamps at zero by design) and asserts the kept frames are
pixel-identical —
toEqual, tolerance zero. A companion test asserts there-encode fallback is not bit-exact, which is what keeps the first test
honest: two re-encodes compared against each other would still pass.
That fallback runs only when
video_copy_aligned = falseor the copy paththrows, which shouldn't happen now that every encode pins its GOP. It used to
be a
console.warnlost among other warnings; it's now a loudconsole.errorsaying the timelapse just lost a generation of quality and what to
investigate.
Derived effects, all settled before publication
original.mp4, stream-copy concatenated intoedited.mp4GET /timings?includeCut=truereturns them ascutTimestampstrackedSecondsraw − cutSecondseverywhere, via one read-side helper; raw preserved asuncutTrackedSecondstrackedSecondsshrinking is deliberate:/timingsandtrackedSecondsmusttell the same story, and cutting can only reduce the number, so there's no
fraud vector.
sessions.tracked_secondsstays raw as an audit trail.One shared membership rule — a unit is cut iff
coalesce(captured_at, requested_at) ∈ [start, end)— lives once inpackages/shared/src/cuts.tsalongsidenormalizeCuts,computeKeptRanges,countCutUnitsandcomputeCutSeconds, and is used identically by theserver's preview, the worker's authoritative write, and the editor's footer.
Cuts are absolute wall-clock intervals, never "video offset + duration",
because one list has to drive three derived views consistently. Granularity is
whole minutes, which is also heartbeat granularity.
API
Token-authenticated, each with its own rate-limit bucket:
POST /:token/stop— optional{ edit: true }. The body schema isdeliberately
additionalProperties: true: this route accepted and ignoredany body before
editexisted, so rejecting unknown fields would turn aworking custom client into a 400 for no benefit (
848c768). No body → nohold, byte-identical to today.
GET /:token/units(10/min) —units,cuts,editable,editableReason,editHoldUntil,expectedUnits,originalVideoUrl,recompilesRemaining. The presigned GET (1 h) is minted only when editable,and deliberately isn't the public media URL — after an edit, cut content
exists only in the original, which must stay reachable through the secret
token alone.
expectedUnits = screenshotCount − 1so the waiting-room copydoesn't promise a minute the video won't hold.
PUT /:token/cuts(20/min) — full replace, idempotent,[]clears.Validates ISO dates and
end > start, clamps to the session window ±5 min,sorts and merges overlapping and adjacent intervals, caps at
MAX_CUT_INTERVALS(120), and rejects a list that would cut every unit.Written under
status = 'stopped' AND edit_hold_until > now(), so a sessionthat published mid-edit can never be mutated.
POST /:token/compile(5/min) — publish. No cuts →publishHeldSession,200
instant: true, no worker round-trip and no recompile consumed.With cuts → claim
stopped → compiling, clear the hold, bumprecompile_count(capped atMAX_USER_RECOMPILES= 5), enqueue. Alreadycomplete→ 200instant: true; alreadycompiling→ 202; mid-build("publish as recorded" before the original exists) → drop the hold and let
the in-flight compile publish normally. Every path is designed so a client
retry is never a spurious failure.
POST /:token/editing(20/min) — renew; returnsheld: falseonce thesession is out so clients stop polling.
lib/publish.ts#publishHeldSessionis the single atomic guard shared by theuser's publish call and the expiry cron, so the two racing each other publish
exactly once and the loser's endpoint still returns 200 — the timelapse is out
either way.
lib/timeouts.ts#purgeEditedOriginalsis the retention backstop on the dailycron for originals orphaned by a crashed publish; the normal path deletes cut
content immediately on an edited publish, and only after the edited video
is uploaded and the row committed, because flipping that order would leave a
crash pointing a session at bytes that no longer exist.
Migrations
0018_session_edits/0019_edit_holdaddcuts,cut_seconds,video_units,original_video_r2_key,video_copy_aligned,recompile_count,last_edit_compile_at,edit_hold_until. Noscreenshotschanges — membership is computed from the interval list, neverdenormalized onto rows.
The editor
Built once in
@lookout/react(<TimelapseEditor>) and used by all threesurfaces. Region-based cutting, as asked for: drag on the timeline creates a
cut region in one gesture, and regions are first-class objects with edge
handles, selection, and delete-key removal — not a brushed mask, because a cut
needs boundaries you can inspect and adjust. Regions are integer
[start, end)unit indices, so they're snapped to capture boundaries by construction. Plain
click seeks, the ruler lane owns scrubbing, playback skips cut regions while
scrubbing passes through them so edges can be judged, and dragging an edge
seeks the preview to that boundary frame.
The filmstrip is generated client-side by seeking a hidden video and drawing to
canvas — no new endpoints, identical for JPEG and clip sessions.
Notable fixes on the way, most of which were "the editor and the server
disagreed":
(
6b7110f). Real captures jitter — the server credits anything within±30 s — so ending an interval at
lastCutUnit + 60 sswallowed a neighbourlanding at +57 s. Selecting 2 of 3 minutes was rejected as "would remove the
entire timelapse" while the footer read "1m kept". Intervals now anchor the
exclusive end to the next kept capture's actual timestamp (still capped at
one interval so a cut before a long pause can't swallow it), and the
footer runs the serialized intervals through the same shared
countCutUnitsthe server uses — two implementations of one question will always drift, so
now there's one. Nine tests, including the reported case at 57 s spacing and
a sweep of gaps from 40 s to 75 s.
d7dfdc7). The compilestarts at stop and runs for tens of seconds, so
preparingis the normalopening state, not an error; it also had to stop reporting a
compilingsession as un-editable, since the worker flips a held session to
compilingwithin a second of the stop and "Edit & save" failed immediately for every
user.
52fc8b6)./unitspresigns a URL and is limited to 10/min; the editor polled it every 1.5 s, so
any compile longer than ~15 s hit the limit and reported a rate-limit error.
The wait broke exactly when there was something worth waiting for. It now
polls
/status(60/min, already carrieseditable) and touches/unitsonly at the edges.
b4cd87b). It's nowanchored to a single start ref, eases asymptotically
(
1 − exp(−2.2·elapsed/estimate)), and is clamped to 99 % so it can neversit at 100 % while the user waits.
(
8d2114a) — now a two-strategy probe: trycrossOrigin="anonymous"andkeep the canvas clean, else fetch to a same-origin
blob:URL (on desktopthat fetch goes through Tauri's HTTP plugin and isn't subject to browser
CORS). This replaced a server-side CORS change, which is why
bb012d1reverts it.
(
e966b16) — one tile per minute meant 19 px smears on a 48-minute session.3211d4f): the drag's pointer-up nolonger clears selection, and re-finds the region after
normalizeRegionsmay have merged and reindexed it, so "Remove cut" is reachable.
f396a19) —"1:29 … 1:45" on a 17-minute timelapse. It's elapsed recording time now.
Same commit dropped the hover-preview card and its second video decoder
(
9254ccd): the filmstrip already shows the footage, and a card chasing thecursor was noise on top of it.
<StopChoiceModal>is where editing is offered — keep recording / stop & save/ edit & save — and it's the only entry point.
<SessionDetail>shows a reviewpanel for any session with a live
editHoldUntil, holds the lease while it'svisible (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.
3. Embedders get all of this for free
Both dialogs portal into
document.body, so the editor opens as a modaloverlay sized to the viewport rather than to whatever width the host gave the
recorder (
b8db804,03591a3) —position: fixedresolves against atransformed ancestor, so an embedder with a
transformup the tree used to geta modal pinned inside their card. Any program that redirects to the hosted
recorder or embeds
<LookoutRecorder>gets the stop dialog and the editor withno code change and no version to adopt.
?edit=falseon the recorder URL or<LookoutRecorder editing={false} />— stopping stays one click.<LookoutProvider accentColor="#16a34a" accentTextColor="#fff">replacesLookout's blue on primary buttons, focus rings and progress (
6bba123).Applied to
document.documentElement, not a wrapper, precisely because theoverlays portal out.
useLookout()hook with its own stopbutton has to opt in, by passing
stop({ edit: true })and rendering<TimelapseEditor>.New exports:
TimelapseEditor,StopChoiceModal,useEditLease,ClipRecorder,regionsToCuts/cutsToRegions/normalizeRegions/gapIndices,useSessionTimerState,deriveDisplaySeconds,computeBestTrackedSeconds,setAccentColor,Overlay,ProgressRing,MinutesFlow,UploadPayload,CutInterval.clients/playground(new workspace, port 5199) is a harness for exercisingthe SDK against a real server. Its own header states the reason: most of the
bugs in this feature were disagreements between the client and the server. So
it has a "server truth" panel printing
editable/editableReasonnext to theeditor, and a "verify against server" button that dry-runs the editor's cut
list through
PUT /cutsand printsunitsCut— which must equal what thefooter says was removed. It also renders the editor in a resizable box with
presets for the shapes that previously broke layout, including the desktop
window's actual minimum.
4. Desktop app
The editor window
The main window is a fixed 480×640, far too small to scrub a multi-hour
timeline, so editing opens a dedicated 940×660 resizable
WebviewWindow(min 620×480,
EditorWindow.tsx, route#/editor?token=…, capability windoweditor-*). macOS draws the window title (fc55288) so it can't drift out ofalignment with the traffic lights the way the hand-placed label did, and the
session name rides along as
Edit — <name>.While it's open the main window steps aside with "Edit your timelapse in
the edit window" (click to bring it forward) rather than two live views of one
session competing for attention. It learns the editor opened from an event and
then polls
isEditorWindowOpenevery second — the poll is what guarantees itcan never get stuck behind the placeholder if the editor is force-quit.
Closing the window is itself the decision (
3211d4f,d61d29f):onCloseRequestedconfirms ("Closing publishes this timelapse with N cutsapplied…"), then publishes and emits the edited event fire-and-forget, guarded
by a ref so saving from inside the editor can't double-publish. Closing focuses
mainfirst — closing the frontmost window otherwise drops the user behindanother app — and on failure falls back to
destroy()instead of the oldswallowed
.catch(() => {}), which left people staring at a window that saidit saved and wouldn't go away.
Native chrome
AddMenu.swift,native_menu.rs): a Raycast-stylenon-activating
NSPanelwith SwiftUI content overNSVisualEffectView, fullkeyboard handling, and program icons served from a main-thread cache warmed by
prefetch_add_menu_iconsso they're on the first frame rather than poppingin.
AddMenuPopup.tsxis the DOM replica for Windows/Linux, documented asmirroring the Swift visual constants.
Gallery'sonAddnow passes anAddAnchorrect so either popup can anchor to the + button.Tray.swift,native_tray.rs): macOS gets a realNSStatusItemhosting a SwiftUI view, so the time digits roll withcontentTransition(.numericText())instead of snapping. Windows doesn'trender tray titles at all, so it gets a tooltip (
Lookout — 12m recorded)with a
⏸prefix while paused.programs.icon_url(0017) andsessions.redirect_url(0016), both manageable from/admin. The appopens the redirect URL in the default browser the moment a session it's
watching flips to
complete— de-duped, and never when re-opening analready-finished session.
javascript:,file://and bare strings arerejected at creation, with tests.
Updates and launch
useAppUpdate.ts+UpdatePill.tsxreplaceUpdateBanner,useUpdateCheckand
useBackgroundUpdate— and the blocking full-screen launch gate thatused to return early before any UI rendered ("Checking for updates…"). Checks
now run in the background every 30 min and surface as a quiet titlebar pill
with a progress ring; installing is strictly behind a click. A relaunch
timestamp with a 60 s cooldown means a bad manifest can't relaunch-loop. On
Windows/Linux there's no overlay titlebar, so the pill floats bottom-left —
top-right overlapped the gallery header (
1cd06ad).Instant launch: permission grants are cached (
lookout-perm-screen/-camera, written only on a real grant, never on Skip) and seeded into statesynchronously, then re-verified in the background and re-gated if revoked.
The clock, in three places, derived once
The menu-bar title and the tray popup both tick their own clock while the main
WebView is throttled, so they go through the exported
deriveDisplaySeconds(mirrored as
tray_display_secondsin Rust, with 6 tests pinning the mirroragainst the JS constant). Two bugs this closes: the pushed payload now carries
the interpolation anchor rather than
displaySeconds, so the popup stopsextrapolating on top of an extrapolation; and the tray's tracked seconds
ratchet with
fetch_max, re-anchoring only on a genuine advance — anidempotent confirm retry can return a lower value, and storing it made the menu
bar jump backwards.
API.mddocuments both rules for anyone else building aticking surface.
Performance, beyond the capture loop
reqwest::Clientin aOnceLock. Every 60 s tick used to build afresh one — new pool, new TLS config, new handshake.
once for the JS preview, and the upload body is
Bytes.the string actually changes (minute granularity against a 1 s ticker); the
per-second
tray-timer-tickemit that nothing listened to is gone.lifetime instead of leaked for the process lifetime, so an idle Lookout
sitting on the gallery no longer prevents the Mac from sleeping.
capture-preview-frameevent, emitted only while the main window isfocused, at ≤854×480 / q65 through a borrowing resize so the full-res frame
stays intact for the clip encoder — IPC gets ~5× lighter and an unfocused
tick in non-clips mode skips the grab entirely. The tray sync went from 3 IPC
calls plus a broadcast every second to an effect keyed on the values that
actually changed (the old rate also drowned the 200-entry debug buffer).
list_running_appsbecamelist_installed_apps+get_app_icon, both async and both memoized, withicons loaded lazily through one
IntersectionObserver. Rows order pinned →open → other from a snapshot taken when the page opened, so toggling a filter
never reshuffles rows under the cursor.
localStoragesummary cache while abackground refresh runs; the old 45-minute presigned-thumbnail cache is gone
because the thumbnail endpoint now streams bytes with ETag +
Cache-Control: max-age=86400and 304 handling instead of redirecting to aURL that changed every request and defeated the browser cache entirely.
Configurable server
serverConfig.tsnormalizes and validates a user-supplied origin (HTTPS only,plus
localhostfor dev), and the new Advanced settings subpage puts aconfirm speed bump and a live probe of
/api/programsin front of it, so atypo fails there rather than silently during the next recording. An active
override is surfaced on the settings menu row and behind a persistent warning
card. Settings itself was restructured into a menu with subpages to hold it.