rpc-agent 0.0.0-stage → 0.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.
- package/CHANGELOG.md +103 -0
- package/LICENSE +21 -0
- package/NOTICE +16 -0
- package/README.md +302 -2
- package/assets/bezels/imac.svg +63 -0
- package/assets/bezels/ipad.svg +27 -0
- package/assets/bezels/iphone-11-pro.svg +35 -0
- package/assets/bezels/iphone-12-mini.svg +26 -0
- package/assets/bezels/iphone-12-pro-max.svg +26 -0
- package/assets/bezels/iphone-17-pro.svg +172 -0
- package/assets/bezels/iphone-se.svg +37 -0
- package/assets/bezels/laptop.svg +25 -0
- package/assets/bezels/macbook-air.svg +235 -0
- package/assets/bezels/macbook-pro-13.svg +63 -0
- package/assets/bezels/macbook-pro-14.svg +115 -0
- package/assets/bezels/macbook-pro.svg +32 -0
- package/assets/bezels/macbook.svg +25 -0
- package/assets/bezels/monitor.svg +57 -0
- package/assets/bezels/phone-classic.svg +17 -0
- package/assets/bezels/phone-home.svg +12 -0
- package/assets/bezels/phone-island.svg +130 -0
- package/assets/bezels/phone-notch.svg +26 -0
- package/assets/bezels/surface-laptop.svg +84 -0
- package/assets/bezels/tablet.svg +25 -0
- package/assets/bezels/windows-laptop.svg +84 -0
- package/dist/args.js +189 -0
- package/dist/bezel.js +74 -0
- package/dist/browser.js +80 -0
- package/dist/capture.js +241 -0
- package/dist/cli.js +175 -0
- package/dist/composite.js +115 -0
- package/dist/devices.js +407 -0
- package/dist/errors.js +115 -0
- package/dist/help.js +110 -0
- package/dist/mcp.js +245 -0
- package/dist/options.js +51 -0
- package/dist/png.js +183 -0
- package/dist/slices.js +59 -0
- package/dist/term.js +46 -0
- package/dist/ui.js +488 -0
- package/package.json +67 -4
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { capture, launchBrowser } from './capture.js';
|
|
6
|
+
import { renderPreview } from './composite.js';
|
|
7
|
+
import { CATEGORY_LABELS, CATEGORY_ORDER, DEFAULT_DEVICES, DEVICE_ALIASES, DEVICES, resolveDevices, } from './devices.js';
|
|
8
|
+
import { COLOR_SCHEMES, DEFAULT_MAX_SLICES, DEFAULT_OUT_DIR, DEFAULT_SLICE_WIDTH, DEFAULT_TIMEOUT_MS, normalizeUrl, } from './options.js';
|
|
9
|
+
import { DEFAULT_MAX_HEIGHT } from './slices.js';
|
|
10
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
|
+
const screenshotInput = {
|
|
12
|
+
url: z
|
|
13
|
+
.string()
|
|
14
|
+
.describe('Page to capture. http(s)://, file://, a bare host like localhost:3000, or a path to a local .html file.'),
|
|
15
|
+
devices: z
|
|
16
|
+
.array(z.string())
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(8)
|
|
19
|
+
.optional()
|
|
20
|
+
.describe(`Device ids or aliases (see list_devices). Default: ${DEFAULT_DEVICES.join(', ')}. ` +
|
|
21
|
+
`Aliases: ${Object.keys(DEVICE_ALIASES).join(', ')}.`),
|
|
22
|
+
bezels: z.boolean().optional().describe('Also write a copy framed in the device bezel (above-the-fold only). Default false.'),
|
|
23
|
+
fullPage: z
|
|
24
|
+
.boolean()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Capture the full scroll height (any height, stitched; capped by maxHeight). The result then shows the whole page ' +
|
|
27
|
+
'as top-to-bottom JPEG slices, one viewport tall each, after the framed first screen when bezels is on. Default false.'),
|
|
28
|
+
maxSlices: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(50)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe(`Full page: most slices returned inline per device. Default ${DEFAULT_MAX_SLICES}. ` +
|
|
35
|
+
'When a page has more, every slice is also saved as a JPEG and the paths are listed so you can Read the rest.'),
|
|
36
|
+
saveSlices: z
|
|
37
|
+
.boolean()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Full page: always (true) or never (false) write slice JPEGs next to the PNGs. Default: only when slices were cut by maxSlices.'),
|
|
40
|
+
sliceWidth: z
|
|
41
|
+
.number()
|
|
42
|
+
.int()
|
|
43
|
+
.min(256)
|
|
44
|
+
.max(1568)
|
|
45
|
+
.optional()
|
|
46
|
+
.describe(`Full page: max slice width in px. Default ${DEFAULT_SLICE_WIDTH}; raise it (e.g. 1200) to read small text on desktop widths.`),
|
|
47
|
+
maxHeight: z
|
|
48
|
+
.number()
|
|
49
|
+
.int()
|
|
50
|
+
.min(500)
|
|
51
|
+
.max(200_000)
|
|
52
|
+
.optional()
|
|
53
|
+
.describe(`Full page: capture at most this many CSS px; taller pages are marked clipped. Default ${DEFAULT_MAX_HEIGHT}.`),
|
|
54
|
+
reducedMotion: z
|
|
55
|
+
.boolean()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('Emulate prefers-reduced-motion: reduce. Helps when scroll-triggered entrance animations (GSAP, Framer Motion) leave sections blank.'),
|
|
58
|
+
waitFor: z.string().optional().describe('CSS selector to wait for (visible) before capturing.'),
|
|
59
|
+
waitMs: z.number().int().min(0).max(30_000).optional().describe('Extra settle delay after load, in ms.'),
|
|
60
|
+
colorScheme: z.enum(COLOR_SCHEMES).optional().describe('Emulate prefers-color-scheme.'),
|
|
61
|
+
outDir: z.string().optional().describe(`Output directory, relative to the server's cwd. Default ${DEFAULT_OUT_DIR}.`),
|
|
62
|
+
timeoutMs: z.number().int().min(1000).max(120_000).optional().describe(`Navigation timeout. Default ${DEFAULT_TIMEOUT_MS}.`),
|
|
63
|
+
inline: z
|
|
64
|
+
.boolean()
|
|
65
|
+
.optional()
|
|
66
|
+
.describe('Return JPEG previews inline: one per device, or the full-page slices when fullPage. Default true.'),
|
|
67
|
+
};
|
|
68
|
+
export function createServer() {
|
|
69
|
+
const server = new McpServer({ name: 'rpc', version: pkg.version });
|
|
70
|
+
server.registerTool('screenshot', {
|
|
71
|
+
title: 'Screenshot at device viewports',
|
|
72
|
+
description: 'Load a URL in headless Chromium at one or more device viewports (with device pixel ratio, mobile user agent and touch) ' +
|
|
73
|
+
'and save PNG screenshots, optionally framed in a device bezel. Use it to check responsive layout of a page you are building, ' +
|
|
74
|
+
'including local dev servers. Returns file paths, page title, HTTP status and console errors, plus inline previews.',
|
|
75
|
+
inputSchema: screenshotInput,
|
|
76
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
77
|
+
}, async (args) => {
|
|
78
|
+
let options;
|
|
79
|
+
try {
|
|
80
|
+
options = {
|
|
81
|
+
url: normalizeUrl(args.url),
|
|
82
|
+
devices: resolveDevices(args.devices ?? DEFAULT_DEVICES).map(d => d.id),
|
|
83
|
+
bezels: args.bezels ?? false,
|
|
84
|
+
fullPage: args.fullPage ?? false,
|
|
85
|
+
outDir: args.outDir ?? DEFAULT_OUT_DIR,
|
|
86
|
+
waitFor: args.waitFor,
|
|
87
|
+
waitMs: args.waitMs ?? 0,
|
|
88
|
+
colorScheme: args.colorScheme,
|
|
89
|
+
timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
90
|
+
emulate: true,
|
|
91
|
+
reducedMotion: args.reducedMotion ?? false,
|
|
92
|
+
maxHeight: args.maxHeight ?? DEFAULT_MAX_HEIGHT,
|
|
93
|
+
saveSlices: args.saveSlices,
|
|
94
|
+
maxSlices: args.maxSlices ?? DEFAULT_MAX_SLICES,
|
|
95
|
+
sliceWidth: args.sliceWidth ?? DEFAULT_SLICE_WIDTH,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
100
|
+
}
|
|
101
|
+
const inline = args.inline ?? true;
|
|
102
|
+
let browser;
|
|
103
|
+
try {
|
|
104
|
+
browser = await launchBrowser();
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return { isError: true, content: [{ type: 'text', text: `Could not start the browser: ${err.message}` }] };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const result = await capture(options, { browser, keepBuffers: true, inlineSlices: inline && options.fullPage });
|
|
111
|
+
const content = [
|
|
112
|
+
{
|
|
113
|
+
type: 'text',
|
|
114
|
+
text: headline(result.shots, options) +
|
|
115
|
+
'\n\n' +
|
|
116
|
+
JSON.stringify(summarise(result.url, result.outDir, result.shots), null, 2),
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
if (inline) {
|
|
120
|
+
for (const s of result.shots) {
|
|
121
|
+
if (s.error)
|
|
122
|
+
continue;
|
|
123
|
+
if (options.fullPage) {
|
|
124
|
+
content.push(...(await fullPageImages(browser, s, options.maxSlices)));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const src = s.framed && s.buffers?.framed ? { buf: s.buffers.framed, size: s.framed } : s.shot && s.buffers?.shot ? { buf: s.buffers.shot, size: s.shot } : undefined;
|
|
128
|
+
if (!src)
|
|
129
|
+
continue;
|
|
130
|
+
const p = await renderPreview(browser, src.buf, src.size);
|
|
131
|
+
content.push({
|
|
132
|
+
type: 'text',
|
|
133
|
+
text: `${s.device}${s.framed ? ' (framed)' : ''}: preview ${p.width}x${p.height}${p.cropped ? ', cropped to top of page' : ''}`,
|
|
134
|
+
});
|
|
135
|
+
content.push({ type: 'image', data: p.data.toString('base64'), mimeType: 'image/jpeg' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const allFailed = result.shots.every(s => s.error);
|
|
139
|
+
return allFailed ? { isError: true, content } : { content };
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await browser.close();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
server.registerTool('list_devices', {
|
|
146
|
+
title: 'List device presets',
|
|
147
|
+
description: 'List device ids, CSS viewport sizes and pixel ratios usable in screenshot.devices, plus short aliases.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
category: z
|
|
150
|
+
.enum(CATEGORY_ORDER)
|
|
151
|
+
.optional()
|
|
152
|
+
.describe('Filter to one category.'),
|
|
153
|
+
},
|
|
154
|
+
annotations: { readOnlyHint: true },
|
|
155
|
+
}, async ({ category }) => {
|
|
156
|
+
const lines = [];
|
|
157
|
+
for (const cat of CATEGORY_ORDER) {
|
|
158
|
+
if (category && cat !== category)
|
|
159
|
+
continue;
|
|
160
|
+
lines.push(`## ${CATEGORY_LABELS[cat]}`);
|
|
161
|
+
for (const d of DEVICES.filter(x => x.category === cat)) {
|
|
162
|
+
lines.push(`${d.id}: ${d.width}x${d.height} @${d.dpr}x (${d.name})`);
|
|
163
|
+
}
|
|
164
|
+
lines.push('');
|
|
165
|
+
}
|
|
166
|
+
lines.push('## Aliases');
|
|
167
|
+
for (const [a, id] of Object.entries(DEVICE_ALIASES))
|
|
168
|
+
lines.push(`${a} -> ${id}`);
|
|
169
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
170
|
+
});
|
|
171
|
+
return server;
|
|
172
|
+
}
|
|
173
|
+
/** Framed first screen (the hero), then the first maxSlices slices, each with a label. */
|
|
174
|
+
async function fullPageImages(browser, s, maxSlices) {
|
|
175
|
+
const out = [];
|
|
176
|
+
if (s.framed && s.buffers?.framed) {
|
|
177
|
+
const p = await renderPreview(browser, s.buffers.framed, s.framed);
|
|
178
|
+
out.push({ type: 'text', text: `${s.device} · first screen in bezel` });
|
|
179
|
+
out.push({ type: 'image', data: p.data.toString('base64'), mimeType: 'image/jpeg' });
|
|
180
|
+
}
|
|
181
|
+
const items = s.slices?.items ?? [];
|
|
182
|
+
const bufs = s.buffers?.slices ?? [];
|
|
183
|
+
for (let i = 0; i < Math.min(items.length, maxSlices); i++) {
|
|
184
|
+
out.push({ type: 'text', text: items[i].label });
|
|
185
|
+
out.push({ type: 'image', data: bufs[i].toString('base64'), mimeType: 'image/jpeg' });
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/** Plain-language first lines: failures with hints, and what full-page output covers. */
|
|
190
|
+
export function headline(shots, options) {
|
|
191
|
+
const lines = [];
|
|
192
|
+
const failed = shots.filter(s => s.error);
|
|
193
|
+
if (failed.length === shots.length)
|
|
194
|
+
lines.push(`All ${shots.length} device(s) failed.`);
|
|
195
|
+
for (const s of shots) {
|
|
196
|
+
if (s.error) {
|
|
197
|
+
lines.push(`FAILED ${s.device}: ${s.error}${s.hint ? `\n hint: ${s.hint}` : ''}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const size = s.shot ? `${s.shot.width}x${s.shot.height}px` : '';
|
|
201
|
+
if (!options.fullPage) {
|
|
202
|
+
lines.push(`ok ${s.device}: ${size}${s.shot ? ` ${s.shot.path}` : ''}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const parts = [
|
|
206
|
+
`ok ${s.device}: full page ${s.capturedHeight} css px tall (${size}` +
|
|
207
|
+
`${s.segments && s.segments > 1 ? `, ${s.segments} segments stitched` : ''}) ${s.shot ? s.shot.path : ''}`,
|
|
208
|
+
];
|
|
209
|
+
if (s.clipped) {
|
|
210
|
+
parts.push(` clipped: the page is ${s.scrollHeight} css px; only the top ${options.maxHeight} were captured (raise maxHeight).`);
|
|
211
|
+
}
|
|
212
|
+
const sl = s.slices;
|
|
213
|
+
if (sl) {
|
|
214
|
+
const shown = Math.min(sl.items.length, options.maxSlices);
|
|
215
|
+
if (sl.total > shown) {
|
|
216
|
+
const firstSkipped = sl.items[shown] ?? undefined;
|
|
217
|
+
const from = firstSkipped ? firstSkipped.y : shown * sl.sliceHeight;
|
|
218
|
+
parts.push(` inline: slices 1-${shown} of ${sl.total}; slices ${shown + 1}-${sl.total} (y=${from}-${s.capturedHeight} css px) skipped inline.`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
parts.push(` inline: all ${sl.total} slice(s), top to bottom.`);
|
|
222
|
+
}
|
|
223
|
+
if (sl.saved) {
|
|
224
|
+
const first = sl.items[0]?.path;
|
|
225
|
+
parts.push(` slice files: ${first ? first.replace(/-slice-\d+\.jpg$/, '-slice-NN.jpg') : '(none)'} (01-${String(sl.total).padStart(2, '0')}); full PNG: ${s.shot ? s.shot.path : ''}`);
|
|
226
|
+
}
|
|
227
|
+
else if (sl.total > shown) {
|
|
228
|
+
parts.push(` full PNG: ${s.shot ? s.shot.path : ''}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
lines.push(parts.join('\n'));
|
|
232
|
+
}
|
|
233
|
+
return lines.join('\n');
|
|
234
|
+
}
|
|
235
|
+
function summarise(url, outDir, shots) {
|
|
236
|
+
return {
|
|
237
|
+
url,
|
|
238
|
+
outDir,
|
|
239
|
+
shots: shots.map(({ buffers: _b, ...s }) => s),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
export async function runMcpServer() {
|
|
243
|
+
const server = createServer();
|
|
244
|
+
await server.connect(new StdioServerTransport());
|
|
245
|
+
}
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { DEFAULT_DEVICES } from './devices.js';
|
|
5
|
+
import { DEFAULT_MAX_HEIGHT } from './slices.js';
|
|
6
|
+
export const COLOR_SCHEMES = ['light', 'dark', 'no-preference'];
|
|
7
|
+
export const DEFAULT_MAX_SLICES = 10;
|
|
8
|
+
export const DEFAULT_SLICE_WIDTH = 768;
|
|
9
|
+
export const DEFAULT_OUT_DIR = '.rpc-shots';
|
|
10
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
11
|
+
export function defaultShotOptions(url) {
|
|
12
|
+
return {
|
|
13
|
+
url: normalizeUrl(url),
|
|
14
|
+
devices: [...DEFAULT_DEVICES],
|
|
15
|
+
bezels: false,
|
|
16
|
+
fullPage: false,
|
|
17
|
+
outDir: DEFAULT_OUT_DIR,
|
|
18
|
+
waitMs: 0,
|
|
19
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
20
|
+
emulate: true,
|
|
21
|
+
reducedMotion: false,
|
|
22
|
+
maxHeight: DEFAULT_MAX_HEIGHT,
|
|
23
|
+
maxSlices: DEFAULT_MAX_SLICES,
|
|
24
|
+
sliceWidth: DEFAULT_SLICE_WIDTH,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Accept what an agent is likely to type:
|
|
29
|
+
* http(s)://…, file://… -> unchanged
|
|
30
|
+
* localhost:3000/x, 127.0.0.1:8080, [::1]:3000 -> http://
|
|
31
|
+
* ./page.html, /abs/page.html (existing file) -> file://
|
|
32
|
+
* example.com -> https://
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeUrl(input, cwd = process.cwd()) {
|
|
35
|
+
const raw = input.trim();
|
|
36
|
+
if (!raw)
|
|
37
|
+
throw new Error('URL is required');
|
|
38
|
+
if (/^(https?|file):\/\//i.test(raw))
|
|
39
|
+
return new URL(raw).href;
|
|
40
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
|
41
|
+
throw new Error(`Unsupported URL scheme in "${raw}" (use http, https or file)`);
|
|
42
|
+
}
|
|
43
|
+
if (/^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|\[::1\])(:\d+)?(\/|$)/i.test(raw)) {
|
|
44
|
+
return new URL(`http://${raw}`).href;
|
|
45
|
+
}
|
|
46
|
+
const asPath = isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
47
|
+
if ((raw.startsWith('.') || raw.startsWith('/') || /\.html?$/i.test(raw)) && existsSync(asPath)) {
|
|
48
|
+
return pathToFileURL(asPath).href;
|
|
49
|
+
}
|
|
50
|
+
return new URL(`https://${raw}`).href;
|
|
51
|
+
}
|
package/dist/png.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createDeflate, inflateSync } from 'node:zlib';
|
|
2
|
+
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
3
|
+
/** Read width/height from a PNG's IHDR chunk without decoding it. */
|
|
4
|
+
export function pngSize(buf) {
|
|
5
|
+
if (buf.length < 24 || SIGNATURE.some((b, i) => buf[i] !== b)) {
|
|
6
|
+
throw new Error('Not a PNG');
|
|
7
|
+
}
|
|
8
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
9
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Decode an 8-bit RGB/RGBA non-interlaced PNG, which is what Chromium
|
|
13
|
+
* screenshots are. Anything else throws: this is not a general decoder.
|
|
14
|
+
*/
|
|
15
|
+
export function decodePng(buf) {
|
|
16
|
+
const { width, height } = pngSize(buf);
|
|
17
|
+
let pos = 8;
|
|
18
|
+
const idat = [];
|
|
19
|
+
let channels;
|
|
20
|
+
while (pos + 8 <= buf.length) {
|
|
21
|
+
const len = buf.readUInt32BE(pos);
|
|
22
|
+
const type = buf.toString('latin1', pos + 4, pos + 8);
|
|
23
|
+
const body = buf.subarray(pos + 8, pos + 8 + len);
|
|
24
|
+
if (type === 'IHDR') {
|
|
25
|
+
const [depth, color, , , interlace] = body.subarray(8, 13);
|
|
26
|
+
if (depth !== 8 || (color !== 2 && color !== 6) || interlace !== 0) {
|
|
27
|
+
throw new Error(`Unsupported PNG (bit depth ${depth}, colour type ${color}, interlace ${interlace})`);
|
|
28
|
+
}
|
|
29
|
+
channels = color === 6 ? 4 : 3;
|
|
30
|
+
}
|
|
31
|
+
else if (type === 'IDAT') {
|
|
32
|
+
idat.push(body);
|
|
33
|
+
}
|
|
34
|
+
else if (type === 'IEND') {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
pos += 12 + len;
|
|
38
|
+
}
|
|
39
|
+
if (!channels)
|
|
40
|
+
throw new Error('PNG has no IHDR');
|
|
41
|
+
const filtered = inflateSync(Buffer.concat(idat));
|
|
42
|
+
const stride = width * channels;
|
|
43
|
+
if (filtered.length < height * (stride + 1))
|
|
44
|
+
throw new Error('Truncated PNG data');
|
|
45
|
+
const out = Buffer.alloc(height * stride);
|
|
46
|
+
const bpp = channels;
|
|
47
|
+
for (let y = 0; y < height; y++) {
|
|
48
|
+
const f = filtered[y * (stride + 1)];
|
|
49
|
+
const src = y * (stride + 1) + 1;
|
|
50
|
+
const dst = y * stride;
|
|
51
|
+
const up = dst - stride;
|
|
52
|
+
for (let i = 0; i < stride; i++) {
|
|
53
|
+
const x = filtered[src + i];
|
|
54
|
+
const a = i >= bpp ? out[dst + i - bpp] : 0;
|
|
55
|
+
const b = y > 0 ? out[up + i] : 0;
|
|
56
|
+
let v;
|
|
57
|
+
switch (f) {
|
|
58
|
+
case 0:
|
|
59
|
+
v = x;
|
|
60
|
+
break;
|
|
61
|
+
case 1:
|
|
62
|
+
v = x + a;
|
|
63
|
+
break;
|
|
64
|
+
case 2:
|
|
65
|
+
v = x + b;
|
|
66
|
+
break;
|
|
67
|
+
case 3:
|
|
68
|
+
v = x + ((a + b) >> 1);
|
|
69
|
+
break;
|
|
70
|
+
case 4: {
|
|
71
|
+
const c = y > 0 && i >= bpp ? out[up + i - bpp] : 0;
|
|
72
|
+
const p = a + b - c;
|
|
73
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
74
|
+
v = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
default: throw new Error(`Bad PNG filter type ${f}`);
|
|
78
|
+
}
|
|
79
|
+
out[dst + i] = v & 0xff;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { width, height, channels, data: out };
|
|
83
|
+
}
|
|
84
|
+
function toChannels(img, channels) {
|
|
85
|
+
if (img.channels === channels)
|
|
86
|
+
return img.data;
|
|
87
|
+
const n = img.width * img.height;
|
|
88
|
+
const out = Buffer.alloc(n * channels, 0xff);
|
|
89
|
+
for (let p = 0; p < n; p++) {
|
|
90
|
+
for (let c = 0; c < 3; c++)
|
|
91
|
+
out[p * channels + c] = img.data[p * img.channels + c];
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
const CRC_TABLE = (() => {
|
|
96
|
+
const t = new Uint32Array(256);
|
|
97
|
+
for (let n = 0; n < 256; n++) {
|
|
98
|
+
let c = n;
|
|
99
|
+
for (let k = 0; k < 8; k++)
|
|
100
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
101
|
+
t[n] = c >>> 0;
|
|
102
|
+
}
|
|
103
|
+
return t;
|
|
104
|
+
})();
|
|
105
|
+
function crc32(parts) {
|
|
106
|
+
let c = 0xffffffff;
|
|
107
|
+
for (const buf of parts)
|
|
108
|
+
for (let i = 0; i < buf.length; i++)
|
|
109
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
110
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
111
|
+
}
|
|
112
|
+
function chunk(type, body) {
|
|
113
|
+
const head = Buffer.alloc(8);
|
|
114
|
+
head.writeUInt32BE(body.length, 0);
|
|
115
|
+
head.write(type, 4, 'latin1');
|
|
116
|
+
const crc = Buffer.alloc(4);
|
|
117
|
+
crc.writeUInt32BE(crc32([head.subarray(4), body]));
|
|
118
|
+
return Buffer.concat([head, body, crc]);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Stack PNGs of equal width top to bottom into one PNG. Each input is decoded,
|
|
122
|
+
* filtered (Up) and streamed into a single deflate stream, so peak memory is
|
|
123
|
+
* about one input's raw pixels plus the compressed output: the result can be
|
|
124
|
+
* far taller than any canvas or Chromium capture allows.
|
|
125
|
+
*/
|
|
126
|
+
export async function stitchPngs(pngs) {
|
|
127
|
+
if (pngs.length === 0)
|
|
128
|
+
throw new Error('Nothing to stitch');
|
|
129
|
+
if (pngs.length === 1)
|
|
130
|
+
return pngs[0];
|
|
131
|
+
const sizes = pngs.map(pngSize);
|
|
132
|
+
const width = sizes[0].width;
|
|
133
|
+
if (sizes.some(s => s.width !== width))
|
|
134
|
+
throw new Error('stitchPngs: inputs differ in width');
|
|
135
|
+
const height = sizes.reduce((h, s) => h + s.height, 0);
|
|
136
|
+
// Peek each IHDR colour type so mixed RGB/RGBA inputs share one format.
|
|
137
|
+
const channels = pngs.some(p => p[25] === 6) ? 4 : 3;
|
|
138
|
+
const stride = width * channels;
|
|
139
|
+
const deflate = createDeflate({ level: 6, memLevel: 9 });
|
|
140
|
+
const compressed = [];
|
|
141
|
+
deflate.on('data', (d) => compressed.push(d));
|
|
142
|
+
const done = new Promise((res, rej) => {
|
|
143
|
+
deflate.on('end', res);
|
|
144
|
+
deflate.on('error', rej);
|
|
145
|
+
});
|
|
146
|
+
let prev;
|
|
147
|
+
for (const png of pngs) {
|
|
148
|
+
const img = decodePng(png);
|
|
149
|
+
const raw = toChannels(img, channels);
|
|
150
|
+
const out = Buffer.alloc(img.height * (stride + 1));
|
|
151
|
+
for (let y = 0; y < img.height; y++) {
|
|
152
|
+
const row = raw.subarray(y * stride, (y + 1) * stride);
|
|
153
|
+
const o = y * (stride + 1);
|
|
154
|
+
if (!prev) {
|
|
155
|
+
out[o] = 0;
|
|
156
|
+
row.copy(out, o + 1);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
out[o] = 2; // Up
|
|
160
|
+
for (let i = 0; i < stride; i++)
|
|
161
|
+
out[o + 1 + i] = (row[i] - prev[i]) & 0xff;
|
|
162
|
+
}
|
|
163
|
+
prev = row;
|
|
164
|
+
}
|
|
165
|
+
// Keep the last row alive past this iteration for the next input's Up filter.
|
|
166
|
+
prev = Buffer.from(prev);
|
|
167
|
+
if (!deflate.write(out))
|
|
168
|
+
await new Promise(r => deflate.once('drain', r));
|
|
169
|
+
}
|
|
170
|
+
deflate.end();
|
|
171
|
+
await done;
|
|
172
|
+
const ihdr = Buffer.alloc(13);
|
|
173
|
+
ihdr.writeUInt32BE(width, 0);
|
|
174
|
+
ihdr.writeUInt32BE(height, 4);
|
|
175
|
+
ihdr[8] = 8;
|
|
176
|
+
ihdr[9] = channels === 4 ? 6 : 2;
|
|
177
|
+
const data = Buffer.concat(compressed);
|
|
178
|
+
const idats = [];
|
|
179
|
+
const MAX_IDAT = 8 * 1024 * 1024;
|
|
180
|
+
for (let i = 0; i < data.length; i += MAX_IDAT)
|
|
181
|
+
idats.push(chunk('IDAT', data.subarray(i, i + MAX_IDAT)));
|
|
182
|
+
return Buffer.concat([SIGNATURE, chunk('IHDR', ihdr), ...idats, chunk('IEND', Buffer.alloc(0))]);
|
|
183
|
+
}
|
package/dist/slices.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Chromium can't produce a bitmap taller than this in one capture. */
|
|
2
|
+
export const MAX_CAPTURE_PX = 16_384;
|
|
3
|
+
/** Device px per stitched segment: well under the limit, a few MB raw each. */
|
|
4
|
+
export const SEGMENT_PX = 8_192;
|
|
5
|
+
/** Default absolute cap on full-page height, in CSS px. */
|
|
6
|
+
export const DEFAULT_MAX_HEIGHT = 60_000;
|
|
7
|
+
const isWhole = (n) => Math.abs(n - Math.round(n)) < 1e-6;
|
|
8
|
+
/**
|
|
9
|
+
* Split `totalCss` into capture segments of at most `maxDevicePx` device px.
|
|
10
|
+
* Every segment but the last is a CSS height whose device height is a whole
|
|
11
|
+
* number of pixels (e.g. multiples of 8 at DPR 2.625), so stitched seams
|
|
12
|
+
* land exactly on pixel rows.
|
|
13
|
+
*/
|
|
14
|
+
export function planSegments(totalCss, dpr, maxDevicePx = SEGMENT_PX) {
|
|
15
|
+
const total = Math.max(1, Math.ceil(totalCss));
|
|
16
|
+
let seg = Math.max(1, Math.floor(maxDevicePx / dpr));
|
|
17
|
+
while (seg > 1 && !isWhole(seg * dpr))
|
|
18
|
+
seg--;
|
|
19
|
+
const out = [];
|
|
20
|
+
for (let y = 0; y < total; y += seg)
|
|
21
|
+
out.push({ y, height: Math.min(seg, total - y) });
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** True when a page this tall fits a single Chromium capture. */
|
|
25
|
+
export function fitsOneCapture(totalCss, dpr) {
|
|
26
|
+
return Math.ceil(totalCss) * dpr <= MAX_CAPTURE_PX;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Cut a page into top-to-bottom review slices of `sliceHeight` CSS px (one
|
|
30
|
+
* viewport by default). A last slice shorter than a fifth of `sliceHeight` is
|
|
31
|
+
* folded into the one before it rather than shown as a sliver.
|
|
32
|
+
*/
|
|
33
|
+
export function planSlices(device, totalCss, sliceHeight) {
|
|
34
|
+
const total = Math.max(1, Math.round(totalCss));
|
|
35
|
+
const step = Math.max(1, Math.round(sliceHeight));
|
|
36
|
+
const bands = [];
|
|
37
|
+
for (let y = 0; y < total; y += step)
|
|
38
|
+
bands.push({ y, height: Math.min(step, total - y) });
|
|
39
|
+
if (bands.length > 1 && bands[bands.length - 1].height < step / 5) {
|
|
40
|
+
const last = bands.pop();
|
|
41
|
+
bands[bands.length - 1].height += last.height;
|
|
42
|
+
}
|
|
43
|
+
return bands.map((b, i) => ({
|
|
44
|
+
...b,
|
|
45
|
+
index: i + 1,
|
|
46
|
+
total: bands.length,
|
|
47
|
+
label: sliceLabel(device, i + 1, bands.length, b),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
export function sliceLabel(device, index, total, b) {
|
|
51
|
+
return `${device} · slice ${index}/${total} · y=${b.y}–${b.y + b.height} css px`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Preview scale (output px per CSS px) for slices: as sharp as the capture
|
|
55
|
+
* allows (never above the DPR) but no wider than `maxWidth`.
|
|
56
|
+
*/
|
|
57
|
+
export function sliceScale(cssWidth, dpr, maxWidth) {
|
|
58
|
+
return Math.min(dpr, maxWidth / cssWidth);
|
|
59
|
+
}
|
package/dist/term.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
function set(v) {
|
|
3
|
+
return v !== undefined && v !== '';
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Rich output needs a real terminal: stdout is a TTY, not CI, TERM isn't
|
|
7
|
+
* `dumb`, and no --json. NO_COLOR turns colour off; FORCE_COLOR (except `0`)
|
|
8
|
+
* turns it on, even for the plain layout, and wins over NO_COLOR.
|
|
9
|
+
*/
|
|
10
|
+
export function outputMode({ isTTY, env, json = false }) {
|
|
11
|
+
if (json)
|
|
12
|
+
return { rich: false, color: false };
|
|
13
|
+
const rich = isTTY && !set(env.CI) && env.TERM !== 'dumb';
|
|
14
|
+
// Same precedence as Node's own util.styleText, which clack uses for its
|
|
15
|
+
// symbols: FORCE_COLOR beats NO_COLOR, NO_COLOR beats the TTY check.
|
|
16
|
+
let color = rich;
|
|
17
|
+
if (set(env.NO_COLOR))
|
|
18
|
+
color = false;
|
|
19
|
+
if (set(env.FORCE_COLOR))
|
|
20
|
+
color = env.FORCE_COLOR !== '0';
|
|
21
|
+
return { rich, color };
|
|
22
|
+
}
|
|
23
|
+
export function currentMode(json = false) {
|
|
24
|
+
return outputMode({ isTTY: Boolean(process.stdout.isTTY), env: process.env, json });
|
|
25
|
+
}
|
|
26
|
+
/** Command that opens a folder or file in the desktop shell. */
|
|
27
|
+
export function openCommand(platform = process.platform) {
|
|
28
|
+
if (platform === 'darwin')
|
|
29
|
+
return { cmd: 'open', args: [] };
|
|
30
|
+
if (platform === 'win32')
|
|
31
|
+
return { cmd: 'explorer', args: [] };
|
|
32
|
+
return { cmd: 'xdg-open', args: [] };
|
|
33
|
+
}
|
|
34
|
+
/** Open a folder or file in the desktop shell, detached and silent. */
|
|
35
|
+
export function openPath(path) {
|
|
36
|
+
const { cmd, args } = openCommand();
|
|
37
|
+
try {
|
|
38
|
+
const child = spawn(cmd, [...args, path], { detached: true, stdio: 'ignore' });
|
|
39
|
+
child.on('error', () => { });
|
|
40
|
+
child.unref();
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|