@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,642 @@
1
+ // diff-engine.js - Client-side visual diffing engine
2
+ const fs = require("fs-extra");
3
+ const path = require("path");
4
+ const { PNG } = require("pngjs");
5
+ const pixelmatch = require("pixelmatch");
6
+ const axios = require("axios");
7
+ const chalk = require("chalk");
8
+
9
+ const CACHE_DIR = path.join(process.cwd(), ".reshot", "cache", "baselines");
10
+
11
+ /**
12
+ * Ensure the cache directory exists
13
+ */
14
+ function ensureCacheDir() {
15
+ fs.ensureDirSync(CACHE_DIR);
16
+ }
17
+
18
+ /**
19
+ * Download baseline image from CDN
20
+ * @param {string} url - CDN URL
21
+ * @param {string} savePath - Local path to save
22
+ * @returns {Promise<string>} Path to downloaded file
23
+ */
24
+ async function downloadBaseline(url, savePath) {
25
+ try {
26
+ fs.ensureDirSync(path.dirname(savePath));
27
+ const response = await axios.get(url, {
28
+ responseType: "arraybuffer",
29
+ timeout: 30000,
30
+ });
31
+ await fs.writeFile(savePath, response.data);
32
+ return savePath;
33
+ } catch (error) {
34
+ throw new Error(
35
+ `Failed to download baseline from ${url}: ${error.message}`
36
+ );
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Download multiple baselines in parallel with offline fallback
42
+ * When downloads fail, falls back to cached versions if available
43
+ * @param {Object} baselines - Map of keys to URLs
44
+ * @returns {Promise<Object>} Map of keys to local paths
45
+ */
46
+ async function downloadBaselines(baselines) {
47
+ ensureCacheDir();
48
+ const localPaths = {};
49
+ const downloads = [];
50
+ let networkFailures = 0;
51
+ let cacheHits = 0;
52
+
53
+ for (const [key, url] of Object.entries(baselines)) {
54
+ const safeName = key.replace(/[^a-zA-Z0-9-_]/g, "_");
55
+ const localPath = path.join(CACHE_DIR, `${safeName}.png`);
56
+
57
+ downloads.push(
58
+ downloadBaseline(url, localPath)
59
+ .then(() => {
60
+ localPaths[key] = localPath;
61
+ })
62
+ .catch((err) => {
63
+ networkFailures++;
64
+ // Check if we have a cached version to fall back to
65
+ if (fs.existsSync(localPath)) {
66
+ cacheHits++;
67
+ console.log(
68
+ chalk.yellow(
69
+ ` ⚠ Network error for ${key}, using cached baseline`
70
+ )
71
+ );
72
+ localPaths[key] = localPath;
73
+ } else {
74
+ console.log(
75
+ chalk.yellow(
76
+ ` ⚠ Failed to download baseline for ${key}: ${err.message}`
77
+ )
78
+ );
79
+ }
80
+ })
81
+ );
82
+ }
83
+
84
+ await Promise.all(downloads);
85
+
86
+ // Warn if we're in offline mode
87
+ if (networkFailures > 0 && cacheHits > 0) {
88
+ console.log(
89
+ chalk.yellow(
90
+ `\n ⚠ Offline mode: ${cacheHits}/${networkFailures} baselines loaded from cache`
91
+ )
92
+ );
93
+ }
94
+
95
+ return localPaths;
96
+ }
97
+
98
+ /**
99
+ * Compare two images and generate diff
100
+ * @param {string} newPath - Path to new image
101
+ * @param {string} baselinePath - Path to baseline image
102
+ * @param {string} diffPath - Path to save diff output
103
+ * @param {Object} options - Comparison options
104
+ * @returns {Promise<Object>} { score, hasDiff, numDiffPixels, totalPixels, reason? }
105
+ */
106
+ async function compareImages(newPath, baselinePath, diffPath, options = {}) {
107
+ const { threshold = 0.1, includeAA = false } = options;
108
+
109
+ // Check if files exist
110
+ if (!fs.existsSync(newPath)) {
111
+ return {
112
+ hasDiff: true,
113
+ score: 1.0,
114
+ reason: "new_image_missing",
115
+ error: `New image not found: ${newPath}`,
116
+ };
117
+ }
118
+
119
+ if (!fs.existsSync(baselinePath)) {
120
+ return {
121
+ hasDiff: true,
122
+ score: 1.0,
123
+ reason: "baseline_missing",
124
+ error: `Baseline not found: ${baselinePath}`,
125
+ };
126
+ }
127
+
128
+ try {
129
+ const img1Data = await fs.readFile(newPath);
130
+ const img2Data = await fs.readFile(baselinePath);
131
+
132
+ const img1 = PNG.sync.read(img1Data);
133
+ const img2 = PNG.sync.read(img2Data);
134
+
135
+ // Dimension mismatch - fail fast
136
+ if (img1.width !== img2.width || img1.height !== img2.height) {
137
+ return {
138
+ hasDiff: true,
139
+ score: 1.0,
140
+ reason: "dimension_mismatch",
141
+ dimensions: {
142
+ new: { width: img1.width, height: img1.height },
143
+ baseline: { width: img2.width, height: img2.height },
144
+ },
145
+ };
146
+ }
147
+
148
+ const { width, height } = img1;
149
+ const diff = new PNG({ width, height });
150
+
151
+ const numDiffPixels = pixelmatch(
152
+ img1.data,
153
+ img2.data,
154
+ diff.data,
155
+ width,
156
+ height,
157
+ { threshold, includeAA }
158
+ );
159
+
160
+ const totalPixels = width * height;
161
+ const score = numDiffPixels / totalPixels;
162
+ const hasDiff = numDiffPixels > 0;
163
+
164
+ if (hasDiff && diffPath) {
165
+ fs.ensureDirSync(path.dirname(diffPath));
166
+ await fs.writeFile(diffPath, PNG.sync.write(diff));
167
+ }
168
+
169
+ return { score, hasDiff, numDiffPixels, totalPixels };
170
+ } catch (error) {
171
+ return {
172
+ hasDiff: true,
173
+ score: 1.0,
174
+ reason: "comparison_error",
175
+ error: error.message,
176
+ };
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Generate manifest for an asset bundle
182
+ * @param {string} assetDir - Asset output directory
183
+ * @param {Object} diffResults - Map of step to diff results
184
+ */
185
+ async function writeManifest(assetDir, diffResults) {
186
+ const manifestPath = path.join(assetDir, "manifest.json");
187
+ const manifest = {
188
+ generatedAt: new Date().toISOString(),
189
+ cliVersion: require("../../package.json").version,
190
+ sentinels: [],
191
+ diffs: [],
192
+ summary: {
193
+ total: 0,
194
+ changed: 0,
195
+ unchanged: 0,
196
+ errors: 0,
197
+ },
198
+ };
199
+
200
+ for (const [step, result] of Object.entries(diffResults)) {
201
+ manifest.summary.total++;
202
+
203
+ manifest.sentinels.push({
204
+ step,
205
+ path: `sentinels/${step}.png`,
206
+ });
207
+
208
+ if (result.error) {
209
+ manifest.summary.errors++;
210
+ manifest.diffs.push({
211
+ step,
212
+ error: result.error,
213
+ reason: result.reason,
214
+ });
215
+ } else if (result.hasDiff) {
216
+ manifest.summary.changed++;
217
+ manifest.diffs.push({
218
+ step,
219
+ path: `diffs/${step}.diff.png`,
220
+ score: result.score,
221
+ numDiffPixels: result.numDiffPixels,
222
+ reason: result.reason,
223
+ });
224
+ } else {
225
+ manifest.summary.unchanged++;
226
+ }
227
+ }
228
+
229
+ await fs.writeJSON(manifestPath, manifest, { spaces: 2 });
230
+ return manifest;
231
+ }
232
+
233
+ /**
234
+ * Clear the baseline cache
235
+ */
236
+ async function clearCache() {
237
+ if (fs.existsSync(CACHE_DIR)) {
238
+ await fs.remove(CACHE_DIR);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Get cache stats
244
+ * @returns {Object} { files, size }
245
+ */
246
+ function getCacheStats() {
247
+ if (!fs.existsSync(CACHE_DIR)) {
248
+ return { files: 0, size: 0 };
249
+ }
250
+
251
+ const files = fs.readdirSync(CACHE_DIR).filter((f) => f.endsWith(".png"));
252
+ let size = 0;
253
+
254
+ for (const file of files) {
255
+ const stat = fs.statSync(path.join(CACHE_DIR, file));
256
+ size += stat.size;
257
+ }
258
+
259
+ return { files: files.length, size };
260
+ }
261
+
262
+ // ============================================
263
+ // LOCAL VERSION-TO-VERSION DIFFING
264
+ // ============================================
265
+
266
+ /**
267
+ * Check if a folder name is a valid timestamp folder
268
+ * @param {string} name - Folder name
269
+ * @returns {boolean}
270
+ */
271
+ function isTimestampFolder(name) {
272
+ return /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(name);
273
+ }
274
+
275
+ /**
276
+ * Get all version timestamps for a scenario, sorted newest first
277
+ * @param {string} outputDir - Base output directory (e.g., .reshot/output)
278
+ * @param {string} scenarioKey - Scenario key
279
+ * @returns {string[]} Array of timestamp folder names, sorted newest first
280
+ */
281
+ function getVersionsForScenario(outputDir, scenarioKey) {
282
+ const scenarioDir = path.join(outputDir, scenarioKey);
283
+
284
+ if (!fs.existsSync(scenarioDir)) {
285
+ return [];
286
+ }
287
+
288
+ try {
289
+ const subFolders = fs.readdirSync(scenarioDir).filter((item) => {
290
+ const fullPath = path.join(scenarioDir, item);
291
+ try {
292
+ return fs.statSync(fullPath).isDirectory();
293
+ } catch {
294
+ return false;
295
+ }
296
+ });
297
+
298
+ return subFolders
299
+ .filter((f) => isTimestampFolder(f))
300
+ .sort()
301
+ .reverse();
302
+ } catch {
303
+ return [];
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Get the previous version timestamp for a scenario (excluding the current one)
309
+ * @param {string} outputDir - Base output directory
310
+ * @param {string} scenarioKey - Scenario key
311
+ * @param {string} currentTimestamp - Current timestamp to exclude
312
+ * @returns {string|null} Previous timestamp or null if none
313
+ */
314
+ function getPreviousVersion(outputDir, scenarioKey, currentTimestamp) {
315
+ const versions = getVersionsForScenario(outputDir, scenarioKey);
316
+
317
+ // Filter out current version and get the next most recent
318
+ const previousVersions = versions.filter((v) => v !== currentTimestamp);
319
+
320
+ return previousVersions.length > 0 ? previousVersions[0] : null;
321
+ }
322
+
323
+ /**
324
+ * Find all visual assets in a version folder (recursively, handles variant subfolders)
325
+ * Prioritizes sentinel images in sentinels/ folder for diffing
326
+ * @param {string} versionDir - Version directory path
327
+ * @returns {Object} Map of asset key -> { path, relativePath, filename, isVideo, isSentinel }
328
+ */
329
+ function findAssetsInVersion(versionDir) {
330
+ const assets = {};
331
+ const imageExtensions = [".png", ".jpg", ".jpeg", ".webp"];
332
+ const videoExtensions = [".mp4", ".webm"];
333
+ const allExtensions = [...imageExtensions, ...videoExtensions];
334
+
335
+ function walk(dir, relativePath = "") {
336
+ if (!fs.existsSync(dir)) return;
337
+
338
+ // Skip 'diffs' subdirectory to avoid picking up diff images
339
+ if (path.basename(dir) === "diffs") return;
340
+
341
+ const isSentinelDir = path.basename(dir) === "sentinels";
342
+
343
+ try {
344
+ const items = fs.readdirSync(dir);
345
+ for (const item of items) {
346
+ const fullPath = path.join(dir, item);
347
+ const relPath = relativePath ? path.join(relativePath, item) : item;
348
+
349
+ try {
350
+ const stat = fs.statSync(fullPath);
351
+ if (stat.isDirectory()) {
352
+ walk(fullPath, relPath);
353
+ } else {
354
+ const ext = path.extname(item).toLowerCase();
355
+ if (allExtensions.includes(ext)) {
356
+ const isVideo = videoExtensions.includes(ext);
357
+
358
+ // For videos, skip - we compare sentinels instead
359
+ if (isVideo) {
360
+ continue;
361
+ }
362
+
363
+ // Use relative path as key to properly namespace by variant
364
+ // e.g., "locale-en/sentinels/step-0-initial"
365
+ const key = relPath.replace(/\.[^/.]+$/, ""); // Remove extension
366
+ assets[key] = {
367
+ path: fullPath,
368
+ relativePath: relPath,
369
+ filename: item,
370
+ isVideo: false,
371
+ isSentinel: isSentinelDir,
372
+ size: stat.size,
373
+ };
374
+ }
375
+ }
376
+ } catch {
377
+ // Skip files we can't stat
378
+ }
379
+ }
380
+ } catch {
381
+ // Skip directories we can't read
382
+ }
383
+ }
384
+
385
+ walk(versionDir);
386
+ return assets;
387
+ }
388
+
389
+ /**
390
+ * Compare current version against previous version locally
391
+ * @param {string} outputDir - Base output directory (e.g., .reshot/output)
392
+ * @param {string} scenarioKey - Scenario key
393
+ * @param {string} currentTimestamp - Current version timestamp
394
+ * @param {Object} options - Comparison options (threshold, includeAA)
395
+ * @returns {Promise<Object>} { results, previousVersion, summary }
396
+ */
397
+ async function compareWithPreviousVersion(
398
+ outputDir,
399
+ scenarioKey,
400
+ currentTimestamp,
401
+ options = {}
402
+ ) {
403
+ const previousTimestamp = getPreviousVersion(
404
+ outputDir,
405
+ scenarioKey,
406
+ currentTimestamp
407
+ );
408
+
409
+ if (!previousTimestamp) {
410
+ return {
411
+ results: {},
412
+ previousVersion: null,
413
+ summary: {
414
+ total: 0,
415
+ compared: 0,
416
+ changed: 0,
417
+ unchanged: 0,
418
+ newAssets: 0,
419
+ errors: 0,
420
+ message: "No previous version to compare against (first run)",
421
+ },
422
+ };
423
+ }
424
+
425
+ const currentDir = path.join(outputDir, scenarioKey, currentTimestamp);
426
+ const previousDir = path.join(outputDir, scenarioKey, previousTimestamp);
427
+
428
+ const currentAssets = findAssetsInVersion(currentDir);
429
+ const previousAssets = findAssetsInVersion(previousDir);
430
+
431
+ const results = {};
432
+ const summary = {
433
+ total: Object.keys(currentAssets).length,
434
+ compared: 0,
435
+ changed: 0,
436
+ unchanged: 0,
437
+ newAssets: 0,
438
+ errors: 0,
439
+ };
440
+
441
+ // Compare each current asset against its previous version
442
+ for (const [assetKey, currentAsset] of Object.entries(currentAssets)) {
443
+ const previousAsset = previousAssets[assetKey];
444
+
445
+ if (!previousAsset) {
446
+ // New asset, no previous version
447
+ results[assetKey] = {
448
+ status: "new",
449
+ hasDiff: true,
450
+ score: 1.0,
451
+ reason: "new_asset",
452
+ currentPath: currentAsset.path,
453
+ };
454
+ summary.newAssets++;
455
+ continue;
456
+ }
457
+
458
+ // Create diff output path - use just the filename, not the full assetKey which includes variant path
459
+ const diffDir = path.join(path.dirname(currentAsset.path), "diffs");
460
+ const baseFilename = path.basename(assetKey); // Just "step-0-initial", not "locale-en/step-0-initial"
461
+ const diffPath = path.join(diffDir, `${baseFilename}.diff.png`);
462
+
463
+ const diffResult = await compareImages(
464
+ currentAsset.path,
465
+ previousAsset.path,
466
+ diffPath,
467
+ options
468
+ );
469
+
470
+ results[assetKey] = {
471
+ ...diffResult,
472
+ status: diffResult.hasDiff ? "changed" : "unchanged",
473
+ currentPath: currentAsset.path,
474
+ previousPath: previousAsset.path,
475
+ diffPath: diffResult.hasDiff ? diffPath : null,
476
+ };
477
+
478
+ summary.compared++;
479
+
480
+ if (diffResult.error) {
481
+ summary.errors++;
482
+ } else if (diffResult.hasDiff) {
483
+ summary.changed++;
484
+ } else {
485
+ summary.unchanged++;
486
+ }
487
+ }
488
+
489
+ return {
490
+ results,
491
+ previousVersion: previousTimestamp,
492
+ currentVersion: currentTimestamp,
493
+ summary,
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Write local diff manifest for a version
499
+ * @param {string} versionDir - Version directory
500
+ * @param {Object} diffData - Result from compareWithPreviousVersion
501
+ */
502
+ async function writeLocalDiffManifest(versionDir, diffData) {
503
+ const manifestPath = path.join(versionDir, "diff-manifest.json");
504
+
505
+ const manifest = {
506
+ generatedAt: new Date().toISOString(),
507
+ cliVersion: require("../../package.json").version,
508
+ comparedAgainst: diffData.previousVersion || diffData.baselineSource,
509
+ currentVersion: diffData.currentVersion,
510
+ baselineSource: diffData.baselineSource || "local",
511
+ summary: diffData.summary,
512
+ assets: {},
513
+ };
514
+
515
+ for (const [key, result] of Object.entries(diffData.results)) {
516
+ manifest.assets[key] = {
517
+ status: result.status,
518
+ hasDiff: result.hasDiff,
519
+ score: result.score,
520
+ reason: result.reason,
521
+ diffPath: result.diffPath
522
+ ? path.relative(versionDir, result.diffPath)
523
+ : null,
524
+ };
525
+ }
526
+
527
+ await fs.writeJSON(manifestPath, manifest, { spaces: 2 });
528
+ return manifest;
529
+ }
530
+
531
+ /**
532
+ * Compare current assets against cloud baselines (approved visuals from platform)
533
+ * @param {string} currentVersionDir - Full path to current version output directory
534
+ * @param {string} scenarioKey - Scenario key
535
+ * @param {Object} cloudBaselines - Map of "scenarioKey/captureKey" to local baseline paths
536
+ * @param {Object} options - Diff options
537
+ * @returns {Object} { baselineSource, results, summary }
538
+ */
539
+ async function compareWithCloudBaselines(currentVersionDir, scenarioKey, cloudBaselines, options = {}) {
540
+ const diffConfig = {
541
+ threshold: options.threshold || 0.1,
542
+ includeAA: options.includeAA || false,
543
+ generateDiffImages: options.generateDiffImages !== false,
544
+ };
545
+
546
+ const results = {};
547
+ const summary = { total: 0, new: 0, changed: 0, unchanged: 0, errors: 0 };
548
+
549
+ // Find all assets in current version (returns Object of assetKey -> asset info)
550
+ const currentAssets = findAssetsInVersion(currentVersionDir);
551
+
552
+ for (const [assetKey, currentAsset] of Object.entries(currentAssets)) {
553
+ const assetPath = currentAsset.path;
554
+ const baseFilename = path.basename(assetKey);
555
+ const fullAssetKey = `${scenarioKey}/${assetKey}`;
556
+ summary.total++;
557
+
558
+ // Check if we have a cloud baseline for this asset
559
+ const baselinePath = cloudBaselines[fullAssetKey];
560
+
561
+ if (!baselinePath) {
562
+ // New asset - no baseline exists
563
+ results[assetKey] = {
564
+ status: "new",
565
+ hasDiff: true,
566
+ score: 1.0,
567
+ reason: "no_cloud_baseline",
568
+ };
569
+ summary.new++;
570
+ continue;
571
+ }
572
+
573
+ // Compare with cloud baseline
574
+ const diffDir = path.join(path.dirname(assetPath), "diffs");
575
+ fs.ensureDirSync(diffDir);
576
+ const diffPath = path.join(diffDir, `${baseFilename}.diff.png`);
577
+
578
+ const diffResult = await compareImages(
579
+ assetPath,
580
+ baselinePath,
581
+ diffConfig.generateDiffImages ? diffPath : null,
582
+ diffConfig
583
+ );
584
+
585
+ if (diffResult.error) {
586
+ results[assetKey] = {
587
+ status: "error",
588
+ hasDiff: true,
589
+ score: 1.0,
590
+ reason: diffResult.reason,
591
+ error: diffResult.error,
592
+ };
593
+ summary.errors++;
594
+ } else if (diffResult.hasDiff) {
595
+ results[assetKey] = {
596
+ status: "changed",
597
+ hasDiff: true,
598
+ score: diffResult.score,
599
+ numDiffPixels: diffResult.numDiffPixels,
600
+ diffPath: diffConfig.generateDiffImages ? diffPath : null,
601
+ };
602
+ summary.changed++;
603
+ } else {
604
+ results[assetKey] = {
605
+ status: "unchanged",
606
+ hasDiff: false,
607
+ score: 0,
608
+ };
609
+ summary.unchanged++;
610
+ // Remove empty diff file if no diff
611
+ if (fs.existsSync(diffPath)) {
612
+ fs.removeSync(diffPath);
613
+ }
614
+ }
615
+ }
616
+
617
+ return {
618
+ baselineSource: "cloud",
619
+ currentVersion: path.basename(currentVersionDir),
620
+ results,
621
+ summary,
622
+ };
623
+ }
624
+
625
+ module.exports = {
626
+ downloadBaseline,
627
+ downloadBaselines,
628
+ compareImages,
629
+ writeManifest,
630
+ clearCache,
631
+ getCacheStats,
632
+ CACHE_DIR,
633
+ // Local diffing functions
634
+ getVersionsForScenario,
635
+ getPreviousVersion,
636
+ findAssetsInVersion,
637
+ compareWithPreviousVersion,
638
+ writeLocalDiffManifest,
639
+ isTimestampFolder,
640
+ // Cloud baseline diffing
641
+ compareWithCloudBaselines,
642
+ };
@@ -0,0 +1,74 @@
1
+ // hash.js - Content hash calculation for asset deduplication
2
+ const crypto = require('crypto');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Calculate SHA256 hash of a file
8
+ * @param {string} filePath - Path to the file
9
+ * @returns {Promise<string>} - Hex-encoded hash
10
+ */
11
+ async function hashFile(filePath) {
12
+ return new Promise((resolve, reject) => {
13
+ const hash = crypto.createHash('sha256');
14
+ const stream = fs.createReadStream(filePath);
15
+
16
+ stream.on('data', (data) => hash.update(data));
17
+ stream.on('end', () => resolve(hash.digest('hex')));
18
+ stream.on('error', reject);
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Calculate hash synchronously (for smaller files)
24
+ * @param {Buffer} buffer - File contents
25
+ * @returns {string} - Hex-encoded hash
26
+ */
27
+ function hashBuffer(buffer) {
28
+ return crypto.createHash('sha256').update(buffer).digest('hex');
29
+ }
30
+
31
+ /**
32
+ * Get MIME type from file extension
33
+ * @param {string} filePath - Path to the file
34
+ * @returns {string} - MIME type
35
+ */
36
+ function getMimeType(filePath) {
37
+ const ext = path.extname(filePath).toLowerCase();
38
+ const mimeTypes = {
39
+ '.png': 'image/png',
40
+ '.jpg': 'image/jpeg',
41
+ '.jpeg': 'image/jpeg',
42
+ '.gif': 'image/gif',
43
+ '.webp': 'image/webp',
44
+ '.mp4': 'video/mp4',
45
+ '.webm': 'video/webm',
46
+ '.svg': 'image/svg+xml',
47
+ };
48
+ return mimeTypes[ext] || 'application/octet-stream';
49
+ }
50
+
51
+ /**
52
+ * Get file extension from MIME type (for generating paths)
53
+ * @param {string} mimeType - MIME type
54
+ * @returns {string} - File extension without dot
55
+ */
56
+ function getExtFromMimeType(mimeType) {
57
+ const extMap = {
58
+ 'image/png': 'png',
59
+ 'image/jpeg': 'jpg',
60
+ 'image/gif': 'gif',
61
+ 'image/webp': 'webp',
62
+ 'video/mp4': 'mp4',
63
+ 'video/webm': 'webm',
64
+ 'image/svg+xml': 'svg',
65
+ };
66
+ return extMap[mimeType] || 'bin';
67
+ }
68
+
69
+ module.exports = {
70
+ hashFile,
71
+ hashBuffer,
72
+ getMimeType,
73
+ getExtFromMimeType,
74
+ };