@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,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CaptureTimeoutError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Capture a single element by selector or xpath.
|
|
7
|
+
*
|
|
8
|
+
* Implements `spec.md` § 8.1.3. **Deprecated as the engine's primary path**
|
|
9
|
+
* — prefer `capturePage` + `cropMany`. Retained because (a) the engine
|
|
10
|
+
* needs a like-for-like replacement during the Phase 3 migration and
|
|
11
|
+
* (b) direct callers (Investigator, ad-hoc tooling) still want it.
|
|
12
|
+
*
|
|
13
|
+
* Failure modes return an ElementCaptureResult with `bytes === null` and
|
|
14
|
+
* `reason` populated rather than throwing. This matches the engine's
|
|
15
|
+
* current `{ screenshot, reason }` contract so consumers can swap
|
|
16
|
+
* implementations without changing control flow.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
20
|
+
* @param {import('../types/index.d.ts').ElementCaptureOptions} [options]
|
|
21
|
+
* @returns {Promise<import('../types/index.d.ts').ElementCaptureResult>}
|
|
22
|
+
*/
|
|
23
|
+
async function captureElement(adapter, options = {}) {
|
|
24
|
+
const {
|
|
25
|
+
selector,
|
|
26
|
+
xpath,
|
|
27
|
+
snippet,
|
|
28
|
+
padding = 10,
|
|
29
|
+
maxDimensions = { width: 4000, height: 4000 },
|
|
30
|
+
scrollIntoView = true,
|
|
31
|
+
timeout = 5000,
|
|
32
|
+
format = 'png',
|
|
33
|
+
quality = 90,
|
|
34
|
+
events = null,
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
if (!selector && !xpath) {
|
|
38
|
+
return failure(
|
|
39
|
+
{ selector, xpath, format },
|
|
40
|
+
'element_not_found',
|
|
41
|
+
null,
|
|
42
|
+
'No selector or xpath provided'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const start = nowMs();
|
|
47
|
+
emit(events, 'capture:start', { format, mode: 'element', selector, xpath });
|
|
48
|
+
|
|
49
|
+
const lookup = await withTimeout(
|
|
50
|
+
adapter.evaluate(findElementInPage, { selector, xpath }),
|
|
51
|
+
timeout,
|
|
52
|
+
{ operation: 'adapter.evaluate(findElement)' }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (!lookup || !lookup.found) {
|
|
56
|
+
return failure({ selector, xpath, format }, 'element_not_found', null, null);
|
|
57
|
+
}
|
|
58
|
+
if (lookup.documentRect.width === 0 || lookup.documentRect.height === 0) {
|
|
59
|
+
return failure({ selector, xpath, format }, 'element_not_visible', lookup, null);
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
lookup.documentRect.width > maxDimensions.width ||
|
|
63
|
+
lookup.documentRect.height > maxDimensions.height
|
|
64
|
+
) {
|
|
65
|
+
return failure({ selector, xpath, format }, 'element_too_large', lookup, null);
|
|
66
|
+
}
|
|
67
|
+
if (snippet) {
|
|
68
|
+
const normalize = s => String(s).replaceAll(/\s+/g, ' ').trim();
|
|
69
|
+
if (normalize(lookup.outerHTML) !== normalize(snippet)) {
|
|
70
|
+
return failure(
|
|
71
|
+
{ selector, xpath, format },
|
|
72
|
+
'element_mismatch',
|
|
73
|
+
lookup,
|
|
74
|
+
'Provided snippet did not match element outerHTML'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let viewportRect = lookup.viewportRect;
|
|
80
|
+
if (scrollIntoView) {
|
|
81
|
+
await adapter.scrollTo({
|
|
82
|
+
x: 0,
|
|
83
|
+
y: Math.max(0, lookup.documentRect.y - padding),
|
|
84
|
+
});
|
|
85
|
+
// Re-evaluate to pick up the new viewport rect after scroll.
|
|
86
|
+
const reread = await withTimeout(
|
|
87
|
+
adapter.evaluate(findElementInPage, { selector, xpath }),
|
|
88
|
+
timeout,
|
|
89
|
+
{ operation: 'adapter.evaluate(findElement after scroll)' }
|
|
90
|
+
);
|
|
91
|
+
if (reread && reread.found) {
|
|
92
|
+
viewportRect = reread.viewportRect;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const clip = {
|
|
97
|
+
x: Math.max(0, Math.round(viewportRect.x - padding)),
|
|
98
|
+
y: Math.max(0, Math.round(viewportRect.y - padding)),
|
|
99
|
+
width: Math.round(viewportRect.width + 2 * padding),
|
|
100
|
+
height: Math.round(viewportRect.height + 2 * padding),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const screenshotOpts = { fullPage: false, type: format, clip };
|
|
104
|
+
if (format === 'jpeg') {
|
|
105
|
+
screenshotOpts.quality = quality;
|
|
106
|
+
}
|
|
107
|
+
const captureStart = nowMs();
|
|
108
|
+
const bytes = await withTimeout(adapter.screenshot(screenshotOpts), timeout, {
|
|
109
|
+
operation: 'adapter.screenshot(element)',
|
|
110
|
+
});
|
|
111
|
+
const captureMs = nowMs() - captureStart;
|
|
112
|
+
|
|
113
|
+
const metrics = await adapter.getMetrics();
|
|
114
|
+
const totalMs = nowMs() - start;
|
|
115
|
+
|
|
116
|
+
const result = {
|
|
117
|
+
format,
|
|
118
|
+
bytes,
|
|
119
|
+
page: metrics,
|
|
120
|
+
timing: { settleMs: 0, captureMs, encodeMs: 0, totalMs },
|
|
121
|
+
warnings: [],
|
|
122
|
+
element: {
|
|
123
|
+
selector: selector ?? '',
|
|
124
|
+
xpath: xpath ?? '',
|
|
125
|
+
documentRect: lookup.documentRect,
|
|
126
|
+
viewportRect,
|
|
127
|
+
verified: Boolean(snippet),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
emit(events, 'capture:complete', { format, totalMs, mode: 'element' });
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Page-context element lookup. Shipped to the browser via adapter.evaluate.
|
|
136
|
+
* Coverage suppressed for the same reason as `settle.js#runInPage` —
|
|
137
|
+
* runs in the browser, not in Node.
|
|
138
|
+
*
|
|
139
|
+
* @param {{selector?: string, xpath?: string}} args
|
|
140
|
+
* @returns {{found: false} | {found: true, outerHTML: string, documentRect: import('../types/index.d.ts').Rect, viewportRect: import('../types/index.d.ts').Rect}}
|
|
141
|
+
*/
|
|
142
|
+
/* v8 ignore start -- runs in the browser via adapter.evaluate(); not
|
|
143
|
+
instrumentable by Node-side coverage. Exercised by the puppeteer/playwright
|
|
144
|
+
element-capture integration tests. */
|
|
145
|
+
function findElementInPage(args) {
|
|
146
|
+
const doc = globalThis.document;
|
|
147
|
+
let element;
|
|
148
|
+
if (args.selector) {
|
|
149
|
+
try {
|
|
150
|
+
element = doc.querySelector(args.selector);
|
|
151
|
+
} catch {
|
|
152
|
+
element = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!element && args.xpath) {
|
|
156
|
+
try {
|
|
157
|
+
const FIRST = 9; // XPathResult.FIRST_ORDERED_NODE_TYPE
|
|
158
|
+
const res = doc.evaluate(args.xpath, doc, null, FIRST, null);
|
|
159
|
+
element = res.singleNodeValue;
|
|
160
|
+
} catch {
|
|
161
|
+
element = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!element) {
|
|
165
|
+
return { found: false };
|
|
166
|
+
}
|
|
167
|
+
const rect = element.getBoundingClientRect();
|
|
168
|
+
const sx = globalThis.scrollX || 0;
|
|
169
|
+
const sy = globalThis.scrollY || 0;
|
|
170
|
+
return {
|
|
171
|
+
found: true,
|
|
172
|
+
outerHTML: element.outerHTML,
|
|
173
|
+
documentRect: {
|
|
174
|
+
x: rect.x + sx,
|
|
175
|
+
y: rect.y + sy,
|
|
176
|
+
width: rect.width,
|
|
177
|
+
height: rect.height,
|
|
178
|
+
},
|
|
179
|
+
viewportRect: {
|
|
180
|
+
x: rect.x,
|
|
181
|
+
y: rect.y,
|
|
182
|
+
width: rect.width,
|
|
183
|
+
height: rect.height,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/* v8 ignore stop */
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build a failure-shaped ElementCaptureResult that preserves the engine's
|
|
191
|
+
* `{ bytes: null, reason }` contract.
|
|
192
|
+
*
|
|
193
|
+
* @param {{selector?: string, xpath?: string, format: string}} input
|
|
194
|
+
* @param {string} reason
|
|
195
|
+
* @param {{outerHTML?: string, documentRect?: import('../types/index.d.ts').Rect, viewportRect?: import('../types/index.d.ts').Rect} | null} lookup
|
|
196
|
+
* @param {string | null} verificationReason
|
|
197
|
+
* @returns {import('../types/index.d.ts').ElementCaptureResult}
|
|
198
|
+
*/
|
|
199
|
+
function failure(input, reason, lookup, verificationReason) {
|
|
200
|
+
return {
|
|
201
|
+
format: input.format,
|
|
202
|
+
bytes: null,
|
|
203
|
+
page: null,
|
|
204
|
+
timing: { settleMs: 0, captureMs: 0, encodeMs: 0, totalMs: 0 },
|
|
205
|
+
warnings: [],
|
|
206
|
+
element: {
|
|
207
|
+
selector: input.selector ?? '',
|
|
208
|
+
xpath: input.xpath ?? '',
|
|
209
|
+
documentRect: lookup?.documentRect ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
210
|
+
viewportRect: lookup?.viewportRect ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
211
|
+
verified: false,
|
|
212
|
+
...(verificationReason ? { verificationReason } : {}),
|
|
213
|
+
},
|
|
214
|
+
reason,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function emit(events, name, payload) {
|
|
219
|
+
if (events && typeof events.emit === 'function') {
|
|
220
|
+
events.emit(name, payload);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function nowMs() {
|
|
225
|
+
return Number(process.hrtime.bigint() / 1_000_000n);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Race a promise against a CaptureTimeoutError-throwing timer.
|
|
230
|
+
*
|
|
231
|
+
* @template T
|
|
232
|
+
* @param {Promise<T>} promise
|
|
233
|
+
* @param {number} ms
|
|
234
|
+
* @param {{operation: string}} ctx
|
|
235
|
+
* @returns {Promise<T>}
|
|
236
|
+
*/
|
|
237
|
+
function withTimeout(promise, ms, ctx) {
|
|
238
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
239
|
+
return promise;
|
|
240
|
+
}
|
|
241
|
+
let timer;
|
|
242
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
243
|
+
timer = setTimeout(() => {
|
|
244
|
+
reject(
|
|
245
|
+
new CaptureTimeoutError(`${ctx.operation} exceeded ${ms}ms timeout`, {
|
|
246
|
+
context: { operation: ctx.operation, timeoutMs: ms },
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
}, ms);
|
|
250
|
+
if (typeof timer.unref === 'function') {
|
|
251
|
+
timer.unref();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { captureElement };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { capturePage } = require('./page.js');
|
|
4
|
+
const { waitForStable } = require('./settle.js');
|
|
5
|
+
const { captureLongPage } = require('./long-page.js');
|
|
6
|
+
const { captureElement } = require('./element.js');
|
|
7
|
+
const { captureViewport } = require('./viewport.js');
|
|
8
|
+
const { captureResponsive } = require('./responsive.js');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
capturePage,
|
|
12
|
+
waitForStable,
|
|
13
|
+
captureLongPage,
|
|
14
|
+
captureElement,
|
|
15
|
+
captureViewport,
|
|
16
|
+
captureResponsive,
|
|
17
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PageTooTallError, CaptureTimeoutError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Capture a page taller than the browser's single-screenshot height limit
|
|
7
|
+
* by scrolling, capturing strips, and stitching them via `sharp`.
|
|
8
|
+
*
|
|
9
|
+
* Implements `spec.md` § 8.1.4. If the document height fits within the
|
|
10
|
+
* adapter's `screenshotMaxHeight` capability, delegates to the regular
|
|
11
|
+
* `adapter.screenshot({ fullPage: true })`. Otherwise iterates over
|
|
12
|
+
* viewport-height strips, scrolling between captures.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
16
|
+
* @param {import('../types/index.d.ts').LongPageCaptureOptions} [options]
|
|
17
|
+
* @returns {Promise<import('../types/index.d.ts').CaptureResult>}
|
|
18
|
+
*/
|
|
19
|
+
async function captureLongPage(adapter, options = {}) {
|
|
20
|
+
const {
|
|
21
|
+
stripHeight = null,
|
|
22
|
+
overlap = 0,
|
|
23
|
+
maxHeight = 32_768,
|
|
24
|
+
format = 'png',
|
|
25
|
+
quality = 90,
|
|
26
|
+
omitBackground = false,
|
|
27
|
+
scrollSettleMs = 100,
|
|
28
|
+
timeout = 30_000,
|
|
29
|
+
events = null,
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
const start = nowMs();
|
|
33
|
+
emit(events, 'capture:start', { format, fullPage: true, mode: 'long-page' });
|
|
34
|
+
|
|
35
|
+
const metrics = await adapter.getMetrics();
|
|
36
|
+
if (metrics.height > maxHeight) {
|
|
37
|
+
throw new PageTooTallError(
|
|
38
|
+
`Page height ${metrics.height}px exceeds maxHeight ${maxHeight}px`,
|
|
39
|
+
{ context: { documentHeight: metrics.height, maxHeight } }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const capabilities = adapter.capabilities ?? { screenshotMaxHeight: 16_384 };
|
|
44
|
+
const browserMax = capabilities.screenshotMaxHeight;
|
|
45
|
+
const captureStart = nowMs();
|
|
46
|
+
|
|
47
|
+
const { bytes: stitched, encodeMs } =
|
|
48
|
+
metrics.height <= browserMax
|
|
49
|
+
? await singleShotCapture(adapter, {
|
|
50
|
+
format,
|
|
51
|
+
quality,
|
|
52
|
+
omitBackground,
|
|
53
|
+
timeout,
|
|
54
|
+
})
|
|
55
|
+
: await stripCapture(adapter, {
|
|
56
|
+
metrics,
|
|
57
|
+
stripHeight,
|
|
58
|
+
overlap,
|
|
59
|
+
scrollSettleMs,
|
|
60
|
+
timeout,
|
|
61
|
+
format,
|
|
62
|
+
quality,
|
|
63
|
+
omitBackground,
|
|
64
|
+
events,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const captureMs = nowMs() - captureStart - encodeMs;
|
|
68
|
+
const totalMs = nowMs() - start;
|
|
69
|
+
|
|
70
|
+
const result = {
|
|
71
|
+
format,
|
|
72
|
+
bytes: stitched,
|
|
73
|
+
page: metrics,
|
|
74
|
+
timing: { settleMs: 0, captureMs, encodeMs, totalMs },
|
|
75
|
+
warnings: [],
|
|
76
|
+
};
|
|
77
|
+
emit(events, 'capture:complete', { format, totalMs });
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Single-shot path: the browser can produce the full document in one call.
|
|
83
|
+
*
|
|
84
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
85
|
+
* @param {{format: import('../types/index.d.ts').Format, quality: number, omitBackground: boolean, timeout: number}} opts
|
|
86
|
+
* @returns {Promise<{bytes: Buffer, encodeMs: number}>}
|
|
87
|
+
*/
|
|
88
|
+
async function singleShotCapture(adapter, opts) {
|
|
89
|
+
const { format, quality, omitBackground, timeout } = opts;
|
|
90
|
+
const bytes = await withTimeout(
|
|
91
|
+
adapter.screenshot({
|
|
92
|
+
fullPage: true,
|
|
93
|
+
type: format === 'webp' ? 'png' : format,
|
|
94
|
+
omitBackground,
|
|
95
|
+
...(format === 'jpeg' ? { quality } : {}),
|
|
96
|
+
}),
|
|
97
|
+
timeout,
|
|
98
|
+
{ operation: 'adapter.screenshot(fullPage)' }
|
|
99
|
+
);
|
|
100
|
+
return { bytes, encodeMs: 0 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Strip-and-stitch path: scroll the page and capture viewport strips,
|
|
105
|
+
* then composite them via sharp.
|
|
106
|
+
*
|
|
107
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
108
|
+
* @param {{metrics: import('../types/index.d.ts').PageMetrics, stripHeight: number|null, overlap: number, scrollSettleMs: number, timeout: number, format: import('../types/index.d.ts').Format, quality: number, omitBackground: boolean, events: object|null}} opts
|
|
109
|
+
* @returns {Promise<{bytes: Buffer, encodeMs: number}>}
|
|
110
|
+
*/
|
|
111
|
+
async function stripCapture(adapter, opts) {
|
|
112
|
+
const {
|
|
113
|
+
metrics,
|
|
114
|
+
stripHeight,
|
|
115
|
+
overlap,
|
|
116
|
+
scrollSettleMs,
|
|
117
|
+
timeout,
|
|
118
|
+
format,
|
|
119
|
+
quality,
|
|
120
|
+
omitBackground,
|
|
121
|
+
events,
|
|
122
|
+
} = opts;
|
|
123
|
+
|
|
124
|
+
const sh = Math.max(1, stripHeight ?? metrics.viewport.height);
|
|
125
|
+
const stride = Math.max(1, sh - overlap);
|
|
126
|
+
const uniqueYs = planStripYs(metrics.height, sh, stride);
|
|
127
|
+
|
|
128
|
+
const strips = [];
|
|
129
|
+
for (let i = 0; i < uniqueYs.length; i += 1) {
|
|
130
|
+
const y = uniqueYs[i];
|
|
131
|
+
await adapter.scrollTo({ x: 0, y });
|
|
132
|
+
if (scrollSettleMs > 0) {
|
|
133
|
+
await sleep(scrollSettleMs);
|
|
134
|
+
}
|
|
135
|
+
const buffer = await withTimeout(
|
|
136
|
+
adapter.screenshot({ fullPage: false, type: 'png', omitBackground }),
|
|
137
|
+
timeout,
|
|
138
|
+
{ operation: `adapter.screenshot(strip ${i + 1}/${uniqueYs.length})` }
|
|
139
|
+
);
|
|
140
|
+
strips.push({ y, buffer });
|
|
141
|
+
emit(events, 'strip:captured', { index: i, total: uniqueYs.length, y });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const stitchStart = nowMs();
|
|
145
|
+
const bytes = await stitchStrips(strips, {
|
|
146
|
+
width: metrics.viewport.width,
|
|
147
|
+
height: metrics.height,
|
|
148
|
+
format,
|
|
149
|
+
quality,
|
|
150
|
+
});
|
|
151
|
+
return { bytes, encodeMs: nowMs() - stitchStart };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compute the scroll-y positions for strip capture. Each strip is `sh` px
|
|
156
|
+
* tall; we advance by `stride` (typically `sh - overlap`). The last strip
|
|
157
|
+
* is anchored at `height - sh` to avoid a partial trailing strip.
|
|
158
|
+
*
|
|
159
|
+
* @param {number} height
|
|
160
|
+
* @param {number} sh strip height
|
|
161
|
+
* @param {number} stride
|
|
162
|
+
* @returns {number[]}
|
|
163
|
+
*/
|
|
164
|
+
function planStripYs(height, sh, stride) {
|
|
165
|
+
const ys = [];
|
|
166
|
+
for (let y = 0; y < height; y += stride) {
|
|
167
|
+
ys.push(Math.min(y, height - sh));
|
|
168
|
+
if (y + sh >= height) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return Array.from(new Set(ys));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Vertically stitch strip buffers into one final image and re-encode to
|
|
177
|
+
* the requested format.
|
|
178
|
+
*
|
|
179
|
+
* @param {Array<{y: number, buffer: Buffer}>} strips
|
|
180
|
+
* @param {{width: number, height: number, format: import('../types/index.d.ts').Format, quality: number}} target
|
|
181
|
+
* @returns {Promise<Buffer>}
|
|
182
|
+
*/
|
|
183
|
+
async function stitchStrips(strips, { width, height, format, quality }) {
|
|
184
|
+
const sharp = require('sharp');
|
|
185
|
+
const canvas = sharp({
|
|
186
|
+
create: {
|
|
187
|
+
width: Math.round(width),
|
|
188
|
+
height: Math.round(height),
|
|
189
|
+
channels: 4,
|
|
190
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
191
|
+
},
|
|
192
|
+
}).png();
|
|
193
|
+
|
|
194
|
+
const composites = strips.map(({ y, buffer }) => ({
|
|
195
|
+
input: buffer,
|
|
196
|
+
top: Math.round(y),
|
|
197
|
+
left: 0,
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
let pipeline = canvas.composite(composites);
|
|
201
|
+
switch (format) {
|
|
202
|
+
case 'webp':
|
|
203
|
+
pipeline = pipeline.webp({ quality });
|
|
204
|
+
break;
|
|
205
|
+
case 'jpeg':
|
|
206
|
+
pipeline = pipeline.jpeg({ quality });
|
|
207
|
+
break;
|
|
208
|
+
case 'png':
|
|
209
|
+
pipeline = pipeline.png();
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
return pipeline.toBuffer();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Sleep for ms. Used between scroll and capture so the page has time to
|
|
217
|
+
* paint the newly-revealed region.
|
|
218
|
+
*
|
|
219
|
+
* @param {number} ms
|
|
220
|
+
* @returns {Promise<void>}
|
|
221
|
+
*/
|
|
222
|
+
function sleep(ms) {
|
|
223
|
+
return new Promise(resolve => {
|
|
224
|
+
setTimeout(resolve, ms);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function emit(events, name, payload) {
|
|
229
|
+
if (events && typeof events.emit === 'function') {
|
|
230
|
+
events.emit(name, payload);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function nowMs() {
|
|
235
|
+
return Number(process.hrtime.bigint() / 1_000_000n);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Race a promise against a CaptureTimeoutError-throwing timer.
|
|
240
|
+
*
|
|
241
|
+
* @template T
|
|
242
|
+
* @param {Promise<T>} promise
|
|
243
|
+
* @param {number} ms
|
|
244
|
+
* @param {{operation: string}} ctx
|
|
245
|
+
* @returns {Promise<T>}
|
|
246
|
+
*/
|
|
247
|
+
function withTimeout(promise, ms, ctx) {
|
|
248
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
249
|
+
return promise;
|
|
250
|
+
}
|
|
251
|
+
let timer;
|
|
252
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
253
|
+
timer = setTimeout(() => {
|
|
254
|
+
reject(
|
|
255
|
+
new CaptureTimeoutError(`${ctx.operation} exceeded ${ms}ms timeout`, {
|
|
256
|
+
context: { operation: ctx.operation, timeoutMs: ms },
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
}, ms);
|
|
260
|
+
if (typeof timer.unref === 'function') {
|
|
261
|
+
timer.unref();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { captureLongPage };
|