Video-.saver — pre-rendered per-preset macOS screensavers¶
Date: 2026-05-29 Status: Shipped as v0.2.5 (merged to main), on-device verified — each preset plays its own video. Two post-release bugs found + fixed (idle-drift freeze, shared Obj-C class). See "On-device bugs found after release" below.
Problem¶
WebGL does not composite in the sandboxed legacyScreenSaver process on
macOS (confirmed on-device, see 2026-05-28-screensaver-native-port-design.md).
The current macOS shipping path is therefore a fullscreen .app, not a real
.saver — it must be launched manually and does not integrate with System
Settings or idle-start.
Idea¶
A video plays through a completely different code path — native AVPlayer /
AVPlayerLayer (AVFoundation) — which does work in the screensaver
process (Apple's Aerial and most popular macOS savers are video-based). So:
pre-render the engine to a looping video and ship a real .saver that just
plays it.
Decisions (2026-05-29, with user)¶
- Add, don't replace. The video
.saveris an addition — the live WebGL.appstays for users who want full procedural variation. - One
.saverper preset. Instead of one.saverwith a config sheet, shipKuro Toxic Haze.saver,Kuro Crimson.saver, … Each bundle embeds a single loop video. macOS lists them all in System Settings — no Swift config UI needed. - Diegetic loop via CRASH sequence. The terminal narrative has a real
dramatic arc (
narrative.ts):ROUTINE → INTRUSION → ALARM → PANIC → SILENCE → reset → ROUTINE, a full cycle ~6-9 min.SILENCEis the story end ("final farewell"). TodaySILENCE → ROUTINEdoes a silentreset(). We replace that with an inszenierter CRT crash (signal collapse → reboot flicker → fresh shift). This is a shared engine feature — it improves the live.appand Web build too, and gives the video a designed loop seam instead of a hidden crossfade. - Rendered clip uses a shortened cycle (~2-3 min). Phase durations scaled to ~⅓ for the render only; the live engine keeps the full 6-9 min arc. Keeps per-preset files ~50-90 MB at 1440p/H.265.
Capture method — IMPORTANT¶
The screensaver is WebGL canvas + DOM overlay layers (CRT sim, HUD,
terminal narrative use mix-blend-mode / overlayHost.appendChild). Therefore
canvas.captureStream() is WRONG — it would capture only the 3D scene without
the CRT look, HUD, or story text.
Use full-page composite capture: Playwright recordVideo (records the
rendered viewport incl. DOM + WebGL), run headful so it uses the real Metal
GPU (avoids the SwiftShader fuzziness and the compositing limitation entirely).
Then ffmpeg → H.265 .mov, trimmed to loop exactly at the crash seam.
Phases¶
| # | What | Risk |
|---|---|---|
| 0 | Render-proof: headful Chromium renders the engine → short video clip of one preset, verify WebGL + DOM overlays are all present and sharp | High — gates everything |
| 1 | Crash-FX engine feature: inszenierter CRT crash at SILENCE end, replaces silent reset (live + web + video) |
Low |
| 2 | ?preset= param in screensaver/main.ts (preset currently hardcoded to toxic-haze) + render-only story-duration scaling |
Low |
| 3 | Render pipeline scripts/render-saver-videos.mjs — one ~2-3 min loop clip per preset → H.265 |
Medium |
| 4 | Video .saver native/macos/KuroVideoSaver/ — slim AVPlayerLayer ScreenSaverView, one build per preset |
Medium |
| 5 | CI/release — render + multi-.saver build in GitHub Action |
Low |
Implementation notes (learned while building)¶
- Bitrate matters more than expected. 6 Mbit smeared the fine moving wireframe lines (the capture is sharp at ~40 Mbit VP8 — the loss was the final encode). Settled on 13 Mbit (~235-257 MB/preset, ~3.1 GB for 13). The content looks information-sparse but isn't: the constant camera move through fine lines destroys temporal redundancy, the worst case for block codecs.
- The crash must recur — idle-drift bug. The 5-min idle-drift mode called
narrative.pause(). A screensaver is idle by definition, so it fired every time at ~5 min and paused the shift loop permanently (no input ever resumes it) — so the diegetic crash stopped recurring and captures past 300s found only 1 crash (≈50% of presets failed "need 2 crashes"). Fixed: idle-drift keeps its visual cue but no longer pauses the narrative. Was a real live-app bug (terminal story froze after 5 min). The render pipeline surfaced it. -
Render is RNG-paced + idempotent. Capture is cached (webm-cache/) so a bitrate change re-encodes in seconds; auto-recapture (≤4 tries) covers any remaining <2-crash capture. With the idle-drift fix, the final batch hit 13/13 on the first attempt.
-
Capture must be headless. A headful Chromium window gets backgrounded/ occluded by macOS within ~1-2 min and the renderer is suspended mid-capture ("Target page … closed"). Diagnosed as NOT a memory leak (JS heap stable at ~10MB).
headless: true(headless=new) renders WebGL fine via ANGLE/Metal and runs indefinitely. Anti-backgrounding launch flags added too. - recordVideo, not CDP screencast. recordVideo gives a stable 25fps full
composite; CDP
Page.startScreencastonly managed ~9fps at 1440p and was fragile. Kept the container at native 25fps (a 25→60 resample judders on the non-integer ratio). - Loop seam = blackdetect, cut mid-black→mid-black.
blackdetect=d=0.2(not 0.4) — the crash's solid-black block is only ~0.3-0.5s (the reboot flicker breaks it with bright spikes), so 0.4 missed it sporadically (circuit/spectre failed with "found 1 crash" until lowered). - Bitrate:
-b:v 6Mis a target, not a cap — bright glow-heavy presets (toxic-haze, phosphor) run to ~185-207MB; most land ~110-120MB. - Render is non-deterministic + idempotent. RNG cycle length varies (~115-178s), so CAPTURE_SECONDS=480 for headroom; the script skips presets whose .mov already exists (FORCE=1 to redo) so re-runs only fill gaps.
On-device bugs found after release (both fixed)¶
Two bugs surfaced only with the real screensaver + multiple presets installed:
- Idle-drift froze the narrative (above) — fixed in v0.2.2.
- Shared Obj-C class → wrong video (v0.2.5). All 13
.savershipped the same principal classKuroVideoSaverView. The Obj-C runtime registers a class name only ONCE per process, so once macOS loaded a second Kuro.saver(e.g. previewing presets),Bundle(for: KuroVideoSaverView.self)resolved to the first-loaded bundle — every preset then played that bundle'sloop.mov(ember showed biolink). Bundle ids were already unique; the class name was the collision. Fix:build-video-savers.shrenames the class per preset (KuroVideoSaver_<key>, via sed) and rebuilds each binary (13 builds, not a template+clone), patchingNSPrincipalClassto match.loop.movis no longer a target resource — it's copied into each bundle after the build. Verified on-device: ember and biolink each play their own video.
Out of scope / open¶
- Windows:
.scralready works via WebView2 (live). Video path is macOS-only for now. - Multi-monitor / Retina resolution is baked into the rendered clip.
- Exact preset selection for shipping (all 13 vs a curated subset) — decide after Phase 0 proves file sizes.