Skip to content

Commit 2d1cd2f

Browse files
feat(cli): --windows — clean multi-window capture with slide transitions
Window mode captures one window cleanly, but a real demo moves between a terminal, an editor, a browser. `record --windows "a,b,c"` captures every matched window continuously in parallel — ScreenCaptureKit window capture, so each feed stays clean even while occluded — plus the focus timeline. `export --follow-windows` then composites one video that switches to whichever window had focus, the incoming window sliding in from the side it sat on (450 ms eased slide; direction follows screen geometry). - Swift helper: new optional `multiWindows` request mode running N SCStreams + writers in one process. This is load-bearing: a second helper *process* interrupts the first stream (SCStreamErrorDomain -3805, empirically verified), while in-process streams coexist. - Focus helper now reports the CGWindowID so focus samples bind exactly to captured windows. - electron/cli/multiWindowRecorder.ts: match windows by title, drive the multi-capture helper + focus sampler, write a `<primary>.multiwindow.json` manifest (windows + focus timeline). - src/lib/windowSwitch/: manifest contracts and a pure, unit-tested switch-timeline builder (1.2 s dwell, flicker collapse, unrecorded windows keep the current screen, geometry-based slide direction). - src/cli/multiWindowCompositor.ts: offline lockstep decode of all window videos (frame channels with backpressure) → slide compositing → VideoEncoder/VideoMuxer intermediate, which then flows through the normal export pipeline (wallpaper, padding, annotations, --audio). macOS only; audio via export --audio for now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ceTJjoPJYa5BdmXZdDc5J
1 parent 31d7c41 commit 2d1cd2f

14 files changed

Lines changed: 1124 additions & 3 deletions

File tree

docs/cli.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,32 @@ Platform notes:
7474
which may show a system picker dialog and requires a desktop session
7575
(headless/SSH sessions without a portal cannot record).
7676

77-
### Multi-window demos: `--follow-windows`
77+
### Multi-window demos: `--windows` (clean capture + slide transitions)
78+
79+
The flagship mode for product demos. Every listed window is captured
80+
**continuously and cleanly in parallel** (ScreenCaptureKit window capture — no
81+
desktop background, no overlap, works even while occluded), together with a
82+
focus timeline. The export step then composites one video that *switches to
83+
whichever window you focused*, with the incoming window sliding in from the
84+
side it sat on:
85+
86+
```bash
87+
openscreen record --windows "Terminal,Code,Chrome" --project demo.openscreen
88+
# ...work normally across the three windows...
89+
openscreen export demo.openscreen -o demo.mp4 --follow-windows
90+
```
91+
92+
- Focus held ≥1.2 s switches the screen; brief flickers are ignored; focusing
93+
an unlisted window keeps the current one on screen.
94+
- Transitions are 450 ms eased slides; the direction follows screen geometry
95+
(a window to the right slides in from the right).
96+
- All windows must live in one capture process: concurrent helper *processes*
97+
interrupt each other's streams (SCStreamErrorDomain -3805), so the Swift
98+
helper gained a `multiWindows` mode running N streams in-process.
99+
- macOS only; no audio yet (add narration with `export --audio`). Composes
100+
with wallpaper/padding/annotations and `--audio` like any export.
101+
102+
### Display recording: `--follow-windows` zoom mode
78103

79104
One window is rarely enough for a product demo — a real workflow moves between
80105
a terminal, an editor, a browser. `--follow-windows` records the whole display

