@openvidstudio/capture 0.0.0-stage → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ import type { Page } from "playwright";
2
+ /**
3
+ * Page-side helpers for splitting a capture into layers: a background with chosen elements
4
+ * gone, and one transparent image per element.
5
+ *
6
+ * Elements are isolated with `visibility`, never `display`, so nothing moves: every other box
7
+ * keeps its layout, and each element stays exactly where it was in the full screenshot. CSS
8
+ * visibility lets a child re-show itself inside a hidden parent, which is what makes "hide
9
+ * everything except this subtree" work without touching the subtree's own look. The shots
10
+ * themselves are ordinary full-viewport screenshots cropped afterwards, not Playwright's
11
+ * element-scoped ones, which CAPTURE.md explains re-measure and auto-scroll at shot time.
12
+ */
13
+ /** One element a layer selector matched, in viewport CSS pixels, with the id used to find it again. */
14
+ export interface LayerElement {
15
+ id: string;
16
+ x: number;
17
+ y: number;
18
+ width: number;
19
+ height: number;
20
+ }
21
+ /**
22
+ * Tags every element a selector matches with a stable id and measures it. `group` keeps ids from
23
+ * different selectors apart. Throws a clear error naming the selector when it is not valid CSS;
24
+ * an empty result is the caller's to report, since only it knows what the layer was for.
25
+ */
26
+ export declare function tagLayerElements(page: Page, selector: string, group: number): Promise<LayerElement[]>;
27
+ /**
28
+ * Freezes every CSS and Web Animations animation where it stands, so the background, the layers
29
+ * and the full screenshot are all the same instant of a page that never stops moving.
30
+ */
31
+ export declare function pauseAnimations(page: Page): Promise<void>;
32
+ /**
33
+ * Leaves only one tagged element (and its subtree) visible, on a transparent page, ready for a
34
+ * screenshot with omitBackground. Everything else keeps its box, so the element does not move.
35
+ */
36
+ export declare function showOnlyLayer(page: Page, id: string): Promise<void>;
37
+ /** Hides every tagged element, leaving the page as it is otherwise: the background layer. */
38
+ export declare function hideAllLayers(page: Page): Promise<void>;
39
+ /**
40
+ * Undoes showOnlyLayer and hideAllLayers, so the page is as it was between shots. Transitions
41
+ * stay off (the isolate attribute is kept, with the page background back), because turning
42
+ * them on in the same step as un-hiding would start a fade-in on every element just restored.
43
+ */
44
+ export declare function restoreLayers(page: Page): Promise<void>;
package/dist/layers.js ADDED
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tagLayerElements = tagLayerElements;
4
+ exports.pauseAnimations = pauseAnimations;
5
+ exports.showOnlyLayer = showOnlyLayer;
6
+ exports.hideAllLayers = hideAllLayers;
7
+ exports.restoreLayers = restoreLayers;
8
+ const LAYER_ATTR = "data-ovs-layer";
9
+ const HIDE_ATTR = "data-ovs-hide";
10
+ const SHOW_ATTR = "data-ovs-show";
11
+ const ISOLATE_ATTR = "data-ovs-isolate";
12
+ const STYLE_ID = "__ovs-layers-style";
13
+ /**
14
+ * Tags every element a selector matches with a stable id and measures it. `group` keeps ids from
15
+ * different selectors apart. Throws a clear error naming the selector when it is not valid CSS;
16
+ * an empty result is the caller's to report, since only it knows what the layer was for.
17
+ */
18
+ async function tagLayerElements(page, selector, group) {
19
+ try {
20
+ return await page.evaluate(({ selector: sel, group: g, attr }) => {
21
+ const found = Array.from(document.querySelectorAll(sel));
22
+ return found.map((el, i) => {
23
+ const id = `${g}-${i}`;
24
+ el.setAttribute(attr, id);
25
+ const r = el.getBoundingClientRect();
26
+ return { id, x: r.x, y: r.y, width: r.width, height: r.height };
27
+ });
28
+ }, { selector, group, attr: LAYER_ATTR });
29
+ }
30
+ catch (err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ throw new Error(`layers: selector "${selector}" could not be queried (${message.split("\n")[0]})`);
33
+ }
34
+ }
35
+ /**
36
+ * Freezes every CSS and Web Animations animation where it stands, so the background, the layers
37
+ * and the full screenshot are all the same instant of a page that never stops moving.
38
+ */
39
+ async function pauseAnimations(page) {
40
+ await page.evaluate(() => {
41
+ for (const animation of document.getAnimations()) {
42
+ try {
43
+ animation.pause();
44
+ }
45
+ catch {
46
+ // An animation that cannot be paused is left running; the shots then differ in its region only.
47
+ }
48
+ }
49
+ });
50
+ }
51
+ async function installStyle(page) {
52
+ await page.evaluate(({ id, hide, show, isolate }) => {
53
+ if (document.getElementById(id))
54
+ return;
55
+ const style = document.createElement("style");
56
+ style.id = id;
57
+ // transition: none is what makes the toggles instant. A page with `transition: all` would
58
+ // otherwise keep a hidden element visible for the length of its transition, and the shot
59
+ // taken a frame later would still contain it.
60
+ style.textContent =
61
+ `html[${isolate}] *, html[${isolate}] *::before, html[${isolate}] *::after { transition: none !important; }` +
62
+ `[${hide}] { visibility: hidden !important; }` +
63
+ `[${show}] { visibility: visible !important; }` +
64
+ `html[${isolate}="clear"], html[${isolate}="clear"] body { background: transparent !important; background-image: none !important; }`;
65
+ document.head.appendChild(style);
66
+ }, { id: STYLE_ID, hide: HIDE_ATTR, show: SHOW_ATTR, isolate: ISOLATE_ATTR });
67
+ }
68
+ async function settleFrames(page) {
69
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
70
+ }
71
+ /**
72
+ * Leaves only one tagged element (and its subtree) visible, on a transparent page, ready for a
73
+ * screenshot with omitBackground. Everything else keeps its box, so the element does not move.
74
+ */
75
+ async function showOnlyLayer(page, id) {
76
+ await installStyle(page);
77
+ await page.evaluate(({ id: target, layerAttr, hide, show, isolate }) => {
78
+ const active = document.querySelector(`[${layerAttr}="${target}"]`);
79
+ if (!active)
80
+ throw new Error(`layers: tagged element ${target} is gone from the page`);
81
+ for (const el of Array.from(document.querySelectorAll("*"))) {
82
+ if (el === active || active.contains(el))
83
+ continue;
84
+ el.setAttribute(hide, "");
85
+ }
86
+ active.setAttribute(show, "");
87
+ document.documentElement.setAttribute(isolate, "clear");
88
+ }, { id, layerAttr: LAYER_ATTR, hide: HIDE_ATTR, show: SHOW_ATTR, isolate: ISOLATE_ATTR });
89
+ await settleFrames(page);
90
+ }
91
+ /** Hides every tagged element, leaving the page as it is otherwise: the background layer. */
92
+ async function hideAllLayers(page) {
93
+ await installStyle(page);
94
+ await page.evaluate(({ layerAttr, hide, isolate }) => {
95
+ document.documentElement.setAttribute(isolate, "keep");
96
+ for (const el of Array.from(document.querySelectorAll(`[${layerAttr}]`)))
97
+ el.setAttribute(hide, "");
98
+ }, { layerAttr: LAYER_ATTR, hide: HIDE_ATTR, isolate: ISOLATE_ATTR });
99
+ await settleFrames(page);
100
+ }
101
+ /**
102
+ * Undoes showOnlyLayer and hideAllLayers, so the page is as it was between shots. Transitions
103
+ * stay off (the isolate attribute is kept, with the page background back), because turning
104
+ * them on in the same step as un-hiding would start a fade-in on every element just restored.
105
+ */
106
+ async function restoreLayers(page) {
107
+ await page.evaluate(({ hide, show, isolate }) => {
108
+ for (const el of Array.from(document.querySelectorAll(`[${hide}], [${show}]`))) {
109
+ el.removeAttribute(hide);
110
+ el.removeAttribute(show);
111
+ }
112
+ document.documentElement.setAttribute(isolate, "keep");
113
+ }, { hide: HIDE_ATTR, show: SHOW_ATTR, isolate: ISOLATE_ATTR });
114
+ await settleFrames(page);
115
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Capture backends that do not involve a browser.
3
+ *
4
+ * Playwright is the right tool for a web app and the wrong tool for everything else.
5
+ * A desktop application, a mobile app, a terminal, or an OS itself has no URL to
6
+ * navigate to, and until this module existed every beat about one of them degraded to
7
+ * `dom-demo`: a hand-drawn panel standing in for software the pipeline could not film.
8
+ * The Day 3 validation test made the cost concrete, producing shot plans for Omarchy
9
+ * (a Linux distribution) and Hermes Agent (a terminal agent) in which 100% of beats
10
+ * were reconstructions, against a product whose entire pitch is that nothing on screen
11
+ * is generated.
12
+ *
13
+ * Everything here is an argv builder plus a thin spawn wrapper. ffmpeg and adb are
14
+ * real executables, never shell shims, so `spawnCapture` runs them with shell:false and
15
+ * an argv array; no argument is ever concatenated into a command line. That is why a
16
+ * window title containing spaces or quotes is safe to pass through verbatim.
17
+ */
18
+ import type { SpawnResult } from "./process";
19
+ export type DesktopPlatform = "win32" | "darwin" | "linux";
20
+ export interface Region {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ }
26
+ export type LinuxDisplayServer = "x11" | "wayland";
27
+ /**
28
+ * pipewiregrab's own options. With neither `fd` nor `node`, the filter opens an
29
+ * xdg-desktop-portal ScreenCast session itself, which shows the desktop's consent dialog
30
+ * and lets the user pick the surface. That prompt is a Wayland design decision, not an
31
+ * ffmpeg one, and it cannot be suppressed: a Wayland capture is not headless.
32
+ */
33
+ export interface PipewireOptions {
34
+ /** PipeWire node id, when the caller already negotiated a portal session. */
35
+ node?: number;
36
+ /** File descriptor for a remote PipeWire instance obtained from the portal. */
37
+ fd?: number;
38
+ /** Draw the pointer into the capture. Defaults to true, matching the other backends. */
39
+ drawMouse?: boolean;
40
+ }
41
+ export interface DesktopCaptureOptions {
42
+ /** Omit to capture the whole display. On win32 this maps to gdigrab's `title=` selector. */
43
+ window?: string;
44
+ /**
45
+ * Draw the pointer into the recording. Defaults to true.
46
+ *
47
+ * A desktop demo with no cursor is hard to follow: the viewer cannot tell what is being
48
+ * clicked, and the UI appears to react to nothing.
49
+ */
50
+ drawMouse?: boolean;
51
+ region?: Region;
52
+ /** Display index. win32 ignores this (gdigrab captures the virtual desktop). */
53
+ display?: number;
54
+ framerate: number;
55
+ durationSeconds: number;
56
+ outPath: string;
57
+ platform: DesktopPlatform;
58
+ /** Linux only. Defaults to x11, which is what x11grab needs. */
59
+ displayServer?: LinuxDisplayServer;
60
+ pipewire?: PipewireOptions;
61
+ }
62
+ /**
63
+ * Which display server this Linux session is actually running.
64
+ *
65
+ * Wayland is the default on current Fedora, Ubuntu, and most GNOME and KDE installs, so
66
+ * x11grab is now the minority case rather than the safe default. XDG_SESSION_TYPE is the
67
+ * documented signal; WAYLAND_DISPLAY is the fallback because some session managers and
68
+ * most containers leave XDG_SESSION_TYPE unset.
69
+ */
70
+ export declare function detectLinuxDisplayServer(env?: NodeJS.ProcessEnv): LinuxDisplayServer;
71
+ /** `ffmpeg -filters` output, for checking whether this build has pipewiregrab (ffmpeg 7.1+). */
72
+ export declare function buildFilterProbeArgs(): string[];
73
+ export declare function hasFilter(ffmpegFiltersOutput: string, name: string): boolean;
74
+ /**
75
+ * Wayland capture through PipeWire.
76
+ *
77
+ * pipewiregrab is a libavfilter source rather than an input device, so it arrives through
78
+ * `-f lavfi` and the framerate is applied downstream with `fps` instead of being requested
79
+ * from the device. There is no equivalent of gdigrab's `title=`: on Wayland a client
80
+ * cannot enumerate or address another client's windows at all, which is the point of the
81
+ * design. The portal's own picker chooses the surface.
82
+ */
83
+ export declare function buildPipewireCaptureArgs(opts: DesktopCaptureOptions): string[];
84
+ /**
85
+ * Pure argv builder for a native screen/window recording, per platform.
86
+ *
87
+ * win32 gdigrab, which can address a window by title without it being focused
88
+ * darwin avfoundation, which can only grab a whole display, so a region becomes a crop
89
+ * linux x11grab, which takes the region directly in its input spec, or pipewiregrab on
90
+ * a Wayland session, where no client may address another client's windows
91
+ */
92
+ export declare function buildDesktopCaptureArgs(opts: DesktopCaptureOptions): string[];
93
+ /**
94
+ * Android capture runs on the device and writes there, because `adb exec-out` streaming
95
+ * is unreliable across OEM builds in a way that silently truncates recordings.
96
+ * `screenrecord` self-terminates at --time-limit, so nothing here needs to kill a process.
97
+ */
98
+ export declare function buildAdbRecordArgs(devicePath: string, durationSeconds: number, deviceId?: string): string[];
99
+ export declare function buildAdbPullArgs(devicePath: string, localPath: string, deviceId?: string): string[];
100
+ export declare function buildAdbCleanupArgs(devicePath: string, deviceId?: string): string[];
101
+ /** iOS Simulator. `simctl recordVideo` runs until interrupted, so the caller stops it on a timer. */
102
+ export declare function buildSimctlRecordArgs(outPath: string, udid?: string): string[];
103
+ /**
104
+ * screenrecord and simctl both emit mp4 already, but neither writes a faststart atom,
105
+ * so Remotion can decode them and cannot seek them. This is a remux, not a re-encode:
106
+ * no quality is lost and it takes about a second.
107
+ */
108
+ export declare function buildRemuxArgs(inputPath: string, outputPath: string): string[];
109
+ /** The screen-tuned encode, exported so every other ffmpeg path in the pipeline matches it. */
110
+ export declare function screenEncodeArgs(): string[];
111
+ /**
112
+ * Spawn that stops the child after `timeoutMs` and treats that stop as success.
113
+ * `simctl recordVideo` has no duration flag: it records until it receives a signal, so
114
+ * "ran the full duration and was then stopped" is the normal, expected path here rather
115
+ * than a failure.
116
+ */
117
+ export declare function spawnForDuration(command: string, args: string[], cwd: string, timeoutMs: number): Promise<SpawnResult & {
118
+ stoppedByTimer: boolean;
119
+ }>;
120
+ export declare function sanitizeDeviceId(value: string): string;
121
+ /**
122
+ * gdigrab parses its input as `title=<everything after the equals sign>`, so a title
123
+ * containing a newline or a NUL truncates the selector and silently captures the wrong
124
+ * thing. Spaces and quotes are fine: shell:false means the argv entry is passed intact.
125
+ */
126
+ export declare function sanitizeWindowTitle(value: string): string;
package/dist/native.js ADDED
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ /**
3
+ * Capture backends that do not involve a browser.
4
+ *
5
+ * Playwright is the right tool for a web app and the wrong tool for everything else.
6
+ * A desktop application, a mobile app, a terminal, or an OS itself has no URL to
7
+ * navigate to, and until this module existed every beat about one of them degraded to
8
+ * `dom-demo`: a hand-drawn panel standing in for software the pipeline could not film.
9
+ * The Day 3 validation test made the cost concrete, producing shot plans for Omarchy
10
+ * (a Linux distribution) and Hermes Agent (a terminal agent) in which 100% of beats
11
+ * were reconstructions, against a product whose entire pitch is that nothing on screen
12
+ * is generated.
13
+ *
14
+ * Everything here is an argv builder plus a thin spawn wrapper. ffmpeg and adb are
15
+ * real executables, never shell shims, so `spawnCapture` runs them with shell:false and
16
+ * an argv array; no argument is ever concatenated into a command line. That is why a
17
+ * window title containing spaces or quotes is safe to pass through verbatim.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.detectLinuxDisplayServer = detectLinuxDisplayServer;
21
+ exports.buildFilterProbeArgs = buildFilterProbeArgs;
22
+ exports.hasFilter = hasFilter;
23
+ exports.buildPipewireCaptureArgs = buildPipewireCaptureArgs;
24
+ exports.buildDesktopCaptureArgs = buildDesktopCaptureArgs;
25
+ exports.buildAdbRecordArgs = buildAdbRecordArgs;
26
+ exports.buildAdbPullArgs = buildAdbPullArgs;
27
+ exports.buildAdbCleanupArgs = buildAdbCleanupArgs;
28
+ exports.buildSimctlRecordArgs = buildSimctlRecordArgs;
29
+ exports.buildRemuxArgs = buildRemuxArgs;
30
+ exports.screenEncodeArgs = screenEncodeArgs;
31
+ exports.spawnForDuration = spawnForDuration;
32
+ exports.sanitizeDeviceId = sanitizeDeviceId;
33
+ exports.sanitizeWindowTitle = sanitizeWindowTitle;
34
+ const node_child_process_1 = require("node:child_process");
35
+ /**
36
+ * Which display server this Linux session is actually running.
37
+ *
38
+ * Wayland is the default on current Fedora, Ubuntu, and most GNOME and KDE installs, so
39
+ * x11grab is now the minority case rather than the safe default. XDG_SESSION_TYPE is the
40
+ * documented signal; WAYLAND_DISPLAY is the fallback because some session managers and
41
+ * most containers leave XDG_SESSION_TYPE unset.
42
+ */
43
+ function detectLinuxDisplayServer(env = process.env) {
44
+ const declared = (env.XDG_SESSION_TYPE ?? "").toLowerCase();
45
+ if (declared === "wayland")
46
+ return "wayland";
47
+ if (declared === "x11")
48
+ return "x11";
49
+ return env.WAYLAND_DISPLAY ? "wayland" : "x11";
50
+ }
51
+ /** `ffmpeg -filters` output, for checking whether this build has pipewiregrab (ffmpeg 7.1+). */
52
+ function buildFilterProbeArgs() {
53
+ return ["-hide_banner", "-filters"];
54
+ }
55
+ function hasFilter(ffmpegFiltersOutput, name) {
56
+ // ffmpeg prints one filter per line as "<flags> <name> <io> <description>".
57
+ const pattern = "^\\s*\\S+\\s+" + name + "\\s";
58
+ return new RegExp(pattern, "m").test(ffmpegFiltersOutput);
59
+ }
60
+ /**
61
+ * Wayland capture through PipeWire.
62
+ *
63
+ * pipewiregrab is a libavfilter source rather than an input device, so it arrives through
64
+ * `-f lavfi` and the framerate is applied downstream with `fps` instead of being requested
65
+ * from the device. There is no equivalent of gdigrab's `title=`: on Wayland a client
66
+ * cannot enumerate or address another client's windows at all, which is the point of the
67
+ * design. The portal's own picker chooses the surface.
68
+ */
69
+ function buildPipewireCaptureArgs(opts) {
70
+ const { framerate, durationSeconds, outPath, region, pipewire } = opts;
71
+ const filterOpts = [`draw_mouse=${pipewire?.drawMouse === false || opts.drawMouse === false ? 0 : 1}`];
72
+ if (pipewire?.fd !== undefined)
73
+ filterOpts.push(`fd=${pipewire.fd}`);
74
+ if (pipewire?.node !== undefined)
75
+ filterOpts.push(`node=${pipewire.node}`);
76
+ const chain = [`fps=${framerate}`];
77
+ if (region)
78
+ chain.push(`crop=${region.width}:${region.height}:${region.x}:${region.y}`);
79
+ return [
80
+ "-nostdin",
81
+ "-f",
82
+ "lavfi",
83
+ "-i",
84
+ `pipewiregrab=${filterOpts.join(":")}`,
85
+ "-t",
86
+ String(durationSeconds),
87
+ ...encodeArgs(outPath, chain.join(",")),
88
+ ];
89
+ }
90
+ /**
91
+ * h264 in yuv420p requires even dimensions, and a hand-picked region is very often
92
+ * odd on one axis. Without this filter a 1365-wide capture fails at the encoder with
93
+ * an error that says nothing about the real cause.
94
+ */
95
+ const EVEN_DIMENSIONS = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
96
+ /**
97
+ * Screen content is not film, and encoding it like film is why screen recordings look bad.
98
+ *
99
+ * x264's defaults (crf 23, preset medium, the film-tuned psy settings) are built for
100
+ * camera footage: grain, motion, no hard edges. A screen is the opposite. It is mostly
101
+ * static, mostly flat colour, and entirely made of hard edges and small text, which is
102
+ * exactly what those defaults spend their bitrate anywhere but on. The result is ringing
103
+ * around glyphs and mush in the flat areas.
104
+ *
105
+ * crf 18 is visually lossless for this material, `slow` buys real compression efficiency
106
+ * on content that is cheap to encode anyway, and `stillimage` turns down the psychovisual
107
+ * optimisations that assume film grain. yuv444p would be better still for text, but
108
+ * Remotion's decoder and most browsers want 4:2:0, so that is not on the table.
109
+ */
110
+ const SCREEN_ENCODE = ["-c:v", "libx264", "-crf", "18", "-preset", "slow", "-tune", "stillimage"];
111
+ /** Shared output encoding: matches buildFfmpegTranscodeArgs so Remotion's OffthreadVideo can seek the result. */
112
+ function encodeArgs(outPath, extraFilter) {
113
+ const filter = extraFilter ? `${extraFilter},${EVEN_DIMENSIONS}` : EVEN_DIMENSIONS;
114
+ return ["-vf", filter, ...SCREEN_ENCODE, "-pix_fmt", "yuv420p", "-movflags", "+faststart", outPath, "-y"];
115
+ }
116
+ /**
117
+ * Pure argv builder for a native screen/window recording, per platform.
118
+ *
119
+ * win32 gdigrab, which can address a window by title without it being focused
120
+ * darwin avfoundation, which can only grab a whole display, so a region becomes a crop
121
+ * linux x11grab, which takes the region directly in its input spec, or pipewiregrab on
122
+ * a Wayland session, where no client may address another client's windows
123
+ */
124
+ function buildDesktopCaptureArgs(opts) {
125
+ const { platform, framerate, durationSeconds, outPath, region, window, display = 0 } = opts;
126
+ const args = ["-nostdin", "-f"];
127
+ const drawMouse = opts.drawMouse === false ? "0" : "1";
128
+ if (platform === "win32") {
129
+ // A generous input queue before the device: gdigrab drops frames at the start of a
130
+ // capture otherwise, which shows up as a visible stutter in the first second, which is
131
+ // the second a demo can least afford to lose.
132
+ args.push("gdigrab", "-thread_queue_size", "512", "-draw_mouse", drawMouse, "-framerate", String(framerate));
133
+ if (region && !window) {
134
+ args.push("-offset_x", String(region.x), "-offset_y", String(region.y), "-video_size", `${region.width}x${region.height}`);
135
+ }
136
+ args.push("-i", window ? `title=${window}` : "desktop");
137
+ args.push("-t", String(durationSeconds));
138
+ // A window capture plus a region means "crop within the window", which gdigrab
139
+ // cannot express in its input spec, so it becomes a filter instead.
140
+ return args.concat(encodeArgs(outPath, window && region ? `crop=${region.width}:${region.height}:${region.x}:${region.y}` : undefined));
141
+ }
142
+ if (platform === "darwin") {
143
+ args.push("avfoundation", "-thread_queue_size", "512", "-capture_cursor", drawMouse, "-framerate", String(framerate), "-i", `${display}:none`, "-t", String(durationSeconds));
144
+ return args.concat(encodeArgs(outPath, region ? `crop=${region.width}:${region.height}:${region.x}:${region.y}` : undefined));
145
+ }
146
+ if ((opts.displayServer ?? "x11") === "wayland")
147
+ return buildPipewireCaptureArgs(opts);
148
+ args.push("x11grab", "-thread_queue_size", "512", "-draw_mouse", drawMouse, "-framerate", String(framerate));
149
+ if (region) {
150
+ args.push("-video_size", `${region.width}x${region.height}`, "-i", `:${display}.0+${region.x},${region.y}`);
151
+ }
152
+ else {
153
+ args.push("-i", `:${display}.0`);
154
+ }
155
+ args.push("-t", String(durationSeconds));
156
+ return args.concat(encodeArgs(outPath));
157
+ }
158
+ /**
159
+ * Android capture runs on the device and writes there, because `adb exec-out` streaming
160
+ * is unreliable across OEM builds in a way that silently truncates recordings.
161
+ * `screenrecord` self-terminates at --time-limit, so nothing here needs to kill a process.
162
+ */
163
+ function buildAdbRecordArgs(devicePath, durationSeconds, deviceId) {
164
+ const prefix = deviceId ? ["-s", deviceId] : [];
165
+ return [...prefix, "shell", "screenrecord", "--time-limit", String(durationSeconds), devicePath];
166
+ }
167
+ function buildAdbPullArgs(devicePath, localPath, deviceId) {
168
+ const prefix = deviceId ? ["-s", deviceId] : [];
169
+ return [...prefix, "pull", devicePath, localPath];
170
+ }
171
+ function buildAdbCleanupArgs(devicePath, deviceId) {
172
+ const prefix = deviceId ? ["-s", deviceId] : [];
173
+ return [...prefix, "shell", "rm", "-f", devicePath];
174
+ }
175
+ /** iOS Simulator. `simctl recordVideo` runs until interrupted, so the caller stops it on a timer. */
176
+ function buildSimctlRecordArgs(outPath, udid = "booted") {
177
+ return ["simctl", "io", udid, "recordVideo", "--codec", "h264", "--force", outPath];
178
+ }
179
+ /**
180
+ * screenrecord and simctl both emit mp4 already, but neither writes a faststart atom,
181
+ * so Remotion can decode them and cannot seek them. This is a remux, not a re-encode:
182
+ * no quality is lost and it takes about a second.
183
+ */
184
+ function buildRemuxArgs(inputPath, outputPath) {
185
+ return ["-nostdin", "-i", inputPath, "-c", "copy", "-movflags", "+faststart", outputPath, "-y"];
186
+ }
187
+ /** The screen-tuned encode, exported so every other ffmpeg path in the pipeline matches it. */
188
+ function screenEncodeArgs() {
189
+ return [...SCREEN_ENCODE];
190
+ }
191
+ /**
192
+ * Spawn that stops the child after `timeoutMs` and treats that stop as success.
193
+ * `simctl recordVideo` has no duration flag: it records until it receives a signal, so
194
+ * "ran the full duration and was then stopped" is the normal, expected path here rather
195
+ * than a failure.
196
+ */
197
+ function spawnForDuration(command, args, cwd, timeoutMs) {
198
+ return new Promise((resolve, reject) => {
199
+ const child = (0, node_child_process_1.spawn)(command, args, { cwd, shell: false });
200
+ let stdout = "";
201
+ let stderr = "";
202
+ let stoppedByTimer = false;
203
+ const timer = setTimeout(() => {
204
+ stoppedByTimer = true;
205
+ // SIGINT, not SIGKILL: simctl finalizes the mp4 container on interrupt, and a
206
+ // hard kill leaves an unplayable file.
207
+ child.kill("SIGINT");
208
+ setTimeout(() => child.kill("SIGKILL"), 5000).unref?.();
209
+ }, timeoutMs);
210
+ child.stdout?.on("data", (c) => (stdout += c.toString()));
211
+ child.stderr?.on("data", (c) => (stderr += c.toString()));
212
+ child.on("error", (err) => {
213
+ clearTimeout(timer);
214
+ reject(err);
215
+ });
216
+ child.on("close", (code) => {
217
+ clearTimeout(timer);
218
+ resolve({ code, stdout, stderr, stoppedByTimer });
219
+ });
220
+ });
221
+ }
222
+ const SAFE_DEVICE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
223
+ function sanitizeDeviceId(value) {
224
+ if (!SAFE_DEVICE_ID.test(value)) {
225
+ throw new Error(`deviceId "${value}" is not a valid device identifier. Expected an adb serial or a simulator UDID ` +
226
+ `(letters, numbers, ".", "_", ":", "-").`);
227
+ }
228
+ return value;
229
+ }
230
+ /**
231
+ * gdigrab parses its input as `title=<everything after the equals sign>`, so a title
232
+ * containing a newline or a NUL truncates the selector and silently captures the wrong
233
+ * thing. Spaces and quotes are fine: shell:false means the argv entry is passed intact.
234
+ */
235
+ function sanitizeWindowTitle(value) {
236
+ if (value.length === 0 || value.length > 256 || /[\r\n\0]/.test(value)) {
237
+ throw new Error(`window title is invalid: it must be 1-256 characters and contain no newlines.`);
238
+ }
239
+ return value;
240
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Child-process primitives shared by every capture backend and by the callers that
3
+ * post-process what a backend produced (ffmpeg remuxes, ffprobe reads, the Remotion CLI).
4
+ *
5
+ * Everything here spawns with an argv array. No caller ever builds a command line by
6
+ * concatenation, which is what makes a window title containing spaces safe to pass
7
+ * through verbatim.
8
+ */
9
+ export interface SpawnResult {
10
+ code: number | null;
11
+ stdout: string;
12
+ stderr: string;
13
+ }
14
+ /**
15
+ * Runs a child process to completion and captures its stdout/stderr in memory.
16
+ * `spawn` with an argv array, never `exec`/`execSync` with an interpolated shell string.
17
+ * Capturing the child's output here has nothing to do with *this* process's own stdout
18
+ * (which, under the MCP server, is the transport); it is returned to the caller as data.
19
+ */
20
+ export declare function spawnCapture(command: string, args: string[], cwd: string): Promise<SpawnResult>;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.spawnCapture = spawnCapture;
4
+ const node_child_process_1 = require("node:child_process");
5
+ // On Windows, spawning a `.cmd`/`.bat` shim (npx.cmd, the only such command any caller of
6
+ // spawnCapture ever passes -- ffmpeg/ffprobe are real .exe) with shell:false throws a
7
+ // synchronous EINVAL: CreateProcess cannot launch a batch file directly, and (verified
8
+ // against this repo's actual Node runtime, Task 4's real end-to-end pipeline run) Node's
9
+ // spawn() does not transparently shell out for it the way an older assumption believed.
10
+ // shell:true routes exactly that one case through cmd.exe, which is also why the callers'
11
+ // own sanitizers block shell metacharacters including `%^"` (cmd.exe's own specials)
12
+ // everywhere a value can reach this function.
13
+ const WINDOWS_SHIM_RE = /\.(cmd|bat)$/i;
14
+ /**
15
+ * Runs a child process to completion and captures its stdout/stderr in memory.
16
+ * `spawn` with an argv array, never `exec`/`execSync` with an interpolated shell string.
17
+ * Capturing the child's output here has nothing to do with *this* process's own stdout
18
+ * (which, under the MCP server, is the transport); it is returned to the caller as data.
19
+ */
20
+ function spawnCapture(command, args, cwd) {
21
+ return new Promise((resolve, reject) => {
22
+ const shell = process.platform === "win32" && WINDOWS_SHIM_RE.test(command);
23
+ // Node's own shell:true + args-array combination does NOT escape/quote args for the
24
+ // caller (it just concatenates them into one command line before handing it to
25
+ // cmd.exe -- Node emits DEP0190 about exactly this), so an argument containing a space
26
+ // would silently word-split into two argv entries once shell:true is in effect. Every
27
+ // arg that reaches this function is already filtered by the caller's sanitizers, which
28
+ // block `"` itself, so wrapping each in double quotes here is always safe and closes
29
+ // that gap without touching the non-shell (ffmpeg/ffprobe) call path at all.
30
+ const finalArgs = shell ? args.map((a) => `"${a}"`) : args;
31
+ // The command gets the same treatment: a shim at C:\Program Files\... would otherwise be
32
+ // cut at its first space and cmd.exe would try to run "C:\Program".
33
+ const finalCommand = shell && /\s/.test(command) ? `"${command}"` : command;
34
+ const child = (0, node_child_process_1.spawn)(finalCommand, finalArgs, { cwd, shell });
35
+ let stdout = "";
36
+ let stderr = "";
37
+ child.stdout?.on("data", (chunk) => {
38
+ stdout += chunk.toString();
39
+ });
40
+ child.stderr?.on("data", (chunk) => {
41
+ stderr += chunk.toString();
42
+ });
43
+ child.on("error", reject);
44
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
45
+ });
46
+ }
@@ -0,0 +1,69 @@
1
+ import type { Page } from "playwright";
2
+ /**
3
+ * Waits for a page to finish becoming itself before anything is captured.
4
+ *
5
+ * This is the difference between a demo that looks deliberate and one that looks broken.
6
+ * A modern page is not finished when `load` fires: webfonts are still swapping, images are
7
+ * still decoding, and any entrance transition is mid-flight. Capture at that moment and you
8
+ * get a frame nobody ever sees in real use, with fallback type, a blank hero, and a panel
9
+ * frozen at 40% opacity halfway through its fade. The page was fine. The capture was early.
10
+ *
11
+ * Three waits, in the order they resolve in a real browser:
12
+ *
13
+ * 1. **Fonts.** `document.fonts.ready` resolves once every font actually used has loaded.
14
+ * Skipping it is the single commonest cause of a capture rendered in Times New Roman.
15
+ * 2. **Images.** Decoded, not merely fetched. A fetched-but-undecoded image still paints
16
+ * blank for a frame or two.
17
+ * 3. **Animations.** Every running animation and transition driven to its end.
18
+ *
19
+ * The third one has a trap in it: an infinite animation never finishes. A looping spinner,
20
+ * a pulsing dot, a permanently drifting gradient will all return a `finished` promise that
21
+ * never resolves, so waiting on everything indiscriminately hangs until the timeout on most
22
+ * real marketing pages. Those are filtered out by their computed timing, which is also the
23
+ * correct behaviour: a loop has no "settled" state to wait for, and the page is as ready as
24
+ * it will ever be while it keeps running.
25
+ */
26
+ export interface SettleOptions {
27
+ /** Give up after this long and report what was still pending. Defaults to 5000ms. */
28
+ timeoutMs?: number;
29
+ /** Wait for webfonts. Defaults to true. */
30
+ fonts?: boolean;
31
+ /** Wait for images to decode. Defaults to true. */
32
+ images?: boolean;
33
+ /** Drive running transitions and animations to their end. Defaults to true. */
34
+ animations?: boolean;
35
+ /** A final quiet pause after everything else, for work no API reports. Defaults to 150ms. */
36
+ quietMs?: number;
37
+ }
38
+ export interface SettleReport {
39
+ /** False when the timeout fired with work still outstanding. */
40
+ settled: boolean;
41
+ waitedMs: number;
42
+ /** Animations still running when the wait ended, excluding infinite ones. */
43
+ pendingAnimations: number;
44
+ /** Infinite animations, which are never waited on and are not a problem. */
45
+ loopingAnimations: number;
46
+ fontsReady: boolean;
47
+ imagesReady: boolean;
48
+ }
49
+ export declare const DEFAULT_SETTLE_TIMEOUT_MS = 5000;
50
+ export declare const DEFAULT_QUIET_MS = 150;
51
+ /**
52
+ * Settles the page, then pauses briefly.
53
+ *
54
+ * The trailing pause is not superstition: a transition can finish one frame before the
55
+ * compositor has painted its final state, and `requestAnimationFrame` ordering means the
56
+ * screenshot can otherwise land on the frame before the last one.
57
+ */
58
+ export declare function settlePage(page: Page, opts?: SettleOptions): Promise<SettleReport>;
59
+ /** Sidecar path a SettleReport is written to, next to the capture artifact it describes. */
60
+ export declare function settleSidecarPath(outPathAbs: string): string;
61
+ /**
62
+ * Persists a SettleReport next to the artifact it describes. `settlePage`'s return value
63
+ * is otherwise only a field in a tool call's result, which nothing downstream keeps: a
64
+ * capture that timed out with animations still pending is a frame caught mid-transition,
65
+ * and this is what lets validate_scenes catch that statically (it reads scene sources and
66
+ * beats.json, it never launches a browser) instead of it going unnoticed until someone
67
+ * watches the render.
68
+ */
69
+ export declare function writeSettleSidecar(outPathAbs: string, report: SettleReport): void;