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.
- package/README.md +13 -34
- package/SKILL.md +69 -45
- package/bin/cli.js +22 -4
- package/bin/commands/clone-site.js +31 -171
- package/bin/commands/help.js +19 -6
- package/bin/commands/init.js +9 -86
- package/bin/commands/uninstall.js +105 -0
- package/bin/commands/update.js +70 -0
- package/bin/commands/verify.js +7 -14
- package/bin/utils/paths.js +28 -0
- package/bin/utils/validate.js +2 -22
- package/bin/utils/version.js +23 -0
- package/docs/code-standards.md +789 -0
- package/docs/codebase-summary.md +533 -286
- package/docs/index.md +74 -0
- package/docs/project-overview-pdr.md +797 -0
- package/docs/system-architecture.md +718 -0
- package/package.json +14 -17
- 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/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/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
- package/src/core/section/section-cropper-helpers.js +43 -0
- package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
- 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 +2 -2
- package/src/core/tests/test-section-detector.js +2 -2
- 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/base-discoverer-utils.js +137 -0
- package/src/route-discoverers/base-discoverer.js +29 -118
- package/src/route-discoverers/index.js +1 -1
- 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 +0 -7
- 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 +3 -69
- 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 +5 -281
- 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 +23 -381
- package/src/verification/verify-header-checks.js +104 -0
- package/src/verification/verify-header-helpers.js +156 -0
- package/src/verification/verify-header.js +23 -365
- package/src/verification/verify-layout-report.js +101 -0
- package/src/verification/verify-layout.js +13 -259
- package/src/verification/verify-menu-checks.js +104 -0
- package/src/verification/verify-menu-helpers.js +112 -0
- package/src/verification/verify-menu.js +17 -285
- 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 +23 -414
- package/.env.example +0 -14
- package/docs/basic-clone.md +0 -63
- package/docs/cli-reference.md +0 -316
- package/docs/design-clone-architecture.md +0 -492
- package/docs/pixel-perfect.md +0 -117
- package/docs/project-roadmap.md +0 -382
- package/docs/troubleshooting.md +0 -170
- package/requirements.txt +0 -5
- package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
- package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
- package/src/ai/analyze-structure.py +0 -375
- package/src/ai/extract-design-tokens.py +0 -782
- 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/__pycache__/ux_audit.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +0 -316
- package/src/ai/prompts/structure_analysis.py +0 -592
- package/src/ai/prompts/ux_audit.py +0 -198
- package/src/ai/ux-audit.js +0 -596
- package/src/core/animation-extractor.js +0 -526
- package/src/core/app-state-snapshot.js +0 -511
- package/src/core/content-counter.js +0 -342
- package/src/core/design-tokens.js +0 -103
- package/src/core/dimension-extractor.js +0 -438
- package/src/core/dimension-output.js +0 -305
- package/src/core/discover-pages.js +0 -542
- package/src/core/dom-tree-analyzer.js +0 -298
- package/src/core/extract-assets.js +0 -468
- package/src/core/filter-css.js +0 -499
- package/src/core/framework-detector.js +0 -538
- package/src/core/html-extractor.js +0 -212
- package/src/core/merge-css.js +0 -407
- package/src/core/multi-page-screenshot.js +0 -380
- package/src/core/rewrite-links.js +0 -226
- package/src/core/screenshot.js +0 -701
- package/src/core/section-detector.js +0 -386
- package/src/core/semantic-enhancer.js +0 -492
- package/src/core/state-capture.js +0 -598
- package/src/core/video-capture.js +0 -546
- 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/core/{css-extractor.js → css/css-extractor.js} +0 -0
- /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
- /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
|
-
};
|
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
|
-
- 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']
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|