Skip to content

[Draft!] Add higher fpm + ability to edit videos - #167

Draft
anscg wants to merge 45 commits into
hackclub:mainfrom
anscg:clips
Draft

[Draft!] Add higher fpm + ability to edit videos#167
anscg wants to merge 45 commits into
hackclub:mainfrom
anscg:clips

Conversation

@anscg

@anscg anscg commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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
Screenshot 2026-07-26 at 6 14 53 PM
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, one
PUT, one confirm per minute. RATE_LIMIT_PER_MINUTE,
MAX_SCREENSHOTS_PER_SESSION and MAX_UPLOAD_REQUESTS_PER_SESSION are
untouched.

The opt-in, and why it's shaped this way

POST /api/internal/sessions takes "clips": true
sessions.clips_enabled (0015_clips.sql). Default off, set only at
creation
— 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-clips
session is silently downgraded — HTTP 200 with format: "jpeg" and a
.jpg key, not an error:

const format = requestedFormat !== "jpeg" && !session.clipsEnabled
  ? "jpeg" : requestedFormat;

A conforming client falls back without an error round-trip, and a stale or
hostile client asking for webm every 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 the
signature, and confirm re-validates via HeadObject against the format
stored 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. frameIntervalMs and clipsEnabled ride
on both GET /api/sessions/:token and every upload-url response, and no
client 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) records
format=mp4 on the GPU:

Platform Encoder
macOS AVAssetWriter → VideoToolbox, muxes MP4 itself
Windows Media Foundation sink writer (hardware MFT when available)
Linux GStreamer appsrc, trying vah264encvaapih264encx264encopenh264enc

Frames 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=zerolatency on x264, whose default medium
preset burned 5–10× the CPU needed). An in-crate test pushes 5 synthetic frames
through the real OS encoder and asserts via ffprobe that the output is
h264, 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_capabilities GETs the session at
loop start, and any failure or non-2xx falls back to Default — clips off,
bit-for-bit legacy behaviour. The server's frameIntervalMs wins, clamped to
500..=30_000 against a misbehaving server.

Uploads run concurrently with capture (a2d3980). Serially awaiting
finalize+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 select arm
that applies the confirm when it lands (tray sync, nextExpectedAt
refinement, error recovery). Strictly one upload in flight, so capturedAt
monotonicity 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 via MediaRecorder,
probing H.264/MP4 first (avc1.6400284d002842e01e
video/mp4) with VP9/VP8 WebM as the Firefox fallback. Measured at matched
output 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:

CLIP_VIDEO_BITS_PER_SECOND (800 kbps, native) and
CLIP_WEB_VIDEO_BITS_PER_SECOND (40 Mbps, browser) are not comparable
numbers — they're denominated in different things.

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.
MediaRecorder ignores wall-clock frame spacing entirely: recording the same
frames 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:

bitrate KB/frame PSNR
0.8 M 53.7 25.8 dB ← the old setting
5 M 114.6 30.9 dB
20 M 192.0 34.3 dB
40 M 335.4 38.6 dB ← knee, ~parity with native
80 M 582.3 43.7 dB (worst case exceeds the 8 MB cap)

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
ClipRecorder halves its bitrate (floored at the native rate), warns, and
returns null so the tick falls back to a JPEG — a server rejection would
cost 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 explicit
requestFrame() (falling back to auto-capture on older Firefox), canvas fixed
to even dimensions for the clip's life, imageSmoothingQuality = "high"
before every drawImage because downscaled 1080p UI text aliases badly, and
cut() races the encoder's onstop against a 10 s timeout so a wedged
encoder can't stall the capture loop.

Cost and performance

The question everyone asks first, so: clips are close to free.

  • CPU: averages ~1.3 % over a minute on a low-end Windows laptop with
    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.
  • Bandwidth: only slightly above the single-JPEG pipeline, because
    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.
  • The capture loop got faster on the way here too: SIMD downscale via
    fast_image_resize (29 ms → 7 ms for 5K→1080p), redaction applied after
    the downscale, and Bytes buffers so an upload retry is a refcount bump
    instead of a full frame copy.

Compiling clips into a timelapse