electron/cli/args.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ Record options (recording is saved into the app's recordings directory):
8484
Write a ready-to-export project file when done
8585
--follow-windows Also record which window has focus (macOS); pair with
8686
"export --follow-windows" for automatic window switching
87+
--windows <t1,t2,...> Multi-window capture (macOS): record every matched window
88+
in parallel; "export --follow-windows" switches between
89+
them with slide transitions as focus changes
8790
--json NDJSON events on stdout
8891
8992
Stopping a recording: send SIGINT/SIGTERM, type "stop" + Enter on stdin,
@@ -297,6 +300,7 @@ function parseRecord(args: string[], cwd: string): CliCommand {
297300
durationMs: null,
298301
projectOut: null,
299302
followWindows: false,
303+
windows: null,
300304
};
301305

302306
for (let i = 0; i < args.length; i++) {
@@ -362,6 +366,21 @@ function parseRecord(args: string[], cwd: string): CliCommand {
362366
case "--follow-windows":
363367
request.followWindows = true;
364368
break;
369+
case "--windows": {
370+
const [value, next] = takeValue(args, i, arg);
371+
const titles = value
372+
.split(",")
373+
.map((title) => title.trim())
374+
.filter(Boolean);
375+
if (titles.length < 2) {
376+
throw new Error(
377+
'--windows needs at least two comma-separated titles, e.g. "Terminal,Chrome"',
378+
);
379+
}
380+
request.windows = titles;
381+
i = next;
382+
break;
383+
}
365384
case "--json":
366385
request.json = true;
367386
break;

electron/cli/cliMain.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { getSelectedDesktopSource, registerIpcHandlers } from "../ipc/handlers";
1717
import { ASSET_BASE_URL_ARG } from "../windows";
1818
import { CLI_USAGE, type CliCommand } from "./args";
1919
import { FocusSampler, isFocusSamplingAvailable, resolveRecordedDisplayId } from "./focusSampler";
20+
import { startMultiWindowRecording } from "./multiWindowRecorder";
2021

2122
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2223
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
@@ -314,6 +315,69 @@ async function runPackCommand(projectPath: string, outDir: string, json: boolean
314315
return 0;
315316
}
316317

318+
async function runMultiWindowRecord(
319+
command: import("../../src/lib/cliContracts").CliRecordRequest & { json?: boolean },
320+
output: CliOutput,
321+
): Promise<void> {
322+
const titles = command.windows ?? [];
323+
output.info(`Starting multi-window capture (${titles.length} windows)…`);
324+
325+
let handle: Awaited<ReturnType<typeof startMultiWindowRecording>>;
326+
try {
327+
handle = await startMultiWindowRecording(titles, command.displayIndex);
328+
await handle.allStarted;
329+
} catch (error) {
330+
output.error(error instanceof Error ? error.message : String(error));
331+
app.exit(1);
332+
return;
333+
}
334+
for (const summary of handle.windowSummaries) {
335+
output.info(`Recording window: ${summary.title}`);
336+
}
337+
output.info("Recording started (all windows)");
338+
output.event("recording-started", { windows: handle.windowSummaries.map((w) => w.title) });
339+
340+
let stopping = false;
341+
const finish = async (reason: string) => {
342+
if (stopping) return;
343+
stopping = true;
344+
output.info(`Stopping recording (${reason})…`);
345+
output.event("stopping", { reason });
346+
try {
347+
const result = await handle.stop();
348+
if (command.projectOut) {
349+
await writeProjectFile(command.projectOut, {
350+
version: 2,
351+
media: { screenVideoPath: result.primaryVideoPath, cursorCaptureMode: "system" },
352+
editor: {},
353+
});
354+
}
355+
output.info(`Recorded ${result.videoPaths.length} windows → ${result.primaryVideoPath}`);
356+
output.info(`Manifest → ${result.manifestPath}`);
357+
if (command.projectOut) output.info(`Project → ${command.projectOut}`);
358+
output.event("done", {
359+
success: true,
360+
screenVideoPath: result.primaryVideoPath,
361+
videoPaths: result.videoPaths,
362+
multiWindowManifestPath: result.manifestPath,
363+
durationMs: result.durationMs,
364+
focusSamples: result.focusSampleCount,
365+
...(command.projectOut ? { projectPath: command.projectOut } : {}),
366+
});
367+
app.exit(0);
368+
} catch (error) {
369+
output.error(error instanceof Error ? error.message : String(error));
370+
output.event("done", { success: false });
371+
app.exit(1);
372+
}
373+
};
374+
375+
setupRecordStopSignals((reason) => void finish(reason));
376+
if (command.durationMs) {
377+
setTimeout(() => void finish(`duration ${command.durationMs}ms`), command.durationMs);
378+
}
379+
}
380+
317381
async function runInfoCommand(projectPath: string, json: boolean): Promise<number> {
318382
const raw = await fs.readFile(projectPath, "utf8");
319383
const data = JSON.parse(raw) as {
@@ -448,6 +512,11 @@ export function runCli(command: CliCommand): void {
448512
return;
449513
}
450514

515+
if (command.kind === "record" && command.windows) {
516+
await runMultiWindowRecord(command, output);
517+
return;
518+
}
519+
451520
if (command.kind === "pack") {
452521
const code = await runPackCommand(
453522
command.projectPath,

electron/cli/focusSampler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const SAMPLE_INTERVAL_MS = 200;
2222
interface RawHelperSample {
2323
type?: string;
2424
timestampMs?: number;
25+
windowNumber?: number;
2526
appName?: string;
2627
windowTitle?: string;
2728
x?: number;
@@ -155,6 +156,7 @@ export class FocusSampler {
155156
}
156157
samples.push({
157158
timeMs: Math.max(0, raw.timestampMs - startedAt),
159+
windowNumber: raw.windowNumber ?? 0,
158160
appName: raw.appName ?? "",
159161
windowTitle: raw.windowTitle ?? "",
160162
x: raw.x ?? 0,

0 commit comments

Comments
 (0)