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.
- package/README.md +32 -39
- package/SKILL.md +69 -45
- package/bin/cli.js +22 -4
- package/bin/commands/clone-site.js +31 -106
- package/bin/commands/help.js +19 -6
- package/bin/commands/init.js +11 -56
- package/bin/commands/uninstall.js +105 -0
- package/bin/commands/update.js +70 -0
- package/bin/commands/verify.js +11 -16
- package/bin/utils/paths.js +28 -0
- package/bin/utils/validate.js +24 -28
- package/bin/utils/version.js +23 -0
- package/docs/code-standards.md +789 -0
- package/docs/codebase-summary.md +556 -0
- package/docs/index.md +74 -0
- package/docs/project-overview-pdr.md +797 -0
- package/docs/system-architecture.md +718 -0
- package/package.json +20 -21
- package/src/ai/prompts/design-tokens/basic.md +80 -0
- package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
- package/src/ai/prompts/design-tokens/section.md +48 -0
- package/src/ai/prompts/design-tokens/with-css.md +87 -0
- package/src/ai/prompts/structure-analysis/basic.md +55 -0
- package/src/ai/prompts/structure-analysis/with-context.md +59 -0
- package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
- package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
- package/src/ai/prompts/ux-audit/aggregation.md +42 -0
- package/src/ai/prompts/ux-audit/desktop.md +92 -0
- package/src/ai/prompts/ux-audit/mobile.md +93 -0
- package/src/ai/prompts/ux-audit/tablet.md +92 -0
- package/src/core/animation/animation-extractor-ast.js +183 -0
- package/src/core/animation/animation-extractor-output.js +152 -0
- package/src/core/animation/animation-extractor.js +178 -0
- package/src/core/animation/state-capture-detection.js +200 -0
- package/src/core/animation/state-capture.js +193 -0
- package/src/core/capture/browser-context-pool.js +96 -0
- package/src/core/capture/multi-page-screenshot-page.js +110 -0
- package/src/core/capture/multi-page-screenshot.js +208 -0
- package/src/core/capture/screenshot-extraction.js +186 -0
- package/src/core/capture/screenshot-helpers.js +175 -0
- package/src/core/capture/screenshot-orchestrator.js +174 -0
- package/src/core/capture/screenshot-viewport.js +93 -0
- package/src/core/capture/screenshot.js +192 -0
- package/src/core/content/content-counter-dom.js +191 -0
- package/src/core/content/content-counter.js +76 -0
- package/src/core/css/breakpoint-detector.js +66 -0
- package/src/core/css/chromium-defaults.json +23 -0
- package/src/core/css/computed-style-extractor.js +102 -0
- package/src/core/css/css-chunker.js +103 -0
- package/src/core/{css-extractor.js → css/css-extractor.js} +4 -4
- package/src/core/css/filter-css-dead-code.js +120 -0
- package/src/core/css/filter-css-html-analyzer.js +110 -0
- package/src/core/css/filter-css-selector-matcher.js +172 -0
- package/src/core/css/filter-css.js +206 -0
- package/src/core/css/merge-css-atrule-processor.js +158 -0
- package/src/core/css/merge-css-file-io.js +68 -0
- package/src/core/css/merge-css.js +148 -0
- package/src/core/detection/framework-detector-routing.js +68 -0
- package/src/core/detection/framework-detector-signals.js +65 -0
- package/src/core/detection/framework-detector.js +198 -0
- package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
- package/src/core/dimension/dimension-extractor.js +317 -0
- package/src/core/dimension/dimension-output-ai-summary.js +111 -0
- package/src/core/dimension/dimension-output.js +173 -0
- package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
- package/src/core/dimension/dom-tree-analyzer.js +191 -0
- package/src/core/discovery/app-state-snapshot-capture.js +195 -0
- package/src/core/discovery/app-state-snapshot-utils.js +178 -0
- package/src/core/discovery/app-state-snapshot.js +131 -0
- package/src/core/discovery/discover-pages-routes.js +84 -0
- package/src/core/discovery/discover-pages-utils.js +177 -0
- package/src/core/discovery/discover-pages.js +191 -0
- package/src/core/html/html-extractor-inline-styler.js +70 -0
- package/src/core/html/html-extractor.js +147 -0
- package/src/core/html/semantic-enhancer-mappings.js +200 -0
- package/src/core/html/semantic-enhancer-page.js +148 -0
- package/src/core/html/semantic-enhancer.js +135 -0
- package/src/core/links/rewrite-links-css-rewriter.js +53 -0
- package/src/core/links/rewrite-links.js +173 -0
- package/src/core/media/asset-validator.js +118 -0
- package/src/core/media/extract-assets-downloader.js +187 -0
- package/src/core/media/extract-assets-page-scraper.js +115 -0
- package/src/core/media/extract-assets.js +159 -0
- package/src/core/media/video-capture-convert.js +200 -0
- package/src/core/media/video-capture.js +201 -0
- package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +1 -1
- package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +44 -46
- package/src/core/{page-readiness.js → page-prep/page-readiness.js} +8 -8
- package/src/core/section/section-cropper-helpers.js +43 -0
- package/src/core/section/section-cropper.js +132 -0
- package/src/core/section/section-detector-strategies.js +139 -0
- package/src/core/section/section-detector-utils.js +100 -0
- package/src/core/section/section-detector.js +88 -0
- package/src/core/tests/test-section-cropper.js +177 -0
- package/src/core/tests/test-section-detector.js +55 -0
- package/src/post-process/enhance-assets.js +29 -4
- package/src/post-process/fetch-images-unsplash-client.js +123 -0
- package/src/post-process/fetch-images.js +60 -263
- package/src/post-process/inject-gosnap.js +88 -0
- package/src/post-process/inject-icons-svg-replacer.js +76 -0
- package/src/post-process/inject-icons.js +47 -200
- package/src/route-discoverers/angular-discoverer.js +157 -0
- package/src/route-discoverers/astro-discoverer.js +123 -0
- package/src/route-discoverers/base-discoverer-utils.js +137 -0
- package/src/route-discoverers/base-discoverer.js +153 -0
- package/src/route-discoverers/index.js +106 -0
- package/src/route-discoverers/next-discoverer.js +130 -0
- package/src/route-discoverers/nuxt-discoverer.js +138 -0
- package/src/route-discoverers/react-discoverer.js +139 -0
- package/src/route-discoverers/svelte-discoverer.js +109 -0
- package/src/route-discoverers/universal-discoverer.js +227 -0
- package/src/route-discoverers/vue-discoverer.js +118 -0
- package/src/shared/config.js +38 -0
- package/src/shared/error-codes.js +31 -0
- package/src/shared/viewports.js +46 -0
- package/src/utils/browser.js +11 -44
- package/src/utils/helpers.js +4 -0
- package/src/utils/log.js +12 -0
- package/src/utils/playwright-loader.js +76 -0
- package/src/utils/playwright.js +147 -0
- package/src/utils/progress.js +32 -0
- package/src/verification/generate-audit-report-css-fixes.js +52 -0
- package/src/verification/generate-audit-report-sections.js +158 -0
- package/src/verification/generate-audit-report.js +122 -0
- package/src/verification/quality-scorer.js +92 -0
- package/src/verification/verify-footer-checks.js +103 -0
- package/src/verification/verify-footer-helpers.js +178 -0
- package/src/verification/verify-footer.js +135 -0
- package/src/verification/verify-header-checks.js +104 -0
- package/src/verification/verify-header-helpers.js +156 -0
- package/src/verification/verify-header.js +144 -0
- package/src/verification/verify-layout-report.js +101 -0
- package/src/verification/verify-layout.js +14 -260
- package/src/verification/verify-menu-checks.js +104 -0
- package/src/verification/verify-menu-helpers.js +112 -0
- package/src/verification/verify-menu.js +18 -302
- package/src/verification/verify-slider-checks.js +115 -0
- package/src/verification/verify-slider-constants.js +65 -0
- package/src/verification/verify-slider-helpers.js +164 -0
- package/src/verification/verify-slider.js +142 -0
- package/.env.example +0 -14
- package/docs/basic-clone.md +0 -63
- package/docs/cli-reference.md +0 -118
- package/docs/design-clone-architecture.md +0 -275
- package/docs/pixel-perfect.md +0 -86
- package/docs/troubleshooting.md +0 -169
- package/requirements.txt +0 -5
- package/src/ai/analyze-structure.py +0 -305
- package/src/ai/extract-design-tokens.py +0 -439
- package/src/ai/prompts/__init__.py +0 -2
- package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +0 -183
- package/src/ai/prompts/structure_analysis.py +0 -273
- package/src/core/animation-extractor.js +0 -526
- package/src/core/design-tokens.js +0 -103
- package/src/core/dimension-extractor.js +0 -366
- package/src/core/dimension-output.js +0 -208
- package/src/core/discover-pages.js +0 -314
- package/src/core/extract-assets.js +0 -468
- package/src/core/filter-css.js +0 -499
- package/src/core/html-extractor.js +0 -171
- package/src/core/merge-css.js +0 -407
- package/src/core/multi-page-screenshot.js +0 -377
- package/src/core/rewrite-links.js +0 -226
- package/src/core/screenshot.js +0 -572
- package/src/core/state-capture.js +0 -602
- package/src/core/video-capture.js +0 -540
- package/src/utils/__init__.py +0 -16
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/env.py +0 -134
- 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
|
-
};
|
package/src/utils/__init__.py
DELETED
|
@@ -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']
|
|
Binary file
|
|
Binary file
|
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
|