@afixt/screenshot-utils 0.9.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/CHANGELOG.md +39 -0
- package/LICENSE +13 -0
- package/README.md +132 -0
- package/bin/afixt-screenshot +14 -0
- package/dist/index.js +2797 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2795 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +164 -0
- package/src/adapters/cdp.js +116 -0
- package/src/adapters/index.js +17 -0
- package/src/adapters/playwright.js +150 -0
- package/src/adapters/puppeteer.js +128 -0
- package/src/capture/element.js +257 -0
- package/src/capture/index.js +17 -0
- package/src/capture/long-page.js +267 -0
- package/src/capture/page.js +207 -0
- package/src/capture/responsive.js +94 -0
- package/src/capture/settle.js +182 -0
- package/src/capture/viewport.js +21 -0
- package/src/cli/index.js +266 -0
- package/src/compose/annotate.js +283 -0
- package/src/compose/diff.js +173 -0
- package/src/compose/heatmap.js +159 -0
- package/src/compose/index.js +8 -0
- package/src/compose/theme.js +45 -0
- package/src/errors/adapter.js +65 -0
- package/src/errors/base.js +48 -0
- package/src/errors/capture.js +175 -0
- package/src/errors/compose.js +78 -0
- package/src/errors/index.js +75 -0
- package/src/errors/optional.js +37 -0
- package/src/errors/redaction.js +42 -0
- package/src/errors/tile.js +61 -0
- package/src/errors/transform.js +78 -0
- package/src/index.js +69 -0
- package/src/redact/index.js +6 -0
- package/src/redact/policy.js +67 -0
- package/src/redact/redact.js +268 -0
- package/src/tile/dzi.js +268 -0
- package/src/tile/index.js +5 -0
- package/src/transform/convert.js +54 -0
- package/src/transform/crop.js +251 -0
- package/src/transform/index.js +8 -0
- package/src/transform/normalize.js +83 -0
- package/src/transform/resize.js +99 -0
- package/src/types/index.d.ts +432 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { InvalidRectError, BufferCorruptError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract a single rectangle from a source image buffer.
|
|
7
|
+
*
|
|
8
|
+
* Implements `spec.md` § 8.2.1. All coordinates are CSS pixels. Padding
|
|
9
|
+
* expands the rect in all four directions and is then clamped to the
|
|
10
|
+
* image bounds (we never error on padding causing under/overflow — the
|
|
11
|
+
* caller asked for slack, not arithmetic).
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
* @param {Buffer} input
|
|
15
|
+
* @param {import('../types/index.d.ts').Rect} rect
|
|
16
|
+
* @param {import('../types/index.d.ts').CropOptions} [options]
|
|
17
|
+
* @returns {Promise<Buffer>}
|
|
18
|
+
*/
|
|
19
|
+
async function crop(input, rect, options = {}) {
|
|
20
|
+
assertBuffer(input);
|
|
21
|
+
assertRect(rect);
|
|
22
|
+
const { padding = 0, format, quality = 90 } = options;
|
|
23
|
+
const sharp = require('sharp');
|
|
24
|
+
|
|
25
|
+
const meta = await readMeta(sharp, input);
|
|
26
|
+
const extract = computeExtract(rect, padding, meta);
|
|
27
|
+
if (extract.width <= 0 || extract.height <= 0) {
|
|
28
|
+
throw new InvalidRectError(
|
|
29
|
+
`Rect resolves to 0×0 after clamping to image bounds (${meta.width}×${meta.height})`,
|
|
30
|
+
{ context: { rect, padding, imageWidth: meta.width, imageHeight: meta.height } }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let pipeline = sharp(input).extract(extract);
|
|
35
|
+
pipeline = applyFormat(pipeline, format, quality);
|
|
36
|
+
return pipeline.toBuffer();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract many rectangles in parallel from one source. Used by the engine's
|
|
41
|
+
* `_captureIssueScreenshots` migration path (one full-page capture + many
|
|
42
|
+
* crops beats N per-element captures by ~20×).
|
|
43
|
+
*
|
|
44
|
+
* Implements `spec.md` § 8.2.2.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
* @param {Buffer} input
|
|
48
|
+
* @param {Array<{ id: string, rect: import('../types/index.d.ts').Rect }>} entries
|
|
49
|
+
* @param {import('../types/index.d.ts').CropManyOptions} [options]
|
|
50
|
+
* @returns {Promise<Map<string, Buffer>>}
|
|
51
|
+
*/
|
|
52
|
+
async function cropMany(input, entries, options = {}) {
|
|
53
|
+
assertBuffer(input);
|
|
54
|
+
if (!Array.isArray(entries)) {
|
|
55
|
+
throw new InvalidRectError('cropMany entries must be an array', {
|
|
56
|
+
context: { received: typeof entries },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const { padding = 0, format, quality = 90, parallelism = 4 } = options;
|
|
60
|
+
const sharp = require('sharp');
|
|
61
|
+
const meta = await readMeta(sharp, input);
|
|
62
|
+
|
|
63
|
+
// Validate all rects up front so we fail fast (and uniformly) before
|
|
64
|
+
// doing any work, rather than partially producing crops then erroring.
|
|
65
|
+
const planned = entries.map(entry => {
|
|
66
|
+
if (!entry || typeof entry.id !== 'string') {
|
|
67
|
+
throw new InvalidRectError('Each cropMany entry must have a string id and a rect', {
|
|
68
|
+
context: { entry },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
assertRect(entry.rect);
|
|
72
|
+
const extract = computeExtract(entry.rect, padding, meta);
|
|
73
|
+
if (extract.width <= 0 || extract.height <= 0) {
|
|
74
|
+
throw new InvalidRectError(
|
|
75
|
+
`Rect for id ${JSON.stringify(entry.id)} resolves to 0×0 after clamping`,
|
|
76
|
+
{
|
|
77
|
+
context: {
|
|
78
|
+
id: entry.id,
|
|
79
|
+
rect: entry.rect,
|
|
80
|
+
padding,
|
|
81
|
+
imageWidth: meta.width,
|
|
82
|
+
imageHeight: meta.height,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return { id: entry.id, extract };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const results = new Map();
|
|
91
|
+
await parallelEach(
|
|
92
|
+
planned,
|
|
93
|
+
async ({ id, extract }) => {
|
|
94
|
+
let pipeline = sharp(input).extract(extract);
|
|
95
|
+
pipeline = applyFormat(pipeline, format, quality);
|
|
96
|
+
const buffer = await pipeline.toBuffer();
|
|
97
|
+
results.set(id, buffer);
|
|
98
|
+
},
|
|
99
|
+
Math.max(1, parallelism)
|
|
100
|
+
);
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run `fn(item)` over every item with at most `limit` in flight at once.
|
|
106
|
+
* Order of completion is irrelevant; the caller stores results by id.
|
|
107
|
+
*
|
|
108
|
+
* @template T
|
|
109
|
+
* @param {readonly T[]} items
|
|
110
|
+
* @param {(item: T, index: number) => Promise<void>} fn
|
|
111
|
+
* @param {number} limit
|
|
112
|
+
* @returns {Promise<void>}
|
|
113
|
+
*/
|
|
114
|
+
async function parallelEach(items, fn, limit) {
|
|
115
|
+
let cursor = 0;
|
|
116
|
+
const workers = Array.from({ length: Math.min(limit, items.length || 1) }, async () => {
|
|
117
|
+
while (cursor < items.length) {
|
|
118
|
+
const i = cursor;
|
|
119
|
+
cursor += 1;
|
|
120
|
+
await fn(items[i], i);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
await Promise.all(workers);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate a rect has the right shape and finite, non-negative dimensions.
|
|
128
|
+
*
|
|
129
|
+
* @param {unknown} rect
|
|
130
|
+
*/
|
|
131
|
+
function assertRect(rect) {
|
|
132
|
+
if (rect === null || typeof rect !== 'object') {
|
|
133
|
+
throw new InvalidRectError('rect must be an object', {
|
|
134
|
+
context: { received: typeof rect },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const { x, y, width, height } = /** @type {Record<string, unknown>} */ (rect);
|
|
138
|
+
for (const [key, value] of [
|
|
139
|
+
['x', x],
|
|
140
|
+
['y', y],
|
|
141
|
+
['width', width],
|
|
142
|
+
['height', height],
|
|
143
|
+
]) {
|
|
144
|
+
if (!Number.isFinite(value)) {
|
|
145
|
+
throw new InvalidRectError(`rect.${key} must be a finite number`, {
|
|
146
|
+
context: { rect },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (width <= 0 || height <= 0) {
|
|
151
|
+
throw new InvalidRectError('rect.width and rect.height must be > 0', {
|
|
152
|
+
context: { rect },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function assertBuffer(input) {
|
|
158
|
+
if (!Buffer.isBuffer(input)) {
|
|
159
|
+
throw new BufferCorruptError('input must be a Buffer', {
|
|
160
|
+
context: { received: typeof input },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Compute the integer rect that sharp.extract expects after applying
|
|
167
|
+
* padding and clamping to image bounds. Padding is symmetric; clamping
|
|
168
|
+
* never throws — we just shrink the requested area.
|
|
169
|
+
*
|
|
170
|
+
* @param {import('../types/index.d.ts').Rect} rect
|
|
171
|
+
* @param {number} padding
|
|
172
|
+
* @param {{width: number, height: number}} meta
|
|
173
|
+
* @returns {{left: number, top: number, width: number, height: number}}
|
|
174
|
+
*/
|
|
175
|
+
function computeExtract(rect, padding, meta) {
|
|
176
|
+
const left = clamp(Math.round(rect.x - padding), 0, meta.width);
|
|
177
|
+
const top = clamp(Math.round(rect.y - padding), 0, meta.height);
|
|
178
|
+
const right = clamp(Math.round(rect.x + rect.width + padding), 0, meta.width);
|
|
179
|
+
const bottom = clamp(Math.round(rect.y + rect.height + padding), 0, meta.height);
|
|
180
|
+
return {
|
|
181
|
+
left,
|
|
182
|
+
top,
|
|
183
|
+
width: Math.max(0, right - left),
|
|
184
|
+
height: Math.max(0, bottom - top),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function clamp(n, lo, hi) {
|
|
189
|
+
if (n < lo) {
|
|
190
|
+
return lo;
|
|
191
|
+
}
|
|
192
|
+
if (n > hi) {
|
|
193
|
+
return hi;
|
|
194
|
+
}
|
|
195
|
+
return n;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read image dimensions via sharp.metadata(). Wraps sharp-thrown errors
|
|
200
|
+
* as BufferCorruptError so callers see the package's error contract.
|
|
201
|
+
*
|
|
202
|
+
* @param {ReturnType<typeof require>} sharp
|
|
203
|
+
* @param {Buffer} input
|
|
204
|
+
* @returns {Promise<{width: number, height: number}>}
|
|
205
|
+
*/
|
|
206
|
+
async function readMeta(sharp, input) {
|
|
207
|
+
try {
|
|
208
|
+
const meta = await sharp(input).metadata();
|
|
209
|
+
if (!meta.width || !meta.height) {
|
|
210
|
+
throw new BufferCorruptError('Image has no detectable dimensions', {
|
|
211
|
+
context: { meta },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return { width: meta.width, height: meta.height };
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error instanceof BufferCorruptError) {
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
throw new BufferCorruptError('sharp could not read input buffer metadata', {
|
|
220
|
+
cause: error,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Apply the requested output format to a sharp pipeline. When `format` is
|
|
227
|
+
* undefined the pipeline keeps its native format (input format inferred).
|
|
228
|
+
*
|
|
229
|
+
* @template T
|
|
230
|
+
* @param {T} pipeline
|
|
231
|
+
* @param {string | undefined} format
|
|
232
|
+
* @param {number} quality
|
|
233
|
+
* @returns {T}
|
|
234
|
+
*/
|
|
235
|
+
function applyFormat(pipeline, format, quality) {
|
|
236
|
+
if (!format) {
|
|
237
|
+
return pipeline;
|
|
238
|
+
}
|
|
239
|
+
switch (format) {
|
|
240
|
+
case 'png':
|
|
241
|
+
return pipeline.png();
|
|
242
|
+
case 'jpeg':
|
|
243
|
+
return pipeline.jpeg({ quality });
|
|
244
|
+
case 'webp':
|
|
245
|
+
return pipeline.webp({ quality });
|
|
246
|
+
default:
|
|
247
|
+
return pipeline;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { crop, cropMany };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { crop, cropMany } = require('./crop.js');
|
|
4
|
+
const { convert } = require('./convert.js');
|
|
5
|
+
const { resize } = require('./resize.js');
|
|
6
|
+
const { normalize } = require('./normalize.js');
|
|
7
|
+
|
|
8
|
+
module.exports = { crop, cropMany, convert, resize, normalize };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BufferCorruptError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize an image buffer to a target device-pixel ratio so its coordinate
|
|
7
|
+
* space is CSS pixels regardless of the source DPR.
|
|
8
|
+
*
|
|
9
|
+
* Implements `spec.md` § 8.2.5. Default behavior preserves the image's
|
|
10
|
+
* intrinsic DPR (caller opts in to normalization for cross-DPR comparison).
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
* @param {Buffer} input
|
|
14
|
+
* @param {import('../types/index.d.ts').NormalizeOptions} [options]
|
|
15
|
+
* @returns {Promise<import('../types/index.d.ts').NormalizeResult>}
|
|
16
|
+
*/
|
|
17
|
+
async function normalize(input, options = {}) {
|
|
18
|
+
assertBuffer(input);
|
|
19
|
+
const sharp = require('sharp');
|
|
20
|
+
const meta = await readMeta(sharp, input);
|
|
21
|
+
|
|
22
|
+
const { fromDpr = inferDpr(meta), toDpr = 1 } = options;
|
|
23
|
+
if (!Number.isFinite(fromDpr) || fromDpr <= 0) {
|
|
24
|
+
throw new BufferCorruptError('Could not infer source DPR and no fromDpr supplied', {
|
|
25
|
+
context: { meta, fromDpr },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const scale = toDpr / fromDpr;
|
|
29
|
+
if (scale === 1) {
|
|
30
|
+
// No-op: keep the input buffer; report its current CSS dimensions.
|
|
31
|
+
return {
|
|
32
|
+
buffer: input,
|
|
33
|
+
dimensions: {
|
|
34
|
+
width: Math.round(meta.width / fromDpr),
|
|
35
|
+
height: Math.round(meta.height / fromDpr),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const targetWidth = Math.max(1, Math.round(meta.width * scale));
|
|
40
|
+
const targetHeight = Math.max(1, Math.round(meta.height * scale));
|
|
41
|
+
const buffer = await sharp(input)
|
|
42
|
+
.resize({ width: targetWidth, height: targetHeight })
|
|
43
|
+
.toBuffer();
|
|
44
|
+
return {
|
|
45
|
+
buffer,
|
|
46
|
+
dimensions: { width: targetWidth, height: targetHeight },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Infer the source image's DPR from metadata. Sharp doesn't expose a
|
|
52
|
+
* direct devicePixelRatio so we use the density hint when present
|
|
53
|
+
* (DPI / 96), otherwise assume 1.
|
|
54
|
+
*
|
|
55
|
+
* @param {Awaited<ReturnType<import('sharp').Sharp['metadata']>>} meta
|
|
56
|
+
* @returns {number}
|
|
57
|
+
*/
|
|
58
|
+
function inferDpr(meta) {
|
|
59
|
+
if (meta && Number.isFinite(meta.density) && meta.density > 0) {
|
|
60
|
+
return meta.density / 96;
|
|
61
|
+
}
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function assertBuffer(input) {
|
|
66
|
+
if (!Buffer.isBuffer(input)) {
|
|
67
|
+
throw new BufferCorruptError('input must be a Buffer', {
|
|
68
|
+
context: { received: typeof input },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readMeta(sharp, input) {
|
|
74
|
+
try {
|
|
75
|
+
return await sharp(input).metadata();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new BufferCorruptError('sharp could not read input buffer metadata', {
|
|
78
|
+
cause: error,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { normalize };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BufferCorruptError, InvalidRectError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resize an image buffer with sharp-native semantics.
|
|
7
|
+
*
|
|
8
|
+
* Implements `spec.md` § 8.2.3. Honors `width`, `height`, `fit`, `position`,
|
|
9
|
+
* `kernel`, and `withoutEnlargement`. At least one of `width` or `height`
|
|
10
|
+
* must be supplied.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
* @param {Buffer} input
|
|
14
|
+
* @param {import('../types/index.d.ts').ResizeOptions} options
|
|
15
|
+
* @returns {Promise<Buffer>}
|
|
16
|
+
*/
|
|
17
|
+
async function resize(input, options) {
|
|
18
|
+
assertBuffer(input);
|
|
19
|
+
if (!options || typeof options !== 'object') {
|
|
20
|
+
throw new InvalidRectError('resize requires an options object', {
|
|
21
|
+
context: { received: typeof options },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const {
|
|
25
|
+
width,
|
|
26
|
+
height,
|
|
27
|
+
fit,
|
|
28
|
+
position,
|
|
29
|
+
kernel,
|
|
30
|
+
withoutEnlargement,
|
|
31
|
+
format,
|
|
32
|
+
quality = 90,
|
|
33
|
+
} = options;
|
|
34
|
+
if (width === undefined && height === undefined) {
|
|
35
|
+
throw new InvalidRectError('resize requires at least one of width or height', {
|
|
36
|
+
context: { options },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (width !== undefined && (!Number.isFinite(width) || width <= 0)) {
|
|
40
|
+
throw new InvalidRectError('resize.width must be a positive finite number', {
|
|
41
|
+
context: { width },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (height !== undefined && (!Number.isFinite(height) || height <= 0)) {
|
|
45
|
+
throw new InvalidRectError('resize.height must be a positive finite number', {
|
|
46
|
+
context: { height },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const sharp = require('sharp');
|
|
50
|
+
const resizeOpts = {};
|
|
51
|
+
if (width !== undefined) {
|
|
52
|
+
resizeOpts.width = Math.round(width);
|
|
53
|
+
}
|
|
54
|
+
if (height !== undefined) {
|
|
55
|
+
resizeOpts.height = Math.round(height);
|
|
56
|
+
}
|
|
57
|
+
if (fit) {
|
|
58
|
+
resizeOpts.fit = fit;
|
|
59
|
+
}
|
|
60
|
+
if (position) {
|
|
61
|
+
resizeOpts.position = position;
|
|
62
|
+
}
|
|
63
|
+
if (kernel) {
|
|
64
|
+
resizeOpts.kernel = kernel;
|
|
65
|
+
}
|
|
66
|
+
if (withoutEnlargement !== undefined) {
|
|
67
|
+
resizeOpts.withoutEnlargement = withoutEnlargement;
|
|
68
|
+
}
|
|
69
|
+
let pipeline = sharp(input).resize(resizeOpts);
|
|
70
|
+
switch (format) {
|
|
71
|
+
case 'png':
|
|
72
|
+
pipeline = pipeline.png();
|
|
73
|
+
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 'jpeg':
|
|
77
|
+
pipeline = pipeline.jpeg({ quality });
|
|
78
|
+
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case 'webp':
|
|
82
|
+
pipeline = pipeline.webp({ quality });
|
|
83
|
+
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
// No default
|
|
87
|
+
}
|
|
88
|
+
return pipeline.toBuffer();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function assertBuffer(input) {
|
|
92
|
+
if (!Buffer.isBuffer(input)) {
|
|
93
|
+
throw new BufferCorruptError('input must be a Buffer', {
|
|
94
|
+
context: { received: typeof input },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { resize };
|