design-clone 1.2.0 → 2.3.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 (174) hide show
  1. package/README.md +32 -39
  2. package/SKILL.md +69 -45
  3. package/bin/cli.js +22 -4
  4. package/bin/commands/clone-site.js +31 -106
  5. package/bin/commands/help.js +19 -6
  6. package/bin/commands/init.js +11 -56
  7. package/bin/commands/uninstall.js +105 -0
  8. package/bin/commands/update.js +70 -0
  9. package/bin/commands/verify.js +11 -16
  10. package/bin/utils/paths.js +28 -0
  11. package/bin/utils/validate.js +24 -28
  12. package/bin/utils/version.js +23 -0
  13. package/docs/code-standards.md +789 -0
  14. package/docs/codebase-summary.md +556 -0
  15. package/docs/index.md +74 -0
  16. package/docs/project-overview-pdr.md +797 -0
  17. package/docs/system-architecture.md +718 -0
  18. package/package.json +20 -21
  19. package/src/ai/prompts/design-tokens/basic.md +80 -0
  20. package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
  21. package/src/ai/prompts/design-tokens/section.md +48 -0
  22. package/src/ai/prompts/design-tokens/with-css.md +87 -0
  23. package/src/ai/prompts/structure-analysis/basic.md +55 -0
  24. package/src/ai/prompts/structure-analysis/with-context.md +59 -0
  25. package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
  26. package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
  27. package/src/ai/prompts/ux-audit/aggregation.md +42 -0
  28. package/src/ai/prompts/ux-audit/desktop.md +92 -0
  29. package/src/ai/prompts/ux-audit/mobile.md +93 -0
  30. package/src/ai/prompts/ux-audit/tablet.md +92 -0
  31. package/src/core/animation/animation-extractor-ast.js +183 -0
  32. package/src/core/animation/animation-extractor-output.js +152 -0
  33. package/src/core/animation/animation-extractor.js +178 -0
  34. package/src/core/animation/state-capture-detection.js +200 -0
  35. package/src/core/animation/state-capture.js +193 -0
  36. package/src/core/capture/browser-context-pool.js +96 -0
  37. package/src/core/capture/multi-page-screenshot-page.js +110 -0
  38. package/src/core/capture/multi-page-screenshot.js +208 -0
  39. package/src/core/capture/screenshot-extraction.js +186 -0
  40. package/src/core/capture/screenshot-helpers.js +175 -0
  41. package/src/core/capture/screenshot-orchestrator.js +174 -0
  42. package/src/core/capture/screenshot-viewport.js +93 -0
  43. package/src/core/capture/screenshot.js +192 -0
  44. package/src/core/content/content-counter-dom.js +191 -0
  45. package/src/core/content/content-counter.js +76 -0
  46. package/src/core/css/breakpoint-detector.js +66 -0
  47. package/src/core/css/chromium-defaults.json +23 -0
  48. package/src/core/css/computed-style-extractor.js +102 -0
  49. package/src/core/css/css-chunker.js +103 -0
  50. package/src/core/{css-extractor.js → css/css-extractor.js} +4 -4
  51. package/src/core/css/filter-css-dead-code.js +120 -0
  52. package/src/core/css/filter-css-html-analyzer.js +110 -0
  53. package/src/core/css/filter-css-selector-matcher.js +172 -0
  54. package/src/core/css/filter-css.js +206 -0
  55. package/src/core/css/merge-css-atrule-processor.js +158 -0
  56. package/src/core/css/merge-css-file-io.js +68 -0
  57. package/src/core/css/merge-css.js +148 -0
  58. package/src/core/detection/framework-detector-routing.js +68 -0
  59. package/src/core/detection/framework-detector-signals.js +65 -0
  60. package/src/core/detection/framework-detector.js +198 -0
  61. package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
  62. package/src/core/dimension/dimension-extractor.js +317 -0
  63. package/src/core/dimension/dimension-output-ai-summary.js +111 -0
  64. package/src/core/dimension/dimension-output.js +173 -0
  65. package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
  66. package/src/core/dimension/dom-tree-analyzer.js +191 -0
  67. package/src/core/discovery/app-state-snapshot-capture.js +195 -0
  68. package/src/core/discovery/app-state-snapshot-utils.js +178 -0
  69. package/src/core/discovery/app-state-snapshot.js +131 -0
  70. package/src/core/discovery/discover-pages-routes.js +84 -0
  71. package/src/core/discovery/discover-pages-utils.js +177 -0
  72. package/src/core/discovery/discover-pages.js +191 -0
  73. package/src/core/html/html-extractor-inline-styler.js +70 -0
  74. package/src/core/html/html-extractor.js +147 -0
  75. package/src/core/html/semantic-enhancer-mappings.js +200 -0
  76. package/src/core/html/semantic-enhancer-page.js +148 -0
  77. package/src/core/html/semantic-enhancer.js +135 -0
  78. package/src/core/links/rewrite-links-css-rewriter.js +53 -0
  79. package/src/core/links/rewrite-links.js +173 -0
  80. package/src/core/media/asset-validator.js +118 -0
  81. package/src/core/media/extract-assets-downloader.js +187 -0
  82. package/src/core/media/extract-assets-page-scraper.js +115 -0
  83. package/src/core/media/extract-assets.js +159 -0
  84. package/src/core/media/video-capture-convert.js +200 -0
  85. package/src/core/media/video-capture.js +201 -0
  86. package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +1 -1
  87. package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +44 -46
  88. package/src/core/{page-readiness.js → page-prep/page-readiness.js} +8 -8
  89. package/src/core/section/section-cropper-helpers.js +43 -0
  90. package/src/core/section/section-cropper.js +132 -0
  91. package/src/core/section/section-detector-strategies.js +139 -0
  92. package/src/core/section/section-detector-utils.js +100 -0
  93. package/src/core/section/section-detector.js +88 -0
  94. package/src/core/tests/test-section-cropper.js +177 -0
  95. package/src/core/tests/test-section-detector.js +55 -0
  96. package/src/post-process/enhance-assets.js +29 -4
  97. package/src/post-process/fetch-images-unsplash-client.js +123 -0
  98. package/src/post-process/fetch-images.js +60 -263
  99. package/src/post-process/inject-gosnap.js +88 -0
  100. package/src/post-process/inject-icons-svg-replacer.js +76 -0
  101. package/src/post-process/inject-icons.js +47 -200
  102. package/src/route-discoverers/angular-discoverer.js +157 -0
  103. package/src/route-discoverers/astro-discoverer.js +123 -0
  104. package/src/route-discoverers/base-discoverer-utils.js +137 -0
  105. package/src/route-discoverers/base-discoverer.js +153 -0
  106. package/src/route-discoverers/index.js +106 -0
  107. package/src/route-discoverers/next-discoverer.js +130 -0
  108. package/src/route-discoverers/nuxt-discoverer.js +138 -0
  109. package/src/route-discoverers/react-discoverer.js +139 -0
  110. package/src/route-discoverers/svelte-discoverer.js +109 -0
  111. package/src/route-discoverers/universal-discoverer.js +227 -0
  112. package/src/route-discoverers/vue-discoverer.js +118 -0
  113. package/src/shared/config.js +38 -0
  114. package/src/shared/error-codes.js +31 -0
  115. package/src/shared/viewports.js +46 -0
  116. package/src/utils/browser.js +11 -44
  117. package/src/utils/helpers.js +4 -0
  118. package/src/utils/log.js +12 -0
  119. package/src/utils/playwright-loader.js +76 -0
  120. package/src/utils/playwright.js +147 -0
  121. package/src/utils/progress.js +32 -0
  122. package/src/verification/generate-audit-report-css-fixes.js +52 -0
  123. package/src/verification/generate-audit-report-sections.js +158 -0
  124. package/src/verification/generate-audit-report.js +122 -0
  125. package/src/verification/quality-scorer.js +92 -0
  126. package/src/verification/verify-footer-checks.js +103 -0
  127. package/src/verification/verify-footer-helpers.js +178 -0
  128. package/src/verification/verify-footer.js +135 -0
  129. package/src/verification/verify-header-checks.js +104 -0
  130. package/src/verification/verify-header-helpers.js +156 -0
  131. package/src/verification/verify-header.js +144 -0
  132. package/src/verification/verify-layout-report.js +101 -0
  133. package/src/verification/verify-layout.js +14 -260
  134. package/src/verification/verify-menu-checks.js +104 -0
  135. package/src/verification/verify-menu-helpers.js +112 -0
  136. package/src/verification/verify-menu.js +18 -302
  137. package/src/verification/verify-slider-checks.js +115 -0
  138. package/src/verification/verify-slider-constants.js +65 -0
  139. package/src/verification/verify-slider-helpers.js +164 -0
  140. package/src/verification/verify-slider.js +142 -0
  141. package/.env.example +0 -14
  142. package/docs/basic-clone.md +0 -63
  143. package/docs/cli-reference.md +0 -118
  144. package/docs/design-clone-architecture.md +0 -275
  145. package/docs/pixel-perfect.md +0 -86
  146. package/docs/troubleshooting.md +0 -169
  147. package/requirements.txt +0 -5
  148. package/src/ai/analyze-structure.py +0 -305
  149. package/src/ai/extract-design-tokens.py +0 -439
  150. package/src/ai/prompts/__init__.py +0 -2
  151. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  152. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  153. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  154. package/src/ai/prompts/design_tokens.py +0 -183
  155. package/src/ai/prompts/structure_analysis.py +0 -273
  156. package/src/core/animation-extractor.js +0 -526
  157. package/src/core/design-tokens.js +0 -103
  158. package/src/core/dimension-extractor.js +0 -366
  159. package/src/core/dimension-output.js +0 -208
  160. package/src/core/discover-pages.js +0 -314
  161. package/src/core/extract-assets.js +0 -468
  162. package/src/core/filter-css.js +0 -499
  163. package/src/core/html-extractor.js +0 -171
  164. package/src/core/merge-css.js +0 -407
  165. package/src/core/multi-page-screenshot.js +0 -377
  166. package/src/core/rewrite-links.js +0 -226
  167. package/src/core/screenshot.js +0 -572
  168. package/src/core/state-capture.js +0 -602
  169. package/src/core/video-capture.js +0 -540
  170. package/src/utils/__init__.py +0 -16
  171. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  172. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  173. package/src/utils/env.py +0 -134
  174. package/src/utils/puppeteer.js +0 -281
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Asset Extraction Script for Pixel-Perfect Clone
4
+ *
5
+ * Downloads and organizes assets from source website:
6
+ * - Images (jpg, png, gif, webp, svg)
7
+ * - Fonts (woff, woff2, ttf, otf)
8
+ * - CSS-embedded images (background-url)
9
+ *
10
+ * Usage:
11
+ * node extract-assets.js --url <url> --output <dir> [--verbose]
12
+ *
13
+ * Options:
14
+ * --url Target website URL (required)
15
+ * --output Output directory (required)
16
+ * --verbose Show detailed progress
17
+ * --timeout Download timeout in ms (default: 30000)
18
+ */
19
+
20
+ import fs from 'fs/promises';
21
+ import path from 'path';
22
+ import { getBrowser, getPage, closeBrowser, disconnectBrowser } from '../../utils/browser.js';
23
+ import { parseArgs, outputJSON, outputError } from '../../utils/helpers.js';
24
+ import { downloadBatch, getSafeFilename, getAssetType } from './extract-assets-downloader.js';
25
+ import { extractCssUrls, extractAssetsFromPage } from './extract-assets-page-scraper.js';
26
+
27
+ /**
28
+ * Main extraction function
29
+ */
30
+ async function extractAssets() {
31
+ const args = parseArgs(process.argv.slice(2));
32
+
33
+ if (!args.url) {
34
+ outputError(new Error('--url is required'));
35
+ process.exit(1);
36
+ }
37
+ if (!args.output) {
38
+ outputError(new Error('--output directory is required'));
39
+ process.exit(1);
40
+ }
41
+
42
+ const verbose = args.verbose === 'true';
43
+ const timeout = args.timeout ? parseInt(args.timeout) : 30000;
44
+
45
+ try {
46
+ // Create output directories
47
+ const assetsDir = path.join(args.output, 'assets');
48
+ await fs.mkdir(path.join(assetsDir, 'images'), { recursive: true });
49
+ await fs.mkdir(path.join(assetsDir, 'fonts'), { recursive: true });
50
+ await fs.mkdir(path.join(assetsDir, 'icons'), { recursive: true });
51
+
52
+ // Launch browser and navigate
53
+ const browser = await getBrowser({ headless: args.headless !== 'false' });
54
+ const page = await getPage(browser);
55
+
56
+ if (verbose) console.error(`\n📦 Extracting assets from: ${args.url}\n`);
57
+
58
+ await page.goto(args.url, { waitUntil: 'networkidle', timeout: 30000 });
59
+
60
+ // Extract assets from page DOM
61
+ const pageAssets = await extractAssetsFromPage(page, args.url);
62
+
63
+ // Collect CSS content for font/background extraction
64
+ let allCssContent = '';
65
+
66
+ const inlineCss = await page.evaluate(() =>
67
+ Array.from(document.querySelectorAll('style')).map(s => s.textContent).join('\n')
68
+ );
69
+ allCssContent += inlineCss;
70
+
71
+ const sourceCssPath = path.join(args.output, 'analysis', 'source.css');
72
+ try {
73
+ const sourceCss = await fs.readFile(sourceCssPath, 'utf-8');
74
+ allCssContent += '\n' + sourceCss;
75
+ } catch { /* source.css not available */ }
76
+
77
+ // Combine all URLs and categorize
78
+ const cssAssetUrls = extractCssUrls(allCssContent, args.url);
79
+ const allUrls = new Set([...pageAssets.images, ...cssAssetUrls]);
80
+
81
+ const downloads = [];
82
+ const urlMapping = {};
83
+
84
+ for (const url of allUrls) {
85
+ const type = getAssetType(url);
86
+ const filename = getSafeFilename(url);
87
+ const destPath = path.join(assetsDir, type === 'other' ? 'images' : type, filename);
88
+ const relativePath = path.relative(args.output, destPath);
89
+
90
+ downloads.push({ url, destPath, type });
91
+ urlMapping[url] = relativePath;
92
+ }
93
+
94
+ if (verbose) {
95
+ console.error(`Found ${downloads.length} assets to download:`);
96
+ console.error(` - Images: ${downloads.filter(d => d.type === 'images').length}`);
97
+ console.error(` - Fonts: ${downloads.filter(d => d.type === 'fonts').length}`);
98
+ console.error(` - Icons: ${downloads.filter(d => d.type === 'icons').length}`);
99
+ console.error('');
100
+ }
101
+
102
+ // Download all assets
103
+ const parsedConcurrency = args.concurrency ? parseInt(args.concurrency) : NaN;
104
+ const concurrency = Number.isNaN(parsedConcurrency) ? undefined : parsedConcurrency;
105
+ const downloadResults = await downloadBatch(downloads, verbose, { maxConcurrent: concurrency });
106
+
107
+ // Validate downloaded assets
108
+ let integrity = null;
109
+ try {
110
+ const { validateBatch } = await import('./asset-validator.js');
111
+ integrity = await validateBatch(assetsDir);
112
+ } catch { /* validation optional */ }
113
+
114
+ // Save inline SVGs
115
+ let savedSvgs = 0;
116
+ for (const svg of pageAssets.inlineSvgs) {
117
+ const filename = `${svg.id.replace(/[^a-zA-Z0-9-_]/g, '_')}.svg`;
118
+ const svgPath = path.join(assetsDir, 'icons', filename);
119
+ try {
120
+ await fs.writeFile(svgPath, svg.content, 'utf-8');
121
+ savedSvgs++;
122
+ } catch { /* ignore */ }
123
+ }
124
+
125
+ // Save URL mapping for HTML rewriting
126
+ const mappingPath = path.join(assetsDir, 'url-mapping.json');
127
+ await fs.writeFile(mappingPath, JSON.stringify(urlMapping, null, 2));
128
+
129
+ // Close browser
130
+ if (args.close === 'true') {
131
+ await closeBrowser();
132
+ } else {
133
+ await disconnectBrowser();
134
+ }
135
+
136
+ outputJSON({
137
+ success: true,
138
+ assetsDir: path.resolve(assetsDir),
139
+ urlMapping: mappingPath,
140
+ stats: {
141
+ total: downloads.length,
142
+ downloaded: downloadResults.success,
143
+ failed: downloadResults.failed,
144
+ skipped: downloadResults.skipped,
145
+ inlineSvgs: savedSvgs
146
+ },
147
+ integrity: integrity || undefined,
148
+ errors: downloadResults.errors.length > 0 ? downloadResults.errors.slice(0, 10) : undefined
149
+ });
150
+ process.exit(0);
151
+
152
+ } catch (error) {
153
+ outputError(error);
154
+ process.exit(1);
155
+ }
156
+ }
157
+
158
+ // Run
159
+ extractAssets();
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Video Capture Conversion
3
+ *
4
+ * ffmpeg dependency management and WebM-to-MP4/GIF format conversion.
5
+ * Lazy-loads fluent-ffmpeg and @ffmpeg-installer/ffmpeg so the module
6
+ * can be imported without those packages installed.
7
+ *
8
+ * @module video-capture-convert
9
+ */
10
+
11
+ import fs from 'fs/promises';
12
+
13
+ // ============================================================================
14
+ // Constants
15
+ // ============================================================================
16
+
17
+ /** Formats requiring ffmpeg for conversion */
18
+ export const FFMPEG_REQUIRED_FORMATS = ['mp4', 'gif'];
19
+
20
+ /** GIF output settings */
21
+ const GIF_DEFAULT_FPS = 10;
22
+ const GIF_DEFAULT_WIDTH = 640;
23
+
24
+ // ============================================================================
25
+ // ffmpeg Dependency Management
26
+ // ============================================================================
27
+
28
+ let ffmpeg = null;
29
+ let ffmpegInitialized = false;
30
+
31
+ /**
32
+ * Initialize ffmpeg dependencies.
33
+ * Lazy-loads fluent-ffmpeg and @ffmpeg-installer/ffmpeg.
34
+ *
35
+ * @returns {Promise<boolean>} True if ffmpeg is available
36
+ */
37
+ export async function initFfmpeg() {
38
+ if (ffmpegInitialized) return ffmpeg !== false;
39
+
40
+ ffmpegInitialized = true;
41
+
42
+ try {
43
+ const [fluentFfmpeg, installer] = await Promise.all([
44
+ import('fluent-ffmpeg'),
45
+ import('@ffmpeg-installer/ffmpeg')
46
+ ]);
47
+
48
+ ffmpeg = fluentFfmpeg.default;
49
+ const ffmpegPath = installer.path;
50
+ ffmpeg.setFfmpegPath(ffmpegPath);
51
+
52
+ return true;
53
+ } catch (importError) {
54
+ ffmpeg = false;
55
+
56
+ if (importError.code !== 'ERR_MODULE_NOT_FOUND') {
57
+ console.error('[video-capture] ffmpeg initialization error:', importError.message);
58
+ }
59
+
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Check if ffmpeg is available for video conversion.
66
+ *
67
+ * @returns {Promise<boolean>} True if ffmpeg dependencies are available
68
+ */
69
+ export async function hasFfmpeg() {
70
+ return await initFfmpeg();
71
+ }
72
+
73
+ // ============================================================================
74
+ // Input Validation
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Validate output path
79
+ * @param {string} outputPath - Output file/directory path
80
+ * @throws {TypeError} If path is invalid
81
+ */
82
+ export function validatePath(outputPath) {
83
+ if (!outputPath || typeof outputPath !== 'string') {
84
+ throw new TypeError('Invalid output path: must be a non-empty string');
85
+ }
86
+ }
87
+
88
+ // ============================================================================
89
+ // Format Conversion
90
+ // ============================================================================
91
+
92
+ /**
93
+ * Convert WebM to MP4 using ffmpeg.
94
+ *
95
+ * Uses H.264 codec with settings optimized for web playback:
96
+ * - libx264 encoder with fast preset
97
+ * - CRF 23 for good quality/size balance
98
+ * - yuv420p pixel format for iOS/Safari compatibility
99
+ * - faststart flag for progressive playback
100
+ *
101
+ * @param {string} inputPath - Path to WebM file
102
+ * @param {string} outputPath - Path for MP4 output
103
+ * @returns {Promise<{path: string, format: string}>} Conversion result
104
+ * @throws {Error} If ffmpeg is not available or conversion fails
105
+ */
106
+ export async function convertToMp4(inputPath, outputPath) {
107
+ validatePath(inputPath);
108
+ validatePath(outputPath);
109
+
110
+ const hasFf = await initFfmpeg();
111
+ if (!hasFf) {
112
+ throw new Error(
113
+ 'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
114
+ );
115
+ }
116
+
117
+ return new Promise((resolve, reject) => {
118
+ ffmpeg(inputPath)
119
+ .outputOptions([
120
+ '-c:v libx264',
121
+ '-preset fast',
122
+ '-crf 23',
123
+ '-pix_fmt yuv420p',
124
+ '-movflags +faststart'
125
+ ])
126
+ .output(outputPath)
127
+ .on('end', () => resolve({ path: outputPath, format: 'mp4' }))
128
+ .on('error', (err) => reject(new Error(`MP4 conversion failed: ${err.message}`)))
129
+ .run();
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Convert WebM to GIF using ffmpeg.
135
+ *
136
+ * Uses two-pass conversion with palette generation for high-quality output:
137
+ * 1. Generate optimized palette from video
138
+ * 2. Create GIF using palette with dithering
139
+ *
140
+ * @param {string} inputPath - Path to WebM file
141
+ * @param {string} outputPath - Path for GIF output
142
+ * @param {Object} [options={}] - GIF options
143
+ * @param {number} [options.fps=10] - Output frame rate
144
+ * @param {number} [options.width=640] - Output width (height auto-calculated)
145
+ * @returns {Promise<{path: string, format: string}>} Conversion result
146
+ * @throws {Error} If ffmpeg is not available or conversion fails
147
+ */
148
+ export async function convertToGif(inputPath, outputPath, options = {}) {
149
+ validatePath(inputPath);
150
+ validatePath(outputPath);
151
+
152
+ const { fps = GIF_DEFAULT_FPS, width = GIF_DEFAULT_WIDTH } = options;
153
+
154
+ const hasFf = await initFfmpeg();
155
+ if (!hasFf) {
156
+ throw new Error(
157
+ 'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
158
+ );
159
+ }
160
+
161
+ const palettePath = inputPath.replace(/\.webm$/i, '-palette.png');
162
+
163
+ try {
164
+ // Pass 1: Generate palette
165
+ await new Promise((resolve, reject) => {
166
+ ffmpeg(inputPath)
167
+ .outputOptions([
168
+ '-vf',
169
+ `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=diff`
170
+ ])
171
+ .output(palettePath)
172
+ .on('end', resolve)
173
+ .on('error', (err) => reject(new Error(`Palette generation failed: ${err.message}`)))
174
+ .run();
175
+ });
176
+
177
+ // Pass 2: Create GIF with palette
178
+ await new Promise((resolve, reject) => {
179
+ ffmpeg(inputPath)
180
+ .input(palettePath)
181
+ .complexFilter([
182
+ `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`
183
+ ])
184
+ .output(outputPath)
185
+ .on('end', resolve)
186
+ .on('error', (err) => reject(new Error(`GIF creation failed: ${err.message}`)))
187
+ .run();
188
+ });
189
+
190
+ return { path: outputPath, format: 'gif' };
191
+ } finally {
192
+ try {
193
+ await fs.unlink(palettePath);
194
+ } catch (cleanupErr) {
195
+ if (process.env.DEBUG) {
196
+ console.error(`[video-capture] Palette cleanup failed: ${cleanupErr.message}`);
197
+ }
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Video Capture Module
3
+ *
4
+ * Record scrolling interactions using Playwright's context-level video
5
+ * recording. Optionally converts WebM to MP4/GIF via video-capture-convert.js.
6
+ *
7
+ * Usage:
8
+ * import { captureVideo, hasFfmpeg } from './video-capture.js';
9
+ * const result = await captureVideo(page, outputDir, { format: 'webm' });
10
+ *
11
+ * @module video-capture
12
+ */
13
+
14
+ import path from 'path';
15
+ import fs from 'fs/promises';
16
+
17
+ import { hasFfmpeg, convertToMp4, convertToGif, validatePath, FFMPEG_REQUIRED_FORMATS } from './video-capture-convert.js';
18
+ import { isTTY } from '../../utils/log.js';
19
+
20
+ const DEFAULT_DURATION = 12000; // ms
21
+ const DEFAULT_HOLD_MS = 500; // hold at top/bottom of scroll
22
+ const MAX_SCROLL_STEPS = 100; // cap to avoid memory exhaustion
23
+ const VIEWPORT_OVERLAP_FRACTION = 0.5; // fraction of viewport per scroll step
24
+ const DEFAULT_VIDEO_VIEWPORT = { width: 1440, height: 900 };
25
+
26
+ /** Log to stderr when running in TTY. */
27
+ function log(message) {
28
+ if (isTTY) console.error(message);
29
+ }
30
+
31
+ /** Validate that page is a Playwright page instance. */
32
+ function validatePage(page) {
33
+ if (!page || typeof page.evaluate !== 'function') throw new TypeError('Invalid page object: must be a Playwright page');
34
+ if (typeof page.context !== 'function') throw new TypeError('Invalid page object: missing context() method');
35
+ }
36
+
37
+ /**
38
+ * Record page scroll using a new Playwright context with video enabled.
39
+ * IMPORTANT: page must be closed before calling video.path() (Playwright requirement).
40
+ * @param {import('playwright').Browser} browser
41
+ * @param {string} pageUrl
42
+ * @param {string} outputDir
43
+ * @param {{duration?, scrollPauseMs?, holdTopMs?, holdBottomMs?, viewport?}} [options]
44
+ * @returns {Promise<{path, format, duration, scrollSteps, pageHeight}>}
45
+ */
46
+ export async function recordScroll(browser, pageUrl, outputDir, options = {}) {
47
+ if (!browser || typeof browser.newContext !== 'function') {
48
+ throw new TypeError('Invalid browser: must be a Playwright browser instance');
49
+ }
50
+ validatePath(outputDir);
51
+
52
+ const {
53
+ duration = DEFAULT_DURATION,
54
+ scrollPauseMs = 50,
55
+ holdTopMs = DEFAULT_HOLD_MS,
56
+ holdBottomMs = DEFAULT_HOLD_MS,
57
+ viewport = DEFAULT_VIDEO_VIEWPORT
58
+ } = options;
59
+
60
+ const context = await browser.newContext({
61
+ recordVideo: { dir: outputDir, size: viewport },
62
+ viewport
63
+ });
64
+
65
+ const page = await context.newPage();
66
+ const startTime = Date.now();
67
+
68
+ try {
69
+ await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30000 });
70
+
71
+ const totalHeight = await page.evaluate(() =>
72
+ Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
73
+ );
74
+
75
+ const viewportHeight = viewport.height;
76
+ const scrollDistance = Math.max(0, totalHeight - viewportHeight);
77
+ const isScrollable = scrollDistance > 0;
78
+
79
+ const rawScrollSteps = isScrollable
80
+ ? Math.ceil(scrollDistance / (viewportHeight * VIEWPORT_OVERLAP_FRACTION))
81
+ : 0;
82
+ const scrollSteps = Math.min(rawScrollSteps, MAX_SCROLL_STEPS);
83
+
84
+ const scrollTime = duration - holdTopMs - holdBottomMs;
85
+ const scrollDelay = scrollSteps > 0
86
+ ? Math.max(scrollPauseMs, Math.floor(scrollTime / (scrollSteps * 2)))
87
+ : 0;
88
+
89
+ await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
90
+ await new Promise(r => setTimeout(r, 200 + holdTopMs));
91
+
92
+ if (isScrollable && scrollSteps > 0) {
93
+ const scroll = (i) => page.evaluate(y => window.scrollTo({ top: y, behavior: 'instant' }), (i / scrollSteps) * scrollDistance);
94
+ for (let i = 1; i <= scrollSteps; i++) { await scroll(i); await new Promise(r => setTimeout(r, scrollDelay)); }
95
+ await new Promise(r => setTimeout(r, holdBottomMs));
96
+ for (let i = scrollSteps - 1; i >= 0; i--) { await scroll(i); await new Promise(r => setTimeout(r, scrollDelay)); }
97
+ await new Promise(r => setTimeout(r, holdTopMs));
98
+ } else {
99
+ await new Promise(r => setTimeout(r, scrollTime + holdBottomMs));
100
+ }
101
+
102
+ const actualDuration = Date.now() - startTime;
103
+ // IMPORTANT: Close page before video.path() (Playwright requirement)
104
+ await page.close();
105
+ const video = page.video();
106
+ const videoPath = video ? await video.path() : null;
107
+ await context.close();
108
+ if (!videoPath) throw new Error('Video recording failed - no path returned');
109
+ return { path: videoPath, format: 'webm', duration: actualDuration, scrollSteps, pageHeight: totalHeight };
110
+
111
+ } catch (error) {
112
+ try {
113
+ await page.close().catch(() => {});
114
+ await context.close().catch(() => {});
115
+ } catch { /* ignore cleanup errors */ }
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Capture scroll video. Records WebM via new browser context, optionally converts to MP4/GIF.
122
+ * @param {import('playwright').Page} page - source of browser + URL
123
+ * @param {string} outputDir
124
+ * @param {{'webm'|'mp4'|'gif'} format?, number duration?, string filename?} [options]
125
+ * @returns {Promise<{webm, mp4?, gif?, output, duration, pageHeight, conversionError?}>}
126
+ */
127
+ export async function captureVideo(page, outputDir, options = {}) {
128
+ validatePage(page);
129
+ validatePath(outputDir);
130
+
131
+ const { format = 'webm', duration = DEFAULT_DURATION, filename = 'preview' } = options;
132
+
133
+ const browser = page.context().browser();
134
+ const pageUrl = page.url();
135
+ const viewport = page.viewportSize() || DEFAULT_VIDEO_VIEWPORT;
136
+
137
+ if (!browser) {
138
+ throw new Error('Cannot get browser from page. Ensure page has browser context.');
139
+ }
140
+
141
+ log('[video] Recording scroll...');
142
+ const recordResult = await recordScroll(browser, pageUrl, outputDir, { duration, viewport });
143
+ log(`[video] Recorded ${(recordResult.duration / 1000).toFixed(1)}s`);
144
+
145
+ // Rename to expected filename (Playwright auto-generates random names)
146
+ const expectedPath = path.join(outputDir, `${filename}.webm`);
147
+ if (recordResult.path !== expectedPath) {
148
+ try {
149
+ await fs.rename(recordResult.path, expectedPath);
150
+ recordResult.path = expectedPath;
151
+ } catch (renameErr) {
152
+ log(`[video] Could not rename video: ${renameErr.message}`);
153
+ }
154
+ }
155
+
156
+ const result = {
157
+ webm: recordResult.path,
158
+ duration: recordResult.duration,
159
+ pageHeight: recordResult.pageHeight,
160
+ output: recordResult.path
161
+ };
162
+
163
+ if (format === 'mp4') {
164
+ const mp4Path = path.join(outputDir, `${filename}.mp4`);
165
+ log('[video] Converting to MP4...');
166
+ try {
167
+ await convertToMp4(recordResult.path, mp4Path);
168
+ result.mp4 = mp4Path;
169
+ result.output = mp4Path;
170
+ log('[video] MP4 conversion complete');
171
+ } catch (e) {
172
+ log(`[video] MP4 conversion failed: ${e.message}`);
173
+ result.conversionError = e.message;
174
+ }
175
+ } else if (format === 'gif') {
176
+ const gifPath = path.join(outputDir, `${filename}.gif`);
177
+ log('[video] Converting to GIF...');
178
+ try {
179
+ await convertToGif(recordResult.path, gifPath);
180
+ result.gif = gifPath;
181
+ result.output = gifPath;
182
+ log('[video] GIF conversion complete');
183
+ } catch (e) {
184
+ log(`[video] GIF conversion failed: ${e.message}`);
185
+ result.conversionError = e.message;
186
+ }
187
+ }
188
+
189
+ return result;
190
+ }
191
+
192
+ // Re-exports for backward compatibility
193
+ export {
194
+ hasFfmpeg,
195
+ convertToMp4,
196
+ convertToGif,
197
+ FFMPEG_REQUIRED_FORMATS,
198
+ DEFAULT_DURATION,
199
+ MAX_SCROLL_STEPS,
200
+ VIEWPORT_OVERLAP_FRACTION
201
+ };
@@ -38,7 +38,7 @@ export const COOKIE_REMOVE_SELECTORS = [
38
38
 
39
39
  /**
40
40
  * Dismiss cookie banners by clicking accept or removing elements
41
- * @param {Page} page - Puppeteer page
41
+ * @param {Page} page - Playwright page
42
42
  * @returns {Promise<{method: string, selector?: string, count?: number}>}
43
43
  */
44
44
  export async function dismissCookieBanner(page) {