design-clone 2.1.0 → 3.0.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 (177) hide show
  1. package/README.md +13 -34
  2. package/SKILL.md +69 -45
  3. package/bin/cli.js +22 -4
  4. package/bin/commands/clone-site.js +31 -171
  5. package/bin/commands/help.js +19 -6
  6. package/bin/commands/init.js +9 -86
  7. package/bin/commands/uninstall.js +105 -0
  8. package/bin/commands/update.js +70 -0
  9. package/bin/commands/verify.js +7 -14
  10. package/bin/utils/paths.js +28 -0
  11. package/bin/utils/validate.js +2 -22
  12. package/bin/utils/version.js +23 -0
  13. package/docs/code-standards.md +789 -0
  14. package/docs/codebase-summary.md +533 -286
  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 +14 -17
  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/filter-css-dead-code.js +120 -0
  51. package/src/core/css/filter-css-html-analyzer.js +110 -0
  52. package/src/core/css/filter-css-selector-matcher.js +172 -0
  53. package/src/core/css/filter-css.js +206 -0
  54. package/src/core/css/merge-css-atrule-processor.js +158 -0
  55. package/src/core/css/merge-css-file-io.js +68 -0
  56. package/src/core/css/merge-css.js +148 -0
  57. package/src/core/detection/framework-detector-routing.js +68 -0
  58. package/src/core/detection/framework-detector-signals.js +65 -0
  59. package/src/core/detection/framework-detector.js +198 -0
  60. package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
  61. package/src/core/dimension/dimension-extractor.js +317 -0
  62. package/src/core/dimension/dimension-output-ai-summary.js +111 -0
  63. package/src/core/dimension/dimension-output.js +173 -0
  64. package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
  65. package/src/core/dimension/dom-tree-analyzer.js +191 -0
  66. package/src/core/discovery/app-state-snapshot-capture.js +195 -0
  67. package/src/core/discovery/app-state-snapshot-utils.js +178 -0
  68. package/src/core/discovery/app-state-snapshot.js +131 -0
  69. package/src/core/discovery/discover-pages-routes.js +84 -0
  70. package/src/core/discovery/discover-pages-utils.js +177 -0
  71. package/src/core/discovery/discover-pages.js +191 -0
  72. package/src/core/html/html-extractor-inline-styler.js +70 -0
  73. package/src/core/html/html-extractor.js +147 -0
  74. package/src/core/html/semantic-enhancer-mappings.js +200 -0
  75. package/src/core/html/semantic-enhancer-page.js +148 -0
  76. package/src/core/html/semantic-enhancer.js +135 -0
  77. package/src/core/links/rewrite-links-css-rewriter.js +53 -0
  78. package/src/core/links/rewrite-links.js +173 -0
  79. package/src/core/media/asset-validator.js +118 -0
  80. package/src/core/media/extract-assets-downloader.js +187 -0
  81. package/src/core/media/extract-assets-page-scraper.js +115 -0
  82. package/src/core/media/extract-assets.js +159 -0
  83. package/src/core/media/video-capture-convert.js +200 -0
  84. package/src/core/media/video-capture.js +201 -0
  85. package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
  86. package/src/core/section/section-cropper-helpers.js +43 -0
  87. package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
  88. package/src/core/section/section-detector-strategies.js +139 -0
  89. package/src/core/section/section-detector-utils.js +100 -0
  90. package/src/core/section/section-detector.js +88 -0
  91. package/src/core/tests/test-section-cropper.js +2 -2
  92. package/src/core/tests/test-section-detector.js +2 -2
  93. package/src/post-process/enhance-assets.js +29 -4
  94. package/src/post-process/fetch-images-unsplash-client.js +123 -0
  95. package/src/post-process/fetch-images.js +60 -263
  96. package/src/post-process/inject-gosnap.js +88 -0
  97. package/src/post-process/inject-icons-svg-replacer.js +76 -0
  98. package/src/post-process/inject-icons.js +47 -200
  99. package/src/route-discoverers/base-discoverer-utils.js +137 -0
  100. package/src/route-discoverers/base-discoverer.js +29 -118
  101. package/src/route-discoverers/index.js +1 -1
  102. package/src/shared/config.js +38 -0
  103. package/src/shared/error-codes.js +31 -0
  104. package/src/shared/viewports.js +46 -0
  105. package/src/utils/browser.js +0 -7
  106. package/src/utils/helpers.js +4 -0
  107. package/src/utils/log.js +12 -0
  108. package/src/utils/playwright-loader.js +76 -0
  109. package/src/utils/playwright.js +3 -69
  110. package/src/utils/progress.js +32 -0
  111. package/src/verification/generate-audit-report-css-fixes.js +52 -0
  112. package/src/verification/generate-audit-report-sections.js +158 -0
  113. package/src/verification/generate-audit-report.js +5 -281
  114. package/src/verification/quality-scorer.js +92 -0
  115. package/src/verification/verify-footer-checks.js +103 -0
  116. package/src/verification/verify-footer-helpers.js +178 -0
  117. package/src/verification/verify-footer.js +23 -381
  118. package/src/verification/verify-header-checks.js +104 -0
  119. package/src/verification/verify-header-helpers.js +156 -0
  120. package/src/verification/verify-header.js +23 -365
  121. package/src/verification/verify-layout-report.js +101 -0
  122. package/src/verification/verify-layout.js +13 -259
  123. package/src/verification/verify-menu-checks.js +104 -0
  124. package/src/verification/verify-menu-helpers.js +112 -0
  125. package/src/verification/verify-menu.js +17 -285
  126. package/src/verification/verify-slider-checks.js +115 -0
  127. package/src/verification/verify-slider-constants.js +65 -0
  128. package/src/verification/verify-slider-helpers.js +164 -0
  129. package/src/verification/verify-slider.js +23 -414
  130. package/.env.example +0 -14
  131. package/docs/basic-clone.md +0 -63
  132. package/docs/cli-reference.md +0 -316
  133. package/docs/design-clone-architecture.md +0 -492
  134. package/docs/pixel-perfect.md +0 -117
  135. package/docs/project-roadmap.md +0 -382
  136. package/docs/troubleshooting.md +0 -170
  137. package/requirements.txt +0 -5
  138. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  139. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  140. package/src/ai/analyze-structure.py +0 -375
  141. package/src/ai/extract-design-tokens.py +0 -782
  142. package/src/ai/prompts/__init__.py +0 -2
  143. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  144. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  145. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  146. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  147. package/src/ai/prompts/design_tokens.py +0 -316
  148. package/src/ai/prompts/structure_analysis.py +0 -592
  149. package/src/ai/prompts/ux_audit.py +0 -198
  150. package/src/ai/ux-audit.js +0 -596
  151. package/src/core/animation-extractor.js +0 -526
  152. package/src/core/app-state-snapshot.js +0 -511
  153. package/src/core/content-counter.js +0 -342
  154. package/src/core/design-tokens.js +0 -103
  155. package/src/core/dimension-extractor.js +0 -438
  156. package/src/core/dimension-output.js +0 -305
  157. package/src/core/discover-pages.js +0 -542
  158. package/src/core/dom-tree-analyzer.js +0 -298
  159. package/src/core/extract-assets.js +0 -468
  160. package/src/core/filter-css.js +0 -499
  161. package/src/core/framework-detector.js +0 -538
  162. package/src/core/html-extractor.js +0 -212
  163. package/src/core/merge-css.js +0 -407
  164. package/src/core/multi-page-screenshot.js +0 -380
  165. package/src/core/rewrite-links.js +0 -226
  166. package/src/core/screenshot.js +0 -701
  167. package/src/core/section-detector.js +0 -386
  168. package/src/core/semantic-enhancer.js +0 -492
  169. package/src/core/state-capture.js +0 -598
  170. package/src/core/video-capture.js +0 -546
  171. package/src/utils/__init__.py +0 -16
  172. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  173. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  174. package/src/utils/env.py +0 -134
  175. /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
  176. /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
  177. /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
