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
@@ -1,540 +0,0 @@
1
- /**
2
- * Video Capture Module
3
- *
4
- * Record scrolling interactions and CSS animations using Puppeteer's
5
- * page.screencast(). 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
- // ============================================================================
41
- // Type Definitions (JSDoc)
42
- // ============================================================================
43
-
44
- /**
45
- * @typedef {Object} RecordOptions
46
- * @property {number} [duration=12000] - Total recording duration in ms
47
- * @property {number} [scrollPauseMs=50] - Pause between scroll steps for smoothness
48
- * @property {number} [holdTopMs=500] - Hold time at page top
49
- * @property {number} [holdBottomMs=500] - Hold time at page bottom
50
- */
51
-
52
- /**
53
- * @typedef {Object} RecordResult
54
- * @property {string} path - Output file path
55
- * @property {string} format - Output format ('webm')
56
- * @property {number} duration - Actual recording duration in ms
57
- * @property {number} scrollSteps - Number of scroll steps taken
58
- * @property {number} pageHeight - Total page height in pixels
59
- */
60
-
61
- /**
62
- * @typedef {Object} ConvertResult
63
- * @property {string} path - Output file path
64
- * @property {string} format - Output format ('mp4' | 'gif')
65
- */
66
-
67
- /**
68
- * @typedef {Object} CaptureOptions
69
- * @property {'webm'|'mp4'|'gif'} [format='webm'] - Output format
70
- * @property {number} [duration=12000] - Recording duration in ms
71
- * @property {string} [filename='preview'] - Output filename (without extension)
72
- */
73
-
74
- /**
75
- * @typedef {Object} CaptureResult
76
- * @property {string} webm - Path to WebM file (always created)
77
- * @property {string} [mp4] - Path to MP4 file (if format='mp4')
78
- * @property {string} [gif] - Path to GIF file (if format='gif')
79
- * @property {string} output - Path to final output file
80
- * @property {number} duration - Recording duration in ms
81
- * @property {number} pageHeight - Total page height in pixels
82
- * @property {string} [conversionError] - Error message if conversion failed
83
- */
84
-
85
- // ============================================================================
86
- // ffmpeg Dependency Management
87
- // ============================================================================
88
-
89
- /**
90
- * ffmpeg module references
91
- * Loaded dynamically to handle missing optional dependency gracefully
92
- */
93
- let ffmpeg = null;
94
- let ffmpegPath = null;
95
- let ffmpegInitialized = false;
96
-
97
- /**
98
- * Initialize ffmpeg dependencies.
99
- * Lazy-loads fluent-ffmpeg and @ffmpeg-installer/ffmpeg.
100
- *
101
- * @returns {Promise<boolean>} True if ffmpeg is available
102
- */
103
- async function initFfmpeg() {
104
- if (ffmpegInitialized) {
105
- return ffmpeg !== false;
106
- }
107
-
108
- ffmpegInitialized = true;
109
-
110
- try {
111
- const [fluentFfmpeg, installer] = await Promise.all([
112
- import('fluent-ffmpeg'),
113
- import('@ffmpeg-installer/ffmpeg')
114
- ]);
115
-
116
- ffmpeg = fluentFfmpeg.default;
117
- ffmpegPath = installer.path;
118
- ffmpeg.setFfmpegPath(ffmpegPath);
119
-
120
- return true;
121
- } catch (importError) {
122
- // Mark as unavailable
123
- ffmpeg = false;
124
-
125
- const isModuleNotFound = importError.code === 'ERR_MODULE_NOT_FOUND';
126
- if (isModuleNotFound) {
127
- // Expected case: optional dependency not installed
128
- // Don't log anything - hasFfmpeg() will handle messaging
129
- } else {
130
- // Unexpected error
131
- console.error(
132
- '[video-capture] ffmpeg initialization error:',
133
- importError.message
134
- );
135
- }
136
-
137
- return false;
138
- }
139
- }
140
-
141
- /**
142
- * Check if ffmpeg is available for video conversion.
143
- *
144
- * @returns {Promise<boolean>} True if ffmpeg dependencies are available
145
- *
146
- * @example
147
- * if (await hasFfmpeg()) {
148
- * await convertToMp4(webmPath, mp4Path);
149
- * }
150
- */
151
- export async function hasFfmpeg() {
152
- return await initFfmpeg();
153
- }
154
-
155
- // ============================================================================
156
- // Logging Helper
157
- // ============================================================================
158
-
159
- /**
160
- * Log message to stderr if running in TTY
161
- * @param {string} message - Message to log
162
- */
163
- function log(message) {
164
- if (process.stderr.isTTY) {
165
- console.error(message);
166
- }
167
- }
168
-
169
- // ============================================================================
170
- // Input Validation
171
- // ============================================================================
172
-
173
- /**
174
- * Validate page object
175
- * @param {Object} page - Puppeteer page object
176
- * @throws {TypeError} If page is invalid
177
- */
178
- function validatePage(page) {
179
- if (!page || typeof page.evaluate !== 'function') {
180
- throw new TypeError('Invalid page object: must be a Puppeteer page');
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
197
- // ============================================================================
198
-
199
- /**
200
- * Record page scroll interaction from top to bottom and back.
201
- *
202
- * Uses Puppeteer's page.screencast() to capture the viewport as the page
203
- * scrolls. Creates smooth animation by calculating scroll steps based on
204
- * page height and desired duration.
205
- *
206
- * @param {Object} page - Puppeteer page object
207
- * @param {string} outputPath - Path for WebM output file
208
- * @param {RecordOptions} [options={}] - Recording options
209
- * @returns {Promise<RecordResult>} Recording result with metadata
210
- *
211
- * @example
212
- * const result = await recordScroll(page, '/tmp/preview.webm', {
213
- * duration: 8000,
214
- * holdTopMs: 1000
215
- * });
216
- * console.log(`Recorded ${result.scrollSteps} scroll steps`);
217
- */
218
- export async function recordScroll(page, outputPath, options = {}) {
219
- validatePage(page);
220
- validatePath(outputPath);
221
-
222
- const {
223
- duration = DEFAULT_DURATION,
224
- scrollPauseMs = 50,
225
- holdTopMs = DEFAULT_HOLD_MS,
226
- holdBottomMs = DEFAULT_HOLD_MS
227
- } = options;
228
-
229
- // Get viewport dimensions (H2: validate viewport exists)
230
- const viewport = page.viewport();
231
- if (!viewport || !viewport.height) {
232
- throw new Error(
233
- 'Page viewport not initialized. Call page.setViewport() before recording.'
234
- );
235
- }
236
- const viewportHeight = viewport.height;
237
-
238
- // Get total page height
239
- const totalHeight = await page.evaluate(() =>
240
- Math.max(
241
- document.body.scrollHeight,
242
- document.documentElement.scrollHeight
243
- )
244
- );
245
-
246
- // Calculate scroll parameters
247
- const scrollDistance = Math.max(0, totalHeight - viewportHeight);
248
-
249
- // M1: Handle zero-height/single-screen pages
250
- const isScrollable = scrollDistance > 0;
251
-
252
- // M2: Cap scroll steps to prevent memory exhaustion on very large pages
253
- const rawScrollSteps = isScrollable
254
- ? Math.ceil(scrollDistance / (viewportHeight * VIEWPORT_OVERLAP_FRACTION))
255
- : 0;
256
- const scrollSteps = Math.min(rawScrollSteps, MAX_SCROLL_STEPS);
257
-
258
- // Distribute time: hold times + scroll down + scroll up
259
- const scrollTime = duration - holdTopMs - holdBottomMs;
260
- const scrollDelay = scrollSteps > 0
261
- ? Math.max(scrollPauseMs, Math.floor(scrollTime / (scrollSteps * 2)))
262
- : 0;
263
-
264
- // Ensure page is at top
265
- await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
266
- await new Promise(r => setTimeout(r, 200));
267
-
268
- // Start recording
269
- const recorder = await page.screencast({ path: outputPath });
270
- const startTime = Date.now();
271
-
272
- // Hold at top
273
- await new Promise(r => setTimeout(r, holdTopMs));
274
-
275
- // Only scroll if page is scrollable (M1: skip no-op scrolls)
276
- if (isScrollable && scrollSteps > 0) {
277
- // Scroll down
278
- for (let i = 1; i <= scrollSteps; i++) {
279
- const y = (i / scrollSteps) * scrollDistance;
280
- await page.evaluate(
281
- (scrollY) => window.scrollTo({ top: scrollY, behavior: 'instant' }),
282
- y
283
- );
284
- await new Promise(r => setTimeout(r, scrollDelay));
285
- }
286
-
287
- // Hold at bottom
288
- await new Promise(r => setTimeout(r, holdBottomMs));
289
-
290
- // Scroll back up
291
- for (let i = scrollSteps - 1; i >= 0; i--) {
292
- const y = (i / scrollSteps) * scrollDistance;
293
- await page.evaluate(
294
- (scrollY) => window.scrollTo({ top: scrollY, behavior: 'instant' }),
295
- y
296
- );
297
- await new Promise(r => setTimeout(r, scrollDelay));
298
- }
299
-
300
- // Hold at top
301
- await new Promise(r => setTimeout(r, holdTopMs));
302
- } else {
303
- // Single-screen page: just hold for the duration
304
- await new Promise(r => setTimeout(r, scrollTime + holdBottomMs));
305
- }
306
-
307
- // Stop recording
308
- await recorder.stop();
309
-
310
- const actualDuration = Date.now() - startTime;
311
-
312
- return {
313
- path: outputPath,
314
- format: 'webm',
315
- duration: actualDuration,
316
- scrollSteps,
317
- pageHeight: totalHeight
318
- };
319
- }
320
-
321
- // ============================================================================
322
- // Format Conversion
323
- // ============================================================================
324
-
325
- /**
326
- * Convert WebM to MP4 using ffmpeg.
327
- *
328
- * Uses H.264 codec with settings optimized for web playback:
329
- * - libx264 encoder with fast preset
330
- * - CRF 23 for good quality/size balance
331
- * - yuv420p pixel format for iOS/Safari compatibility
332
- * - faststart flag for progressive playback
333
- *
334
- * @param {string} inputPath - Path to WebM file
335
- * @param {string} outputPath - Path for MP4 output
336
- * @returns {Promise<ConvertResult>} Conversion result
337
- * @throws {Error} If ffmpeg is not available or conversion fails
338
- *
339
- * @example
340
- * const result = await convertToMp4('/tmp/preview.webm', '/tmp/preview.mp4');
341
- */
342
- export async function convertToMp4(inputPath, outputPath) {
343
- validatePath(inputPath);
344
- validatePath(outputPath);
345
-
346
- const hasFf = await initFfmpeg();
347
- if (!hasFf) {
348
- throw new Error(
349
- 'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
350
- );
351
- }
352
-
353
- return new Promise((resolve, reject) => {
354
- ffmpeg(inputPath)
355
- .outputOptions([
356
- '-c:v libx264',
357
- '-preset fast',
358
- '-crf 23',
359
- '-pix_fmt yuv420p',
360
- '-movflags +faststart'
361
- ])
362
- .output(outputPath)
363
- .on('end', () => resolve({ path: outputPath, format: 'mp4' }))
364
- .on('error', (err) => reject(new Error(`MP4 conversion failed: ${err.message}`)))
365
- .run();
366
- });
367
- }
368
-
369
- /**
370
- * Convert WebM to GIF using ffmpeg.
371
- *
372
- * Uses two-pass conversion with palette generation for high-quality output:
373
- * 1. Generate optimized palette from video
374
- * 2. Create GIF using palette with dithering
375
- *
376
- * @param {string} inputPath - Path to WebM file
377
- * @param {string} outputPath - Path for GIF output
378
- * @param {Object} [options={}] - GIF options
379
- * @param {number} [options.fps=10] - Output frame rate
380
- * @param {number} [options.width=640] - Output width (height auto-calculated)
381
- * @returns {Promise<ConvertResult>} Conversion result
382
- * @throws {Error} If ffmpeg is not available or conversion fails
383
- *
384
- * @example
385
- * const result = await convertToGif('/tmp/preview.webm', '/tmp/preview.gif', {
386
- * fps: 15,
387
- * width: 800
388
- * });
389
- */
390
- export async function convertToGif(inputPath, outputPath, options = {}) {
391
- validatePath(inputPath);
392
- validatePath(outputPath);
393
-
394
- const { fps = GIF_DEFAULT_FPS, width = GIF_DEFAULT_WIDTH } = options;
395
-
396
- const hasFf = await initFfmpeg();
397
- if (!hasFf) {
398
- throw new Error(
399
- 'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
400
- );
401
- }
402
-
403
- // Palette path for two-pass conversion
404
- const palettePath = inputPath.replace(/\.webm$/i, '-palette.png');
405
-
406
- try {
407
- // Pass 1: Generate palette
408
- await new Promise((resolve, reject) => {
409
- ffmpeg(inputPath)
410
- .outputOptions([
411
- '-vf',
412
- `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=diff`
413
- ])
414
- .output(palettePath)
415
- .on('end', resolve)
416
- .on('error', (err) => reject(new Error(`Palette generation failed: ${err.message}`)))
417
- .run();
418
- });
419
-
420
- // Pass 2: Create GIF with palette
421
- await new Promise((resolve, reject) => {
422
- ffmpeg(inputPath)
423
- .input(palettePath)
424
- .complexFilter([
425
- `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`
426
- ])
427
- .output(outputPath)
428
- .on('end', resolve)
429
- .on('error', (err) => reject(new Error(`GIF creation failed: ${err.message}`)))
430
- .run();
431
- });
432
-
433
- return { path: outputPath, format: 'gif' };
434
- } finally {
435
- // H1: Cleanup palette file with debug logging for failures
436
- try {
437
- await fs.unlink(palettePath);
438
- } catch (cleanupErr) {
439
- // Log cleanup failures in debug mode (when process.env.DEBUG is set)
440
- if (process.env.DEBUG) {
441
- console.error(`[video-capture] Palette cleanup failed: ${cleanupErr.message}`);
442
- }
443
- }
444
- }
445
- }
446
-
447
- // ============================================================================
448
- // Main Capture Function
449
- // ============================================================================
450
-
451
- /**
452
- * Capture video of page scroll interaction.
453
- *
454
- * Records page scrolling and optionally converts to MP4 or GIF.
455
- * WebM is always created first (native Puppeteer screencast format).
456
- *
457
- * @param {Object} page - Puppeteer page object
458
- * @param {string} outputDir - Directory for output files
459
- * @param {CaptureOptions} [options={}] - Capture options
460
- * @returns {Promise<CaptureResult>} Capture result with file paths
461
- *
462
- * @example
463
- * // WebM only (no ffmpeg needed)
464
- * const result = await captureVideo(page, './output', { format: 'webm' });
465
- *
466
- * @example
467
- * // MP4 with custom duration
468
- * const result = await captureVideo(page, './output', {
469
- * format: 'mp4',
470
- * duration: 15000,
471
- * filename: 'scroll-demo'
472
- * });
473
- */
474
- export async function captureVideo(page, outputDir, options = {}) {
475
- validatePage(page);
476
- validatePath(outputDir);
477
-
478
- const {
479
- format = 'webm',
480
- duration = DEFAULT_DURATION,
481
- filename = 'preview'
482
- } = options;
483
-
484
- const webmPath = path.join(outputDir, `${filename}.webm`);
485
-
486
- // Record WebM
487
- log('[video] Recording scroll...');
488
- const recordResult = await recordScroll(page, webmPath, { duration });
489
- log(`[video] Recorded ${(recordResult.duration / 1000).toFixed(1)}s`);
490
-
491
- /** @type {CaptureResult} */
492
- const result = {
493
- webm: webmPath,
494
- duration: recordResult.duration,
495
- pageHeight: recordResult.pageHeight,
496
- output: webmPath
497
- };
498
-
499
- // Convert if needed
500
- if (format === 'mp4') {
501
- const mp4Path = path.join(outputDir, `${filename}.mp4`);
502
- log('[video] Converting to MP4...');
503
-
504
- try {
505
- await convertToMp4(webmPath, mp4Path);
506
- result.mp4 = mp4Path;
507
- result.output = mp4Path;
508
- log('[video] MP4 conversion complete');
509
- } catch (e) {
510
- log(`[video] MP4 conversion failed: ${e.message}`);
511
- result.conversionError = e.message;
512
- }
513
- } else if (format === 'gif') {
514
- const gifPath = path.join(outputDir, `${filename}.gif`);
515
- log('[video] Converting to GIF...');
516
-
517
- try {
518
- await convertToGif(webmPath, gifPath);
519
- result.gif = gifPath;
520
- result.output = gifPath;
521
- log('[video] GIF conversion complete');
522
- } catch (e) {
523
- log(`[video] GIF conversion failed: ${e.message}`);
524
- result.conversionError = e.message;
525
- }
526
- }
527
-
528
- return result;
529
- }
530
-
531
- // ============================================================================
532
- // Exports
533
- // ============================================================================
534
-
535
- export {
536
- DEFAULT_DURATION,
537
- FFMPEG_REQUIRED_FORMATS,
538
- MAX_SCROLL_STEPS,
539
- VIEWPORT_OVERLAP_FRACTION
540
- };
@@ -1,16 +0,0 @@
1
- """
2
- Design Clone skill library modules.
3
-
4
- JavaScript modules:
5
- - browser.js: Browser abstraction facade
6
- - puppeteer.js: Standalone Puppeteer 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