Zum Inhalt

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)

  1. Add, don't replace. The video .saver is an addition — the live WebGL .app stays for users who want full procedural variation.
  2. One .saver per preset. Instead of one .saver with a config sheet, ship Kuro 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.
  3. 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. SILENCE is the story end ("final farewell"). Today SILENCE → ROUTINE does a silent reset(). We replace that with an inszenierter CRT crash (signal collapse → reboot flicker → fresh shift). This is a shared engine feature — it improves the live .app and Web build too, and gives the video a designed loop seam instead of a hidden crossfade.
  4. 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.startScreencast only 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 6M is 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 .saver shipped the same principal class KuroVideoSaverView. 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's loop.mov (ember showed biolink). Bundle ids were already unique; the class name was the collision. Fix: build-video-savers.sh renames the class per preset (KuroVideoSaver_<key>, via sed) and rebuilds each binary (13 builds, not a template+clone), patching NSPrincipalClass to match. loop.mov is 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: .scr already 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.