A macOS-only product-demo recorder. Agent-driven by default, with a menu-bar app for human-driven follow-mode recording. Captures real clicks, real scrolling, and real window state — then composites them into a cinematic, presentation-ready MP4 with spring-eased camera motion, a synthetic cursor, motion blur, and a clean desktop stage.
Zero LLM usage. Every frame is produced locally; no network calls are made during capture or render.
Download DemoRecorder-<version>.dmg from the latest release,
open it, and drag DemoRecorder.app onto the Applications shortcut.
Launch it from Launchpad or /Applications. The first launch prompts
macOS to grant Accessibility and Screen Recording to the signed
helper at DemoRecorder.app/Contents/Resources/app/.cache/DemoDriver;
approve both in System Settings → Privacy & Security. The helper is
signed with a stable Developer ID designated identifier, so the grants
survive future installs of the same release.
git clone https://github.com/Orthic-Labs/demoright
cd demoright
pnpm install
pnpm build:native # builds .cache/DemoDriver + bin/<arch>/DemoRecorder
open bin/$(node -p 'process.arch')/DemoRecorderYou can also produce the same .app bundle and DMG locally:
node scripts/build-app.mjs # -> dist/DemoRecorder.app (signed, hardened runtime)
node scripts/build-dmg.mjs # -> dist/DemoRecorder-<version>.dmgOnce running, the menu-bar icon (record.circle) gives you:
- Start recording… — pick a scenario JSON, capture begins.
- Stop recording — terminates the in-flight
right-demo follow. - Open last output — opens the most recent
.cache/demos/…folder in Finder. - Run doctor — surfaces TCC / signing / ffmpeg status.
A desktop notification fires on start, finish, and failure.
The same CLI shipped inside the .app works from any folder:
/Applications/DemoRecorder.app/Contents/Resources/app/bin/demo.mjs doctorIf you keep the bundle on PATH, symlink it:
ln -sf /Applications/DemoRecorder.app/Contents/Resources/app/bin/demo.mjs \
/usr/local/bin/right-demonode bin/demo.mjs doctor # sanity-check the host
node bin/demo.mjs inspect \
--scenario <scenario.json> \
--foreground-ok # dry-walk the script
node bin/demo.mjs run \
--scenario <scenario.json> \
--foreground-ok --desktop-ok # capture + render
node bin/demo.mjs render \
--session .cache/demos/<session> \
--timeline .cache/demos/<session>/timeline-edited.json # re-renderThe recorder writes an editable session bundle under
.cache/demos/<name>-<stamp>/:
scenario.json— the input that drove the takeevents.ndjson— append-only event timeline (clicks, scrolls, focus, …)timeline.json— derived camera/cursor keyframesstyle.json— resolved style contract for the rendererclean.mov— privacy-scrubbed raw capturefinal.mp4— the presentation deliverable
Re-render after editing the timeline without taking the app again.
A scripted scenario has to name every control it clicks, and a wrong
name does not fail loudly: the click still fires, the step still
reports ok: true, and the take is quietly wrong. Follow mode removes
the targeting problem by letting a human drive.
node bin/demo.mjs follow --scenario <file> --seconds 90 \
--foreground-ok --desktop-okIt prepares the clean desktop, starts capture, then stays out of the
way for --seconds. Press Escape to end early. Real clicks are
read from a CGEventTap and emitted in the same shape a scripted run
produces, so the camera work is identical — spring zoom onto each
click, pans between them, wide when idle.
Two differences from a scripted run, both deliberate:
- The real cursor is kept unless you ask for a synthetic one
(
capture.cursor.style: "synthetic"). The tapped path is de-jittered with a One Euro filter (Casiez 2012) before it reaches the renderer, because a human hand shakes in a way a generated path does not. - Clicks are thinned, not rejected. A human may click any number of times, so the focus list is evenly reduced to the camera cap (first and last always kept) rather than failing at compositing.
Three treatments are on by default:
- Spring camera. Every pan and zoom uses a physical spring:
zoom is critically damped (scale never overshoots), pans carry
capture.camera.bounce(default0.15, Apple-style settle).capture.camera.easing: "cosine"restores the old curve. - Synthetic cursor (
capture.cursor). The real cursor is hidden at capture (ScreenCaptureKit showsCursor=false) while the driver still physically moves and clicks it. The native renderer redraws the exported macOS arrow from the event timeline, hotspot-anchored with a press pulse andscale(default 1.5×) for presentation size. - Motion blur. Activation ramps continuously with pan and zoom
velocity; the shutter window and sample count (1–8) both grow with
velocity, so fast pans read as cinematic shutter drag instead of a
fixed four-sample blur snapping on at a threshold. Disable with
capture.camera.motionBlur: false.
Window scope additionally gets framing (capture.frame, default
on): the app is composited at exact fitted dimensions with rounded
corners (radius, default 14 logical px, rendered via a single-frame
antialiased mask) and a soft drop shadow (shadow: false to disable)
over the blurred backdrop. Display scope rejects capture.frame — the
full desktop is its own frame.
capture.scope controls framing:
"window"is the safe default and records only the target app window."display"records the full primary desktop for a more cinematic app-in-context look. It requires the separate--desktop-okacknowledgement because wallpaper, menu-bar items, notifications, and transient system overlays can appear.
capture.quality controls delivery:
"high"is the 1920×1080 H.264 default."maximum"preserves the Retina ScreenCaptureKit source, renders 3840×2160 at 60 fps with Lanczos scaling, and encodes H.264 with the slow preset at CRF 10. This is the preferred presentation master; use a separate ProRes transcode only when a downstream editor explicitly requires it.
For "display", clean-desktop preparation is automatic and mandatory.
Before capture, the runner hides every currently visible regular app
except the allowlisted target, brings the target window forward, and
records the exact hidden process IDs. It restores those same apps
after capture, including when recording or app control fails. Some
apps cannot be hidden: the runner moves their visible windows beyond
the primary display first, falls back to minimizing only when movement
is unavailable, and restores the exact window state afterward. It
aborts rather than record if any unrelated visible window cannot be
cleared.
The privacy-sensitive raw capture is deleted automatically after the
scrub stage, including on a scrub or render failure. It is never
written into the session bundle: it lives in a right-demo-raw
directory under the system temp dir, and every run sweeps that
directory before capturing. A finally cannot run when the process
is killed — keeping the file out of the bundle and sweeping on start
is what makes the rule hold under SIGKILL.
Display and window recording use Apple's ScreenCaptureKit through the native helper at the target backing scale (2880×1800 on the current 2× primary display), rather than downscaling to logical points. The helper reports readiness before scripted actions begin, records the real cursor with click circles disabled, and adds one-pixel/no-click start and end anchors for display scope so static pre-roll and post-roll retain their wall-clock duration. The runner probes both source and final durations and rejects truncated files.
The native helper accepts input only from a validated scenario file, allowlists one bundle identifier per run, refuses input when the target app loses focus, and constrains every pointer action to the resolved target window.
The native per-frame renderer evaluates camera and cursor state
directly instead of encoding motion as nested FFmpeg expressions. Add
a camera-only focus action when the visible result appears somewhere
other than the clicked control. Configure the movement under
capture.camera; set mode to static when a shot should remain
wide.
{
"action": "focus",
"target": { "fallback": { "x": 0.33, "y": 0.38 } },
"zoom": 1.22,
"holdMs": 1400
}focus resolves the same semantic identifier, label, role, or
bounded window-relative fallback as a click, but it sends no mouse or
keyboard input. zoom is optional; when omitted, the compositor fits
the resolved element rectangle with margin and caps it at
capture.camera.zoom.
Each of these cost a full recording before it was understood. Do not "optimise" them back.
- The old 20-focus and 96-keyframe ceilings are retired. They were measured limits of the nested FFmpeg expression compositor, not motion-quality rules. The native renderer has a generous 60-move validation bound; the director coalesces and paces dense action streams.
- Escape aborts, via an event tap — never by polling key state.
CGEventSource.keyStatereports whether a key is held at that instant, so a tap is already released by the next poll, and synthesized presses never register at all. Both were verified failing. - Label matching is a case-insensitive
contains."ViewRight"matched 13 elements including the window titleviewright-demo.md, so a click aimed at the H1 landed in the title bar. Prefer long, unique body text. AXHeadingrects span the full column width; the glyphs do not. Clicking the centre lands in empty space, which a rich-text editor reads as a block selection rather than a caret. Target theAXStaticTextinside it.- The accessibility tree populates asynchronously. It can hold 2 elements one moment and 43 the next. Resolution retries before falling back, and a named target that cannot be resolved now aborts the run instead of guessing at a coordinate.
- A raw display capture is deleted even when compositing fails. That is a privacy rule, not a bug — so validate anything that can fail at compositing before the take instead.
- macOS only. There is no Windows or iOS backend. The native driver refuses to build or run anywhere else.
- No audio. Capture sets
capturesAudio = falseand the encode passes-an, so takes are silent by design. Voiceover is a separate pass in another tool; there is no inline mic recording. - Display scope is primary-display only. Window scope uses ScreenCaptureKit's independent-window filter at the target display's backing scale and supports secondary displays and negative bounds.
- Accessibility and Screen Recording must be granted once, by hand.
The driver is signed with the shared Developer ID identity, which
keeps its designated requirement stable across rebuilds, so a
grant survives future builds. On a machine without that certificate
the build falls back to ad-hoc signing, where every rebuild
changes the binary's identity and silently revokes both
permissions.
bin/demo.mjs doctorreports the current state and prints the exact re-grant step innextSteps. - No detection of an active human. The recorder takes over the pointer and hides applications without checking whether someone is mid-task. Interacting during a take changes window state underneath the preparation snapshot and can make restore partially fail. Stop working before starting a run.
src/ # recorder pipeline, director, scenarios, motion, audio, render
test/ # node --test suite (unit + contract)
native/ # Swift native helper (CGEvent tap, ScreenCaptureKit, AX)
menu/ # Swift menu-bar controller (NSStatusItem)
schemas/ # v2 JSON schemas for scenario / timeline / style / session / etc
scripts/ # build-native.mjs, render-fixtures.mjs, build-app.mjs, build-dmg.mjs, notarize.mjs
fixtures/ # baseline outputs + schema validity fixtures
bin/ # built binaries (gitignored); bin/demo.mjs ships in the .app
dist/ # built .app bundle and DMG (gitignored)
package.json # @damned/demo-recorder
The build pulls icon assets from local-only paths outside the repo (icons are large binaries that should be regenerated from a separate design tool rather than committed):
- App bundle icon:
/Volumes/D/Downloads/DemoRecorder.iconset(iconutil-compatible directory of standard macOS sizes).build-app.mjsconverts it toAppIcon.icnsand drops it intoContents/Resources/. TheCFBundleIconFile+CFBundleIconNamekeys inInfo.plistreference it. - Menu-bar template glyph:
/Volumes/D/Downloads/record-template-pack/black/(22/44/66 PNGs).build-app.mjscopies them intoContents/Resources/record-template{,.png,@2x.png,@3x.png}; the Swift binary loads viaNSImage(named:)and setsisTemplate = trueso macOS recolors for light/dark menu bars.
If either path is missing, the build still succeeds — the bundle ships
with the generic executable icon and the menu-bar binary falls back to
the SF Symbol record.circle.
The DMG shipped in GitHub Releases is fully code-signed and notarized — Apple has accepted the ticket, so Gatekeeper lets the installer through without prompting.
node scripts/build-app.mjs # produces dist/DemoRecorder.app (signed, hardened runtime, timestamped)
node scripts/build-dmg.mjs # produces dist/DemoRecorder-<version>.dmg (notarized, ticket stapled)build-dmg.mjs calls scripts/notarize.mjs, which resolves Apple
credentials in the same order as the rightkit release helper:
APPLE_API_KEY_PATH/APPLE_API_KEY/APPLE_API_ISSUER(App Store Connect API key — preferred for CI)--keychain-profile apple-dev-notary(login keychain profile)
When neither is set, the build still produces a signed .app + DMG but
skips notarization — useful for local iteration, but Gatekeeper will
block the install until the credentials are configured. The .app itself
is intentionally not submitted directly: notarytool + --force
currently SIGBUS-crashes during pre-submission on macOS 26.x; the DMG
submission path is the supported Apple workflow and Gatekeeper honors
the .app inside the notarized DMG transparently.
The DMG mounts to a Demo Recorder <version> volume that contains the
.app, a /Applications symlink for drag-to-install, and a short README.
MIT — see LICENSE.
- Product overview — what this is and does (generated, code-grounded)
- Architecture — components, flows, interfaces (generated, code-grounded)