Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DemoRight

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.

Quickstart (human, no agent)

Install the packaged app (recommended)

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.

Build from source (for contributors)

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')/DemoRecorder

You 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>.dmg

Once 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.

Headless from the installed bundle

The same CLI shipped inside the .app works from any folder:

/Applications/DemoRecorder.app/Contents/Resources/app/bin/demo.mjs doctor

If you keep the bundle on PATH, symlink it:

ln -sf /Applications/DemoRecorder.app/Contents/Resources/app/bin/demo.mjs \
  /usr/local/bin/right-demo

Quickstart (agent)

node 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-render

The recorder writes an editable session bundle under .cache/demos/<name>-<stamp>/:

  • scenario.json — the input that drove the take
  • events.ndjson — append-only event timeline (clicks, scrolls, focus, …)
  • timeline.json — derived camera/cursor keyframes
  • style.json — resolved style contract for the renderer
  • clean.mov — privacy-scrubbed raw capture
  • final.mp4 — the presentation deliverable

Re-render after editing the timeline without taking the app again.

Follow mode — you drive, it records

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-ok

It 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.

Smart motion (Screen Studio-class polish, baked in)

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 (default 0.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 and scale (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 and quality

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-ok acknowledgement 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.

Camera actions

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.

Hard-won constraints

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.keyState reports 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 title viewright-demo.md, so a click aimed at the H1 landed in the title bar. Prefer long, unique body text.
  • AXHeading rects 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 the AXStaticText inside 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.

Limitations

  • macOS only. There is no Windows or iOS backend. The native driver refuses to build or run anywhere else.
  • No audio. Capture sets capturesAudio = false and 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 doctor reports the current state and prints the exact re-grant step in nextSteps.
  • 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.

Repository layout

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

Icons

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.mjs converts it to AppIcon.icns and drops it into Contents/Resources/. The CFBundleIconFile + CFBundleIconName keys in Info.plist reference it.
  • Menu-bar template glyph: /Volumes/D/Downloads/record-template-pack/black/ (22/44/66 PNGs). build-app.mjs copies them into Contents/Resources/record-template{,.png,@2x.png,@3x.png}; the Swift binary loads via NSImage(named:) and sets isTemplate = true so 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.

Releasing

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:

  1. APPLE_API_KEY_PATH / APPLE_API_KEY / APPLE_API_ISSUER (App Store Connect API key — preferred for CI)
  2. --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.

License

MIT — see LICENSE.

Repository truth docs

  • Product overview — what this is and does (generated, code-grounded)
  • Architecture — components, flows, interfaces (generated, code-grounded)

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages