@y11i-3d/chrome-recording 1.1.0 → 2.0.0
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.
- package/README.md +60 -14
- package/dist/capture.d.ts +14 -0
- package/dist/capture.js +143 -0
- package/dist/chrome.d.ts +1 -0
- package/dist/chrome.js +84 -4
- package/dist/chrome.ps1 +17 -5
- package/dist/cli.js +2 -0
- package/dist/record.js +3 -127
- package/dist/resize.js +14 -4
- package/dist/screenshot.d.ts +2 -0
- package/dist/screenshot.js +16 -0
- package/package.json +4 -3
- package/dist/postinstall.d.ts +0 -2
- package/dist/postinstall.js +0 -19
package/README.md
CHANGED
|
@@ -19,21 +19,23 @@ CLI for recording Windows Chrome content from WSL, using ffmpeg's ddagrab.
|
|
|
19
19
|
npm install -g @y11i-3d/chrome-recording
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Or run directly with `npx`:
|
|
22
|
+
Or run directly with `npx` or `bunx`:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
npx @y11i-3d/chrome-recording <subcommand> [options]
|
|
26
|
+
bunx @y11i-3d/chrome-recording <subcommand> [options]
|
|
26
27
|
```
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
The `record`, `screenshot` and `resize` commands need a PowerShell script (`chrome.ps1`) on the Windows side. It is copied to `%LOCALAPPDATA%\y11i-3d\chrome-recording\` every time one of them runs.
|
|
29
30
|
|
|
30
31
|
## Subcommands
|
|
31
32
|
|
|
32
|
-
| Subcommand
|
|
33
|
-
|
|
|
34
|
-
| `record`
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
33
|
+
| Subcommand | Description |
|
|
34
|
+
| ------------ | ----------------------------------------------- |
|
|
35
|
+
| `record` | Capture Chrome browser content as a video |
|
|
36
|
+
| `screenshot` | Capture Chrome browser content as a still image |
|
|
37
|
+
| `resize` | Set the Chrome viewport size |
|
|
38
|
+
| `concat` | Concatenate recorded mp4 files into one |
|
|
37
39
|
|
|
38
40
|
## record
|
|
39
41
|
|
|
@@ -43,11 +45,11 @@ Capture the Chrome content area using ffmpeg's ddagrab.
|
|
|
43
45
|
chrome-recording record [options]
|
|
44
46
|
```
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
Recording starts immediately and stops when you press Enter, unless `--manual` or `--duration` is given. The output is saved to `./recordings/<timestamp>.mp4`.
|
|
47
49
|
|
|
48
50
|
| Option | Description |
|
|
49
51
|
| ------------------------------------ | --------------------------------------------------------------------------------------- |
|
|
50
|
-
| `-s, --
|
|
52
|
+
| `-s, --size <width>x<height>` | Size of the captured region (see below) |
|
|
51
53
|
| `-o, --output <file>` | Output file path |
|
|
52
54
|
| `-c, --crop <top:right:bottom:left>` | Crop in CSS shorthand order (px) |
|
|
53
55
|
| `-m, --manual` | Wait for Enter before starting capture |
|
|
@@ -58,14 +60,18 @@ By default, recording starts immediately and stops when you press Enter. The out
|
|
|
58
60
|
| `-t, --title <text>` | Select a window whose page title contains this text |
|
|
59
61
|
| `-v, --verbose` | Show ffmpeg output |
|
|
60
62
|
|
|
63
|
+
`--size` captures a region of that size from the top-left corner of the Chrome
|
|
64
|
+
content area, instead of the whole area. `--crop` is ignored when `--size` is
|
|
65
|
+
given.
|
|
66
|
+
|
|
61
67
|
### Examples
|
|
62
68
|
|
|
63
69
|
```bash
|
|
64
70
|
# Record until Enter is pressed
|
|
65
71
|
chrome-recording record
|
|
66
72
|
|
|
67
|
-
#
|
|
68
|
-
chrome-recording record -s
|
|
73
|
+
# Record a 1920x1080 region, ignoring the padding kept by `resize`
|
|
74
|
+
chrome-recording record -s 1920x1080
|
|
69
75
|
|
|
70
76
|
# Record for 10 seconds at CRF 18
|
|
71
77
|
chrome-recording record -d 10 -q 18
|
|
@@ -77,10 +83,44 @@ chrome-recording record -m -c 40:0:0:0
|
|
|
77
83
|
chrome-recording record -o output.mp4 -q 4M
|
|
78
84
|
```
|
|
79
85
|
|
|
86
|
+
## screenshot
|
|
87
|
+
|
|
88
|
+
Capture a single frame of the Chrome content area as a PNG.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
chrome-recording screenshot [options]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The capture happens immediately, unless `--manual` is given. The output is saved
|
|
95
|
+
to `./recordings/<timestamp>.png`.
|
|
96
|
+
|
|
97
|
+
| Option | Description |
|
|
98
|
+
| ------------------------------------ | --------------------------------------------------- |
|
|
99
|
+
| `-s, --size <width>x<height>` | Size of the captured region (same as `record`) |
|
|
100
|
+
| `-o, --output <file>` | Output file path |
|
|
101
|
+
| `-c, --crop <top:right:bottom:left>` | Crop in CSS shorthand order (px) |
|
|
102
|
+
| `-m, --manual` | Wait for Enter before capturing |
|
|
103
|
+
| `-n, --no-pointer` | Hide the mouse pointer |
|
|
104
|
+
| `-t, --title <text>` | Select a window whose page title contains this text |
|
|
105
|
+
| `-v, --verbose` | Show ffmpeg output |
|
|
106
|
+
|
|
107
|
+
### Examples
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Capture the Chrome content area
|
|
111
|
+
chrome-recording screenshot
|
|
112
|
+
|
|
113
|
+
# Capture a 1920x1080 region without the mouse pointer
|
|
114
|
+
chrome-recording screenshot -s 1920x1080 -n
|
|
115
|
+
|
|
116
|
+
# Capture after pressing Enter, to a specific file
|
|
117
|
+
chrome-recording screenshot -m -o shot.png
|
|
118
|
+
```
|
|
119
|
+
|
|
80
120
|
## resize
|
|
81
121
|
|
|
82
122
|
Set the viewport size through the Chrome DevTools Protocol. Chrome asks you to
|
|
83
|
-
approve the connection
|
|
123
|
+
approve the connection.
|
|
84
124
|
|
|
85
125
|
```bash
|
|
86
126
|
chrome-recording resize <width>x<height> [options]
|
|
@@ -94,10 +134,12 @@ Padding is added to the right and the bottom.
|
|
|
94
134
|
| Option | Description |
|
|
95
135
|
| -------------------------------- | -------------------------------------------------- |
|
|
96
136
|
| `-p, --padding <width>x<height>` | Padding kept outside the viewport (default: `0x0`) |
|
|
137
|
+
| `-n, --no-scrollbar` | Hide the scrollbars |
|
|
97
138
|
| `-t, --title <text>` | Select a page whose title contains this text |
|
|
98
139
|
|
|
99
|
-
|
|
100
|
-
|
|
140
|
+
With `--padding` or `--no-scrollbar`, the command waits instead of exiting.
|
|
141
|
+
Press Enter to release the viewport. The setting survives a page reload while it
|
|
142
|
+
waits.
|
|
101
143
|
|
|
102
144
|
### Examples
|
|
103
145
|
|
|
@@ -106,8 +148,12 @@ reloaded.
|
|
|
106
148
|
chrome-recording resize 1920x1080
|
|
107
149
|
|
|
108
150
|
# Keep 4px below the viewport so the rounded corners are not captured
|
|
151
|
+
# (waits until Enter is pressed)
|
|
109
152
|
chrome-recording resize 1920x1080 -p 0x4
|
|
110
153
|
|
|
154
|
+
# Hide the scrollbars (waits until Enter is pressed)
|
|
155
|
+
chrome-recording resize 1920x1080 -n
|
|
156
|
+
|
|
111
157
|
# Target a specific page
|
|
112
158
|
chrome-recording resize 1280x720 -t "Example"
|
|
113
159
|
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface CaptureOptions {
|
|
2
|
+
size?: string;
|
|
3
|
+
output?: string;
|
|
4
|
+
crop?: string;
|
|
5
|
+
manual: boolean;
|
|
6
|
+
pointer: boolean;
|
|
7
|
+
title?: string;
|
|
8
|
+
verbose: boolean;
|
|
9
|
+
duration?: string;
|
|
10
|
+
quality?: string;
|
|
11
|
+
fps?: string;
|
|
12
|
+
}
|
|
13
|
+
export type CaptureMode = "screenshot" | "video";
|
|
14
|
+
export declare function runCapture(mode: CaptureMode, options: CaptureOptions): Promise<void>;
|
package/dist/capture.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execSync, spawn } from "child_process";
|
|
2
|
+
import { mkdirSync } from "fs";
|
|
3
|
+
import { dirname, resolve } from "path";
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
import { parseSize, runChromeScript } from "./chrome.js";
|
|
6
|
+
function requireCommands() {
|
|
7
|
+
const missing = ["powershell.exe", "ffmpeg.exe", "wslpath"].filter((cmd) => {
|
|
8
|
+
try {
|
|
9
|
+
execSync(`which ${cmd}`, { stdio: "ignore" });
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
if (missing.length > 0) {
|
|
17
|
+
throw new Error(`Command(s) not found: ${missing.join(", ")}\nWSL + Windows environment required.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// The content rect is the render widget, which may be larger than the page
|
|
21
|
+
// viewport when `resize --padding` is in effect.
|
|
22
|
+
function locateContent(title) {
|
|
23
|
+
const infoArgs = ["info"];
|
|
24
|
+
if (title)
|
|
25
|
+
infoArgs.push("-Title", title);
|
|
26
|
+
const info = runChromeScript(infoArgs);
|
|
27
|
+
const contentLine = info.find((l) => l.startsWith("content"));
|
|
28
|
+
if (!contentLine) {
|
|
29
|
+
throw new Error("Failed to get Chrome content area info");
|
|
30
|
+
}
|
|
31
|
+
const contentParts = contentLine.split(/\s+/).slice(1).map(Number);
|
|
32
|
+
const contentX = contentParts[0] ?? 0;
|
|
33
|
+
const contentY = contentParts[1] ?? 0;
|
|
34
|
+
const contentW = contentParts[2] ?? 0;
|
|
35
|
+
const contentH = contentParts[3] ?? 0;
|
|
36
|
+
const monitors = info
|
|
37
|
+
.filter((l) => l.startsWith("monitor"))
|
|
38
|
+
.map((l) => {
|
|
39
|
+
const parts = l.split(/\s+/).map(Number);
|
|
40
|
+
const idx = parts[1] ?? 0;
|
|
41
|
+
const left = parts[2] ?? 0;
|
|
42
|
+
const top = parts[3] ?? 0;
|
|
43
|
+
const right = parts[4] ?? 0;
|
|
44
|
+
const bottom = parts[5] ?? 0;
|
|
45
|
+
return { idx, left, top, right, bottom };
|
|
46
|
+
});
|
|
47
|
+
const monitor = monitors.find((m) => contentX >= m.left &&
|
|
48
|
+
contentX < m.right &&
|
|
49
|
+
contentY >= m.top &&
|
|
50
|
+
contentY < m.bottom);
|
|
51
|
+
if (!monitor)
|
|
52
|
+
throw new Error("Chrome content area not found");
|
|
53
|
+
return {
|
|
54
|
+
monitorIdx: monitor.idx,
|
|
55
|
+
offsetX: contentX - monitor.left,
|
|
56
|
+
offsetY: contentY - monitor.top,
|
|
57
|
+
width: contentW,
|
|
58
|
+
height: contentH,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// The crop filter also rounds the dimensions down to even numbers for H.264, so
|
|
62
|
+
// it stays in the chain even when nothing is cropped away.
|
|
63
|
+
function buildCropFilter(crop) {
|
|
64
|
+
const [top, right, bottom, left] = crop
|
|
65
|
+
? crop.split(":").map(Number)
|
|
66
|
+
: [0, 0, 0, 0];
|
|
67
|
+
const width = `floor((iw-${(left ?? 0) + (right ?? 0)})/2)*2`;
|
|
68
|
+
const height = `floor((ih-${(top ?? 0) + (bottom ?? 0)})/2)*2`;
|
|
69
|
+
return `crop=${width}:${height}:${left ?? 0}:${top ?? 0}`;
|
|
70
|
+
}
|
|
71
|
+
export async function runCapture(mode, options) {
|
|
72
|
+
requireCommands();
|
|
73
|
+
const region = locateContent(options.title);
|
|
74
|
+
// `--size` picks the captured region itself rather than cropping it down, so
|
|
75
|
+
// any `--crop` becomes meaningless and is dropped.
|
|
76
|
+
const size = options.size ? parseSize(options.size, "size") : null;
|
|
77
|
+
const captureW = size?.width ?? region.width;
|
|
78
|
+
const captureH = size?.height ?? region.height;
|
|
79
|
+
const cropFilter = buildCropFilter(size ? undefined : options.crop);
|
|
80
|
+
console.log(`Capturing monitor ${region.monitorIdx + 1} at (${region.offsetX}, ${region.offsetY}), size ${captureW}x${captureH}`);
|
|
81
|
+
const rl = createInterface({
|
|
82
|
+
input: process.stdin,
|
|
83
|
+
output: process.stdout,
|
|
84
|
+
});
|
|
85
|
+
const waitEnter = (msg) => new Promise((res) => {
|
|
86
|
+
process.stdout.write(msg);
|
|
87
|
+
rl.once("line", () => res());
|
|
88
|
+
});
|
|
89
|
+
const screenshot = mode === "screenshot";
|
|
90
|
+
const duration = options.duration !== undefined ? Number(options.duration) : null;
|
|
91
|
+
const qualityArgs = options.quality === undefined
|
|
92
|
+
? []
|
|
93
|
+
: /[a-zA-Z]$/.test(options.quality)
|
|
94
|
+
? ["-b:v", options.quality]
|
|
95
|
+
: ["-crf", options.quality];
|
|
96
|
+
const ext = screenshot ? "png" : "mp4";
|
|
97
|
+
const outputLinux = options.output ??
|
|
98
|
+
resolve(process.cwd(), "recordings", `${Date.now()}.${ext}`);
|
|
99
|
+
mkdirSync(dirname(outputLinux), { recursive: true });
|
|
100
|
+
const outputWin = execSync(`wslpath -w "${outputLinux}"`).toString().trim();
|
|
101
|
+
if (options.manual) {
|
|
102
|
+
await waitEnter(`Press Enter to ${screenshot ? "capture" : "start recording"}...`);
|
|
103
|
+
}
|
|
104
|
+
const ddagrabParams = [
|
|
105
|
+
`ddagrab=output_idx=${region.monitorIdx}`,
|
|
106
|
+
`offset_x=${region.offsetX}`,
|
|
107
|
+
`offset_y=${region.offsetY}`,
|
|
108
|
+
`video_size=${captureW}x${captureH}`,
|
|
109
|
+
...(options.fps !== undefined ? [`framerate=${options.fps}`] : []),
|
|
110
|
+
...(options.pointer ? [] : ["draw_mouse=0"]),
|
|
111
|
+
];
|
|
112
|
+
const ddagrab = ddagrabParams.join(":");
|
|
113
|
+
const filter = `${ddagrab},hwdownload,format=bgra,${cropFilter}`;
|
|
114
|
+
const ffmpegArgs = screenshot
|
|
115
|
+
? ["-filter_complex", filter, "-frames:v", "1", "-y", outputWin]
|
|
116
|
+
: [
|
|
117
|
+
"-filter_complex",
|
|
118
|
+
filter,
|
|
119
|
+
...(duration !== null ? ["-t", String(duration)] : []),
|
|
120
|
+
"-c:v",
|
|
121
|
+
"libx264",
|
|
122
|
+
"-pix_fmt",
|
|
123
|
+
"yuv420p",
|
|
124
|
+
...qualityArgs,
|
|
125
|
+
"-y",
|
|
126
|
+
outputWin,
|
|
127
|
+
];
|
|
128
|
+
const io = options.verbose ? "inherit" : "ignore";
|
|
129
|
+
const ffmpeg = spawn("ffmpeg.exe", ffmpegArgs, {
|
|
130
|
+
stdio: ["pipe", io, io],
|
|
131
|
+
});
|
|
132
|
+
ffmpeg.on("close", () => {
|
|
133
|
+
console.log(`\nDone: ${outputLinux}`);
|
|
134
|
+
});
|
|
135
|
+
if (screenshot || duration !== null) {
|
|
136
|
+
rl.close();
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await waitEnter("Press Enter to stop recording...");
|
|
140
|
+
rl.close();
|
|
141
|
+
ffmpeg.stdin?.end("q");
|
|
142
|
+
}
|
|
143
|
+
}
|
package/dist/chrome.d.ts
CHANGED
package/dist/chrome.js
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
|
-
import { execSync } from "child_process";
|
|
1
|
+
import { execSync, spawn } from "child_process";
|
|
2
|
+
import { copyFileSync, mkdirSync, rmSync } from "fs";
|
|
3
|
+
import { dirname, resolve } from "path";
|
|
4
|
+
import { createInterface } from "readline";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import pkg from "../package.json" with { type: "json" };
|
|
2
7
|
const SCRIPT_NAME = "chrome.ps1";
|
|
3
|
-
|
|
8
|
+
const LEGACY_SCRIPT_NAME = "get_chrome_info.ps1";
|
|
9
|
+
function deployScript() {
|
|
4
10
|
const localAppDataWin = execSync("powershell.exe -Command '$env:LOCALAPPDATA'")
|
|
5
11
|
.toString()
|
|
6
12
|
.trim();
|
|
7
|
-
|
|
13
|
+
const localAppDataLinux = execSync(`wslpath -u "${localAppDataWin}"`)
|
|
14
|
+
.toString()
|
|
15
|
+
.trim();
|
|
16
|
+
const segments = pkg.name.replace(/^@/, "").split("/");
|
|
17
|
+
const destDirLinux = resolve(localAppDataLinux, ...segments);
|
|
18
|
+
mkdirSync(destDirLinux, { recursive: true });
|
|
19
|
+
const srcDir = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
copyFileSync(resolve(srcDir, SCRIPT_NAME), resolve(destDirLinux, SCRIPT_NAME));
|
|
21
|
+
rmSync(resolve(destDirLinux, LEGACY_SCRIPT_NAME), { force: true });
|
|
22
|
+
return [localAppDataWin, ...segments, SCRIPT_NAME].join("\\");
|
|
8
23
|
}
|
|
9
24
|
export function runChromeScript(args) {
|
|
25
|
+
const scriptPathWin = deployScript();
|
|
10
26
|
const quoted = args.map((arg) => `"${arg.replace(/"/g, '""')}"`).join(" ");
|
|
11
|
-
const command = `powershell.exe -ExecutionPolicy Bypass -File "${scriptPathWin
|
|
27
|
+
const command = `powershell.exe -ExecutionPolicy Bypass -File "${scriptPathWin}" ${quoted}`;
|
|
12
28
|
try {
|
|
13
29
|
return execSync(command, { stdio: ["ignore", "pipe", "pipe"] })
|
|
14
30
|
.toString()
|
|
@@ -24,6 +40,70 @@ export function runChromeScript(args) {
|
|
|
24
40
|
});
|
|
25
41
|
}
|
|
26
42
|
}
|
|
43
|
+
// Keeps the script's stdin open so it can hold the CDP session alive, and
|
|
44
|
+
// releases it by writing a newline. Output is streamed as it arrives.
|
|
45
|
+
export function runChromeScriptInteractive(args) {
|
|
46
|
+
const scriptPathWin = deployScript();
|
|
47
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
48
|
+
const child = spawn("powershell.exe", ["-ExecutionPolicy", "Bypass", "-File", scriptPathWin, ...args], { stdio: ["pipe", "pipe", "pipe"] });
|
|
49
|
+
let pending = "";
|
|
50
|
+
const emit = (line) => {
|
|
51
|
+
const text = line.trim().replace(/\r/, "");
|
|
52
|
+
if (text.length > 0)
|
|
53
|
+
console.log(text);
|
|
54
|
+
};
|
|
55
|
+
child.stdout.setEncoding("utf8");
|
|
56
|
+
child.stdout.on("data", (chunk) => {
|
|
57
|
+
pending += chunk;
|
|
58
|
+
const lines = pending.split("\n");
|
|
59
|
+
pending = lines.pop() ?? "";
|
|
60
|
+
for (const line of lines)
|
|
61
|
+
emit(line);
|
|
62
|
+
});
|
|
63
|
+
let stderrText = "";
|
|
64
|
+
child.stderr.setEncoding("utf8");
|
|
65
|
+
child.stderr.on("data", (chunk) => {
|
|
66
|
+
stderrText += chunk;
|
|
67
|
+
});
|
|
68
|
+
const rl = createInterface({
|
|
69
|
+
input: process.stdin,
|
|
70
|
+
output: process.stdout,
|
|
71
|
+
});
|
|
72
|
+
let released = false;
|
|
73
|
+
const release = () => {
|
|
74
|
+
if (released)
|
|
75
|
+
return;
|
|
76
|
+
released = true;
|
|
77
|
+
child.stdin.end("\n");
|
|
78
|
+
};
|
|
79
|
+
rl.once("line", release);
|
|
80
|
+
rl.once("close", release);
|
|
81
|
+
// readline puts a TTY stdin in raw mode, so Ctrl+C never reaches the child.
|
|
82
|
+
let interrupted = false;
|
|
83
|
+
rl.on("SIGINT", () => {
|
|
84
|
+
interrupted = true;
|
|
85
|
+
child.kill();
|
|
86
|
+
});
|
|
87
|
+
child.on("error", (error) => {
|
|
88
|
+
released = true;
|
|
89
|
+
rl.close();
|
|
90
|
+
rejectPromise(error);
|
|
91
|
+
});
|
|
92
|
+
child.on("close", (code) => {
|
|
93
|
+
emit(pending);
|
|
94
|
+
released = true;
|
|
95
|
+
rl.close();
|
|
96
|
+
if (code === 0 || interrupted) {
|
|
97
|
+
resolvePromise();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const message = stderrText.trim();
|
|
101
|
+
rejectPromise(new Error(message.length > 0
|
|
102
|
+
? message
|
|
103
|
+
: `chrome.ps1 exited with code ${String(code)}.`));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
27
107
|
export function parseSize(value, label) {
|
|
28
108
|
const match = /^(\d+)x(\d+)$/.exec(value);
|
|
29
109
|
if (!match) {
|
package/dist/chrome.ps1
CHANGED
|
@@ -4,7 +4,9 @@ param(
|
|
|
4
4
|
[int]$ViewportWidth = 0,
|
|
5
5
|
[int]$ViewportHeight = 0,
|
|
6
6
|
[int]$PaddingWidth = 0,
|
|
7
|
-
[int]$PaddingHeight = 0
|
|
7
|
+
[int]$PaddingHeight = 0,
|
|
8
|
+
[switch]$NoScrollbar,
|
|
9
|
+
[switch]$Hold
|
|
8
10
|
)
|
|
9
11
|
|
|
10
12
|
$ErrorActionPreference = "Stop"
|
|
@@ -194,12 +196,17 @@ try {
|
|
|
194
196
|
|
|
195
197
|
$sessionId = (Invoke-Cdp "Target.attachToTarget" @{ targetId = $target.targetId; flatten = $true } $null).result.sessionId
|
|
196
198
|
Invoke-Cdp "Emulation.setDeviceMetricsOverride" @{
|
|
197
|
-
width
|
|
198
|
-
height
|
|
199
|
-
deviceScaleFactor
|
|
200
|
-
mobile
|
|
199
|
+
width = $ViewportWidth
|
|
200
|
+
height = $ViewportHeight
|
|
201
|
+
deviceScaleFactor = 1
|
|
202
|
+
mobile = $false
|
|
203
|
+
dontSetVisibleSize = $true
|
|
201
204
|
} $sessionId | Out-Null
|
|
202
205
|
|
|
206
|
+
if ($NoScrollbar) {
|
|
207
|
+
Invoke-Cdp "Emulation.setScrollbarsHidden" @{ hidden = $true } $sessionId | Out-Null
|
|
208
|
+
}
|
|
209
|
+
|
|
203
210
|
Write-Output "viewport $ViewportWidth $ViewportHeight"
|
|
204
211
|
Write-Output "content $contentWidth $contentHeight"
|
|
205
212
|
Write-Output "title $($target.title)"
|
|
@@ -209,4 +216,9 @@ catch {
|
|
|
209
216
|
$ws.Dispose()
|
|
210
217
|
exit 1
|
|
211
218
|
}
|
|
219
|
+
|
|
220
|
+
if ($Hold) {
|
|
221
|
+
Write-Output "Holding the viewport. Press Enter to release."
|
|
222
|
+
[Console]::ReadLine() | Out-Null
|
|
223
|
+
}
|
|
212
224
|
$ws.Dispose()
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import { registerConcatCommand } from "./concat.js";
|
|
4
4
|
import { registerRecordCommand } from "./record.js";
|
|
5
5
|
import { registerResizeCommand } from "./resize.js";
|
|
6
|
+
import { registerScreenshotCommand } from "./screenshot.js";
|
|
6
7
|
function reportAndExit(error) {
|
|
7
8
|
console.error(error instanceof Error ? error.message : String(error));
|
|
8
9
|
process.exit(1);
|
|
@@ -14,6 +15,7 @@ program
|
|
|
14
15
|
.name("chrome-recording")
|
|
15
16
|
.description("Record Chrome browser content in WSL");
|
|
16
17
|
registerRecordCommand(program);
|
|
18
|
+
registerScreenshotCommand(program);
|
|
17
19
|
registerResizeCommand(program);
|
|
18
20
|
registerConcatCommand(program);
|
|
19
21
|
program.parse();
|
package/dist/record.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdirSync } from "fs";
|
|
3
|
-
import { dirname, resolve } from "path";
|
|
4
|
-
import { createInterface } from "readline";
|
|
5
|
-
import { runChromeScript } from "./chrome.js";
|
|
1
|
+
import { runCapture } from "./capture.js";
|
|
6
2
|
export function registerRecordCommand(program) {
|
|
7
3
|
program
|
|
8
4
|
.command("record")
|
|
9
5
|
.description("Record Chrome browser content")
|
|
10
|
-
.option("-s, --
|
|
6
|
+
.option("-s, --size <width>x<height>", "Capture size")
|
|
11
7
|
.option("-o, --output <file>", "Output file path")
|
|
12
8
|
.option("-c, --crop <top:right:bottom:left>", "Crop (CSS order)")
|
|
13
9
|
.option("-m, --manual", "Start recording manually")
|
|
@@ -18,126 +14,6 @@ export function registerRecordCommand(program) {
|
|
|
18
14
|
.option("-t, --title <text>", "Select a window whose page title contains this text")
|
|
19
15
|
.option("-v, --verbose", "Show ffmpeg output")
|
|
20
16
|
.action(async (options) => {
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
execSync(`which ${cmd}`, { stdio: "ignore" });
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
if (missing.length > 0) {
|
|
31
|
-
throw new Error(`Command(s) not found: ${missing.join(", ")}\nWSL + Windows environment required.`);
|
|
32
|
-
}
|
|
33
|
-
const infoArgs = ["info"];
|
|
34
|
-
if (options.title)
|
|
35
|
-
infoArgs.push("-Title", options.title);
|
|
36
|
-
const info = runChromeScript(infoArgs);
|
|
37
|
-
const contentLine = info.find((l) => l.startsWith("content"));
|
|
38
|
-
if (!contentLine) {
|
|
39
|
-
throw new Error("Failed to get Chrome content area info");
|
|
40
|
-
}
|
|
41
|
-
const contentParts = contentLine.split(/\s+/).slice(1).map(Number);
|
|
42
|
-
const contentX = contentParts[0] ?? 0;
|
|
43
|
-
const contentY = contentParts[1] ?? 0;
|
|
44
|
-
const contentW = contentParts[2] ?? 0;
|
|
45
|
-
const contentH = contentParts[3] ?? 0;
|
|
46
|
-
const monitors = info
|
|
47
|
-
.filter((l) => l.startsWith("monitor"))
|
|
48
|
-
.map((l) => {
|
|
49
|
-
const parts = l.split(/\s+/).map(Number);
|
|
50
|
-
const idx = parts[1] ?? 0;
|
|
51
|
-
const left = parts[2] ?? 0;
|
|
52
|
-
const top = parts[3] ?? 0;
|
|
53
|
-
const right = parts[4] ?? 0;
|
|
54
|
-
const bottom = parts[5] ?? 0;
|
|
55
|
-
return { idx, left, top, right, bottom };
|
|
56
|
-
});
|
|
57
|
-
const monitor = monitors.find((m) => contentX >= m.left &&
|
|
58
|
-
contentX < m.right &&
|
|
59
|
-
contentY >= m.top &&
|
|
60
|
-
contentY < m.bottom);
|
|
61
|
-
if (!monitor)
|
|
62
|
-
throw new Error("Chrome content area not found");
|
|
63
|
-
const offsetX = contentX - monitor.left;
|
|
64
|
-
const offsetY = contentY - monitor.top;
|
|
65
|
-
console.log(`Capturing monitor ${monitor.idx + 1} at (${offsetX}, ${offsetY}), size ${contentW}x${contentH}`);
|
|
66
|
-
const rl = createInterface({
|
|
67
|
-
input: process.stdin,
|
|
68
|
-
output: process.stdout,
|
|
69
|
-
});
|
|
70
|
-
const waitEnter = (msg) => new Promise((res) => {
|
|
71
|
-
process.stdout.write(msg);
|
|
72
|
-
rl.once("line", () => res());
|
|
73
|
-
});
|
|
74
|
-
const [cropT, cropR, cropB, cropL] = options.crop
|
|
75
|
-
? options.crop.split(":").map(Number)
|
|
76
|
-
: [0, 0, 0, 0];
|
|
77
|
-
const cropW = `floor((iw-${(cropL ?? 0) + (cropR ?? 0)})/2)*2`;
|
|
78
|
-
const cropH = `floor((ih-${(cropT ?? 0) + (cropB ?? 0)})/2)*2`;
|
|
79
|
-
const cropFilter = `crop=${cropW}:${cropH}:${cropL ?? 0}:${cropT ?? 0}`;
|
|
80
|
-
const screenshot = options.screenshot;
|
|
81
|
-
const duration = options.duration !== undefined ? Number(options.duration) : null;
|
|
82
|
-
const qualityArgs = options.quality === undefined
|
|
83
|
-
? []
|
|
84
|
-
: /[a-zA-Z]$/.test(options.quality)
|
|
85
|
-
? ["-b:v", options.quality]
|
|
86
|
-
: ["-crf", options.quality];
|
|
87
|
-
const ext = screenshot ? "png" : "mp4";
|
|
88
|
-
const outputLinux = options.output ??
|
|
89
|
-
resolve(process.cwd(), "recordings", `${Date.now()}.${ext}`);
|
|
90
|
-
mkdirSync(dirname(outputLinux), { recursive: true });
|
|
91
|
-
const outputWin = execSync(`wslpath -w "${outputLinux}"`)
|
|
92
|
-
.toString()
|
|
93
|
-
.trim();
|
|
94
|
-
if (options.manual) {
|
|
95
|
-
await waitEnter(`Press Enter to ${screenshot ? "capture" : "start recording"}...`);
|
|
96
|
-
}
|
|
97
|
-
const ddagrabParams = [
|
|
98
|
-
`ddagrab=output_idx=${monitor.idx}`,
|
|
99
|
-
`offset_x=${offsetX}`,
|
|
100
|
-
`offset_y=${offsetY}`,
|
|
101
|
-
`video_size=${contentW}x${contentH}`,
|
|
102
|
-
...(options.fps !== undefined ? [`framerate=${options.fps}`] : []),
|
|
103
|
-
...(options.pointer ? [] : ["draw_mouse=0"]),
|
|
104
|
-
];
|
|
105
|
-
const ddagrab = ddagrabParams.join(":");
|
|
106
|
-
const ffmpegArgs = screenshot
|
|
107
|
-
? [
|
|
108
|
-
"-filter_complex",
|
|
109
|
-
`${ddagrab},hwdownload,format=bgra,${cropFilter}`,
|
|
110
|
-
"-frames:v",
|
|
111
|
-
"1",
|
|
112
|
-
"-y",
|
|
113
|
-
outputWin,
|
|
114
|
-
]
|
|
115
|
-
: [
|
|
116
|
-
"-filter_complex",
|
|
117
|
-
`${ddagrab},hwdownload,format=bgra,${cropFilter}`,
|
|
118
|
-
...(duration !== null ? ["-t", String(duration)] : []),
|
|
119
|
-
"-c:v",
|
|
120
|
-
"libx264",
|
|
121
|
-
"-pix_fmt",
|
|
122
|
-
"yuv420p",
|
|
123
|
-
...qualityArgs,
|
|
124
|
-
"-y",
|
|
125
|
-
outputWin,
|
|
126
|
-
];
|
|
127
|
-
const io = options.verbose ? "inherit" : "ignore";
|
|
128
|
-
const ffmpeg = spawn("ffmpeg.exe", ffmpegArgs, {
|
|
129
|
-
stdio: ["pipe", io, io],
|
|
130
|
-
});
|
|
131
|
-
ffmpeg.on("close", () => {
|
|
132
|
-
console.log(`\nDone: ${outputLinux}`);
|
|
133
|
-
});
|
|
134
|
-
if (screenshot || duration !== null) {
|
|
135
|
-
rl.close();
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
await waitEnter("Press Enter to stop recording...");
|
|
139
|
-
rl.close();
|
|
140
|
-
ffmpeg.stdin?.end("q");
|
|
141
|
-
}
|
|
17
|
+
await runCapture("video", options);
|
|
142
18
|
});
|
|
143
19
|
}
|
package/dist/resize.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { parseSize, runChromeScript } from "./chrome.js";
|
|
1
|
+
import { parseSize, runChromeScript, runChromeScriptInteractive, } from "./chrome.js";
|
|
2
2
|
export function registerResizeCommand(program) {
|
|
3
3
|
program
|
|
4
4
|
.command("resize")
|
|
5
5
|
.description("Resize the Chrome viewport")
|
|
6
6
|
.argument("<size>", "Viewport size as <width>x<height>")
|
|
7
7
|
.option("-p, --padding <width>x<height>", "Padding kept outside the viewport (right and bottom)", "0x0")
|
|
8
|
+
.option("-n, --no-scrollbar", "Hide the scrollbars")
|
|
8
9
|
.option("-t, --title <text>", "Select a page whose title contains this text")
|
|
9
|
-
.action((size, options) => {
|
|
10
|
+
.action(async (size, options) => {
|
|
10
11
|
const viewport = parseSize(size, "size");
|
|
11
12
|
const padding = parseSize(options.padding, "padding");
|
|
12
13
|
const args = [
|
|
@@ -20,10 +21,19 @@ export function registerResizeCommand(program) {
|
|
|
20
21
|
"-PaddingHeight",
|
|
21
22
|
String(padding.height),
|
|
22
23
|
];
|
|
24
|
+
if (!options.scrollbar)
|
|
25
|
+
args.push("-NoScrollbar");
|
|
23
26
|
if (options.title)
|
|
24
27
|
args.push("-Title", options.title);
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
// Chrome drops the device metrics override the moment the CDP session
|
|
29
|
+
// closes, so padding and hidden scrollbars need the session held open.
|
|
30
|
+
const hold = padding.width > 0 || padding.height > 0 || !options.scrollbar;
|
|
31
|
+
if (!hold) {
|
|
32
|
+
for (const line of runChromeScript(args))
|
|
33
|
+
console.log(line);
|
|
34
|
+
return;
|
|
27
35
|
}
|
|
36
|
+
args.push("-Hold");
|
|
37
|
+
await runChromeScriptInteractive(args);
|
|
28
38
|
});
|
|
29
39
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { runCapture } from "./capture.js";
|
|
2
|
+
export function registerScreenshotCommand(program) {
|
|
3
|
+
program
|
|
4
|
+
.command("screenshot")
|
|
5
|
+
.description("Capture Chrome browser content as a still image")
|
|
6
|
+
.option("-s, --size <width>x<height>", "Capture size")
|
|
7
|
+
.option("-o, --output <file>", "Output file path")
|
|
8
|
+
.option("-c, --crop <top:right:bottom:left>", "Crop (CSS order)")
|
|
9
|
+
.option("-m, --manual", "Capture manually")
|
|
10
|
+
.option("-n, --no-pointer", "Hide the mouse pointer")
|
|
11
|
+
.option("-t, --title <text>", "Select a window whose page title contains this text")
|
|
12
|
+
.option("-v, --verbose", "Show ffmpeg output")
|
|
13
|
+
.action(async (options) => {
|
|
14
|
+
await runCapture("screenshot", options);
|
|
15
|
+
});
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"windows",
|
|
9
9
|
"wsl"
|
|
10
10
|
],
|
|
11
|
-
"version": "
|
|
11
|
+
"version": "2.0.0",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"files": [
|
|
14
14
|
"dist"
|
|
@@ -17,14 +17,15 @@
|
|
|
17
17
|
"chrome-recording": "dist/cli.js"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"build": "rm -rf dist && tsc && cp src/chrome.ps1 dist/"
|
|
21
|
-
"postinstall": "node dist/postinstall.js"
|
|
20
|
+
"build": "rm -rf dist && tsc && cp src/chrome.ps1 dist/"
|
|
22
21
|
},
|
|
23
22
|
"devDependencies": {
|
|
24
23
|
"@eslint/js": "^10.0.1",
|
|
25
24
|
"@types/node": "^25.6.0",
|
|
26
25
|
"eslint": "^10.2.0",
|
|
27
26
|
"globals": "^17.4.0",
|
|
27
|
+
"prettier": "^3.9.6",
|
|
28
|
+
"prettier-plugin-organize-imports": "^4.3.0",
|
|
28
29
|
"typescript": "^6.0.2",
|
|
29
30
|
"typescript-eslint": "^8.58.1"
|
|
30
31
|
},
|
package/dist/postinstall.d.ts
DELETED
package/dist/postinstall.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { copyFileSync, mkdirSync } from "fs";
|
|
3
|
-
import { dirname, resolve } from "path";
|
|
4
|
-
import { fileURLToPath } from "url";
|
|
5
|
-
import { execSync } from "child_process";
|
|
6
|
-
import pkg from "../package.json" with { type: "json" };
|
|
7
|
-
const [scope, pkgName] = pkg.name.replace(/^@/, "").split("/");
|
|
8
|
-
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
const ps1Src = resolve(scriptDir, "chrome.ps1");
|
|
10
|
-
const localAppDataWin = execSync("powershell.exe -Command '$env:LOCALAPPDATA'")
|
|
11
|
-
.toString()
|
|
12
|
-
.trim();
|
|
13
|
-
const localAppDataLinux = execSync(`wslpath -u "${localAppDataWin}"`)
|
|
14
|
-
.toString()
|
|
15
|
-
.trim();
|
|
16
|
-
const destDir = resolve(localAppDataLinux, scope ?? pkg.name, pkgName ?? "");
|
|
17
|
-
mkdirSync(destDir, { recursive: true });
|
|
18
|
-
copyFileSync(ps1Src, resolve(destDir, "chrome.ps1"));
|
|
19
|
-
console.log(`Installed chrome.ps1 to ${destDir}`);
|