@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 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
- After installation, a PowerShell script (`chrome.ps1`) is automatically copied to `%LOCALAPPDATA%\y11i-3d\chrome-recording\` on Windows. This script is required by the `record` and `resize` commands.
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 | Description |
33
- | ---------- | ------------------------------------------------------- |
34
- | `record` | Capture Chrome browser content as a video or screenshot |
35
- | `resize` | Set the Chrome viewport size |
36
- | `concat` | Concatenate recorded mp4 files into one |
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
- By default, recording starts immediately and stops when you press Enter. The output is saved to `./recordings/<timestamp>.mp4`.
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, --screenshot` | Take a screenshot instead of recording a video |
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
- # Take a screenshot
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 each time this command runs.
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
- The setting survives after the command exits, but it is lost when the page is
100
- reloaded.
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>;
@@ -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
@@ -1,4 +1,5 @@
1
1
  export declare function runChromeScript(args: string[]): string[];
2
+ export declare function runChromeScriptInteractive(args: string[]): Promise<void>;
2
3
  export declare function parseSize(value: string, label: string): {
3
4
  width: number;
4
5
  height: number;
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
- function scriptPathWin() {
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
- return `${localAppDataWin}\\y11i-3d\\chrome-recording\\${SCRIPT_NAME}`;
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()}" ${quoted}`;
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 = $ViewportWidth
198
- height = $ViewportHeight
199
- deviceScaleFactor = 1
200
- mobile = $false
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 { execSync, spawn } from "child_process";
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, --screenshot", "Take a screenshot", false)
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
- const missing = ["powershell.exe", "ffmpeg.exe", "wslpath"].filter((cmd) => {
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
- for (const line of runChromeScript(args)) {
26
- console.log(line);
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,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerScreenshotCommand(program: Command): void;
@@ -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": "1.1.0",
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
  },
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
@@ -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}`);