@reshotdev/screenshot 0.0.1-beta.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 (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,396 @@
1
+ // image-crop.js - High-quality image cropping with industry best practices
2
+ // Uses Sharp for lossless, high-quality image manipulation
3
+ const sharp = require("sharp");
4
+ const path = require("path");
5
+ const fs = require("fs-extra");
6
+
7
+ /**
8
+ * Crop configuration schema:
9
+ * {
10
+ * enabled: boolean, // Whether cropping is enabled for this scenario/step
11
+ * region: { // Absolute pixel coordinates (required if enabled)
12
+ * x: number, // X offset from top-left
13
+ * y: number, // Y offset from top-left
14
+ * width: number, // Width of crop region
15
+ * height: number // Height of crop region
16
+ * },
17
+ * scaleMode: 'none' | 'fit' | 'fill', // How to handle the cropped result (default: 'none')
18
+ * targetSize?: { // Optional target dimensions for scaling (only if scaleMode !== 'none')
19
+ * width: number,
20
+ * height: number
21
+ * },
22
+ * padding?: { // Optional padding around the crop region
23
+ * top?: number,
24
+ * right?: number,
25
+ * bottom?: number,
26
+ * left?: number
27
+ * },
28
+ * preserveAspectRatio: boolean // Whether to preserve aspect ratio when scaling (default: true)
29
+ * }
30
+ */
31
+
32
+ /**
33
+ * Validate crop configuration
34
+ * @param {Object} cropConfig - The crop configuration to validate
35
+ * @returns {{ valid: boolean, error?: string }}
36
+ */
37
+ function validateCropConfig(cropConfig) {
38
+ if (!cropConfig) {
39
+ return { valid: true }; // No crop config means cropping is disabled
40
+ }
41
+
42
+ if (!cropConfig.enabled) {
43
+ return { valid: true }; // Explicitly disabled
44
+ }
45
+
46
+ if (!cropConfig.region) {
47
+ return {
48
+ valid: false,
49
+ error: "Crop region is required when cropping is enabled",
50
+ };
51
+ }
52
+
53
+ const { x, y, width, height } = cropConfig.region;
54
+
55
+ if (typeof x !== "number" || x < 0) {
56
+ return {
57
+ valid: false,
58
+ error: "Crop region.x must be a non-negative number",
59
+ };
60
+ }
61
+
62
+ if (typeof y !== "number" || y < 0) {
63
+ return {
64
+ valid: false,
65
+ error: "Crop region.y must be a non-negative number",
66
+ };
67
+ }
68
+
69
+ if (typeof width !== "number" || width <= 0) {
70
+ return {
71
+ valid: false,
72
+ error: "Crop region.width must be a positive number",
73
+ };
74
+ }
75
+
76
+ if (typeof height !== "number" || height <= 0) {
77
+ return {
78
+ valid: false,
79
+ error: "Crop region.height must be a positive number",
80
+ };
81
+ }
82
+
83
+ if (
84
+ cropConfig.scaleMode &&
85
+ !["none", "fit", "fill"].includes(cropConfig.scaleMode)
86
+ ) {
87
+ return {
88
+ valid: false,
89
+ error: "scaleMode must be 'none', 'fit', or 'fill'",
90
+ };
91
+ }
92
+
93
+ if (
94
+ cropConfig.scaleMode &&
95
+ cropConfig.scaleMode !== "none" &&
96
+ cropConfig.targetSize
97
+ ) {
98
+ const { width: tw, height: th } = cropConfig.targetSize;
99
+ if (
100
+ typeof tw !== "number" ||
101
+ tw <= 0 ||
102
+ typeof th !== "number" ||
103
+ th <= 0
104
+ ) {
105
+ return {
106
+ valid: false,
107
+ error: "targetSize width and height must be positive numbers",
108
+ };
109
+ }
110
+ }
111
+
112
+ return { valid: true };
113
+ }
114
+
115
+ /**
116
+ * Apply padding to a crop region, adjusting for image boundaries
117
+ * @param {Object} region - { x, y, width, height }
118
+ * @param {Object} padding - { top, right, bottom, left }
119
+ * @param {Object} imageDimensions - { width, height }
120
+ * @returns {Object} Adjusted region with padding applied
121
+ */
122
+ function applyPaddingToRegion(region, padding = {}, imageDimensions) {
123
+ const { top = 0, right = 0, bottom = 0, left = 0 } = padding;
124
+
125
+ // Expand region by padding
126
+ let newX = Math.max(0, region.x - left);
127
+ let newY = Math.max(0, region.y - top);
128
+ let newWidth = region.width + left + right;
129
+ let newHeight = region.height + top + bottom;
130
+
131
+ // Adjust for image boundaries
132
+ if (imageDimensions) {
133
+ const maxWidth = imageDimensions.width - newX;
134
+ const maxHeight = imageDimensions.height - newY;
135
+ newWidth = Math.min(newWidth, maxWidth);
136
+ newHeight = Math.min(newHeight, maxHeight);
137
+ }
138
+
139
+ return {
140
+ x: Math.round(newX),
141
+ y: Math.round(newY),
142
+ width: Math.round(newWidth),
143
+ height: Math.round(newHeight),
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Scale a crop region by a device pixel ratio
149
+ * This ensures crop coordinates work correctly on high-DPI displays
150
+ * @param {Object} region - { x, y, width, height }
151
+ * @param {number} deviceScaleFactor - The device pixel ratio (e.g., 2 for retina)
152
+ * @returns {Object} Scaled region
153
+ */
154
+ function scaleRegionByDPR(region, deviceScaleFactor = 1) {
155
+ if (deviceScaleFactor === 1) {
156
+ return region;
157
+ }
158
+
159
+ return {
160
+ x: Math.round(region.x * deviceScaleFactor),
161
+ y: Math.round(region.y * deviceScaleFactor),
162
+ width: Math.round(region.width * deviceScaleFactor),
163
+ height: Math.round(region.height * deviceScaleFactor),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Crop an image buffer using Sharp with high-quality settings
169
+ * @param {Buffer} imageBuffer - The input image buffer (PNG)
170
+ * @param {Object} cropConfig - The crop configuration
171
+ * @param {Object} options - Additional options
172
+ * @param {number} options.deviceScaleFactor - Device pixel ratio for coordinate scaling
173
+ * @returns {Promise<Buffer>} The cropped image buffer
174
+ */
175
+ async function cropImageBuffer(imageBuffer, cropConfig, options = {}) {
176
+ const { deviceScaleFactor = 1 } = options;
177
+
178
+ if (!cropConfig || !cropConfig.enabled || !cropConfig.region) {
179
+ return imageBuffer; // Return original if no cropping
180
+ }
181
+
182
+ const validation = validateCropConfig(cropConfig);
183
+ if (!validation.valid) {
184
+ throw new Error(`Invalid crop config: ${validation.error}`);
185
+ }
186
+
187
+ // Get image metadata to validate crop bounds
188
+ const metadata = await sharp(imageBuffer).metadata();
189
+ const imageDimensions = { width: metadata.width, height: metadata.height };
190
+
191
+ // Scale region by device pixel ratio
192
+ let region = scaleRegionByDPR(cropConfig.region, deviceScaleFactor);
193
+
194
+ // Apply padding if specified
195
+ if (cropConfig.padding) {
196
+ const scaledPadding = {
197
+ top: (cropConfig.padding.top || 0) * deviceScaleFactor,
198
+ right: (cropConfig.padding.right || 0) * deviceScaleFactor,
199
+ bottom: (cropConfig.padding.bottom || 0) * deviceScaleFactor,
200
+ left: (cropConfig.padding.left || 0) * deviceScaleFactor,
201
+ };
202
+ region = applyPaddingToRegion(region, scaledPadding, imageDimensions);
203
+ }
204
+
205
+ // Validate that crop region is within image bounds
206
+ if (region.x >= imageDimensions.width || region.y >= imageDimensions.height) {
207
+ throw new Error(
208
+ `Crop region (${region.x}, ${region.y}) is outside image bounds (${imageDimensions.width}x${imageDimensions.height})`
209
+ );
210
+ }
211
+
212
+ // Clamp region to image bounds (handle edge cases gracefully)
213
+ const clampedWidth = Math.min(region.width, imageDimensions.width - region.x);
214
+ const clampedHeight = Math.min(
215
+ region.height,
216
+ imageDimensions.height - region.y
217
+ );
218
+
219
+ if (clampedWidth <= 0 || clampedHeight <= 0) {
220
+ throw new Error(
221
+ `Crop region results in zero-size image after clamping to bounds`
222
+ );
223
+ }
224
+
225
+ // Build Sharp pipeline with high-quality settings
226
+ let pipeline = sharp(imageBuffer, {
227
+ // Disable libvips cache for consistent results
228
+ failOnError: false,
229
+ }).extract({
230
+ left: region.x,
231
+ top: region.y,
232
+ width: clampedWidth,
233
+ height: clampedHeight,
234
+ });
235
+
236
+ // Apply scaling if configured
237
+ if (
238
+ cropConfig.scaleMode &&
239
+ cropConfig.scaleMode !== "none" &&
240
+ cropConfig.targetSize
241
+ ) {
242
+ const { width: targetWidth, height: targetHeight } = cropConfig.targetSize;
243
+ const preserveAspectRatio = cropConfig.preserveAspectRatio !== false;
244
+
245
+ if (cropConfig.scaleMode === "fit") {
246
+ pipeline = pipeline.resize(targetWidth, targetHeight, {
247
+ fit: preserveAspectRatio ? "inside" : "fill",
248
+ withoutEnlargement: true, // Don't upscale
249
+ kernel: sharp.kernel.lanczos3, // High-quality downscaling
250
+ });
251
+ } else if (cropConfig.scaleMode === "fill") {
252
+ pipeline = pipeline.resize(targetWidth, targetHeight, {
253
+ fit: preserveAspectRatio ? "cover" : "fill",
254
+ position: "center",
255
+ kernel: sharp.kernel.lanczos3,
256
+ });
257
+ }
258
+ }
259
+
260
+ // Output as PNG with high quality (lossless)
261
+ return pipeline
262
+ .png({
263
+ compressionLevel: 6, // Balanced compression
264
+ adaptiveFiltering: true, // Better compression for photos
265
+ })
266
+ .toBuffer();
267
+ }
268
+
269
+ /**
270
+ * Crop an image file and save to the same or different path
271
+ * @param {string} inputPath - Path to the input image
272
+ * @param {string} outputPath - Path for the output image (can be same as input)
273
+ * @param {Object} cropConfig - The crop configuration
274
+ * @param {Object} options - Additional options
275
+ * @returns {Promise<{ success: boolean, originalSize: Object, croppedSize: Object }>}
276
+ */
277
+ async function cropImageFile(inputPath, outputPath, cropConfig, options = {}) {
278
+ if (!cropConfig || !cropConfig.enabled) {
279
+ return { success: true, skipped: true };
280
+ }
281
+
282
+ const imageBuffer = await fs.readFile(inputPath);
283
+ const originalMetadata = await sharp(imageBuffer).metadata();
284
+
285
+ const croppedBuffer = await cropImageBuffer(imageBuffer, cropConfig, options);
286
+ const croppedMetadata = await sharp(croppedBuffer).metadata();
287
+
288
+ await fs.writeFile(outputPath, croppedBuffer);
289
+
290
+ return {
291
+ success: true,
292
+ skipped: false,
293
+ originalSize: {
294
+ width: originalMetadata.width,
295
+ height: originalMetadata.height,
296
+ },
297
+ croppedSize: {
298
+ width: croppedMetadata.width,
299
+ height: croppedMetadata.height,
300
+ },
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Create a crop config from a bounding box (e.g., from element.boundingBox())
306
+ * @param {Object} boundingBox - { x, y, width, height }
307
+ * @param {Object} options - Additional options
308
+ * @param {number} options.padding - Uniform padding to add around the box
309
+ * @param {Object} options.customPadding - { top, right, bottom, left }
310
+ * @returns {Object} Crop configuration
311
+ */
312
+ function createCropConfigFromBoundingBox(boundingBox, options = {}) {
313
+ if (!boundingBox) {
314
+ return { enabled: false };
315
+ }
316
+
317
+ const { padding = 0, customPadding } = options;
318
+
319
+ const cropConfig = {
320
+ enabled: true,
321
+ region: {
322
+ x: Math.round(boundingBox.x),
323
+ y: Math.round(boundingBox.y),
324
+ width: Math.round(boundingBox.width),
325
+ height: Math.round(boundingBox.height),
326
+ },
327
+ scaleMode: "none",
328
+ preserveAspectRatio: true,
329
+ };
330
+
331
+ if (customPadding) {
332
+ cropConfig.padding = customPadding;
333
+ } else if (padding > 0) {
334
+ cropConfig.padding = {
335
+ top: padding,
336
+ right: padding,
337
+ bottom: padding,
338
+ left: padding,
339
+ };
340
+ }
341
+
342
+ return cropConfig;
343
+ }
344
+
345
+ /**
346
+ * Merge a base crop config with step-level overrides
347
+ * Step-level config takes precedence over scenario-level config
348
+ * @param {Object} baseCropConfig - Scenario-level crop configuration
349
+ * @param {Object} stepCropConfig - Step-level crop configuration (if any)
350
+ * @returns {Object} Merged crop configuration
351
+ */
352
+ function mergeCropConfigs(baseCropConfig, stepCropConfig) {
353
+ // If step has its own crop config (even if disabled), use it
354
+ if (stepCropConfig !== undefined) {
355
+ if (stepCropConfig === null || stepCropConfig.enabled === false) {
356
+ return { enabled: false };
357
+ }
358
+ // Merge with base, step takes precedence
359
+ return {
360
+ ...baseCropConfig,
361
+ ...stepCropConfig,
362
+ region: stepCropConfig.region || baseCropConfig?.region,
363
+ padding:
364
+ stepCropConfig.padding !== undefined
365
+ ? stepCropConfig.padding
366
+ : baseCropConfig?.padding,
367
+ };
368
+ }
369
+
370
+ // Use base config
371
+ return baseCropConfig || { enabled: false };
372
+ }
373
+
374
+ /**
375
+ * Check if Sharp is available
376
+ * @returns {boolean}
377
+ */
378
+ function isSharpAvailable() {
379
+ try {
380
+ require("sharp");
381
+ return true;
382
+ } catch (e) {
383
+ return false;
384
+ }
385
+ }
386
+
387
+ module.exports = {
388
+ validateCropConfig,
389
+ applyPaddingToRegion,
390
+ scaleRegionByDPR,
391
+ cropImageBuffer,
392
+ cropImageFile,
393
+ createCropConfigFromBoundingBox,
394
+ mergeCropConfigs,
395
+ isSharpAvailable,
396
+ };
@@ -0,0 +1,89 @@
1
+ // matrix.js - Matrix expansion and context merging utilities
2
+
3
+ /**
4
+ * Expand matrix definition into all combinations
5
+ * @param {Array<Array<string>>} matrix - Array of axes, each containing context keys
6
+ * @returns {Array<Array<string>>} Array of all combinations
7
+ */
8
+ function expandMatrix(matrix) {
9
+ if (!matrix || matrix.length === 0) {
10
+ return [[]]; // Single empty variation if no matrix
11
+ }
12
+
13
+ // Cartesian product of all axes
14
+ const result = [];
15
+
16
+ function generateCombinations(current, depth) {
17
+ if (depth === matrix.length) {
18
+ result.push([...current]);
19
+ return;
20
+ }
21
+
22
+ for (const item of matrix[depth]) {
23
+ current.push(item);
24
+ generateCombinations(current, depth + 1);
25
+ current.pop();
26
+ }
27
+ }
28
+
29
+ generateCombinations([], 0);
30
+ return result;
31
+ }
32
+
33
+ /**
34
+ * Deep merge two objects
35
+ * @param {Object} target - Target object
36
+ * @param {Object} source - Source object
37
+ * @returns {Object} Merged object
38
+ */
39
+ function deepMerge(target, source) {
40
+ const result = { ...target };
41
+
42
+ for (const key in source) {
43
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
44
+ result[key] = deepMerge(result[key] || {}, source[key]);
45
+ } else {
46
+ result[key] = source[key];
47
+ }
48
+ }
49
+
50
+ return result;
51
+ }
52
+
53
+ /**
54
+ * Merge contexts based on selected context keys
55
+ * @param {Object} baseContext - Base context object
56
+ * @param {Array<string>} selectedContextKeys - Array of context keys to merge
57
+ * @param {Object} allContexts - Object mapping context keys to context objects
58
+ * @returns {Object} Merged context
59
+ */
60
+ function mergeContexts(baseContext, selectedContextKeys, allContexts) {
61
+ let result = { ...baseContext };
62
+
63
+ for (const key of selectedContextKeys) {
64
+ if (allContexts[key]) {
65
+ result = deepMerge(result, allContexts[key]);
66
+ }
67
+ }
68
+
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * Create a slug from variation keys
74
+ * @param {Array<string>} variation - Array of context keys
75
+ * @returns {string} Slug for the variation
76
+ */
77
+ function variationToSlug(variation) {
78
+ if (variation.length === 0) {
79
+ return 'default';
80
+ }
81
+ return variation.join('_');
82
+ }
83
+
84
+ module.exports = {
85
+ expandMatrix,
86
+ mergeContexts,
87
+ variationToSlug
88
+ };
89
+