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
@@ -1,546 +0,0 @@
1
- /**
2
- * Video Capture Module
3
- *
4
- * Record scrolling interactions and CSS animations using Playwright's
5
- * context-level video recording. Optionally convert WebM to MP4/GIF using ffmpeg.
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
- // ============================================================================
18
- // Constants
19
- // ============================================================================
20
-
21
- /** Default recording duration in milliseconds */
22
- const DEFAULT_DURATION = 12000;
23
-
24
- /** Default hold time at top/bottom of scroll */
25
- const DEFAULT_HOLD_MS = 500;
26
-
27
- /** Formats requiring ffmpeg for conversion */
28
- const FFMPEG_REQUIRED_FORMATS = ['mp4', 'gif'];
29
-
30
- /** GIF output settings */
31
- const GIF_DEFAULT_FPS = 10;
32
- const GIF_DEFAULT_WIDTH = 640;
33
-
34
- /** Maximum scroll steps to prevent memory exhaustion on very large pages */
35
- const MAX_SCROLL_STEPS = 100;
36
-
37
- /** Viewport overlap fraction for scroll step calculation */
38
- const VIEWPORT_OVERLAP_FRACTION = 0.5;
39
-
40
- /** Default viewport for video recording */
41
- const DEFAULT_VIDEO_VIEWPORT = { width: 1440, height: 900 };
42
-
43
- // ============================================================================
44
- // Type Definitions (JSDoc)
45
- // ============================================================================
46
-
47
- /**
48
- * @typedef {Object} RecordOptions
49
- * @property {number} [duration=12000] - Total recording duration in ms
50
- * @property {number} [scrollPauseMs=50] - Pause between scroll steps for smoothness
51
- * @property {number} [holdTopMs=500] - Hold time at page top
52
- * @property {number} [holdBottomMs=500] - Hold time at page bottom
53
- * @property {{width: number, height: number}} [viewport] - Viewport dimensions
54
- */
55
-
56
- /**
57
- * @typedef {Object} RecordResult
58
- * @property {string} path - Output file path
59
- * @property {string} format - Output format ('webm')
60
- * @property {number} duration - Actual recording duration in ms
61
- * @property {number} scrollSteps - Number of scroll steps taken
62
- * @property {number} pageHeight - Total page height in pixels
63
- */
64
-
65
- /**
66
- * @typedef {Object} ConvertResult
67
- * @property {string} path - Output file path
68
- * @property {string} format - Output format ('mp4' | 'gif')
69
- */
70
-
71
- /**
72
- * @typedef {Object} CaptureOptions
73
- * @property {'webm'|'mp4'|'gif'} [format='webm'] - Output format
74
- * @property {number} [duration=12000] - Recording duration in ms
75
- * @property {string} [filename='preview'] - Output filename (without extension)
76
- */
77
-
78
- /**
79
- * @typedef {Object} CaptureResult
80
- * @property {string} webm - Path to WebM file (always created)
81
- * @property {string} [mp4] - Path to MP4 file (if format='mp4')
82
- * @property {string} [gif] - Path to GIF file (if format='gif')
83
- * @property {string} output - Path to final output file
84
- * @property {number} duration - Recording duration in ms
85
- * @property {number} pageHeight - Total page height in pixels
86
- * @property {string} [conversionError] - Error message if conversion failed
87
- */
88
-
89
- // ============================================================================
90
- // ffmpeg Dependency Management
91
- // ============================================================================
92
-
93
- /**
94
- * ffmpeg module references
95
- * Loaded dynamically to handle missing optional dependency gracefully
96
- */
97
- let ffmpeg = null;
98
- let ffmpegPath = null;
99
- let ffmpegInitialized = false;
100
-
101
- /**
102
- * Initialize ffmpeg dependencies.
103
- * Lazy-loads fluent-ffmpeg and @ffmpeg-installer/ffmpeg.
104
- *
105
- * @returns {Promise<boolean>} True if ffmpeg is available
106
- */
107
- async function initFfmpeg() {
108
- if (ffmpegInitialized) {
109
- return ffmpeg !== false;
110
- }
111
-
112
- ffmpegInitialized = true;
113
-
114
- try {
115
- const [fluentFfmpeg, installer] = await Promise.all([
116
- import('fluent-ffmpeg'),
117
- import('@ffmpeg-installer/ffmpeg')
118
- ]);
119
-
120
- ffmpeg = fluentFfmpeg.default;
121
- ffmpegPath = installer.path;
122
- ffmpeg.setFfmpegPath(ffmpegPath);
123
-
124
- return true;
125
- } catch (importError) {
126
- // Mark as unavailable
127
- ffmpeg = false;
128
-
129
- const isModuleNotFound = importError.code === 'ERR_MODULE_NOT_FOUND';
130
- if (!isModuleNotFound) {
131
- // Unexpected error
132
- console.error(
133
- '[video-capture] ffmpeg initialization error:',
134
- importError.message
135
- );
136
- }
137
-
138
- return false;
139
- }
140
- }
141
-
142
- /**
143
- * Check if ffmpeg is available for video conversion.
144
- *
145
- * @returns {Promise<boolean>} True if ffmpeg dependencies are available
146
- */
147
- export async function hasFfmpeg() {
148
- return await initFfmpeg();
149
- }
150
-
151
- // ============================================================================
152
- // Logging Helper
153
- // ============================================================================
154
-
155
- /**
156
- * Log message to stderr if running in TTY
157
- * @param {string} message - Message to log
158
- */
159
- function log(message) {
160
- if (process.stderr.isTTY) {
161
- console.error(message);
162
- }
163
- }
164
-
165
- // ============================================================================
166
- // Input Validation
167
- // ============================================================================
168
-
169
- /**
170
- * Validate page object (Playwright page)
171
- * @param {import('playwright').Page} page - Playwright page object
172
- * @throws {TypeError} If page is invalid
173
- */
174
- function validatePage(page) {
175
- if (!page || typeof page.evaluate !== 'function') {
176
- throw new TypeError('Invalid page object: must be a Playwright page');
177
- }
178
- // Playwright-specific check
179
- if (typeof page.context !== 'function') {
180
- throw new TypeError('Invalid page object: missing context() method');
181
- }
182
- }
183
-
184
- /**
185
- * Validate output path
186
- * @param {string} outputPath - Output file/directory path
187
- * @throws {TypeError} If path is invalid
188
- */
189
- function validatePath(outputPath) {
190
- if (!outputPath || typeof outputPath !== 'string') {
191
- throw new TypeError('Invalid output path: must be a non-empty string');
192
- }
193
- }
194
-
195
- // ============================================================================
196
- // Scroll Recording (Playwright Context-Level Video)
197
- // ============================================================================
198
-
199
- /**
200
- * Record page scroll interaction using Playwright context-level video.
201
- *
202
- * Creates a new browser context with video recording enabled, navigates to
203
- * the page URL, performs scroll animation, then closes to finalize video.
204
- *
205
- * @param {import('playwright').Browser} browser - Playwright browser instance
206
- * @param {string} pageUrl - URL to navigate and record
207
- * @param {string} outputDir - Directory for video output
208
- * @param {RecordOptions} [options={}] - Recording options
209
- * @returns {Promise<RecordResult>} Recording result with metadata
210
- */
211
- export async function recordScroll(browser, pageUrl, outputDir, options = {}) {
212
- if (!browser || typeof browser.newContext !== 'function') {
213
- throw new TypeError('Invalid browser: must be a Playwright browser instance');
214
- }
215
- validatePath(outputDir);
216
-
217
- const {
218
- duration = DEFAULT_DURATION,
219
- scrollPauseMs = 50,
220
- holdTopMs = DEFAULT_HOLD_MS,
221
- holdBottomMs = DEFAULT_HOLD_MS,
222
- viewport = DEFAULT_VIDEO_VIEWPORT
223
- } = options;
224
-
225
- // Create context with video recording enabled
226
- const context = await browser.newContext({
227
- recordVideo: {
228
- dir: outputDir,
229
- size: viewport
230
- },
231
- viewport
232
- });
233
-
234
- const page = await context.newPage();
235
- const startTime = Date.now();
236
-
237
- try {
238
- // Navigate to page
239
- await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30000 });
240
-
241
- // Get page dimensions
242
- const totalHeight = await page.evaluate(() =>
243
- Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
244
- );
245
-
246
- const viewportHeight = viewport.height;
247
- const scrollDistance = Math.max(0, totalHeight - viewportHeight);
248
- const isScrollable = scrollDistance > 0;
249
-
250
- const rawScrollSteps = isScrollable
251
- ? Math.ceil(scrollDistance / (viewportHeight * VIEWPORT_OVERLAP_FRACTION))
252
- : 0;
253
- const scrollSteps = Math.min(rawScrollSteps, MAX_SCROLL_STEPS);
254
-
255
- const scrollTime = duration - holdTopMs - holdBottomMs;
256
- const scrollDelay = scrollSteps > 0
257
- ? Math.max(scrollPauseMs, Math.floor(scrollTime / (scrollSteps * 2)))
258
- : 0;
259
-
260
- // Ensure at top
261
- await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
262
- await new Promise(r => setTimeout(r, 200));
263
-
264
- // Hold at top
265
- await new Promise(r => setTimeout(r, holdTopMs));
266
-
267
- if (isScrollable && scrollSteps > 0) {
268
- // Scroll down
269
- for (let i = 1; i <= scrollSteps; i++) {
270
- const y = (i / scrollSteps) * scrollDistance;
271
- await page.evaluate(scrollY => window.scrollTo({ top: scrollY, behavior: 'instant' }), y);
272
- await new Promise(r => setTimeout(r, scrollDelay));
273
- }
274
-
275
- // Hold at bottom
276
- await new Promise(r => setTimeout(r, holdBottomMs));
277
-
278
- // Scroll back up
279
- for (let i = scrollSteps - 1; i >= 0; i--) {
280
- const y = (i / scrollSteps) * scrollDistance;
281
- await page.evaluate(scrollY => window.scrollTo({ top: scrollY, behavior: 'instant' }), y);
282
- await new Promise(r => setTimeout(r, scrollDelay));
283
- }
284
-
285
- // Hold at top
286
- await new Promise(r => setTimeout(r, holdTopMs));
287
- } else {
288
- // Single-screen: hold for duration
289
- await new Promise(r => setTimeout(r, scrollTime + holdBottomMs));
290
- }
291
-
292
- const actualDuration = Date.now() - startTime;
293
-
294
- // IMPORTANT: Close page before getting video path (Playwright requirement)
295
- await page.close();
296
-
297
- // Get video path (only available after page close)
298
- const video = page.video();
299
- const videoPath = video ? await video.path() : null;
300
-
301
- // Cleanup context
302
- await context.close();
303
-
304
- if (!videoPath) {
305
- throw new Error('Video recording failed - no path returned');
306
- }
307
-
308
- return {
309
- path: videoPath,
310
- format: 'webm',
311
- duration: actualDuration,
312
- scrollSteps,
313
- pageHeight: totalHeight
314
- };
315
-
316
- } catch (error) {
317
- // Cleanup on error
318
- try {
319
- await page.close().catch(() => {});
320
- await context.close().catch(() => {});
321
- } catch { /* ignore cleanup errors */ }
322
- throw error;
323
- }
324
- }
325
-
326
- // ============================================================================
327
- // Format Conversion
328
- // ============================================================================
329
-
330
- /**
331
- * Convert WebM to MP4 using ffmpeg.
332
- *
333
- * Uses H.264 codec with settings optimized for web playback:
334
- * - libx264 encoder with fast preset
335
- * - CRF 23 for good quality/size balance
336
- * - yuv420p pixel format for iOS/Safari compatibility
337
- * - faststart flag for progressive playback
338
- *
339
- * @param {string} inputPath - Path to WebM file
340
- * @param {string} outputPath - Path for MP4 output
341
- * @returns {Promise<ConvertResult>} Conversion result
342
- * @throws {Error} If ffmpeg is not available or conversion fails
343
- */
344
- export async function convertToMp4(inputPath, outputPath) {
345
- validatePath(inputPath);
346
- validatePath(outputPath);
347
-
348
- const hasFf = await initFfmpeg();
349
- if (!hasFf) {
350
- throw new Error(
351
- 'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
352
- );
353
- }
354
-
355
- return new Promise((resolve, reject) => {
356
- ffmpeg(inputPath)
357
- .outputOptions([
358
- '-c:v libx264',
359
- '-preset fast',
360
- '-crf 23',
361
- '-pix_fmt yuv420p',
362
- '-movflags +faststart'
363
- ])
364
- .output(outputPath)
365
- .on('end', () => resolve({ path: outputPath, format: 'mp4' }))
366
- .on('error', (err) => reject(new Error(`MP4 conversion failed: ${err.message}`)))
367
- .run();
368
- });
369
- }
370
-
371
- /**
372
- * Convert WebM to GIF using ffmpeg.
373
- *
374
- * Uses two-pass conversion with palette generation for high-quality output:
375
- * 1. Generate optimized palette from video
376
- * 2. Create GIF using palette with dithering
377
- *
378
- * @param {string} inputPath - Path to WebM file
379
- * @param {string} outputPath - Path for GIF output
380
- * @param {Object} [options={}] - GIF options
381
- * @param {number} [options.fps=10] - Output frame rate
382
- * @param {number} [options.width=640] - Output width (height auto-calculated)
383
- * @returns {Promise<ConvertResult>} Conversion result
384
- * @throws {Error} If ffmpeg is not available or conversion fails
385
- */
386
- export async function convertToGif(inputPath, outputPath, options = {}) {
387
- validatePath(inputPath);
388
- validatePath(outputPath);
389
-
390
- const { fps = GIF_DEFAULT_FPS, width = GIF_DEFAULT_WIDTH } = options;
391
-
392
- const hasFf = await initFfmpeg();
393
- if (!hasFf) {
394
- throw new Error(
395
- 'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
396
- );
397
- }
398
-
399
- // Palette path for two-pass conversion
400
- const palettePath = inputPath.replace(/\.webm$/i, '-palette.png');
401
-
402
- try {
403
- // Pass 1: Generate palette
404
- await new Promise((resolve, reject) => {
405
- ffmpeg(inputPath)
406
- .outputOptions([
407
- '-vf',
408
- `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=diff`
409
- ])
410
- .output(palettePath)
411
- .on('end', resolve)
412
- .on('error', (err) => reject(new Error(`Palette generation failed: ${err.message}`)))
413
- .run();
414
- });
415
-
416
- // Pass 2: Create GIF with palette
417
- await new Promise((resolve, reject) => {
418
- ffmpeg(inputPath)
419
- .input(palettePath)
420
- .complexFilter([
421
- `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`
422
- ])
423
- .output(outputPath)
424
- .on('end', resolve)
425
- .on('error', (err) => reject(new Error(`GIF creation failed: ${err.message}`)))
426
- .run();
427
- });
428
-
429
- return { path: outputPath, format: 'gif' };
430
- } finally {
431
- // Cleanup palette file
432
- try {
433
- await fs.unlink(palettePath);
434
- } catch (cleanupErr) {
435
- if (process.env.DEBUG) {
436
- console.error(`[video-capture] Palette cleanup failed: ${cleanupErr.message}`);
437
- }
438
- }
439
- }
440
- }
441
-
442
- // ============================================================================
443
- // Main Capture Function
444
- // ============================================================================
445
-
446
- /**
447
- * Capture video of page scroll interaction.
448
- *
449
- * Creates a new browser context for recording (Playwright requirement),
450
- * records page scrolling, and optionally converts to MP4 or GIF.
451
- * WebM is always created first (native Playwright format).
452
- *
453
- * @param {import('playwright').Page} page - Playwright page (used for browser reference and URL)
454
- * @param {string} outputDir - Directory for output files
455
- * @param {CaptureOptions} [options={}] - Capture options
456
- * @returns {Promise<CaptureResult>} Capture result with file paths
457
- */
458
- export async function captureVideo(page, outputDir, options = {}) {
459
- validatePage(page);
460
- validatePath(outputDir);
461
-
462
- const {
463
- format = 'webm',
464
- duration = DEFAULT_DURATION,
465
- filename = 'preview'
466
- } = options;
467
-
468
- // Get browser and current URL from page
469
- const browser = page.context().browser();
470
- const pageUrl = page.url();
471
- const viewport = page.viewportSize() || DEFAULT_VIDEO_VIEWPORT;
472
-
473
- if (!browser) {
474
- throw new Error('Cannot get browser from page. Ensure page has browser context.');
475
- }
476
-
477
- // Record using new context (Playwright context-level video)
478
- log('[video] Recording scroll...');
479
- const recordResult = await recordScroll(browser, pageUrl, outputDir, {
480
- duration,
481
- viewport
482
- });
483
- log(`[video] Recorded ${(recordResult.duration / 1000).toFixed(1)}s`);
484
-
485
- // Rename video file to expected name (Playwright auto-generates random name)
486
- const expectedPath = path.join(outputDir, `${filename}.webm`);
487
- if (recordResult.path !== expectedPath) {
488
- try {
489
- await fs.rename(recordResult.path, expectedPath);
490
- recordResult.path = expectedPath;
491
- } catch (renameErr) {
492
- // If rename fails, keep original path
493
- log(`[video] Could not rename video: ${renameErr.message}`);
494
- }
495
- }
496
-
497
- /** @type {CaptureResult} */
498
- const result = {
499
- webm: recordResult.path,
500
- duration: recordResult.duration,
501
- pageHeight: recordResult.pageHeight,
502
- output: recordResult.path
503
- };
504
-
505
- // Convert if needed (ffmpeg logic unchanged)
506
- if (format === 'mp4') {
507
- const mp4Path = path.join(outputDir, `${filename}.mp4`);
508
- log('[video] Converting to MP4...');
509
-
510
- try {
511
- await convertToMp4(recordResult.path, mp4Path);
512
- result.mp4 = mp4Path;
513
- result.output = mp4Path;
514
- log('[video] MP4 conversion complete');
515
- } catch (e) {
516
- log(`[video] MP4 conversion failed: ${e.message}`);
517
- result.conversionError = e.message;
518
- }
519
- } else if (format === 'gif') {
520
- const gifPath = path.join(outputDir, `${filename}.gif`);
521
- log('[video] Converting to GIF...');
522
-
523
- try {
524
- await convertToGif(recordResult.path, gifPath);
525
- result.gif = gifPath;
526
- result.output = gifPath;
527
- log('[video] GIF conversion complete');
528
- } catch (e) {
529
- log(`[video] GIF conversion failed: ${e.message}`);
530
- result.conversionError = e.message;
531
- }
532
- }
533
-
534
- return result;
535
- }
536
-
537
- // ============================================================================
538
- // Exports
539
- // ============================================================================
540
-
541
- export {
542
- DEFAULT_DURATION,
543
- FFMPEG_REQUIRED_FORMATS,
544
- MAX_SCROLL_STEPS,
545
- VIEWPORT_OVERLAP_FRACTION
546
- };
@@ -1,16 +0,0 @@
1
- """
2
- Design Clone skill library modules.
3
-
4
- JavaScript modules:
5
- - browser.js: Browser abstraction facade
6
- - playwright.js: Playwright browser wrapper
7
- - utils.js: CLI utilities
8
- - env.js: Environment variable resolution
9
-
10
- Python modules:
11
- - env.py: Environment variable resolution
12
- """
13
-
14
- from .env import resolve_env, load_env, require_env, get_skill_dir
15
-
16
- __all__ = ['resolve_env', 'load_env', 'require_env', 'get_skill_dir']
package/src/utils/env.py DELETED
@@ -1,134 +0,0 @@
1
- """
2
- Environment variable resolution for design-clone scripts.
3
-
4
- Search order (first found wins, os.environ takes precedence):
5
- 1. os.environ (already set)
6
- 2. .env in current working directory
7
- 3. .env in skill directory (scripts/design-clone/)
8
- 4. .env in ~/.claude/skills/
9
- 5. .env in ~/.claude/
10
-
11
- Usage:
12
- from lib.env import resolve_env, load_env, get_skill_dir
13
-
14
- # Load all .env files
15
- load_env()
16
-
17
- # Get specific variable with fallback
18
- api_key = resolve_env('GEMINI_API_KEY', default=None)
19
- """
20
-
21
- import os
22
- from pathlib import Path
23
- from typing import Dict, List, Optional
24
-
25
- # Skill directory - from src/utils/ go up 2 levels to reach design-clone/
26
- SKILL_DIR = Path(__file__).parent.parent.parent.resolve()
27
-
28
-
29
- def get_env_search_paths() -> List[Path]:
30
- """Get list of directories to search for .env files."""
31
- return [
32
- Path.cwd(),
33
- SKILL_DIR,
34
- Path.home() / '.claude' / 'skills',
35
- Path.home() / '.claude'
36
- ]
37
-
38
-
39
- def parse_env_file(file_path: Path) -> Dict[str, str]:
40
- """
41
- Parse .env file into key-value dict.
42
- Handles: KEY=value, KEY="quoted value", comments (#), empty lines
43
- """
44
- result = {}
45
-
46
- try:
47
- with open(file_path, 'r', encoding='utf-8') as f:
48
- for line in f:
49
- line = line.strip()
50
-
51
- # Skip empty lines and comments
52
- if not line or line.startswith('#'):
53
- continue
54
-
55
- # Parse KEY=value
56
- if '=' in line:
57
- key, _, value = line.partition('=')
58
- key = key.strip()
59
- value = value.strip()
60
-
61
- # Remove quotes if present
62
- if (value.startswith('"') and value.endswith('"')) or \
63
- (value.startswith("'") and value.endswith("'")):
64
- value = value[1:-1]
65
-
66
- result[key] = value
67
- except Exception as e:
68
- print(f"[env] Failed to read {file_path}: {e}")
69
-
70
- return result
71
-
72
-
73
- def load_env() -> Optional[Path]:
74
- """
75
- Load environment variables from .env files.
76
- Only sets variables not already in os.environ.
77
-
78
- Returns:
79
- Path to loaded .env file, or None if none found.
80
- """
81
- for dir_path in get_env_search_paths():
82
- env_file = dir_path / '.env'
83
-
84
- if env_file.exists():
85
- parsed = parse_env_file(env_file)
86
-
87
- # Only set vars not already in environ
88
- for key, value in parsed.items():
89
- if key not in os.environ:
90
- os.environ[key] = value
91
-
92
- return env_file
93
-
94
- return None
95
-
96
-
97
- def resolve_env(key: str, default: Optional[str] = None) -> Optional[str]:
98
- """
99
- Get environment variable with optional default.
100
-
101
- Args:
102
- key: Environment variable name
103
- default: Default value if not found
104
-
105
- Returns:
106
- Variable value or default
107
- """
108
- return os.environ.get(key, default)
109
-
110
-
111
- def require_env(key: str, hint: str = '') -> str:
112
- """
113
- Require environment variable, raise if not found.
114
-
115
- Args:
116
- key: Environment variable name
117
- hint: Hint message for how to set the variable
118
-
119
- Returns:
120
- Variable value
121
-
122
- Raises:
123
- OSError: If variable not set
124
- """
125
- value = os.environ.get(key)
126
- if not value:
127
- hint_msg = f'\nHint: {hint}' if hint else ''
128
- raise OSError(f'Required environment variable {key} not set.{hint_msg}')
129
- return value
130
-
131
-
132
- def get_skill_dir() -> Path:
133
- """Get skill directory path."""
134
- return SKILL_DIR