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.
Files changed (66) hide show
  1. package/README.md +26 -12
  2. package/bin/commands/clone-site.js +75 -10
  3. package/bin/commands/init.js +33 -1
  4. package/bin/commands/verify.js +5 -3
  5. package/bin/utils/validate.js +24 -8
  6. package/docs/cli-reference.md +200 -2
  7. package/docs/codebase-summary.md +309 -0
  8. package/docs/design-clone-architecture.md +259 -42
  9. package/docs/pixel-perfect.md +35 -4
  10. package/docs/project-roadmap.md +382 -0
  11. package/docs/troubleshooting.md +5 -4
  12. package/package.json +10 -8
  13. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  14. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  15. package/src/ai/analyze-structure.py +73 -3
  16. package/src/ai/extract-design-tokens.py +356 -13
  17. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  18. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  19. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  20. package/src/ai/prompts/design_tokens.py +133 -0
  21. package/src/ai/prompts/structure_analysis.py +329 -10
  22. package/src/ai/prompts/ux_audit.py +198 -0
  23. package/src/ai/ux-audit.js +596 -0
  24. package/src/core/app-state-snapshot.js +511 -0
  25. package/src/core/content-counter.js +342 -0
  26. package/src/core/cookie-handler.js +1 -1
  27. package/src/core/css-extractor.js +4 -4
  28. package/src/core/dimension-extractor.js +93 -21
  29. package/src/core/dimension-output.js +103 -6
  30. package/src/core/discover-pages.js +242 -14
  31. package/src/core/dom-tree-analyzer.js +298 -0
  32. package/src/core/extract-assets.js +1 -1
  33. package/src/core/framework-detector.js +538 -0
  34. package/src/core/html-extractor.js +45 -4
  35. package/src/core/lazy-loader.js +7 -7
  36. package/src/core/multi-page-screenshot.js +9 -6
  37. package/src/core/page-readiness.js +8 -8
  38. package/src/core/screenshot.js +138 -9
  39. package/src/core/section-cropper.js +209 -0
  40. package/src/core/section-detector.js +386 -0
  41. package/src/core/semantic-enhancer.js +492 -0
  42. package/src/core/state-capture.js +18 -22
  43. package/src/core/tests/test-section-cropper.js +177 -0
  44. package/src/core/tests/test-section-detector.js +55 -0
  45. package/src/core/video-capture.js +152 -146
  46. package/src/route-discoverers/angular-discoverer.js +157 -0
  47. package/src/route-discoverers/astro-discoverer.js +123 -0
  48. package/src/route-discoverers/base-discoverer.js +242 -0
  49. package/src/route-discoverers/index.js +106 -0
  50. package/src/route-discoverers/next-discoverer.js +130 -0
  51. package/src/route-discoverers/nuxt-discoverer.js +138 -0
  52. package/src/route-discoverers/react-discoverer.js +139 -0
  53. package/src/route-discoverers/svelte-discoverer.js +109 -0
  54. package/src/route-discoverers/universal-discoverer.js +227 -0
  55. package/src/route-discoverers/vue-discoverer.js +118 -0
  56. package/src/utils/__init__.py +1 -1
  57. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  58. package/src/utils/browser.js +11 -37
  59. package/src/utils/playwright.js +213 -0
  60. package/src/verification/generate-audit-report.js +398 -0
  61. package/src/verification/verify-footer.js +493 -0
  62. package/src/verification/verify-header.js +486 -0
  63. package/src/verification/verify-layout.js +2 -2
  64. package/src/verification/verify-menu.js +4 -20
  65. package/src/verification/verify-slider.js +533 -0
  66. 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 Puppeteer's
5
- * page.screencast(). Optionally convert WebM to MP4/GIF using ffmpeg.
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 {Object} page - Puppeteer page object
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 Puppeteer page');
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 from top to bottom and back.
200
+ * Record page scroll interaction using Playwright context-level video.
201
201
  *
