# I recorded my app's demo videos without Screen Recording

> How NotchStar records repeatable SwiftUI demo clips from an offscreen NSPanel, with synthetic events, safe data and measured failure lessons.

Canonical: https://www.notchstar.com/blog/demo-videos-without-screen-recording/
Last updated: 2026-11-01

*A real SwiftUI view, driven with synthetic input and captured frame by frame in an offscreen panel.*

Published 2026-11-01.

## TL;DR

- NotchStar records its real SwiftUI interface inside an `NSPanel` parked at x = `-30000`, so it never reads the visible desktop and does not need Screen Recording permission.
- A script sends synthetic mouse and keyboard events to registered control frames. The recorder captures about 30 frames per second, composites a cursor, and writes H.264 video.
- Demo mode supplies safe sample music, calendar, clipboard and disk data. It never reads the owner's real versions of those things.
- This makes product clips repeatable. It does not replace testing on a real display with real input and permissions.

## Contents

1. Why I stopped recording the desktop
2. The pipeline
3. Rendering the real view
4. Driving controls
5. Cursor and screen composition
6. Safe sample data
7. Five failures that shaped the recorder
8. Encoding and verification
9. Try it yourself
10. Cheat sheet

## Why not record the desktop

A normal product clip starts with Screen Recording permission. Then the desktop has to be cleaned, notifications silenced, sample files prepared, and the pointer moved at the right speed. A tiny mistimed hover can mean another take. A later text change means doing the performance again.

That is manageable for one launch video. It is awkward for a site with many short clips, where every tool needs the same window position, timing, sample data and pointer style. The source becomes a human performance rather than a repeatable artifact.

NotchStar already had deterministic interface tests. The useful question was whether the same real view could be rendered into frames while a script drove it. The answer was yes, with an important boundary: the recorder renders an application surface, not the user's display.