@@ -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
+ };
@@ -84,50 +84,48 @@ export async function forceAnimatedElementsVisible(page) {
84
84
  */
85
85
  export async function triggerLazyLoad(page, maxIterations = 20, scrollDelay = 1500) {
86
86
  return await page.evaluate(async ({ maxIter, pauseMs }) => {
87
- return new Promise(async (resolve) => {
88
- const viewportHeight = window.innerHeight;
89
- const totalHeight = document.body.scrollHeight;
90
- const scrollStep = viewportHeight * 0.5;
91
- const pauseTime = pauseMs;
92
-
93
- let position = 0;
94
- let iterations = 0;
95
-
96
- // First pass: scroll through entire page
97
- while (position < totalHeight && iterations < maxIter) {
98
- window.scrollTo({ top: position, behavior: 'instant' });
99
- await new Promise(r => setTimeout(r, pauseTime));
100
- position += scrollStep;
101
- iterations++;
102
- }
87
+ const viewportHeight = window.innerHeight;
88
+ const totalHeight = document.body.scrollHeight;
89
+ const scrollStep = viewportHeight * 0.5;
90
+ const pauseTime = pauseMs;
91
+
92
+ let position = 0;
93
+ let iterations = 0;
94
+
95
+ // First pass: scroll through entire page
96
+ while (position < totalHeight && iterations < maxIter) {
97
+ window.scrollTo({ top: position, behavior: 'instant' });
98
+ await new Promise(r => setTimeout(r, pauseTime));
99
+ position += scrollStep;
100
+ iterations++;
101
+ }
103
102
 
104
- // Scroll to bottom
105
- window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' });
106
- await new Promise(r => setTimeout(r, 1000));
103
+ // Scroll to bottom
104
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' });
105
+ await new Promise(r => setTimeout(r, 1000));
107
106
 
108
- // Second pass: scroll back up
109
- position = document.body.scrollHeight;
110
- while (position > 0) {
111
- position -= scrollStep;
112
- window.scrollTo({ top: Math.max(0, position), behavior: 'instant' });
113
- await new Promise(r => setTimeout(r, 300));
114
- }
107
+ // Second pass: scroll back up
108
+ position = document.body.scrollHeight;
109
+ while (position > 0) {
110
+ position -= scrollStep;
111
+ window.scrollTo({ top: Math.max(0, position), behavior: 'instant' });
112
+ await new Promise(r => setTimeout(r, 300));
113
+ }
115
114
 
116
- // Return to top
117
- window.scrollTo({ top: 0, behavior: 'instant' });
118
- await new Promise(r => setTimeout(r, 1500));
115
+ // Return to top
116
+ window.scrollTo({ top: 0, behavior: 'instant' });
117
+ await new Promise(r => setTimeout(r, 1500));
119
118
 
120
- if (window.scrollY !== 0) {
121
- window.scrollTo({ top: 0, behavior: 'instant' });
122
- await new Promise(r => setTimeout(r, 500));
123
- }
119
+ if (window.scrollY !== 0) {
120
+ window.scrollTo({ top: 0, behavior: 'instant' });
121
+ await new Promise(r => setTimeout(r, 500));
122
+ }
124
123
 
125
- resolve({
126
- scrolled: iterations,
127
- height: document.body.scrollHeight,
128
- stableAt: iterations
129
- });
130
- });
124
+ return {
125
+ scrolled: iterations,
126
+ height: document.body.scrollHeight,
127
+ stableAt: iterations
128
+ };
131
129
  }, { maxIter: maxIterations, pauseMs: scrollDelay });
132
130
  }
133
131
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Section Cropper Helpers
3
+ *
4
+ * Bounds validation and filename sanitization utilities
5
+ * extracted from section-cropper.js to keep each file under 200 lines.
6
+ */
7
+
8
+ /**
9
+ * Validate and clamp section bounds to image dimensions
10
+ * @param {{x: number, y: number, width: number, height: number}} bounds - Section bounds
11
+ * @param {number} imageWidth - Source image width in pixels
12
+ * @param {number} imageHeight - Source image height in pixels
13
+ * @returns {{left: number, top: number, width: number, height: number}}
14
+ */
15
+ export function validateBounds(bounds, imageWidth, imageHeight) {
16
+ const left = Math.max(0, Math.round(bounds.x));
17
+ const top = Math.max(0, Math.round(bounds.y));
18
+
19
+ const maxWidth = imageWidth - left;
20
+ const maxHeight = imageHeight - top;
21
+
22
+ const width = Math.min(Math.round(bounds.width), maxWidth);
23
+ const height = Math.min(Math.round(bounds.height), maxHeight);
24
+
25
+ return { left, top, width, height };
26
+ }
27
+
28
+ /**
29
+ * Sanitize a section name for use as a filename component
30
+ * - Lowercased, non-alphanumeric chars replaced with hyphens
31
+ * - Collapsed and trimmed hyphens
32
+ * - Truncated to 50 characters
33
+ * @param {string} name
34
+ * @returns {string}
35
+ */
36
+ export function sanitizeName(name) {
37
+ return name
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9-]/g, '-')
40
+ .replace(/-+/g, '-')
41
+ .replace(/^-|-$/g, '')
42
+ .substring(0, 50) || 'unnamed';
43
+ }