202
- * Uses Puppeteer's page.screencast() to capture the viewport as the page
203
- * scrolls. Creates smooth animation by calculating scroll steps based on
204
- * page height and desired duration.
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 {Object} page - Puppeteer page object
207
- * @param {string} outputPath - Path for WebM output file
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(page, outputPath, options = {}) {
219
- validatePage(page);
220
- validatePath(outputPath);
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
- // Get viewport dimensions (H2: validate viewport exists)
230
- const viewport = page.viewport();
231
- if (!viewport || !viewport.height) {
232
- throw new Error(
233
- 'Page viewport not initialized. Call page.setViewport() before recording.'
234
- );
235
- }
236
- const viewportHeight = viewport.height;
237
-
238
- // Get total page height
239
- const totalHeight = await page.evaluate(() =>
240
- Math.max(
241
- document.body.scrollHeight,
242
- document.documentElement.scrollHeight
243
- )
244
- );
245
-
246
- // Calculate scroll parameters
247
- const scrollDistance = Math.max(0, totalHeight - viewportHeight);
248
-
249
- // M1: Handle zero-height/single-screen pages
250
- const isScrollable = scrollDistance > 0;
251
-
252
- // M2: Cap scroll steps to prevent memory exhaustion on very large pages
253
- const rawScrollSteps = isScrollable
254
- ? Math.ceil(scrollDistance / (viewportHeight * VIEWPORT_OVERLAP_FRACTION))
255
- : 0;
256
- const scrollSteps = Math.min(rawScrollSteps, MAX_SCROLL_STEPS);
257
-
258
- // Distribute time: hold times + scroll down + scroll up
259
- const scrollTime = duration - holdTopMs - holdBottomMs;
260
- const scrollDelay = scrollSteps > 0
261
- ? Math.max(scrollPauseMs, Math.floor(scrollTime / (scrollSteps * 2)))
262
- : 0;
263
-
264
- // Ensure page is at top
265
- await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
266
- await new Promise(r => setTimeout(r, 200));
267
-
268
- // Start recording
269
- const recorder = await page.screencast({ path: outputPath });
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
- // Hold at top
273
- await new Promise(r => setTimeout(r, holdTopMs));
274
-
275
- // Only scroll if page is scrollable (M1: skip no-op scrolls)
276
- if (isScrollable && scrollSteps > 0) {
277
- // Scroll down
278
- for (let i = 1; i <= scrollSteps; i++) {
279
- const y = (i / scrollSteps) * scrollDistance;
280
- await page.evaluate(
281
- (scrollY) => window.scrollTo({ top: scrollY, behavior: 'instant' }),
282
- y
283
- );
284
- await new Promise(r => setTimeout(r, scrollDelay));
285
- }
237
+ try {
238
+ // Navigate to page
239
+ await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30000 });
286
240
 
287
- // Hold at bottom
288
- await new Promise(r => setTimeout(r, holdBottomMs));
241
+ // Get page dimensions
242
+ const totalHeight = await page.evaluate(() =>
243
+ Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
244
+ );
289
245
 
290
- // Scroll back up
291
- for (let i = scrollSteps - 1; i >= 0; i--) {
292
- const y = (i / scrollSteps) * scrollDistance;
293
- await page.evaluate(
294
- (scrollY) => window.scrollTo({ top: scrollY, behavior: 'instant' }),
295
- y
296
- );
297
- await new Promise(r => setTimeout(r, scrollDelay));
298
- }
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
- // Stop recording
308
- await recorder.stop();
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
- const actualDuration = Date.now() - startTime;
275
+ // Hold at bottom
276
+ await new Promise(r => setTimeout(r, holdBottomMs));
311
277
 
312
- return {
313
- path: outputPath,
314
- format: 'webm',
315
- duration: actualDuration,
316
- scrollSteps,
317
- pageHeight: totalHeight
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
- // H1: Cleanup palette file with debug logging for failures
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
- * Records page scrolling and optionally converts to MP4 or GIF.
455
- * WebM is always created first (native Puppeteer screencast format).
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 {Object} page - Puppeteer page object
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
- const webmPath = path.join(outputDir, `${filename}.webm`);
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 WebM
477
+ // Record using new context (Playwright context-level video)
487
478
  log('[video] Recording scroll...');
488
- const recordResult = await recordScroll(page, webmPath, { duration });
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: webmPath,
499
+ webm: recordResult.path,
494
500
  duration: recordResult.duration,
495
501
  pageHeight: recordResult.pageHeight,
496
- output: webmPath
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(webmPath, mp4Path);
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(webmPath, gifPath);
524
+ await convertToGif(recordResult.path, gifPath);
519
525
  result.gif = gifPath;
520
526
  result.output = gifPath;
521
527
  log('[video] GIF conversion complete');