packages/worker/src/segments.ts (new, 333 lines, deliberately free of DB/R2
imports 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:

  • One ffmpeg pass per unit. For a clip: setpts=N/(frames*TB) spreads the N
    real demuxed frames evenly across [0, 1 s), then fps=30, then
    tpad=stop_mode=clone:stop=-1 clone-extends the last frame so PTS rounding
    can never come up short, then -frames:v 30. fa22053 removed the
    intermediate JPEG round-trip the first draft had — one fewer lossy
    generation per frame and ~40 % less per-clip work.
  • The client's reported frame_count is never trustedffprobe -count_packets counts. Clips are VFR and a static screen legitimately emits
    fewer frames than nominal, so FRAMES_PER_CLIP = 15 is informational only.
    verifySegmentFrameCount is then strict equality, because a short
    segment would silently desync every later minute of the video.
  • Downloads are pipelined into segment builds with no barrier, so early
    units encode while later ones are still downloading, with
    SEGMENT_CONCURRENCY = 8 and -threads 1 per ffmpeg (parallelism lives in
    the pool, not in x264).
  • Segment CRF went 28 → 18 (f977898): the compile step must not be a quality
    event — 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.
  • A corrupt unit is caught and skipped per-minute instead of poisoning the
    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 yuv420p so segments stay
bit-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 first
capture 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 /timings and
the credit history are untouched.

What program authors have to do

Nothing. trackedSeconds, screenshotCount, /timings (still one timestamp
per minute, so Hackatime forwarding is unchanged), videoUrl and every
response 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.

recording ──Stop──> ┌ Keep recording ────────────────────> (back to recording)
                    ├ Stop & save ───> compile ──────────> complete
                    └ Edit & save ──> stop {edit:true}
                                        │ compile runs, session is HELD
                                        │ (status stays "stopped", unpublished)
                                        ├ user cuts → publish ─> compiling ─> complete
                                        ├ "publish as recorded" ──────────> complete
                                        └ lease lapses (~2 min) ──────────> complete

Editing lives inside the stop flow, never after complete. 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 → 409).

The hold is a lease, not a countdown. Any surface representing active
editing renews it every EDIT_HEARTBEAT_SECONDS (30 s) via
POST /:token/editing; the server holds the session EDIT_LEASE_SECONDS
(120 s) past the last renewal, bounded by EDIT_HOLD_MAX_MINUTES (120) from
the 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 be
pulled back.

A lapsed lease publishes. lib/timeouts.ts#publishExpiredHolds runs on the
existing 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:

  1. Video-second i is capture unit i, whose capturedAt is known.
    sessions.video_units is that map, and it's built from the segment list,
    not the sampled rows
    , so build-failure holes can never desync it.
  2. Each kept range is extracted with an input seek to its IDR (-ss) plus an
    exact copied packet count (-frames:v n×30) into a TS intermediate, then
    the ranges stream-copy concat. Not the concat demuxer's
    inpoint/outpointoutpoint is dts-based and B-frame dts offsets leak
    ~2 frames of the cut region past each boundary.
  3. A cut-compile is I/O-bound. Seconds even for a 12-hour session, and zero
    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 a
cut restarts timestamps at zero by design) and asserts the kept frames are
pixel-identical — toEqual, tolerance zero. A companion test asserts the
re-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 = false or the copy path
throws, which shouldn't happen now that every encode pins its GOP. It used to
be a console.warn lost among other warnings; it's now a loud console.error
saying the timelapse just lost a generation of quality and what to
investigate.

Derived effects, all settled before publication

Consumer Effect
Published video Kept ranges of original.mp4, stream-copy concatenated into edited.mp4
GET /timings Cut timestamps excluded by default, so existing Hackatime forwarders respect edits with zero code changes; ?includeCut=true returns them as cutTimestamps
trackedSeconds raw − cutSeconds everywhere, via one read-side helper; raw preserved as uncutTrackedSeconds

trackedSeconds shrinking is deliberate: /timings and trackedSeconds must
tell the same story, and cutting can only reduce the number, so there's no
fraud vector. sessions.tracked_seconds stays raw as an audit trail.

One shared membership rule — a unit is cut iff
coalesce(captured_at, requested_at) ∈ [start, end) — lives once in
packages/shared/src/cuts.ts alongside normalizeCuts, computeKeptRanges,
countCutUnits and computeCutSeconds, and is used identically by the
server'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 is
    deliberately additionalProperties: true: 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 (848c768). No body → no
    hold, 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 − 1 so the waiting-room copy
    doesn'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 session
    that 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, bump
    recompile_count (capped at MAX_USER_RECOMPILES = 5), enqueue. Already
    complete → 200 instant: true; already compiling → 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; returns held: false once the
    session is out so clients stop polling.

