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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Asset Extraction Script for Pixel-Perfect Clone
|
|
4
|
+
*
|
|
5
|
+
* Downloads and organizes assets from source website:
|
|
6
|
+
* - Images (jpg, png, gif, webp, svg)
|
|
7
|
+
* - Fonts (woff, woff2, ttf, otf)
|
|
8
|
+
* - CSS-embedded images (background-url)
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node extract-assets.js --url <url> --output <dir> [--verbose]
|
|
12
|
+
*
|
|
13
|
+
* Options:
|
|
14
|
+
* --url Target website URL (required)
|
|
15
|
+
* --output Output directory (required)
|
|
16
|
+
* --verbose Show detailed progress
|
|
17
|
+
* --timeout Download timeout in ms (default: 30000)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs/promises';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { getBrowser, getPage, closeBrowser, disconnectBrowser } from '../../utils/browser.js';
|
|
23
|
+
import { parseArgs, outputJSON, outputError } from '../../utils/helpers.js';
|
|
24
|
+
import { downloadBatch, getSafeFilename, getAssetType } from './extract-assets-downloader.js';
|
|
25
|
+
import { extractCssUrls, extractAssetsFromPage } from './extract-assets-page-scraper.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Main extraction function
|
|
29
|
+
*/
|
|
30
|
+
async function extractAssets() {
|
|
31
|
+
const args = parseArgs(process.argv.slice(2));
|
|
32
|
+
|
|
33
|
+
if (!args.url) {
|
|
34
|
+
outputError(new Error('--url is required'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
if (!args.output) {
|
|
38
|
+
outputError(new Error('--output directory is required'));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const verbose = args.verbose === 'true';
|
|
43
|
+
const timeout = args.timeout ? parseInt(args.timeout) : 30000;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// Create output directories
|
|
47
|
+
const assetsDir = path.join(args.output, 'assets');
|
|
48
|
+
await fs.mkdir(path.join(assetsDir, 'images'), { recursive: true });
|
|
49
|
+
await fs.mkdir(path.join(assetsDir, 'fonts'), { recursive: true });
|
|
50
|
+
await fs.mkdir(path.join(assetsDir, 'icons'), { recursive: true });
|
|
51
|
+
|
|
52
|
+
// Launch browser and navigate
|
|
53
|
+
const browser = await getBrowser({ headless: args.headless !== 'false' });
|
|
54
|
+
const page = await getPage(browser);
|
|
55
|
+
|
|
56
|
+
if (verbose) console.error(`\n📦 Extracting assets from: ${args.url}\n`);
|
|
57
|
+
|
|
58
|
+
await page.goto(args.url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
59
|
+
|
|
60
|
+
// Extract assets from page DOM
|
|
61
|
+
const pageAssets = await extractAssetsFromPage(page, args.url);
|
|
62
|
+
|
|
63
|
+
// Collect CSS content for font/background extraction
|
|
64
|
+
let allCssContent = '';
|
|
65
|
+
|
|
66
|
+
const inlineCss = await page.evaluate(() =>
|
|
67
|
+
Array.from(document.querySelectorAll('style')).map(s => s.textContent).join('\n')
|
|
68
|
+
);
|
|
69
|
+
allCssContent += inlineCss;
|
|
70
|
+
|
|
71
|
+
const sourceCssPath = path.join(args.output, 'analysis', 'source.css');
|
|
72
|
+
try {
|
|
73
|
+
const sourceCss = await fs.readFile(sourceCssPath, 'utf-8');
|
|
74
|
+
allCssContent += '\n' + sourceCss;
|
|
75
|
+
} catch { /* source.css not available */ }
|
|
76
|
+
|
|
77
|
+
// Combine all URLs and categorize
|
|
78
|
+
const cssAssetUrls = extractCssUrls(allCssContent, args.url);
|
|
79
|
+
const allUrls = new Set([...pageAssets.images, ...cssAssetUrls]);
|
|
80
|
+
|
|
81
|
+
const downloads = [];
|
|
82
|
+
const urlMapping = {};
|
|
83
|
+
|
|
84
|
+
for (const url of allUrls) {
|
|
85
|
+
const type = getAssetType(url);
|
|
86
|
+
const filename = getSafeFilename(url);
|
|
87
|
+
const destPath = path.join(assetsDir, type === 'other' ? 'images' : type, filename);
|
|
88
|
+
const relativePath = path.relative(args.output, destPath);
|
|
89
|
+
|
|
90
|
+
downloads.push({ url, destPath, type });
|
|
91
|
+
urlMapping[url] = relativePath;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (verbose) {
|
|
95
|
+
console.error(`Found ${downloads.length} assets to download:`);
|
|
96
|
+
console.error(` - Images: ${downloads.filter(d => d.type === 'images').length}`);
|
|
97
|
+
console.error(` - Fonts: ${downloads.filter(d => d.type === 'fonts').length}`);
|
|
98
|
+
console.error(` - Icons: ${downloads.filter(d => d.type === 'icons').length}`);
|
|
99
|
+
console.error('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Download all assets
|
|
103
|
+
const parsedConcurrency = args.concurrency ? parseInt(args.concurrency) : NaN;
|
|
104
|
+
const concurrency = Number.isNaN(parsedConcurrency) ? undefined : parsedConcurrency;
|
|
105
|
+
const downloadResults = await downloadBatch(downloads, verbose, { maxConcurrent: concurrency });
|
|
106
|
+
|
|
107
|
+
// Validate downloaded assets
|
|
108
|
+
let integrity = null;
|
|
109
|
+
try {
|
|
110
|
+
const { validateBatch } = await import('./asset-validator.js');
|
|
111
|
+
integrity = await validateBatch(assetsDir);
|
|
112
|
+
} catch { /* validation optional */ }
|
|
113
|
+
|
|
114
|
+
// Save inline SVGs
|
|
115
|
+
let savedSvgs = 0;
|
|
116
|
+
for (const svg of pageAssets.inlineSvgs) {
|
|
117
|
+
const filename = `${svg.id.replace(/[^a-zA-Z0-9-_]/g, '_')}.svg`;
|
|
118
|
+
const svgPath = path.join(assetsDir, 'icons', filename);
|
|
119
|
+
try {
|
|
120
|
+
await fs.writeFile(svgPath, svg.content, 'utf-8');
|
|
121
|
+
savedSvgs++;
|
|
122
|
+
} catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Save URL mapping for HTML rewriting
|
|
126
|
+
const mappingPath = path.join(assetsDir, 'url-mapping.json');
|
|
127
|
+
await fs.writeFile(mappingPath, JSON.stringify(urlMapping, null, 2));
|
|
128
|
+
|
|
129
|
+
// Close browser
|
|
130
|
+
if (args.close === 'true') {
|
|
131
|
+
await closeBrowser();
|
|
132
|
+
} else {
|
|
133
|
+
await disconnectBrowser();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
outputJSON({
|
|
137
|
+
success: true,
|
|
138
|
+
assetsDir: path.resolve(assetsDir),
|
|
139
|
+
urlMapping: mappingPath,
|
|
140
|
+
stats: {
|
|
141
|
+
total: downloads.length,
|
|
142
|
+
downloaded: downloadResults.success,
|
|
143
|
+
failed: downloadResults.failed,
|
|
144
|
+
skipped: downloadResults.skipped,
|
|
145
|
+
inlineSvgs: savedSvgs
|
|
146
|
+
},
|
|
147
|
+
integrity: integrity || undefined,
|
|
148
|
+
errors: downloadResults.errors.length > 0 ? downloadResults.errors.slice(0, 10) : undefined
|
|
149
|
+
});
|
|
150
|
+
process.exit(0);
|
|
151
|
+
|
|
152
|
+
} catch (error) {
|
|
153
|
+
outputError(error);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Run
|
|
159
|
+
extractAssets();
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video Capture Conversion
|
|
3
|
+
*
|
|
4
|
+
* ffmpeg dependency management and WebM-to-MP4/GIF format conversion.
|
|
5
|
+
* Lazy-loads fluent-ffmpeg and @ffmpeg-installer/ffmpeg so the module
|
|
6
|
+
* can be imported without those packages installed.
|
|
7
|
+
*
|
|
8
|
+
* @module video-capture-convert
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Constants
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/** Formats requiring ffmpeg for conversion */
|
|
18
|
+
export const FFMPEG_REQUIRED_FORMATS = ['mp4', 'gif'];
|
|
19
|
+
|
|
20
|
+
/** GIF output settings */
|
|
21
|
+
const GIF_DEFAULT_FPS = 10;
|
|
22
|
+
const GIF_DEFAULT_WIDTH = 640;
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// ffmpeg Dependency Management
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
let ffmpeg = null;
|
|
29
|
+
let ffmpegInitialized = false;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Initialize ffmpeg dependencies.
|
|
33
|
+
* Lazy-loads fluent-ffmpeg and @ffmpeg-installer/ffmpeg.
|
|
34
|
+
*
|
|
35
|
+
* @returns {Promise<boolean>} True if ffmpeg is available
|
|
36
|
+
*/
|
|
37
|
+
export async function initFfmpeg() {
|
|
38
|
+
if (ffmpegInitialized) return ffmpeg !== false;
|
|
39
|
+
|
|
40
|
+
ffmpegInitialized = true;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const [fluentFfmpeg, installer] = await Promise.all([
|
|
44
|
+
import('fluent-ffmpeg'),
|
|
45
|
+
import('@ffmpeg-installer/ffmpeg')
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
ffmpeg = fluentFfmpeg.default;
|
|
49
|
+
const ffmpegPath = installer.path;
|
|
50
|
+
ffmpeg.setFfmpegPath(ffmpegPath);
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
} catch (importError) {
|
|
54
|
+
ffmpeg = false;
|
|
55
|
+
|
|
56
|
+
if (importError.code !== 'ERR_MODULE_NOT_FOUND') {
|
|
57
|
+
console.error('[video-capture] ffmpeg initialization error:', importError.message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if ffmpeg is available for video conversion.
|
|
66
|
+
*
|
|
67
|
+
* @returns {Promise<boolean>} True if ffmpeg dependencies are available
|
|
68
|
+
*/
|
|
69
|
+
export async function hasFfmpeg() {
|
|
70
|
+
return await initFfmpeg();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Input Validation
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate output path
|
|
79
|
+
* @param {string} outputPath - Output file/directory path
|
|
80
|
+
* @throws {TypeError} If path is invalid
|
|
81
|
+
*/
|
|
82
|
+
export function validatePath(outputPath) {
|
|
83
|
+
if (!outputPath || typeof outputPath !== 'string') {
|
|
84
|
+
throw new TypeError('Invalid output path: must be a non-empty string');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Format Conversion
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert WebM to MP4 using ffmpeg.
|
|
94
|
+
*
|
|
95
|
+
* Uses H.264 codec with settings optimized for web playback:
|
|
96
|
+
* - libx264 encoder with fast preset
|
|
97
|
+
* - CRF 23 for good quality/size balance
|
|
98
|
+
* - yuv420p pixel format for iOS/Safari compatibility
|
|
99
|
+
* - faststart flag for progressive playback
|
|
100
|
+
*
|
|
101
|
+
* @param {string} inputPath - Path to WebM file
|
|
102
|
+
* @param {string} outputPath - Path for MP4 output
|
|
103
|
+
* @returns {Promise<{path: string, format: string}>} Conversion result
|
|
104
|
+
* @throws {Error} If ffmpeg is not available or conversion fails
|
|
105
|
+
*/
|
|
106
|
+
export async function convertToMp4(inputPath, outputPath) {
|
|
107
|
+
validatePath(inputPath);
|
|
108
|
+
validatePath(outputPath);
|
|
109
|
+
|
|
110
|
+
const hasFf = await initFfmpeg();
|
|
111
|
+
if (!hasFf) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
ffmpeg(inputPath)
|
|
119
|
+
.outputOptions([
|
|
120
|
+
'-c:v libx264',
|
|
121
|
+
'-preset fast',
|
|
122
|
+
'-crf 23',
|
|
123
|
+
'-pix_fmt yuv420p',
|
|
124
|
+
'-movflags +faststart'
|
|
125
|
+
])
|
|
126
|
+
.output(outputPath)
|
|
127
|
+
.on('end', () => resolve({ path: outputPath, format: 'mp4' }))
|
|
128
|
+
.on('error', (err) => reject(new Error(`MP4 conversion failed: ${err.message}`)))
|
|
129
|
+
.run();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Convert WebM to GIF using ffmpeg.
|
|
135
|
+
*
|
|
136
|
+
* Uses two-pass conversion with palette generation for high-quality output:
|
|
137
|
+
* 1. Generate optimized palette from video
|
|
138
|
+
* 2. Create GIF using palette with dithering
|
|
139
|
+
*
|
|
140
|
+
* @param {string} inputPath - Path to WebM file
|
|
141
|
+
* @param {string} outputPath - Path for GIF output
|
|
142
|
+
* @param {Object} [options={}] - GIF options
|
|
143
|
+
* @param {number} [options.fps=10] - Output frame rate
|
|
144
|
+
* @param {number} [options.width=640] - Output width (height auto-calculated)
|
|
145
|
+
* @returns {Promise<{path: string, format: string}>} Conversion result
|
|
146
|
+
* @throws {Error} If ffmpeg is not available or conversion fails
|
|
147
|
+
*/
|
|
148
|
+
export async function convertToGif(inputPath, outputPath, options = {}) {
|
|
149
|
+
validatePath(inputPath);
|
|
150
|
+
validatePath(outputPath);
|
|
151
|
+
|
|
152
|
+
const { fps = GIF_DEFAULT_FPS, width = GIF_DEFAULT_WIDTH } = options;
|
|
153
|
+
|
|
154
|
+
const hasFf = await initFfmpeg();
|
|
155
|
+
if (!hasFf) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
'ffmpeg not available. Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const palettePath = inputPath.replace(/\.webm$/i, '-palette.png');
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// Pass 1: Generate palette
|
|
165
|
+
await new Promise((resolve, reject) => {
|
|
166
|
+
ffmpeg(inputPath)
|
|
167
|
+
.outputOptions([
|
|
168
|
+
'-vf',
|
|
169
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=diff`
|
|
170
|
+
])
|
|
171
|
+
.output(palettePath)
|
|
172
|
+
.on('end', resolve)
|
|
173
|
+
.on('error', (err) => reject(new Error(`Palette generation failed: ${err.message}`)))
|
|
174
|
+
.run();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Pass 2: Create GIF with palette
|
|
178
|
+
await new Promise((resolve, reject) => {
|
|
179
|
+
ffmpeg(inputPath)
|
|
180
|
+
.input(palettePath)
|
|
181
|
+
.complexFilter([
|
|
182
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`
|
|
183
|
+
])
|
|
184
|
+
.output(outputPath)
|
|
185
|
+
.on('end', resolve)
|
|
186
|
+
.on('error', (err) => reject(new Error(`GIF creation failed: ${err.message}`)))
|
|
187
|
+
.run();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return { path: outputPath, format: 'gif' };
|
|
191
|
+
} finally {
|
|
192
|
+
try {
|
|
193
|
+
await fs.unlink(palettePath);
|
|
194
|
+
} catch (cleanupErr) {
|
|
195
|
+
if (process.env.DEBUG) {
|
|
196
|
+
console.error(`[video-capture] Palette cleanup failed: ${cleanupErr.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video Capture Module
|
|
3
|
+
*
|
|
4
|
+
* Record scrolling interactions using Playwright's context-level video
|
|
5
|
+
* recording. Optionally converts WebM to MP4/GIF via video-capture-convert.js.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { captureVideo, hasFfmpeg } from './video-capture.js';
|
|
9
|
+
* const result = await captureVideo(page, outputDir, { format: 'webm' });
|
|
10
|
+
*
|
|
11
|
+
* @module video-capture
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import fs from 'fs/promises';
|
|
16
|
+
|
|
17
|
+
import { hasFfmpeg, convertToMp4, convertToGif, validatePath, FFMPEG_REQUIRED_FORMATS } from './video-capture-convert.js';
|
|
18
|
+
import { isTTY } from '../../utils/log.js';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_DURATION = 12000; // ms
|
|
21
|
+
const DEFAULT_HOLD_MS = 500; // hold at top/bottom of scroll
|
|
22
|
+
const MAX_SCROLL_STEPS = 100; // cap to avoid memory exhaustion
|
|
23
|
+
const VIEWPORT_OVERLAP_FRACTION = 0.5; // fraction of viewport per scroll step
|
|
24
|
+
const DEFAULT_VIDEO_VIEWPORT = { width: 1440, height: 900 };
|
|
25
|
+
|
|
26
|
+
/** Log to stderr when running in TTY. */
|
|
27
|
+
function log(message) {
|
|
28
|
+
if (isTTY) console.error(message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Validate that page is a Playwright page instance. */
|
|
32
|
+
function validatePage(page) {
|
|
33
|
+
if (!page || typeof page.evaluate !== 'function') throw new TypeError('Invalid page object: must be a Playwright page');
|
|
34
|
+
if (typeof page.context !== 'function') throw new TypeError('Invalid page object: missing context() method');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Record page scroll using a new Playwright context with video enabled.
|
|
39
|
+
* IMPORTANT: page must be closed before calling video.path() (Playwright requirement).
|
|
40
|
+
* @param {import('playwright').Browser} browser
|
|
41
|
+
* @param {string} pageUrl
|
|
42
|
+
* @param {string} outputDir
|
|
43
|
+
* @param {{duration?, scrollPauseMs?, holdTopMs?, holdBottomMs?, viewport?}} [options]
|
|
44
|
+
* @returns {Promise<{path, format, duration, scrollSteps, pageHeight}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function recordScroll(browser, pageUrl, outputDir, options = {}) {
|
|
47
|
+
if (!browser || typeof browser.newContext !== 'function') {
|
|
48
|
+
throw new TypeError('Invalid browser: must be a Playwright browser instance');
|
|
49
|
+
}
|
|
50
|
+
validatePath(outputDir);
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
duration = DEFAULT_DURATION,
|
|
54
|
+
scrollPauseMs = 50,
|
|
55
|
+
holdTopMs = DEFAULT_HOLD_MS,
|
|
56
|
+
holdBottomMs = DEFAULT_HOLD_MS,
|
|
57
|
+
viewport = DEFAULT_VIDEO_VIEWPORT
|
|
58
|
+
} = options;
|
|
59
|
+
|
|
60
|
+
const context = await browser.newContext({
|
|
61
|
+
recordVideo: { dir: outputDir, size: viewport },
|
|
62
|
+
viewport
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const page = await context.newPage();
|
|
66
|
+
const startTime = Date.now();
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30000 });
|
|
70
|
+
|
|
71
|
+
const totalHeight = await page.evaluate(() =>
|
|
72
|
+
Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const viewportHeight = viewport.height;
|
|
76
|
+
const scrollDistance = Math.max(0, totalHeight - viewportHeight);
|
|
77
|
+
const isScrollable = scrollDistance > 0;
|
|
78
|
+
|
|
79
|
+
const rawScrollSteps = isScrollable
|
|
80
|
+
? Math.ceil(scrollDistance / (viewportHeight * VIEWPORT_OVERLAP_FRACTION))
|
|
81
|
+
: 0;
|
|
82
|
+
const scrollSteps = Math.min(rawScrollSteps, MAX_SCROLL_STEPS);
|
|
83
|
+
|
|
84
|
+
const scrollTime = duration - holdTopMs - holdBottomMs;
|
|
85
|
+
const scrollDelay = scrollSteps > 0
|
|
86
|
+
? Math.max(scrollPauseMs, Math.floor(scrollTime / (scrollSteps * 2)))
|
|
87
|
+
: 0;
|
|
88
|
+
|
|
89
|
+
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
|
90
|
+
await new Promise(r => setTimeout(r, 200 + holdTopMs));
|
|
91
|
+
|
|
92
|
+
if (isScrollable && scrollSteps > 0) {
|
|
93
|
+
const scroll = (i) => page.evaluate(y => window.scrollTo({ top: y, behavior: 'instant' }), (i / scrollSteps) * scrollDistance);
|
|
94
|
+
for (let i = 1; i <= scrollSteps; i++) { await scroll(i); await new Promise(r => setTimeout(r, scrollDelay)); }
|
|
95
|
+
await new Promise(r => setTimeout(r, holdBottomMs));
|
|
96
|
+
for (let i = scrollSteps - 1; i >= 0; i--) { await scroll(i); await new Promise(r => setTimeout(r, scrollDelay)); }
|
|
97
|
+
await new Promise(r => setTimeout(r, holdTopMs));
|
|
98
|
+
} else {
|
|
99
|
+
await new Promise(r => setTimeout(r, scrollTime + holdBottomMs));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const actualDuration = Date.now() - startTime;
|
|
103
|
+
// IMPORTANT: Close page before video.path() (Playwright requirement)
|
|
104
|
+
await page.close();
|
|
105
|
+
const video = page.video();
|
|
106
|
+
const videoPath = video ? await video.path() : null;
|
|
107
|
+
await context.close();
|
|
108
|
+
if (!videoPath) throw new Error('Video recording failed - no path returned');
|
|
109
|
+
return { path: videoPath, format: 'webm', duration: actualDuration, scrollSteps, pageHeight: totalHeight };
|
|
110
|
+
|
|
111
|
+
} catch (error) {
|
|
112
|
+
try {
|
|
113
|
+
await page.close().catch(() => {});
|
|
114
|
+
await context.close().catch(() => {});
|
|
115
|
+
} catch { /* ignore cleanup errors */ }
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Capture scroll video. Records WebM via new browser context, optionally converts to MP4/GIF.
|
|
122
|
+
* @param {import('playwright').Page} page - source of browser + URL
|
|
123
|
+
* @param {string} outputDir
|
|
124
|
+
* @param {{'webm'|'mp4'|'gif'} format?, number duration?, string filename?} [options]
|
|
125
|
+
* @returns {Promise<{webm, mp4?, gif?, output, duration, pageHeight, conversionError?}>}
|
|
126
|
+
*/
|
|
127
|
+
export async function captureVideo(page, outputDir, options = {}) {
|
|
128
|
+
validatePage(page);
|
|
129
|
+
validatePath(outputDir);
|
|
130
|
+
|
|
131
|
+
const { format = 'webm', duration = DEFAULT_DURATION, filename = 'preview' } = options;
|
|
132
|
+
|
|
133
|
+
const browser = page.context().browser();
|
|
134
|
+
const pageUrl = page.url();
|
|
135
|
+
const viewport = page.viewportSize() || DEFAULT_VIDEO_VIEWPORT;
|
|
136
|
+
|
|
137
|
+
if (!browser) {
|
|
138
|
+
throw new Error('Cannot get browser from page. Ensure page has browser context.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
log('[video] Recording scroll...');
|
|
142
|
+
const recordResult = await recordScroll(browser, pageUrl, outputDir, { duration, viewport });
|
|
143
|
+
log(`[video] Recorded ${(recordResult.duration / 1000).toFixed(1)}s`);
|
|
144
|
+
|
|
145
|
+
// Rename to expected filename (Playwright auto-generates random names)
|
|
146
|
+
const expectedPath = path.join(outputDir, `${filename}.webm`);
|
|
147
|
+
if (recordResult.path !== expectedPath) {
|
|
148
|
+
try {
|
|
149
|
+
await fs.rename(recordResult.path, expectedPath);
|
|
150
|
+
recordResult.path = expectedPath;
|
|
151
|
+
} catch (renameErr) {
|
|
152
|
+
log(`[video] Could not rename video: ${renameErr.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = {
|
|
157
|
+
webm: recordResult.path,
|
|
158
|
+
duration: recordResult.duration,
|
|
159
|
+
pageHeight: recordResult.pageHeight,
|
|
160
|
+
output: recordResult.path
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (format === 'mp4') {
|
|
164
|
+
const mp4Path = path.join(outputDir, `${filename}.mp4`);
|
|
165
|
+
log('[video] Converting to MP4...');
|
|
166
|
+
try {
|
|
167
|
+
await convertToMp4(recordResult.path, mp4Path);
|
|
168
|
+
result.mp4 = mp4Path;
|
|
169
|
+
result.output = mp4Path;
|
|
170
|
+
log('[video] MP4 conversion complete');
|
|
171
|
+
} catch (e) {
|
|
172
|
+
log(`[video] MP4 conversion failed: ${e.message}`);
|
|
173
|
+
result.conversionError = e.message;
|
|
174
|
+
}
|
|
175
|
+
} else if (format === 'gif') {
|
|
176
|
+
const gifPath = path.join(outputDir, `${filename}.gif`);
|
|
177
|
+
log('[video] Converting to GIF...');
|
|
178
|
+
try {
|
|
179
|
+
await convertToGif(recordResult.path, gifPath);
|
|
180
|
+
result.gif = gifPath;
|
|
181
|
+
result.output = gifPath;
|
|
182
|
+
log('[video] GIF conversion complete');
|
|
183
|
+
} catch (e) {
|
|
184
|
+
log(`[video] GIF conversion failed: ${e.message}`);
|
|
185
|
+
result.conversionError = e.message;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Re-exports for backward compatibility
|
|
193
|
+
export {
|
|
194
|
+
hasFfmpeg,
|
|
195
|
+
convertToMp4,
|
|
196
|
+
convertToGif,
|
|
197
|
+
FFMPEG_REQUIRED_FORMATS,
|
|
198
|
+
DEFAULT_DURATION,
|
|
199
|
+
MAX_SCROLL_STEPS,
|
|
200
|
+
VIEWPORT_OVERLAP_FRACTION
|
|
201
|
+
};
|
|
@@ -38,7 +38,7 @@ export const COOKIE_REMOVE_SELECTORS = [
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Dismiss cookie banners by clicking accept or removing elements
|
|
41
|
-
* @param {Page} page -
|
|
41
|
+
* @param {Page} page - Playwright page
|
|
42
42
|
* @returns {Promise<{method: string, selector?: string, count?: number}>}
|
|
43
43
|
*/
|
|
44
44
|
export async function dismissCookieBanner(page) {
|