chore(deps): bump actions/setup-node from 6.3.0 to 7.0.0 - #160
Open
dependabot[bot] wants to merge 69 commits into
Open
chore(deps): bump actions/setup-node from 6.3.0 to 7.0.0#160dependabot[bot] wants to merge 69 commits into
dependabot[bot] wants to merge 69 commits into
Conversation
Add JA4 TLS fingerprint telemetry per screenshot
- 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
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.
"Does cutting affect quality" deserved evidence, not an assertion. The cut now has a test that hashes every DECODED frame of the original and of the edited output (`-f framemd5`) and asserts the kept frames are identical, pixel for pixel. A stream copy moves the same encoded packets, so it passes; any re-encode, however visually lossless, would not. A companion test asserts the re-encode fallback is NOT bit-exact. That keeps the first test honest: if someone made the copy path silently re-encode, a test comparing two re-encodes could still pass. The fallback is the one way a cut can cost quality — it only runs if the copy path throws, which shouldn't happen now that every encode pins the GOP. It was a console.warn among other warnings; it's now an error that says plainly that the timelapse just took a generation of loss and what to investigate.
Adding the `edit` flag brought a body schema with additionalProperties false. That route previously had no body schema at all, so it accepted and ignored anything sent to it — meaning a custom client that posts its own field would have started getting 400s on stop, the one call it cannot afford to fail. Relaxed to permissive, with a test. Audited the rest of the branch's surface against main for the same class of problem: every other change is additive (new endpoints, new response fields, new nullable columns). The two behavioural changes only apply to sessions a client explicitly opted into editing — trackedSeconds and /timings subtract cuts, and both are settled before the session ever reaches `complete`, which is the point programs consume it.
The whole point of putting the flow inside the recorder was that programs adopt nothing. The section described the behaviour but never said that outright, and named the one case that does need work: a program driving the headless hook with its own stop button.
PageContainer defaults to 640px, so the inline editor got ~590px of usable width — less than the 800 the recorder itself uses, and only about six filmstrip frames to cut against. Widened to 960, roughly matching the desktop editor window. The timeline is a precision surface; every 100px of width is another whole frame.
Embedders put the recorder in columns and cards of arbitrary width, and inline meant the timeline inherited that width — a precision tool you can't be precise with. The overlay takes the viewport instead, and the detail view keeps rendering behind it rather than being replaced. New `Overlay` primitive, portalled to document.body. That matters for an SDK dropped into pages we don't control: `position: fixed` resolves against the nearest transformed or filtered ancestor, so an embedder with a transform anywhere up the tree would otherwise get a modal pinned inside their card. It also locks body scroll and moves focus into the dialog. StopChoiceModal now uses it too, since it had the same exposure. The editor overlay has no dismiss: leaving without deciding would strand an unpublished session, so Save is the exit — and an abandoned tab is covered by the edit lease.
`<LookoutProvider accentColor="#16a34a">` recolours primary buttons, focus rings, and the compile progress ring — everywhere the UI says "this is the main action". `accentTextColor` covers the case where a light brand colour makes white labels unreadable. `setAccentColor()` is exported for the surfaces used without a provider (SessionDetail, TimelapseEditor). Three decisions worth stating: - Semantic colours are deliberately excluded. Success green, warning amber and the red marking removed footage carry meaning, not brand; a green "this will be deleted" would be worse than an off-brand one. - Applied to the document root rather than a wrapper, because the stop dialog and the editor portal to document.body and a scoped subtree wouldn't reach them. The provider restores the previous value on unmount so a temporarily-mounted Lookout doesn't leak its accent. - The hover shade is derived via color-mix so embedders supply one colour, with a literal fallback declared first — an unsupported color-mix is dropped at parse time and the literal survives, rather than the hover background resolving to nothing. The playground gains an accent picker so this is checkable against the editor and both dialogs.
The tray's SwiftPM .build directory was tracked, including a 64MB index database, and a .env.bak sat alongside it. Both are now purged from this branch's history; these rules stop them coming back.
Work in progress that was sitting uncommitted: the compile_progress column plus the gallery/editor surfaces that read it, and the clock-style tray title.
The fixture pinned T0 to 2026-07-01 — a future date when it was written, so it passed CI happily until the calendar caught up. The edit hold's ceiling is measured from stoppedAt (EDIT_HOLD_MAX_MINUTES after the stop), so once that date was more than two hours in the past every lease test failed with held:false for reasons that had nothing to do with leases.
The editor used to wait on a full-quality 1080p CRF18 build, and then the
published video was a byte-copy of it — so making the preview cheaper would
have made the published timelapse worse. Split the two:
preview 720p CRF30 superfast, built only to open the editor, deleted
on publish. 431 -> 83 ms/unit measured, at half the bytes.
published re-encoded at full quality from the capture units, with cuts
applied by not encoding the removed units — so cutting half a
session makes publishing cheaper, not dearer.
superfast rather than ultrafast deliberately: 15% slower for half the size,
and the preview is uploaded and then streamed back by the editor, so its size
is part of the latency this exists to reduce. ultrafast's output was actually
larger than the 1080p publish tier.
Guards, because a preview must never reach a viewer:
- original_is_preview marks such a build; a null/false value means
publish-grade, so legacy sessions and sessions that never entered the edit
flow keep the lossless-copy path untouched.
- A preview build never publishes. If the hold lapses mid-build it hands over
to the publish path rather than shipping a low-res video.
- If the units are gone (retention purge, R2 outage), it publishes the preview
with a loud error rather than stranding the recording.
- Sampled units are now load-bearing until publish; cleanup keeps them.
Two changes to how clips are configured. Cadence is now 10s (6 frames/min). Every knob that used to be denominated in frames is derived from it — the per-frame byte budget, the native encoder bitrate, the stall cap — so per-frame QUALITY is held constant when the cadence moves, in either direction. That mattered here: native encoders are handed real presentation timestamps, so their bitrate buys bytes per second of MEDIA time, and a fixed 800kbps would have inflated every clip toward the server's 8MB cap as the interval grew. The formula reproduces the hand-tuned 800kbps at the old 4s cadence exactly, which is what makes it trustworthy elsewhere. Clips are also the default now: clips_enabled defaults true and a program opts OUT with clips:false. Existing rows are deliberately not backfilled — a session's capture character is immutable, so in-flight sessions keep the mode they started with. Clients that can't record clips are unaffected either way; they keep uploading JPEGs to the same session, which stays fully valid. Smoothness scales with the cadence, so this is 6 distinct images per output second rather than 15. Bandwidth falls with it: ~1.1 MB/min for a typical screen, under half what 15/min cost.
An out-of-envelope capturedAt returned 400 from upload-url, which meant a client whose clock was more than five minutes off never received a presigned URL and uploaded NOTHING for the entire session. A skewed clock is common, invisible to the user, and none of their doing. The server now adopts its own clock for those captures instead. That is strictly safer than accepting the claim — server time is unforgeable, so a hostile client gains nothing by sending a wild timestamp; it just gets stamped with the moment the server saw it. It costs precision, not the recording: the capture carries upload latency, so credit is measured a little late. Adoption is for clocks, not for requests. Only the two envelope failures are absorbed; non-monotonic and pre-session timestamps are still refused, and the substituted value is re-validated against both, so replay protection survives. The response reports capturedAtAdopted so a client can correct itself, and ClockOffset (shared) does exactly that: it learns the offset from the server's own timestamps, bracketing each sample so the round trip is charged to latency rather than to the offset. A healthy clock stays inside a deadband and its stamps pass through untouched. Also here, both small: - POST /compile returned 202 for any compiling session, which shadowed the branch that drops the edit hold. A user who declined editing mid-preview got a cheerful 202 while their session stayed held, then waited for the very preview they had just declined. The discriminator is whether an original exists: with one, the in-flight job is the publish; without, it is the preview build and the request means 'publish as recorded'. - R2_ENDPOINT override so the stack can run against a local S3-compatible endpoint. Unset in production, where the account-derived host is used.
Four compounding failures on a slow uplink, all in the browser client. The desktop loop already had the right shape; this brings the web one in line. 1. No timeout anywhere. fetch has no default, so a half-open socket parked the capture loop indefinitely with no error to retry on. Every step now has a 30s deadline, which fits inside the 120s presigned-URL expiry with room for the retry budget. 2. The upload blocked the loop. Each second of latency was a second the recorder wasn't cutting on schedule, so clips stretched to cover minutes each — and a clip renders as ONE second of video however long it took to record, so that footage was genuinely lost. Uploads now run concurrently, strictly one in flight, settled at the next tick to keep them ordered. 3. The in-flight clip grew unbounded. A five-minute stall produced a clip that blew the 8MB cap and was refused on arrival, costing the whole window. Now capped at 3x nominal; the tail of a stalled window is dropped instead. 4. The oversize path halved the encoder bitrate permanently, so one network incident left the rest of the session soft — a user on bad wifi ended up with a worse timelapse than one on no wifi. The backoff no longer fires for a clip that was merely stalled. And the two fallbacks desktop had that web didn't: - A failed CLIP upload is retried as a JPEG reusing the clip's own capturedAtMs, so the minute still credits. Previously the whole minute was lost. - Clips latch off after 3 consecutive failures, or immediately on a typed ClipFormatRejectedError. That error's message used to claim 'falling back to JPEG captures' while nothing did.
Two real bugs in the Media Foundation path, both found by reading it rather than running it — this is the one platform CI can only type-check: - Sample duration was pinned at 3000ms, a cadence the app no longer uses, so every sample claimed a span that disagreed with its own timestamps. Now derived from the real frame interval. - MFStartup leaked a refcount on every failed init. Invisible when init succeeds (finish() balances it), unbounded on a machine whose encoder always fails, because the loop retried init once per frame for the whole session. Now RAII-guarded, with ownership passed to the encoder on success. Media Foundation also gets a two-attempt media-type negotiation: declare the real sub-1fps cadence first, so the bitrate and the frame-rate hint agree about what a second means, and fall back to the 1fps hint this shipped with (scaling the bitrate to match) if the MFT refuses fractional rates. Only if both fail does the interval fall back to a JPEG. And a failure latch: after 3 consecutive encoder failures, clips turn off for the run. Each failure was already survivable — the interval falls back to a JPEG — but a machine where the encoder can never initialise was paying the full cost of constructing and tearing down an OS encoder several times a minute for hours. Any clip that finalises resets the count, so a transient hiccup (a display mode change, a busy GPU) never disables clips. Windows per-frame output remains unmeasured on real hardware; if clips come back soft or oversize, that negotiation is where to look first.
cargo test/check ran the debug profile. The clip encoders are unsafe FFI against VideoToolbox, Media Foundation and GStreamer — exactly the code whose behaviour differs under optimisation, and this project has already been bitten once by a misalignment crash that only appeared in a release build. Testing a debug binary proves the wrong artifact. The Windows job is the only one that compiles the Media Foundation encoder at all, so it should compile it the way users get it. Also adds the worker job (ffmpeg segment pipeline) and the shared package's unit tests, which now cover clock-offset estimation.
…resilience Skips 0.3.6, which is taken by the unreleased crash-handler branch. Workspace packages move 0.3.3 -> 0.3.7 and the desktop app 0.3.5 -> 0.3.7, so both tracks read the same number from here on. Also adds the shared package's test script (its units are new in this branch) and corrects the SDK doc's stale v0.1.0 header.
During the edit hold the original holds every minute the user is about to cut out, and it stays readable until the publish deletes it. Its key was `timelapses/<sessionId>/original.mp4` — and sessionId is public, appearing in the /api/media/:sessionId/... URLs handed out with any shared timelapse. So anyone with a share link could reconstruct the original's URL and, if the bucket is readable at all (R2_PUBLIC_DOMAIN fronts it publicly in the documented setup), fetch the cut footage — straight past the token gate that /units presigns behind. Now `original-<128 random bits>.mp4`. That holds whether or not the bucket is public, which is the property worth having: it doesn't depend on an ACL staying correct. The published video keeps its predictable key, since being fetchable is the point of it. A recompile reuses the session's existing key rather than minting a new one, so the old object is overwritten instead of orphaned. Nothing rebuilds this key from the session id — every reader takes it from sessions.original_video_r2_key, which is what made the change contained.
The Desktop Rust job has been failing since clips landed — every test that constructs a real ClipRecorder dies with 'no H.264 encoder element available'. It installs libgstreamer1.0-dev and libgstreamer-plugins-base1.0-dev, which are DEV headers; the elements the encoder actually builds live in four separate runtime packages: videoconvert in -base, mp4mux in -good, h264parse in -bad, x264enc in -ugly. None were present, so ENCODER_CANDIDATES matched nothing. Not caused by the switch to --release; the run on d938b7e failed identically. Worth having rather than skipping the tests, because this job is the only place the Linux encoder path is executed at all — macOS runs it on a dev machine and Windows is check-only. With the plugins present it goes from compiled to tested.
bundle.linux was empty, so the .deb and .rpm declared nothing at all — and the Linux build needs GStreamer elements spread across several packages. The worst of it isn't clips: pipewiresrc is how the app captures the screen at all (pipewire.rs), and it ships in gstreamer1.0-pipewire. A user could install the package and have capture simply not work. Depends covers what the app cannot run without — pipewiresrc, videoconvert — plus mp4mux and h264parse, since clips are the default capture mode now. The H.264 encoder is Recommends rather than Depends on purpose. -ugly carries GPL x264, and gstreamer1-plugins-ugly isn't in Fedora proper (RPMFusion), so a hard dependency would either impose that licence on every install or make the rpm uninstallable on a stock Fedora. apt and dnf install Recommends by default, so the ordinary user still gets an encoder; anyone excluding it gets a working app whose clips fall back to one JPEG a minute, which the failure latch now handles quietly. This is the same gap that had CI failing: dev headers were installed, runtime plugins were not.
Found by stress-testing the encode cycle: RSS grew 4,894 KB for every clip recorded. One clip a minute means ~3.5 GB over a 12-hour session. Every Cocoa object the encoder builds — the settings dictionaries, the NSNumbers in them, the writer, the input, the pixel-buffer adaptor, the CVPixelBuffers — is autoreleased. The capture loop calls this from a tokio worker thread, and unlike the main run loop a tokio thread never drains an autorelease pool, so none of it was ever freed. Nothing was wrong with the retain/release balance; there was simply no pool to drain into. All three entry points now run inside an explicit autoreleasepool. Measured over 200 cycles (~3.3 hours of recording): 4,894 KB/cycle -> 2 KB/cycle, and baseline process RSS fell from 84 MB to 55 MB. The discard path went from 2,439 to 51 KB/cycle and is sub-linear, so that remainder is warm-up, not accumulation. Adds the tests that found it, plus the cost measurement: - encode_cycle_does_not_leak / discard_path_does_not_leak — ignored stress tests, LEAK_CYCLES tunable, budget scales with the run. - clip_temp_files_are_always_removed — runs in CI; covers finish, discard, and the finish-with-no-frames error path. - encode_cost_at_1080p — 96 ms/clip worst case (13.7 ms/frame, 2 MB/clip), i.e. 0.16% of one core at a clip a minute. The leak tests stay manual: they assert on RSS, which is too environment- dependent to gate CI on. The temp-file test is the one that runs every push.
Opening Filtered Apps was doing all its work synchronously inside an `async fn` command. `async` alone bought nothing: the body never yields, so it occupied a tokio worker for its whole duration — and the capture loop runs on those same workers, so a slow app scan could delay a capture tick, not just the page. Three things, in order of how much they cost: - The command body now runs on the blocking pool via spawn_blocking, so it cannot hold a capture worker regardless of how slow the machine's filesystem or window enumeration is. - The installed-app cache is pre-warmed in a background thread, DEFERRED five seconds. The scan is disk-bound, and launch is the most I/O-contended moment in the process's life (the webview is loading its own assets), so warming it immediately would trade a faster Settings page for a slower app open. Five seconds is long before anyone navigates there and after launch I/O settles. A failed spawn is ignored — the cache then fills lazily, exactly as before. - The Windows Start Menu walk used `entry.path().is_dir()`, which stats every entry a second time; the directory enumeration already returned the attributes. `entry.file_type()` uses them. The Start Menu has hundreds of entries and every stat goes through whatever filter drivers and AV hooks are installed. `file_type()` doesn't follow symlinks where `is_dir()` did, so a directory junction still falls back to the stat rather than silently stopping being traversed. Note what is NOT fixed: `running_apps()` enumerates windows on every open and is uncached, by design — it has to be live for a running app to be listed. It is now merely off the capture workers rather than cheaper. Windows behaviour is unverified by me; the cross-platform check job compiles it.
clip_temp_files_are_always_removed compared a COUNT of lookout-clip-* files in
the OS temp dir before and after. That directory is shared by the whole
process, cargo runs tests in parallel, and encodes_frames_into_playable_mp4
stages two files of its own there for ffprobe — so an interleaving where the
count rises by one across the window is not just possible, it is exactly the
observed failure ("left: 1, right: 0").
My bug, not a product one: no path leaks a clip container. finish() and
discard() both remove the file unconditionally, including when finish errors on
a zero-frame clip, which is the case the test exists to cover.
Both tests now hold a TEMP_DIR mutex, so the counting window can't overlap a
creator. Poisoning is ignored deliberately — one test panicking should surface
as that test's failure, not cascade. The assertion also reports leaked
FILENAMES rather than a count, so if it ever fails for real the name says which
recorder left it.
Verified with 25 consecutive runs at --test-threads=8: no failures.
… temp dir My previous attempt at this was still wrong. Locking two tests wasn't enough — empty_clip_errors and resized_frames_are_normalized build recorders too, so their in-flight containers still landed in the counting window and got reported as leaks. Adding them to the lock would have worked, but the design was the problem: a test that inspects a directory shared by the whole process is coupled to every other test in it. It now asks each recorder for its own path and asserts that path is gone. No shared state, no lock, correct under any amount of parallelism, and the failure message points at one specific recorder. 30 full-suite runs at 16 threads: no failures. Also fixes a real orphan the investigation turned up: if platform::Encoder::new FAILS, the container may already exist — GStreamer's filesink opens it the moment the pipeline goes Playing — and there is no ClipRecorder yet whose finish()/discard() would remove it. That matters more now the capture loop retries init before latching clips off: a machine with a broken encoder would drip one orphan into the temp dir per attempt. ClipRecorder::new now removes the path on the error path.
Clips at 6 frames/minute, on by default, plus stop-time editing. Capture: - Cadence 10s (6 fpm), server-authoritative. Per-frame quality is now derived from the cadence, so changing the rate holds frame quality constant. - clips_enabled defaults TRUE; programs opt OUT with `clips: false`. - Web uploads run off the capture loop's critical path, clips are bounded against a stalled upload, a failed clip upload retries as a JPEG, and clips latch off after repeated failures. - A client clock outside the trust envelope is adopted as server time instead of failing the upload; clients learn their offset and self-correct. Compile: - Two tiers: a throwaway 720p preview opens the editor (~5x faster to build), and the published video is re-encoded at full quality from the capture units with cuts applied, so cutting makes publishing cheaper. Fixes worth calling out: - macOS encoder leaked ~4.9 MB per clip (no autorelease pool on the tokio worker) — ~3.5 GB over a 12-hour session. Now 2 KB/cycle. - Windows Media Foundation: sample duration was pinned to an unused cadence, and MFStartup leaked a refcount per failed init. - The uncut original moved to an unguessable R2 key; it was derivable from the public session id. - Linux runtime dependencies are declared for the first time (pipewiresrc is how capture works at all on Linux). - CI builds and tests the desktop client in release, and now installs the GStreamer plugins the Linux encoder needs — that job had been failing. Migrations: 0020 compile_progress, 0021 original_is_preview, 0022 clips_default_on. 0022 changes behaviour for every new session. Unverified at merge time: Windows runtime behaviour (compile-checked only) and whether the new .deb/.rpm dependency names resolve on a clean distro.
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](actions/setup-node@53b8394...8207627) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
dependabot
Bot
force-pushed
the
dependabot/github_actions/actions/setup-node-7.0.0
branch
from
August 9, 2026 06:02
b403fe3 to
1f8d09c
Compare
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.
Bumps actions/setup-node from 6.3.0 to 7.0.0.
Release notes
Sourced from actions/setup-node's releases.
Commits
8207627Migrate to ESM and upgrade dependencies (#1574)04be95cAdd cache-primary-key and cache-matched-key as outputs (#1577)7c2c68ddocs: Update caching recommendations to mitigate cache poisoning risks (#1567)6a61c03Merge pull request #1569 from jasongin/update-actions-cache-5.1.030eb73bResolve high-severity audit issues4e1a87aUpdate dist360237fStrict equality4f8aac5Bump@actions/cacheto 5.1.0, log cache write deniedf4a67bbOnly usemirrorTokeningetManifestif it's provided (#1548)0355742Remove dummy NODE_AUTH_TOKEN export (#1558)