![A demo script drives an offscreen panel, which renders SwiftUI frames that receive a cursor and H.264 encoding](https://www.notchstar.com/blog/media/04-demo-videos-without-screen-recording/pipeline.svg)

*The recording pipeline has no desktop capture stage.*
Video: [One finished output from the recorder, using sample data rather than the owner's desktop or accounts.](https://www.notchstar.com/blog/media/07-mac-notch-complete-guide/hero.mp4)
## The pipeline

The recorder creates the actual `NotchRootView` used by the app. It places that view in an AppKit hosting view, then puts the hosting view in a borderless panel far outside the visible desktop. A script finds named targets, creates events, waits for interface state, and asks the view for a bitmap at each frame boundary.

| Stage | Input | Output |
|---|---|---|
| Demo mode | A fixed scenario | Safe observable models |
| Script | Named targets and timings | Mouse and keyboard events |
| Offscreen panel | Real SwiftUI hierarchy | Laid out AppKit view |
| Frame capture | The hosting view | Bitmap frames |
| Composition | Frame and pointer state | Finished video frame |
| Writer | Timed pixel buffers | H.264 movie |

This arrangement keeps the visual source close to production. A button is not redrawn by a separate animation project. It is the app's button, with its real hover and pressed state. When the interface changes, the next recording exposes that change.

It also makes retakes cheap. A recording is a test command with a predictable duration. If a clip fails, the script can report which target never appeared instead of leaving a person to compare two performances by eye.

## Rendering the real SwiftUI view

An `NSHostingView` bridges the SwiftUI hierarchy into AppKit. The recorder keeps one hosting view alive for the clip, gives it the target size, and asks it to lay out before capture. Recreating the hierarchy for every frame would reset state and make animation impossible.

The host lives in an `NSPanel` whose origin is far to the left:

```swift
let panel = NSPanel(
    contentRect: CGRect(x: -30_000, y: 0, width: width, height: height),
    styleMask: [.borderless],
    backing: .buffered,
    defer: false
)
panel.contentView = hostingView
panel.orderFrontRegardless()
```

The panel is present enough for AppKit and SwiftUI to perform layout, hover handling and animation. It is not on a user's visible display. Each capture allocates or reuses a bitmap representation, caches the view's display into it, and moves those pixels into the video pipeline.

```swift
hostingView.layoutSubtreeIfNeeded()
guard let bitmap = hostingView.bitmapImageRepForCachingDisplay(
    in: hostingView.bounds
) else { throw CaptureError.noBitmap }
hostingView.cacheDisplay(in: hostingView.bounds, to: bitmap)
```

The current recorder aims for about 30 frames per second. A capture takes about 23 milliseconds on the development Mac, leaving limited time for layout, composition and encoding inside a roughly 33 millisecond frame. That number is a measured local result, not a promise for another Mac. The writer uses presentation timestamps so playback timing remains coherent even when an individual frame costs more.

### Frame time is a budget, not a sleep interval

Sleeping for 33 milliseconds after each capture would make every expensive frame extend the movie. A ten second script could become twelve seconds while its video timestamps still claim another duration. The recorder instead derives each target presentation time from the recording clock. Work begins for the next frame, and the writer receives the time that frame represents.

When capture falls behind, there are only honest choices: accept fewer distinct frames, reduce the canvas cost, or slow the scripted action. Repeating the last frame can preserve stream cadence, but it cannot restore motion that was never rendered. This is why frame cost is logged during development rather than hidden by the encoder.

Animation state also needs a stable clock. A view that reads wall time can change between a scripted assertion and the frame captured after it. Demo state uses controlled values where practical, while the recording loop advances at known boundaries. The result is not bit identical across every machine, because framework rendering can differ, but the sequence and data remain repeatable.

> **Note:** An offscreen panel can exercise layout and scripted interactions, but it cannot prove what happens on a real notched display, under another display scale, or with the user's actual input devices. Those remain separate tests.
## Driving controls with synthetic events

A script needs stable locations. Raw coordinates are brittle because a label width or panel size can move everything after it. During demo mode, controls register their current frames with a name through `.demoTarget("name")`. Production behavior does not need those names.

The script asks the registry for a target, converts its center into panel coordinates, moves the synthetic cursor there, and emits AppKit mouse events. Keyboard steps send key down and key up events to the same panel. Delays are expressed as script steps rather than calls scattered through a view.

```swift
let point = try await targets.center(of: "clipboard.search")
postMouseMoved(to: point)
postMouseDown(at: point)
postMouseUp(at: point)
typeText("invoice")
```

Hover deserves explicit movement. Jumping directly to a click can skip the frames in which SwiftUI enters its hover state. The recorder interpolates the cursor over time, dispatches movement events, and captures the transition. Clicks can add a short ripple during composition so a silent video still communicates the action.

The registry also gives failures a useful name. Instead of clicking an outdated coordinate and recording the wrong control, a script can stop with `clipboard.search not found`. That turns an interface change into a repairable test failure.

Before a recording starts, the script checks the initial target set. During the run, it waits for targets created by navigation. The waiting is bounded. A missing view should fail the clip rather than produce twenty seconds of an idle cursor.

## Making the frame look like a Mac demo

The raw capture is only the panel. The final frame can place it over a MacBook sized canvas, add the menu bar and wallpaper used for the recording, and draw a pointer on top. Composition is deterministic because every layer is an input file or a value controlled by the script.

The canvas models a 1512 by 982 point MacBook screen at its intended scale. The pointer has one coordinate system from script through capture. A click ripple is a brief drawn circle, not a second cursor recorded by the operating system.

Wallpaper conversion happens before the frame loop. Images from modern Macs can use color spaces and formats that are expensive to interpret repeatedly. Converting once produces a frame ready background. Doing that work inside every frame was one of the earliest performance mistakes.

## Safe demo data is part of the architecture

Repeatability would be useless if a site clip exposed a real note, copied password, calendar title or album. Demo mode replaces external data at the model boundary.

The Home card receives a sample track, events and reminders. Clipboard uses fixed text and image entries. Disk views use a sample file tree and timed scan results. The mirror uses a generated placeholder rather than opening the camera. File sharing and destructive actions do not touch real files.

That separation is more than redaction after capture. The private data never enters the rendered hierarchy. A forgotten layer or a debug frame therefore cannot reveal it.

- [x] Fixed track and artwork
- [x] Fixed calendar and reminder names
- [x] Fixed clipboard entries
- [x] Generated camera placeholder
- [x] Simulated disk results and progress
- [x] No real AirDrop or file removal

The live app and demo mode still share the views. Only the source models and action handlers differ. This catches layout regressions without granting the recorder access to the owner's environment.

### What this method cannot see

The recorder cannot tell whether another window covers the notch, whether a real pointer crossing displays triggers the same hover boundary, or whether a permission prompt appears in front of the app. It does not exercise camera hardware, an actual clipboard, AirDrop discovery, or the user's calendar database. Color on a different physical display may also look different from the encoded frame.

Those gaps define the rest of the test plan. Unit tests cover models and transformations. Interface tests drive the real app with the real mouse and keyboard. A human pass checks the notch on a physical display, permissions, animation and sound. The recorder adds repeatable product media and a focused rendering check; it does not collapse all of those layers into one.

This boundary is especially important when a demo looks better than the running app. The video is then evidence of the offscreen configuration only. The right response is to reproduce the difference on the real display, not to treat the recorded frame as proof that production is correct.

## Five failures that shaped the recorder

### 1. Wallpaper work took minutes

One run spent 327 seconds processing an `NSImage`, and the first usable frame took about 70 seconds. Those numbers were observed once in logs, not a benchmark. The cause was expensive image conversion happening on the capture path. Preparing one bitmap before recording removed that repeated cost.

The lesson is to measure the first frame separately. An average frame time can hide a huge setup penalty that makes every clip feel broken.

### 2. Main actor work blocked progress

SwiftUI and AppKit view operations belong on the main actor. Encoding and file work do not. An early version mixed long asynchronous work into the same actor, so waits and capture could prevent the interface from advancing.

The fix was to keep view mutation and display capture on the main actor, then hand immutable pixel data to the writer. Actor boundaries now follow framework ownership rather than convenience.

> **Note:** Measure setup time, first frame time and steady frame time separately. Each points to a different class of fault.
### 3. ProgressView did not animate offscreen

Some system controls depend on a normal display refresh path. An indeterminate `ProgressView` appeared frozen in the parked panel even while application state changed. Repeated capture alone did not make that system animation reliable.

For the demo state, a simple explicit animation driven by the recording clock produced frames the recorder could control. This is a reminder that offscreen rendering does not reproduce every compositor behavior.

### 4. The accessibility tree was empty

Accessibility seemed like a natural way to find controls, but the offscreen hierarchy did not expose a useful tree in this setup. Relying on it made target lookup fail before a script began.

The `.demoTarget` registry became the smaller contract. It records only the controls a demo needs and only while demo mode is active. It does not require Accessibility permission and does not pretend to test assistive technology behavior.

### 5. A long hero contained mostly waiting

The first hero clip ran for 43 seconds and included about 12 seconds where nothing important happened. Every action was correct, but the result felt slow. Determinism made this easy to fix: shorten named waits, increase pointer speed where comprehension remains clear, and remove pauses that do not explain state.

The script is therefore both a test and an edit decision. Timing belongs beside the action it supports, where it can be reviewed rather than trimmed by feel after recording.

| Symptom | Root cause | Durable fix |
|---|---|---|
| Very slow first frame | Image conversion in capture path | Prepare the background once |
| Interface does not advance | Too much work on main actor | Separate view work from encoding |
| Spinner looks frozen | Display driven system animation | Use an explicit demo clock animation |
| Script cannot find controls | Empty offscreen accessibility tree | Register named demo target frames |
| Clip feels slow | Script waits outlast meaning | Review timing beside each action |

## Encoding and verification

The recorder writes H.264 for broad browser support. Site delivery can then create the exact dimensions and poster frame needed by the page. The source movie remains the evidence artifact, while web versions are derived.

```bash
ffmpeg -i source.mp4 -vf "scale=1280:-2" \
  -c:v libx264 -crf 22 -preset slow -pix_fmt yuv420p \
  -movflags +faststart -an demo.mp4
```

Verification is visual and mechanical. Check duration with `ffprobe`, extract a contact sheet, inspect the first and last frames, and watch the final file at normal speed. Also rerun the same script and compare target timing. A file existing is not proof that the intended control was clicked.

## A wrong turn: treating the recorder as a second app

It would be easy to recreate each screen with drawing commands tuned for marketing. That gives perfect control but creates a second interface that can drift from the product. It may look cleaner precisely because it no longer exposes real layout faults.

The recorder instead accepts a little framework friction to keep the product view as the source. Demo specific code supplies data, target names and a clock. It does not supply a duplicate card or tab bar.

## Try it yourself

Start with one small SwiftUI view, not a whole application:

1. Put the view in one persistent `NSHostingView`.
2. Place it in a borderless `NSPanel` outside the visible desktop.
3. Call layout, then capture with `cacheDisplay`.
4. Save ten numbered PNG frames before adding video.
5. Add one named target and one synthetic click.
6. Draw a pointer into each output frame.
7. Move encoding off the main actor.
8. Add a deadline for every target wait.
9. Run once with deliberately missing data.
10. Watch the result and test the same view on a real display.

Keep the first experiment disposable. The hard questions are whether your controls respond offscreen, whether animations advance, and whether the capture fits inside the frame budget on your development Mac. Only then is a general script format worth building.

## Cheat sheet

| Goal | Rule |
|---|---|
| Product fidelity | Render the real view hierarchy |
| Repeatability | Script named targets, not raw coordinates |
| Privacy | Inject sample models before rendering |
| Stable timing | Use a recording clock and bounded waits |
| Smooth output | Measure first frame and steady frame cost |
| Honest testing | Keep separate real display and permission tests |

The finished clips appear throughout the [complete guide to the Mac notch](https://www.notchstar.com/blog/mac-notch-complete-guide/). You can also [download NotchStar](https://www.notchstar.com/download/) to compare a recorded flow with the real app on your Mac.

## More posts

- [How a notch app reads Now Playing on macOS 15.4 and later](https://www.notchstar.com/blog/how-notch-apps-read-now-playing-deep-dive/)
- [Shipping an indie Mac app: signing and notarization](https://www.notchstar.com/blog/signing-and-notarizing-an-indie-mac-app/)