lib/publish.ts#publishHeldSession is the single atomic guard shared by the
user'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#purgeEditedOriginals is the retention backstop on the daily
cron 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_hold add cuts, cut_seconds,
video_units, original_video_r2_key, video_copy_aligned,
recompile_count, last_edit_compile_at, edit_hold_until. No
screenshots changes
— membership is computed from the interval list, never
denormalized onto rows.

The editor

Built once in @lookout/react (<TimelapseEditor>) and used by all three
surfaces. 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":

  • Cut intervals assumed a 60 s stride, so the server over-counted
    (6b7110f). Real captures jitter — the server credits anything within
    ±30 s — so ending an interval at lastCutUnit + 60 s swallowed a neighbour
    landing 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 countCutUnits
    the 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.
  • The editor opens before the preview exists (d7dfdc7). The compile
    starts at stop and runs for tens of seconds, so preparing is the normal
    opening state, not an error; it also had to stop reporting a compiling
    session as un-editable, since the worker flips a held session to compiling
    within a second of the stop and "Edit & save" failed immediately for every
    user.
  • Waiting for the preview 429'd on its own poll (52fc8b6). /units
    presigns 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 carries editable) and touches /units
    only at the edges.
  • The progress ring restarted at 0 on every poll (b4cd87b). It's now
    anchored to a single start ref, eases asymptotically
    (1 − exp(−2.2·elapsed/estimate)), and is clamped to 99 % so it can never
    sit at 100 % while the user waits.
  • Thumbnails failed silently when the preview lacked CORS headers
    (8d2114a) — now a two-strategy probe: try crossOrigin="anonymous" and
    keep the canvas clean, else fetch to a same-origin blob: URL (on desktop
    that fetch goes through Tauri's HTTP plugin and isn't subject to browser
    CORS). This replaced a server-side CORS change, which is why bb012d1
    reverts it.
  • The filmstrip tiles whole frames sized by the track at device resolution
    (e966b16) — one tile per minute meant 19 px smears on a 48-minute session.
  • Cuts could never stay selected (3211d4f): the drag's pointer-up no
    longer clears selection, and re-finds the region after normalizeRegions
    may have merged and reindexed it, so "Remove cut" is reachable.
  • The ruler labelled time of day, which read as a duration (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 the
    cursor 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 review
panel for any session with a live editHoldUntil, holds the lease while it's
visible (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 modal
overlay
sized to the viewport rather than to whatever width the host gave the
recorder (b8db804, 03591a3) — position: fixed resolves against a
transformed ancestor, so an embedder with a transform up the tree used to get
a modal pinned inside their card. Any program that redirects to the hosted
recorder or embeds <LookoutRecorder> gets the stop dialog and the editor with
no code change and no version to adopt.

  • Opt out with ?edit=false on the recorder URL or
    <LookoutRecorder editing={false} /> — stopping stays one click.
  • <LookoutProvider accentColor="#16a34a" accentTextColor="#fff"> replaces
    Lookout's blue on primary buttons, focus rings and progress (6bba123).
    Applied to document.documentElement, not a wrapper, precisely because the
    overlays portal out.
  • Only a program driving the headless useLookout() hook with its own stop
    button 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 exercising
the 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/editableReason next to the
editor, and a "verify against server" button that dry-runs the editor's cut
list through PUT /cuts and prints unitsCut — which must equal what the
footer 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 window
editor-*). macOS draws the window title (fc55288) so it can't drift out of
alignment 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 isEditorWindowOpen every second — the poll is what guarantees it
can never get stuck behind the placeholder if the editor is force-quit.

Closing the window is itself the decision (3211d4f, d61d29f):
onCloseRequested confirms ("Closing publishes this timelapse with N cuts
applied…"), 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
main first — closing the frontmost window otherwise drops the user behind
another app — and on failure falls back to destroy() instead of the old
swallowed .catch(() => {}), which left people staring at a window that said
it saved and wouldn't go away.

Native chrome

  • Add-menu (AddMenu.swift, native_menu.rs): a Raycast-style
    non-activating NSPanel with SwiftUI content over NSVisualEffectView, full
    keyboard handling, and program icons served from a main-thread cache warmed by
    prefetch_add_menu_icons so they're on the first frame rather than popping
    in. AddMenuPopup.tsx is the DOM replica for Windows/Linux, documented as
    mirroring the Swift visual constants. Gallery's onAdd now passes an
    AddAnchor rect so either popup can anchor to the + button.
  • Menu-bar item (Tray.swift, native_tray.rs): macOS gets a real
    NSStatusItem hosting a SwiftUI view, so the time digits roll with
    contentTransition(.numericText()) instead of snapping. Windows doesn't
    render tray titles at all, so it gets a tooltip (Lookout — 12m recorded)
    with a prefix while paused.
  • Program icons + redirect URLs. programs.icon_url (0017) and
    sessions.redirect_url (0016), both manageable from /admin. The app
    opens the redirect URL in the default browser the moment a session it's
    watching flips to complete — de-duped, and never when re-opening an
    already-finished session. javascript:, file:// and bare strings are
    rejected at creation, with tests.

Updates and launch

useAppUpdate.ts + UpdatePill.tsx replace UpdateBanner, useUpdateCheck
and useBackgroundUpdateand the blocking full-screen launch gate that
used 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 state
synchronously, 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_seconds in Rust, with 6 tests pinning the mirror
against the JS constant). Two bugs this closes: the pushed payload now carries
the interpolation anchor rather than displaySeconds, so the popup stops
extrapolating on top of an extrapolation; and the tray's tracked seconds
ratchet with fetch_max, re-anchoring only on a genuine advance — an
idempotent confirm retry can return a lower value, and storing it made the menu
bar jump backwards. API.md documents both rules for anyone else building a
ticking surface.

Performance, beyond the capture loop

  • One shared reqwest::Client in a OnceLock. Every 60 s tick used to build a
    fresh one — new pool, new TLS config, new handshake.
  • No base64 round-tripping: capture returns raw JPEG bytes, base64 runs exactly
    once for the JS preview, and the upload body is Bytes.
  • The tray ticker caches its last title and only touches the native tray when
    the string actually changes (minute granularity against a 1 s ticker); the
    per-second tray-timer-tick emit that nothing listened to is gone.
  • macOS App Nap / idle-sleep assertion is now scoped to the tray-timer
    lifetime instead of leaked for the process lifetime, so an idle Lookout
    sitting on the gallery no longer prevents the Mac from sleeping.
  • New capture-preview-frame event, emitted only while the main window is
    focused, 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).
  • Settings no longer polls running apps every 5 s. list_running_apps became
    list_installed_apps + get_app_icon, both async and both memoized, with
    icons 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.
  • Gallery paints instantly from a localStorage summary cache while a
    background refresh runs; the old 45-minute presigned-thumbnail cache is gone
    because the thumbnail endpoint now streams bytes with ETag +
    Cache-Control: max-age=86400 and 304 handling instead of redirecting to a
    URL that changed every request and defeated the browser cache entirely.

Configurable server

serverConfig.ts normalizes and validates a user-supplied origin (HTTPS only,
plus localhost for dev), and the new Advanced settings subpage puts a
confirm speed bump and a live probe of /api/programs in front of it, so a
typo 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.

claude and others added 30 commits July 24, 2026 13:05
- 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
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.
anscg and others added 15 commits July 27, 2026 02:01
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>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​@​number-flow/​react@​0.6.0 ⏵ 0.6.21001007391 +8100
Updatednpm/​vitest@​4.1.6 ⏵ 4.1.1098 +11007999 +2100
Addednpm/​@​phosphor-icons/​react@​2.1.109410010080100
Addedcargo/​fast_image_resize@​5.5.09810093100100
Updatednpm/​@​lookout/​react@​0.2.11 ⏵ 0.3.3N/AN/AN/AN/AN/A
Updatednpm/​@​lookout/​shared@​0.2.11 ⏵ 0.3.3N/AN/AN/AN/AN/A

View full report

@anscg
anscg marked this pull request as draft July 27, 2026 17:36
@anscg anscg changed the title Add higher fpm + ability to edit videos [Draft!] Add higher fpm + ability to edit videos Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants