@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +13 -0
  3. package/README.md +132 -0
  4. package/bin/afixt-screenshot +14 -0
  5. package/dist/index.js +2797 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +2795 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +164 -0
  10. package/src/adapters/cdp.js +116 -0
  11. package/src/adapters/index.js +17 -0
  12. package/src/adapters/playwright.js +150 -0
  13. package/src/adapters/puppeteer.js +128 -0
  14. package/src/capture/element.js +257 -0
  15. package/src/capture/index.js +17 -0
  16. package/src/capture/long-page.js +267 -0
  17. package/src/capture/page.js +207 -0
  18. package/src/capture/responsive.js +94 -0
  19. package/src/capture/settle.js +182 -0
  20. package/src/capture/viewport.js +21 -0
  21. package/src/cli/index.js +266 -0
  22. package/src/compose/annotate.js +283 -0
  23. package/src/compose/diff.js +173 -0
  24. package/src/compose/heatmap.js +159 -0
  25. package/src/compose/index.js +8 -0
  26. package/src/compose/theme.js +45 -0
  27. package/src/errors/adapter.js +65 -0
  28. package/src/errors/base.js +48 -0
  29. package/src/errors/capture.js +175 -0
  30. package/src/errors/compose.js +78 -0
  31. package/src/errors/index.js +75 -0
  32. package/src/errors/optional.js +37 -0
  33. package/src/errors/redaction.js +42 -0
  34. package/src/errors/tile.js +61 -0
  35. package/src/errors/transform.js +78 -0
  36. package/src/index.js +69 -0
  37. package/src/redact/index.js +6 -0
  38. package/src/redact/policy.js +67 -0
  39. package/src/redact/redact.js +268 -0
  40. package/src/tile/dzi.js +268 -0
  41. package/src/tile/index.js +5 -0
  42. package/src/transform/convert.js +54 -0
  43. package/src/transform/crop.js +251 -0
  44. package/src/transform/index.js +8 -0
  45. package/src/transform/normalize.js +83 -0
  46. package/src/transform/resize.js +99 -0
  47. package/src/types/index.d.ts +432 -0
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+
3
+ const { BufferCorruptError, RedactionError, InvalidRectError } = require('../errors/index.js');
4
+
5
+ const VALID_MODES = new Set(['blur', 'mask', 'pixelate']);
6
+
7
+ /**
8
+ * Apply one of three redaction modes to specified regions of an image.
9
+ *
10
+ * Implements `spec.md` § 8.5.1.
11
+ *
12
+ * - `mode: 'blur'` — Gaussian blur with the given `radius`.
13
+ * - `mode: 'mask'` — solid-color fill (`color`, default `#000`).
14
+ * - `mode: 'pixelate'` — downscale-then-upscale with the given `cellSize`.
15
+ *
16
+ * @public
17
+ * @param {Buffer} input
18
+ * @param {Array<import('../types/index.d.ts').RedactRegion>} regions
19
+ * @param {import('../types/index.d.ts').RedactOptions} [options]
20
+ * @returns {Promise<Buffer>}
21
+ */
22
+ async function redact(input, regions, options = {}) {
23
+ if (process.env.AFIXT_SCREENSHOT_DISABLE_REDACTION === '1') {
24
+ // Spec § 11.3 escape hatch for test fixtures only.
25
+ return input;
26
+ }
27
+ assertBuffer(input);
28
+ if (!Array.isArray(regions)) {
29
+ throw new RedactionError('regions must be an array', {
30
+ context: { received: typeof regions },
31
+ });
32
+ }
33
+ const { default: defaultMode = 'blur', defaultRadius = 8 } = options;
34
+ if (!VALID_MODES.has(defaultMode)) {
35
+ throw new RedactionError(
36
+ `default mode must be one of ${[...VALID_MODES].join(', ')}; received ${defaultMode}`,
37
+ { context: { received: defaultMode } }
38
+ );
39
+ }
40
+
41
+ const sharp = require('sharp');
42
+ const meta = await readMeta(sharp, input);
43
+
44
+ if (regions.length === 0) {
45
+ return input;
46
+ }
47
+
48
+ // Build the composite layer list. Each region produces ONE overlay
49
+ // that sharp composites at the right offset. Tiny rects (< 1px) are
50
+ // silently dropped — they cannot produce useful output.
51
+ const overlays = [];
52
+ for (const [i, region] of regions.entries()) {
53
+ const overlay = await buildOverlay(sharp, input, region, {
54
+ index: i,
55
+ defaultMode,
56
+ defaultRadius,
57
+ meta,
58
+ });
59
+ if (overlay) {
60
+ overlays.push(overlay);
61
+ }
62
+ }
63
+
64
+ return sharp(input).composite(overlays).toBuffer();
65
+ }
66
+
67
+ /**
68
+ * Produce a single composite descriptor for one region. Returns null
69
+ * when the region clamps to a zero-area rect after image-bound clamping.
70
+ *
71
+ * @param {ReturnType<typeof require>} sharp
72
+ * @param {Buffer} input
73
+ * @param {import('../types/index.d.ts').RedactRegion} region
74
+ * @param {{index: number, defaultMode: string, defaultRadius: number, meta: {width: number, height: number}}} ctx
75
+ * @returns {Promise<{input: Buffer, top: number, left: number} | null>}
76
+ */
77
+ async function buildOverlay(sharp, input, region, ctx) {
78
+ assertRegion(region, ctx.index);
79
+ const mode = region.mode ?? ctx.defaultMode;
80
+ if (!VALID_MODES.has(mode)) {
81
+ throw new RedactionError(
82
+ `regions[${ctx.index}].mode must be 'blur', 'mask', or 'pixelate'; received ${mode}`,
83
+ { context: { index: ctx.index, mode } }
84
+ );
85
+ }
86
+ const clamped = clampRect(region.rect, ctx.meta);
87
+ if (clamped.width <= 0 || clamped.height <= 0) {
88
+ return null;
89
+ }
90
+
91
+ switch (mode) {
92
+ case 'blur':
93
+ return blurOverlay(sharp, input, clamped, region.radius ?? ctx.defaultRadius);
94
+ case 'mask':
95
+ return maskOverlay(sharp, clamped, region.color ?? '#000');
96
+ case 'pixelate':
97
+ return pixelateOverlay(sharp, input, clamped, region.cellSize ?? 16);
98
+ default:
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Build a blurred crop overlay.
105
+ *
106
+ * @param {ReturnType<typeof require>} sharp
107
+ * @param {Buffer} input
108
+ * @param {{left: number, top: number, width: number, height: number}} rect
109
+ * @param {number} radius
110
+ * @returns {Promise<{input: Buffer, top: number, left: number}>}
111
+ */
112
+ async function blurOverlay(sharp, input, rect, radius) {
113
+ const validRadius = Math.max(0.3, Math.min(1000, Number(radius) || 8));
114
+ const buffer = await sharp(input)
115
+ .extract({ left: rect.left, top: rect.top, width: rect.width, height: rect.height })
116
+ .blur(validRadius)
117
+ .toBuffer();
118
+ return { input: buffer, top: rect.top, left: rect.left };
119
+ }
120
+
121
+ /**
122
+ * Build a solid-color mask overlay.
123
+ *
124
+ * @param {ReturnType<typeof require>} sharp
125
+ * @param {{left: number, top: number, width: number, height: number}} rect
126
+ * @param {string} color CSS color string (#000 by default).
127
+ * @returns {Promise<{input: Buffer, top: number, left: number}>}
128
+ */
129
+ async function maskOverlay(sharp, rect, color) {
130
+ const { r, g, b, alpha } = parseColor(color);
131
+ const buffer = await sharp({
132
+ create: {
133
+ width: rect.width,
134
+ height: rect.height,
135
+ channels: 4,
136
+ background: { r, g, b, alpha },
137
+ },
138
+ })
139
+ .png()
140
+ .toBuffer();
141
+ return { input: buffer, top: rect.top, left: rect.left };
142
+ }
143
+
144
+ /**
145
+ * Build a pixelated crop overlay (downsize then upsize, nearest-neighbour).
146
+ *
147
+ * @param {ReturnType<typeof require>} sharp
148
+ * @param {Buffer} input
149
+ * @param {{left: number, top: number, width: number, height: number}} rect
150
+ * @param {number} cellSize
151
+ * @returns {Promise<{input: Buffer, top: number, left: number}>}
152
+ */
153
+ async function pixelateOverlay(sharp, input, rect, cellSize) {
154
+ const cs = Math.max(2, Math.round(cellSize) || 16);
155
+ const downW = Math.max(1, Math.floor(rect.width / cs));
156
+ const downH = Math.max(1, Math.floor(rect.height / cs));
157
+ const buffer = await sharp(input)
158
+ .extract({ left: rect.left, top: rect.top, width: rect.width, height: rect.height })
159
+ .resize({ width: downW, height: downH, kernel: 'nearest' })
160
+ .resize({ width: rect.width, height: rect.height, kernel: 'nearest' })
161
+ .toBuffer();
162
+ return { input: buffer, top: rect.top, left: rect.left };
163
+ }
164
+
165
+ /**
166
+ * Clamp a rect to image bounds and round to integers (sharp.extract needs ints).
167
+ *
168
+ * @param {import('../types/index.d.ts').Rect} rect
169
+ * @param {{width: number, height: number}} meta
170
+ * @returns {{left: number, top: number, width: number, height: number}}
171
+ */
172
+ function clampRect(rect, meta) {
173
+ const left = Math.max(0, Math.min(meta.width, Math.round(rect.x)));
174
+ const top = Math.max(0, Math.min(meta.height, Math.round(rect.y)));
175
+ const right = Math.max(0, Math.min(meta.width, Math.round(rect.x + rect.width)));
176
+ const bottom = Math.max(0, Math.min(meta.height, Math.round(rect.y + rect.height)));
177
+ return {
178
+ left,
179
+ top,
180
+ width: Math.max(0, right - left),
181
+ height: Math.max(0, bottom - top),
182
+ };
183
+ }
184
+
185
+ function assertRegion(region, index) {
186
+ if (!region || typeof region !== 'object') {
187
+ throw new RedactionError(`regions[${index}] must be an object`, {
188
+ context: { index, received: typeof region },
189
+ });
190
+ }
191
+ const rect = region.rect;
192
+ if (
193
+ !rect ||
194
+ !Number.isFinite(rect.x) ||
195
+ !Number.isFinite(rect.y) ||
196
+ !Number.isFinite(rect.width) ||
197
+ !Number.isFinite(rect.height)
198
+ ) {
199
+ throw new InvalidRectError(`regions[${index}].rect must be a finite Rect`, {
200
+ context: { index, rect },
201
+ });
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Parse a CSS color string (`#rgb`, `#rrggbb`, `#rrggbbaa`, or named black).
207
+ *
208
+ * @param {string} color
209
+ * @returns {{r: number, g: number, b: number, alpha: number}}
210
+ */
211
+ function parseColor(color) {
212
+ if (typeof color !== 'string') {
213
+ return { r: 0, g: 0, b: 0, alpha: 1 };
214
+ }
215
+ const hex = color.trim().replace(/^#/, '');
216
+ if (hex.length === 3) {
217
+ return {
218
+ r: Number.parseInt(hex[0] + hex[0], 16),
219
+ g: Number.parseInt(hex[1] + hex[1], 16),
220
+ b: Number.parseInt(hex[2] + hex[2], 16),
221
+ alpha: 1,
222
+ };
223
+ }
224
+ if (hex.length === 6) {
225
+ return {
226
+ r: Number.parseInt(hex.slice(0, 2), 16),
227
+ g: Number.parseInt(hex.slice(2, 4), 16),
228
+ b: Number.parseInt(hex.slice(4, 6), 16),
229
+ alpha: 1,
230
+ };
231
+ }
232
+ if (hex.length === 8) {
233
+ return {
234
+ r: Number.parseInt(hex.slice(0, 2), 16),
235
+ g: Number.parseInt(hex.slice(2, 4), 16),
236
+ b: Number.parseInt(hex.slice(4, 6), 16),
237
+ alpha: Number.parseInt(hex.slice(6, 8), 16) / 255,
238
+ };
239
+ }
240
+ return { r: 0, g: 0, b: 0, alpha: 1 };
241
+ }
242
+
243
+ function assertBuffer(input) {
244
+ if (!Buffer.isBuffer(input)) {
245
+ throw new BufferCorruptError('input must be a Buffer', {
246
+ context: { received: typeof input },
247
+ });
248
+ }
249
+ }
250
+
251
+ async function readMeta(sharp, input) {
252
+ try {
253
+ const meta = await sharp(input).metadata();
254
+ if (!meta.width || !meta.height) {
255
+ throw new BufferCorruptError('Image has no detectable dimensions');
256
+ }
257
+ return { width: meta.width, height: meta.height };
258
+ } catch (error) {
259
+ if (error instanceof BufferCorruptError) {
260
+ throw error;
261
+ }
262
+ throw new BufferCorruptError('sharp could not read redact input buffer', {
263
+ cause: error,
264
+ });
265
+ }
266
+ }
267
+
268
+ module.exports = { redact, parseColor };
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const { randomUUID } = require('node:crypto');
7
+
8
+ const { TileSizeError, TileWriteError, BufferCorruptError } = require('../errors/index.js');
9
+
10
+ const MAX_TILES = 10_000;
11
+
12
+ /**
13
+ * Generate a Deep Zoom Image pyramid from a source buffer using
14
+ * `sharp().tile({ layout: 'dz' })`.
15
+ *
16
+ * Implements `spec.md` § 8.4.1. Three output modes:
17
+ * - `'buffers'` (default): returns tile bytes in memory, cleans up the temp dir.
18
+ * - `'directory'`: returns the path to the temp dir; the caller is responsible
19
+ * for moving / uploading / deleting it.
20
+ * - `'stream'`: NOT IMPLEMENTED in v1.0 (per spec § 22 — deferred to v1.1).
21
+ *
22
+ * @public
23
+ * @param {Buffer} input
24
+ * @param {import('../types/index.d.ts').TileDziOptions} [options]
25
+ * @returns {Promise<import('../types/index.d.ts').DziResult>}
26
+ */
27
+ async function tileDzi(input, options = {}) {
28
+ assertBuffer(input);
29
+ const {
30
+ tileSize = 256,
31
+ overlap = 1,
32
+ tileFormat = 'webp',
33
+ quality = 80,
34
+ output = 'buffers',
35
+ name,
36
+ } = options;
37
+
38
+ if (tileSize !== 256 && tileSize !== 512) {
39
+ throw new TileSizeError(`tileSize must be 256 or 512; received ${tileSize}`, {
40
+ context: { tileSize },
41
+ });
42
+ }
43
+ if (output === 'stream') {
44
+ throw new TileSizeError(
45
+ "output: 'stream' is deferred to v1.1; use 'buffers' or 'directory'",
46
+ { context: { output } }
47
+ );
48
+ }
49
+ if (!['buffers', 'directory'].includes(output)) {
50
+ throw new TileSizeError(`output must be 'buffers' or 'directory'; received ${output}`, {
51
+ context: { output },
52
+ });
53
+ }
54
+
55
+ const sharp = require('sharp');
56
+
57
+ const meta = await readMeta(sharp, input);
58
+ const totalEstimate = estimateTileCount(meta.width, meta.height, tileSize);
59
+ if (totalEstimate > MAX_TILES) {
60
+ throw new TileSizeError(
61
+ `Estimated tile count ${totalEstimate} exceeds the ${MAX_TILES} ceiling for a single tileDzi() call`,
62
+ {
63
+ context: {
64
+ width: meta.width,
65
+ height: meta.height,
66
+ tileSize,
67
+ estimate: totalEstimate,
68
+ },
69
+ }
70
+ );
71
+ }
72
+
73
+ const dirName = name ?? `dzi-${randomUUID()}`;
74
+ let tempDir;
75
+ try {
76
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'screenshot-utils-dzi-'));
77
+ } catch (error) {
78
+ throw new TileWriteError('Failed to create temp directory for DZI output', {
79
+ cause: error,
80
+ });
81
+ }
82
+
83
+ const outputBase = path.join(tempDir, dirName);
84
+ try {
85
+ await applyTileFormat(sharp(input), tileFormat, quality)
86
+ .tile({ size: tileSize, overlap, layout: 'dz' })
87
+ .toFile(outputBase);
88
+ } catch (error) {
89
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
90
+ throw new TileWriteError('sharp().tile().toFile() failed', { cause: error });
91
+ }
92
+
93
+ const dziXml = await readDziXml(outputBase);
94
+ const tiles = await collectTiles({
95
+ outputBase,
96
+ dirName,
97
+ output,
98
+ });
99
+
100
+ if (output === 'buffers') {
101
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
102
+ }
103
+
104
+ return {
105
+ descriptor: {
106
+ xml: dziXml,
107
+ format: tileFormat,
108
+ width: meta.width,
109
+ height: meta.height,
110
+ tileSize,
111
+ overlap,
112
+ },
113
+ tiles,
114
+ outputDir: output === 'directory' ? tempDir : undefined,
115
+ totalBytes: tiles.reduce((sum, t) => sum + (t.bytes ? t.bytes.length : 0), 0),
116
+ totalTiles: tiles.length,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Estimate the total tile count across all pyramid levels for sanity-limit
122
+ * enforcement before invoking sharp. Each level halves the dimensions; the
123
+ * lowest level is always 1×1.
124
+ *
125
+ * @param {number} width
126
+ * @param {number} height
127
+ * @param {number} tileSize
128
+ * @returns {number}
129
+ */
130
+ function estimateTileCount(width, height, tileSize) {
131
+ let total = 0;
132
+ let w = width;
133
+ let h = height;
134
+ while (w >= 1 && h >= 1) {
135
+ const cols = Math.ceil(w / tileSize);
136
+ const rows = Math.ceil(h / tileSize);
137
+ total += cols * rows;
138
+ if (w === 1 && h === 1) {
139
+ break;
140
+ }
141
+ w = Math.max(1, Math.ceil(w / 2));
142
+ h = Math.max(1, Math.ceil(h / 2));
143
+ }
144
+ return total;
145
+ }
146
+
147
+ /**
148
+ * Apply the requested tile format on a sharp pipeline before .tile().
149
+ * Sharp uses the pipeline's selected format for emitted tile files.
150
+ *
151
+ * @template T
152
+ * @param {T} pipeline
153
+ * @param {string} format
154
+ * @param {number} quality
155
+ * @returns {T}
156
+ */
157
+ function applyTileFormat(pipeline, format, quality) {
158
+ switch (format) {
159
+ case 'png':
160
+ return pipeline.png();
161
+ case 'jpeg':
162
+ return pipeline.jpeg({ quality });
163
+ case 'webp':
164
+ return pipeline.webp({ quality });
165
+ default:
166
+ throw new TileSizeError(`tileFormat must be png, jpeg, or webp; received ${format}`, {
167
+ context: { tileFormat: format },
168
+ });
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Read the .dzi XML descriptor written by sharp.
174
+ *
175
+ * @param {string} outputBase Path without extension.
176
+ * @returns {Promise<string>}
177
+ */
178
+ async function readDziXml(outputBase) {
179
+ try {
180
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
181
+ return await fs.readFile(`${outputBase}.dzi`, 'utf8');
182
+ } catch (error) {
183
+ throw new TileWriteError('Sharp did not produce the .dzi descriptor file', {
184
+ cause: error,
185
+ });
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Walk the `<base>_files/` tree and assemble the tile list. Each tile path
191
+ * is recorded relative to the DZI root (matching the DZI spec).
192
+ *
193
+ * @param {{outputBase: string, dirName: string, output: 'buffers'|'directory'}} args
194
+ * @returns {Promise<Array<import('../types/index.d.ts').DziTile>>}
195
+ */
196
+ async function collectTiles({ outputBase, dirName, output }) {
197
+ const filesDir = `${outputBase}_files`;
198
+ const tiles = [];
199
+ /** @type {string[]} */
200
+ let levels;
201
+ try {
202
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
203
+ levels = await fs.readdir(filesDir);
204
+ } catch (error) {
205
+ throw new TileWriteError('Sharp did not produce the _files directory', {
206
+ cause: error,
207
+ });
208
+ }
209
+ const levelNums = levels
210
+ .map(Number)
211
+ .filter(n => Number.isFinite(n))
212
+ .toSorted((a, b) => a - b);
213
+
214
+ for (const level of levelNums) {
215
+ const levelDir = path.join(filesDir, String(level));
216
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
217
+ const dirEntries = await fs.readdir(levelDir);
218
+ const fileNames = dirEntries.toSorted();
219
+ for (const fileName of fileNames) {
220
+ const match = /^(\d+)_(\d+)\.\w+$/.exec(fileName);
221
+ if (!match) {
222
+ continue;
223
+ }
224
+ const col = Number(match[1]);
225
+ const row = Number(match[2]);
226
+ const relPath = `${dirName}_files/${level}/${fileName}`;
227
+ const entry = {
228
+ level,
229
+ col,
230
+ row,
231
+ path: relPath,
232
+ };
233
+ if (output === 'buffers') {
234
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
235
+ entry.bytes = await fs.readFile(path.join(levelDir, fileName));
236
+ }
237
+ tiles.push(entry);
238
+ }
239
+ }
240
+ return tiles;
241
+ }
242
+
243
+ async function readMeta(sharp, input) {
244
+ try {
245
+ const meta = await sharp(input).metadata();
246
+ if (!meta.width || !meta.height) {
247
+ throw new BufferCorruptError('Input image has no detectable dimensions');
248
+ }
249
+ return { width: meta.width, height: meta.height };
250
+ } catch (error) {
251
+ if (error instanceof BufferCorruptError) {
252
+ throw error;
253
+ }
254
+ throw new BufferCorruptError('sharp could not read tile input buffer', {
255
+ cause: error,
256
+ });
257
+ }
258
+ }
259
+
260
+ function assertBuffer(input) {
261
+ if (!Buffer.isBuffer(input)) {
262
+ throw new BufferCorruptError('input must be a Buffer', {
263
+ context: { received: typeof input },
264
+ });
265
+ }
266
+ }
267
+
268
+ module.exports = { tileDzi, MAX_TILES };
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const { tileDzi } = require('./dzi.js');
4
+
5
+ module.exports = { tileDzi };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const { BufferCorruptError, UnsupportedFormatError } = require('../errors/index.js');
4
+
5
+ /**
6
+ * Re-encode a buffer to a different image format.
7
+ *
8
+ * Implements `spec.md` § 8.2.4. Thin wrapper over `sharp(buf).toFormat()`
9
+ * with safer defaults and the package's error contract.
10
+ *
11
+ * @public
12
+ * @param {Buffer} input
13
+ * @param {import('../types/index.d.ts').ConvertOptions} options
14
+ * @returns {Promise<Buffer>}
15
+ */
16
+ async function convert(input, options) {
17
+ assertBuffer(input);
18
+ if (!options || typeof options !== 'object') {
19
+ throw new UnsupportedFormatError('convert requires an options object with a format', {
20
+ context: { received: typeof options },
21
+ });
22
+ }
23
+ const { format, quality = 90, lossless = false, effort = 4 } = options;
24
+ if (!['png', 'jpeg', 'webp'].includes(format)) {
25
+ throw new UnsupportedFormatError(
26
+ `format must be 'png', 'jpeg', or 'webp'; received ${JSON.stringify(format)}`,
27
+ { context: { received: format } }
28
+ );
29
+ }
30
+ const sharp = require('sharp');
31
+ let pipeline = sharp(input);
32
+ switch (format) {
33
+ case 'png':
34
+ pipeline = pipeline.png();
35
+ break;
36
+ case 'jpeg':
37
+ pipeline = pipeline.jpeg({ quality });
38
+ break;
39
+ case 'webp':
40
+ pipeline = pipeline.webp({ quality, lossless, effort });
41
+ break;
42
+ }
43
+ return pipeline.toBuffer();
44
+ }
45
+
46
+ function assertBuffer(input) {
47
+ if (!Buffer.isBuffer(input)) {
48
+ throw new BufferCorruptError('input must be a Buffer', {
49
+ context: { received: typeof input },
50
+ });
51
+ }
52
+ }
53
+
54
+ module.exports = { convert };