design-clone 1.2.0 → 2.1.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 +26 -12
- package/bin/commands/clone-site.js +75 -10
- package/bin/commands/init.js +33 -1
- package/bin/commands/verify.js +5 -3
- package/bin/utils/validate.js +24 -8
- package/docs/cli-reference.md +200 -2
- package/docs/codebase-summary.md +309 -0
- package/docs/design-clone-architecture.md +259 -42
- package/docs/pixel-perfect.md +35 -4
- package/docs/project-roadmap.md +382 -0
- package/docs/troubleshooting.md +5 -4
- package/package.json +10 -8
- 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 +73 -3
- package/src/ai/extract-design-tokens.py +356 -13
- 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 +133 -0
- package/src/ai/prompts/structure_analysis.py +329 -10
- package/src/ai/prompts/ux_audit.py +198 -0
- package/src/ai/ux-audit.js +596 -0
- package/src/core/app-state-snapshot.js +511 -0
- package/src/core/content-counter.js +342 -0
- package/src/core/cookie-handler.js +1 -1
- package/src/core/css-extractor.js +4 -4
- package/src/core/dimension-extractor.js +93 -21
- package/src/core/dimension-output.js +103 -6
- package/src/core/discover-pages.js +242 -14
- package/src/core/dom-tree-analyzer.js +298 -0
- package/src/core/extract-assets.js +1 -1
- package/src/core/framework-detector.js +538 -0
- package/src/core/html-extractor.js +45 -4
- package/src/core/lazy-loader.js +7 -7
- package/src/core/multi-page-screenshot.js +9 -6
- package/src/core/page-readiness.js +8 -8
- package/src/core/screenshot.js +138 -9
- package/src/core/section-cropper.js +209 -0
- package/src/core/section-detector.js +386 -0
- package/src/core/semantic-enhancer.js +492 -0
- package/src/core/state-capture.js +18 -22
- package/src/core/tests/test-section-cropper.js +177 -0
- package/src/core/tests/test-section-detector.js +55 -0
- package/src/core/video-capture.js +152 -146
- package/src/route-discoverers/angular-discoverer.js +157 -0
- package/src/route-discoverers/astro-discoverer.js +123 -0
- package/src/route-discoverers/base-discoverer.js +242 -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/utils/__init__.py +1 -1
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/browser.js +11 -37
- package/src/utils/playwright.js +213 -0
- package/src/verification/generate-audit-report.js +398 -0
- package/src/verification/verify-footer.js +493 -0
- package/src/verification/verify-header.js +486 -0
- package/src/verification/verify-layout.js +2 -2
- package/src/verification/verify-menu.js +4 -20
- package/src/verification/verify-slider.js +533 -0
- package/src/utils/puppeteer.js +0 -281
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Section Cropper
|
|
3
|
+
*
|
|
4
|
+
* Usage: node src/core/tests/test-section-cropper.js [screenshot-path]
|
|
5
|
+
*
|
|
6
|
+
* Tests the section cropper with a real screenshot.
|
|
7
|
+
* If no path provided, uses a sample from cloned-designs if available.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chromium } from 'playwright';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import fs from 'fs/promises';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { detectSections, getSectionSummary } from '../section-detector.js';
|
|
15
|
+
import { cropSections, isSharpAvailable, getCropperSummary } from '../section-cropper.js';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const projectRoot = path.join(__dirname, '../../..');
|
|
19
|
+
|
|
20
|
+
async function findTestScreenshot() {
|
|
21
|
+
// Look for existing screenshots in cloned-designs
|
|
22
|
+
const clonedDir = path.join(projectRoot, 'cloned-designs');
|
|
23
|
+
try {
|
|
24
|
+
const dirs = await fs.readdir(clonedDir);
|
|
25
|
+
for (const dir of dirs.reverse()) { // newest first
|
|
26
|
+
const desktopPath = path.join(clonedDir, dir, 'analysis', 'desktop.png');
|
|
27
|
+
try {
|
|
28
|
+
await fs.access(desktopPath);
|
|
29
|
+
return desktopPath;
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// No cloned-designs directory
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function testWithUrl(url, outputDir) {
|
|
41
|
+
console.log(`\n=== Testing with URL: ${url} ===\n`);
|
|
42
|
+
|
|
43
|
+
const browser = await chromium.launch({ headless: true });
|
|
44
|
+
const context = await browser.newContext({
|
|
45
|
+
viewport: { width: 1440, height: 900 }
|
|
46
|
+
});
|
|
47
|
+
const page = await context.newPage();
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// Navigate
|
|
51
|
+
console.log('Loading page...');
|
|
52
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
53
|
+
await page.waitForTimeout(1000);
|
|
54
|
+
|
|
55
|
+
// Take full-page screenshot
|
|
56
|
+
const screenshotPath = path.join(outputDir, 'test-full.png');
|
|
57
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
58
|
+
console.log(`Screenshot saved: ${screenshotPath}`);
|
|
59
|
+
|
|
60
|
+
// Detect sections
|
|
61
|
+
console.log('\nDetecting sections...');
|
|
62
|
+
const sections = await detectSections(page, { padding: 40 });
|
|
63
|
+
console.log(`Found ${sections.length} sections:`);
|
|
64
|
+
sections.forEach(s => {
|
|
65
|
+
console.log(` [${s.index}] ${s.name} (${s.role}) - ${s.bounds.height}px`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Crop sections
|
|
69
|
+
console.log('\nCropping sections...');
|
|
70
|
+
const result = await cropSections(screenshotPath, sections, outputDir);
|
|
71
|
+
|
|
72
|
+
console.log(`\nCropped ${result.sections.length} sections:`);
|
|
73
|
+
result.sections.forEach(s => {
|
|
74
|
+
console.log(` [${s.index}] ${s.filename} - ${s.bounds.width}x${s.bounds.height}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (result.skipped.length > 0) {
|
|
78
|
+
console.log(`\nSkipped ${result.skipped.length} sections:`);
|
|
79
|
+
result.skipped.forEach(s => {
|
|
80
|
+
console.log(` [${s.index}] ${s.name} - ${s.reason}`);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(`\nSummary saved: ${result.summary}`);
|
|
85
|
+
console.log(`Sections directory: ${result.directory}`);
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
|
|
89
|
+
} finally {
|
|
90
|
+
await browser.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function testWithExistingScreenshot(screenshotPath, outputDir) {
|
|
95
|
+
console.log(`\n=== Testing with existing screenshot ===`);
|
|
96
|
+
console.log(`Screenshot: ${screenshotPath}\n`);
|
|
97
|
+
|
|
98
|
+
// We need a browser to detect sections from the page
|
|
99
|
+
// For existing screenshots, we'll create mock sections based on image height
|
|
100
|
+
const { default: sharp } = await import('sharp');
|
|
101
|
+
const metadata = await sharp(screenshotPath).metadata();
|
|
102
|
+
|
|
103
|
+
console.log(`Image size: ${metadata.width}x${metadata.height}`);
|
|
104
|
+
|
|
105
|
+
// Create mock sections based on viewport chunking
|
|
106
|
+
const viewportHeight = 900;
|
|
107
|
+
const sections = [];
|
|
108
|
+
let y = 0;
|
|
109
|
+
let index = 0;
|
|
110
|
+
|
|
111
|
+
while (y < metadata.height) {
|
|
112
|
+
const height = Math.min(viewportHeight, metadata.height - y);
|
|
113
|
+
sections.push({
|
|
114
|
+
index,
|
|
115
|
+
name: `viewport-${index}`,
|
|
116
|
+
role: 'viewport-chunk',
|
|
117
|
+
bounds: { x: 0, y, width: metadata.width, height }
|
|
118
|
+
});
|
|
119
|
+
y += viewportHeight - 90; // 10% overlap
|
|
120
|
+
index++;
|
|
121
|
+
if (index > 20) break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(`Created ${sections.length} viewport chunks`);
|
|
125
|
+
|
|
126
|
+
// Crop sections
|
|
127
|
+
console.log('\nCropping sections...');
|
|
128
|
+
const result = await cropSections(screenshotPath, sections, outputDir);
|
|
129
|
+
|
|
130
|
+
console.log(`\nCropped ${result.sections.length} sections:`);
|
|
131
|
+
result.sections.forEach(s => {
|
|
132
|
+
console.log(` [${s.index}] ${s.filename} - ${s.bounds.width}x${s.bounds.height}`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function main() {
|
|
139
|
+
// Check Sharp availability
|
|
140
|
+
if (!isSharpAvailable()) {
|
|
141
|
+
console.error('ERROR: Sharp is not installed. Run: npm install sharp');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
console.log('Sharp is available');
|
|
145
|
+
|
|
146
|
+
const arg = process.argv[2];
|
|
147
|
+
const outputDir = path.join(projectRoot, 'test-output', 'section-cropper-test');
|
|
148
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
149
|
+
|
|
150
|
+
let result;
|
|
151
|
+
|
|
152
|
+
if (arg && arg.startsWith('http')) {
|
|
153
|
+
// Test with URL
|
|
154
|
+
result = await testWithUrl(arg, outputDir);
|
|
155
|
+
} else if (arg) {
|
|
156
|
+
// Test with provided screenshot path
|
|
157
|
+
result = await testWithExistingScreenshot(arg, outputDir);
|
|
158
|
+
} else {
|
|
159
|
+
// Find existing screenshot or use default URL
|
|
160
|
+
const existingScreenshot = await findTestScreenshot();
|
|
161
|
+
if (existingScreenshot) {
|
|
162
|
+
result = await testWithExistingScreenshot(existingScreenshot, outputDir);
|
|
163
|
+
} else {
|
|
164
|
+
result = await testWithUrl('https://example.com', outputDir);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Final summary
|
|
169
|
+
console.log('\n=== Final Summary ===');
|
|
170
|
+
console.log(getCropperSummary(result));
|
|
171
|
+
console.log(`\nOutput directory: ${outputDir}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
main().catch(err => {
|
|
175
|
+
console.error('Test failed:', err);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Test script for section-detector.js
|
|
4
|
+
* Usage: node src/core/test-section-detector.js [url]
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { detectSections, getSectionSummary } from './section-detector.js';
|
|
8
|
+
import { getBrowser, getPage, closeBrowser } from '../utils/browser.js';
|
|
9
|
+
|
|
10
|
+
const url = process.argv[2] || 'https://www.techno-concier.co.jp/';
|
|
11
|
+
|
|
12
|
+
async function test() {
|
|
13
|
+
console.error(`Testing section detection on: ${url}\n`);
|
|
14
|
+
|
|
15
|
+
const browser = await getBrowser({ headless: true });
|
|
16
|
+
const page = await getPage(browser);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
await page.goto(url, {
|
|
20
|
+
waitUntil: 'domcontentloaded',
|
|
21
|
+
timeout: 30000
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Wait for page to stabilize
|
|
25
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
26
|
+
|
|
27
|
+
const sections = await detectSections(page, {
|
|
28
|
+
padding: 40,
|
|
29
|
+
minSections: 3,
|
|
30
|
+
minSectionHeight: 150
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const summary = getSectionSummary(sections);
|
|
34
|
+
|
|
35
|
+
console.log('=== Summary ===');
|
|
36
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
37
|
+
|
|
38
|
+
console.log('\n=== Sections ===');
|
|
39
|
+
for (const s of sections) {
|
|
40
|
+
console.log(` [${s.index}] ${s.name.padEnd(20)} (${s.role.padEnd(15)}) y:${String(s.bounds.y).padStart(5)} h:${String(s.bounds.height).padStart(5)}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Output JSON result
|
|
44
|
+
console.log('\n=== JSON Output ===');
|
|
45
|
+
console.log(JSON.stringify({ success: true, sections }, null, 2));
|
|
46
|
+
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('Error:', err.message);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
} finally {
|
|
51
|
+
await closeBrowser();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test();
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Video Capture Module
|
|
3
3
|
*
|
|
4
|
-
* Record scrolling interactions and CSS animations using
|
|
5
|
-
*
|
|
4
|
+
* Record scrolling interactions and CSS animations using Playwright's
|
|
5
|
+
* context-level video recording. Optionally convert WebM to MP4/GIF using ffmpeg.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* import { captureVideo, hasFfmpeg } from './video-capture.js';
|
|
@@ -37,6 +37,9 @@ const MAX_SCROLL_STEPS = 100;
|
|
|
37
37
|
/** Viewport overlap fraction for scroll step calculation */
|
|
38
38
|
const VIEWPORT_OVERLAP_FRACTION = 0.5;
|
|
39
39
|
|
|
40
|
+
/** Default viewport for video recording */
|
|
41
|
+
const DEFAULT_VIDEO_VIEWPORT = { width: 1440, height: 900 };
|
|
42
|
+
|
|
40
43
|
// ============================================================================
|
|
41
44
|
// Type Definitions (JSDoc)
|
|
42
45
|
// ============================================================================
|
|
@@ -47,6 +50,7 @@ const VIEWPORT_OVERLAP_FRACTION = 0.5;
|
|
|
47
50
|
* @property {number} [scrollPauseMs=50] - Pause between scroll steps for smoothness
|
|
48
51
|
* @property {number} [holdTopMs=500] - Hold time at page top
|
|
49
52
|
* @property {number} [holdBottomMs=500] - Hold time at page bottom
|
|
53
|
+
* @property {{width: number, height: number}} [viewport] - Viewport dimensions
|
|
50
54
|
*/
|
|
51
55
|
|
|
52
56
|
/**
|
|
@@ -123,10 +127,7 @@ async function initFfmpeg() {
|
|
|
123
127
|
ffmpeg = false;
|
|
124
128
|
|
|
125
129
|
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
|
+
if (!isModuleNotFound) {
|
|
130
131
|
// Unexpected error
|
|
131
132
|
console.error(
|
|
132
133
|
'[video-capture] ffmpeg initialization error:',
|
|
@@ -142,11 +143,6 @@ async function initFfmpeg() {
|
|
|
142
143
|
* Check if ffmpeg is available for video conversion.
|
|
143
144
|
*
|
|
144
145
|
* @returns {Promise<boolean>} True if ffmpeg dependencies are available
|
|
145
|
-
*
|
|
146
|
-
* @example
|
|
147
|
-
* if (await hasFfmpeg()) {
|
|
148
|
-
* await convertToMp4(webmPath, mp4Path);
|
|
149
|
-
* }
|
|
150
146
|
*/
|
|
151
147
|
export async function hasFfmpeg() {
|
|
152
148
|
return await initFfmpeg();
|
|
@@ -171,13 +167,17 @@ function log(message) {
|
|
|
171
167
|
// ============================================================================
|
|
172
168
|
|
|
173
169
|
/**
|
|
174
|
-
* Validate page object
|
|
175
|
-
* @param {
|
|
170
|
+
* Validate page object (Playwright page)
|
|
171
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
176
172
|
* @throws {TypeError} If page is invalid
|
|
177
173
|
*/
|
|
178
174
|
function validatePage(page) {
|
|
179
175
|
if (!page || typeof page.evaluate !== 'function') {
|
|
180
|
-
throw new TypeError('Invalid page object: must be a
|
|
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
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
@@ -193,129 +193,134 @@ function validatePath(outputPath) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
// ============================================================================
|
|
196
|
-
// Scroll Recording
|
|
196
|
+
// Scroll Recording (Playwright Context-Level Video)
|
|
197
197
|
// ============================================================================
|
|
198
198
|
|
|
199
199
|
/**
|
|
200
|
-
* Record page scroll interaction
|
|
200
|
+
* Record page scroll interaction using Playwright context-level video.
|
|
201
201
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* page height and desired duration.
|
|
202
|
+
* Creates a new browser context with video recording enabled, navigates to
|
|
203
|
+
* the page URL, performs scroll animation, then closes to finalize video.
|
|
205
204
|
*
|
|
206
|
-
* @param {
|
|
207
|
-
* @param {string}
|
|
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
208
|
* @param {RecordOptions} [options={}] - Recording options
|
|
209
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
210
|
*/
|
|
218
|
-
export async function recordScroll(
|
|
219
|
-
|
|
220
|
-
|
|
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);
|
|
221
216
|
|
|
222
217
|
const {
|
|
223
218
|
duration = DEFAULT_DURATION,
|
|
224
219
|
scrollPauseMs = 50,
|
|
225
220
|
holdTopMs = DEFAULT_HOLD_MS,
|
|
226
|
-
holdBottomMs = DEFAULT_HOLD_MS
|
|
221
|
+
holdBottomMs = DEFAULT_HOLD_MS,
|
|
222
|
+
viewport = DEFAULT_VIDEO_VIEWPORT
|
|
227
223
|
} = options;
|
|
228
224
|
|
|
229
|
-
//
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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 });
|
|
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();
|
|
270
235
|
const startTime = Date.now();
|
|
271
236
|
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
}
|
|
237
|
+
try {
|
|
238
|
+
// Navigate to page
|
|
239
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30000 });
|
|
286
240
|
|
|
287
|
-
//
|
|
288
|
-
|
|
241
|
+
// Get page dimensions
|
|
242
|
+
const totalHeight = await page.evaluate(() =>
|
|
243
|
+
Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
|
|
244
|
+
);
|
|
289
245
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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));
|
|
299
263
|
|
|
300
264
|
// Hold at top
|
|
301
265
|
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
266
|
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
}
|
|
309
274
|
|
|
310
|
-
|
|
275
|
+
// Hold at bottom
|
|
276
|
+
await new Promise(r => setTimeout(r, holdBottomMs));
|
|
311
277
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
|
319
324
|
}
|
|
320
325
|
|
|
321
326
|
// ============================================================================
|
|
@@ -335,9 +340,6 @@ export async function recordScroll(page, outputPath, options = {}) {
|
|
|
335
340
|
* @param {string} outputPath - Path for MP4 output
|
|
336
341
|
* @returns {Promise<ConvertResult>} Conversion result
|
|
337
342
|
* @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
343
|
*/
|
|
342
344
|
export async function convertToMp4(inputPath, outputPath) {
|
|
343
345
|
validatePath(inputPath);
|
|
@@ -380,12 +382,6 @@ export async function convertToMp4(inputPath, outputPath) {
|
|
|
380
382
|
* @param {number} [options.width=640] - Output width (height auto-calculated)
|
|
381
383
|
* @returns {Promise<ConvertResult>} Conversion result
|
|
382
384
|
* @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
385
|
*/
|
|
390
386
|
export async function convertToGif(inputPath, outputPath, options = {}) {
|
|
391
387
|
validatePath(inputPath);
|
|
@@ -432,11 +428,10 @@ export async function convertToGif(inputPath, outputPath, options = {}) {
|
|
|
432
428
|
|
|
433
429
|
return { path: outputPath, format: 'gif' };
|
|
434
430
|
} finally {
|
|
435
|
-
//
|
|
431
|
+
// Cleanup palette file
|
|
436
432
|
try {
|
|
437
433
|
await fs.unlink(palettePath);
|
|
438
434
|
} catch (cleanupErr) {
|
|
439
|
-
// Log cleanup failures in debug mode (when process.env.DEBUG is set)
|
|
440
435
|
if (process.env.DEBUG) {
|
|
441
436
|
console.error(`[video-capture] Palette cleanup failed: ${cleanupErr.message}`);
|
|
442
437
|
}
|
|
@@ -451,25 +446,14 @@ export async function convertToGif(inputPath, outputPath, options = {}) {
|
|
|
451
446
|
/**
|
|
452
447
|
* Capture video of page scroll interaction.
|
|
453
448
|
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
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).
|
|
456
452
|
*
|
|
457
|
-
* @param {
|
|
453
|
+
* @param {import('playwright').Page} page - Playwright page (used for browser reference and URL)
|
|
458
454
|
* @param {string} outputDir - Directory for output files
|
|
459
455
|
* @param {CaptureOptions} [options={}] - Capture options
|
|
460
456
|
* @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
457
|
*/
|
|
474
458
|
export async function captureVideo(page, outputDir, options = {}) {
|
|
475
459
|
validatePage(page);
|
|
@@ -481,28 +465,50 @@ export async function captureVideo(page, outputDir, options = {}) {
|
|
|
481
465
|
filename = 'preview'
|
|
482
466
|
} = options;
|
|
483
467
|
|
|
484
|
-
|
|
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
|
+
}
|
|
485
476
|
|
|
486
|
-
// Record
|
|
477
|
+
// Record using new context (Playwright context-level video)
|
|
487
478
|
log('[video] Recording scroll...');
|
|
488
|
-
const recordResult = await recordScroll(
|
|
479
|
+
const recordResult = await recordScroll(browser, pageUrl, outputDir, {
|
|
480
|
+
duration,
|
|
481
|
+
viewport
|
|
482
|
+
});
|
|
489
483
|
log(`[video] Recorded ${(recordResult.duration / 1000).toFixed(1)}s`);
|
|
490
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
|
+
|
|
491
497
|
/** @type {CaptureResult} */
|
|
492
498
|
const result = {
|
|
493
|
-
webm:
|
|
499
|
+
webm: recordResult.path,
|
|
494
500
|
duration: recordResult.duration,
|
|
495
501
|
pageHeight: recordResult.pageHeight,
|
|
496
|
-
output:
|
|
502
|
+
output: recordResult.path
|
|
497
503
|
};
|
|
498
504
|
|
|
499
|
-
// Convert if needed
|
|
505
|
+
// Convert if needed (ffmpeg logic unchanged)
|
|
500
506
|
if (format === 'mp4') {
|
|
501
507
|
const mp4Path = path.join(outputDir, `${filename}.mp4`);
|
|
502
508
|
log('[video] Converting to MP4...');
|
|
503
509
|
|
|
504
510
|
try {
|
|
505
|
-
await convertToMp4(
|
|
511
|
+
await convertToMp4(recordResult.path, mp4Path);
|
|
506
512
|
result.mp4 = mp4Path;
|
|
507
513
|
result.output = mp4Path;
|
|
508
514
|
log('[video] MP4 conversion complete');
|
|
@@ -515,7 +521,7 @@ export async function captureVideo(page, outputDir, options = {}) {
|
|
|
515
521
|
log('[video] Converting to GIF...');
|
|
516
522
|
|
|
517
523
|
try {
|
|
518
|
-
await convertToGif(
|
|
524
|
+
await convertToGif(recordResult.path, gifPath);
|
|
519
525
|
result.gif = gifPath;
|
|
520
526
|
result.output = gifPath;
|
|
521
527
|
log('[video] GIF conversion complete');
|