/** * Clone Architect — Visual Comparison (pixel-level) * * Compare le screenshot original avec le code genere via pixelmatch. * Produit un diff image (pixels rouges = differences) et un score de similarite. */ import { chromium } from 'playwright'; import { readFile, writeFile, mkdir, readdir, stat } from 'fs/promises'; import { join } from 'path'; import { PNG } from 'pngjs'; import pixelmatch from 'pixelmatch'; interface ComparisonResult { domain: string; timestamp: string; originalScreenshot: string; generatedScreenshot: string; diffImage: string; viewport: string; differences: string[]; score: number; verdict: 'PASS' | 'NEEDS-REVISION' | 'FAIL'; pixelStats: { totalPixels: number; diffPixels: number; matchPercent: number; }; } /** * Parse a PNG file buffer into a PNG object. */ function decodePNG(buffer: Buffer): PNG { return PNG.sync.read(buffer); } /** * Resize a PNG to target dimensions by cropping (top-left) or padding (transparent). * Returns a new PNG with exactly targetWidth x targetHeight. */ function resizePNG(src: PNG, targetWidth: number, targetHeight: number): PNG { const dst = new PNG({ width: targetWidth, height: targetHeight, fill: true }); // fill with transparent black dst.data.fill(0); const copyW = Math.min(src.width, targetWidth); const copyH = Math.min(src.height, targetHeight); for (let y = 0; y < copyH; y++) { const srcOffset = y * src.width * 4; const dstOffset = y * targetWidth * 4; src.data.copy(dst.data, dstOffset, srcOffset, srcOffset + copyW * 4); } return dst; } async function screenshotGenerated( htmlPath: string, outputPath: string, viewport: { width: number; height: number }, ): Promise { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); const context = await browser.newContext({ viewport }); const page = await context.newPage(); await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle' }); await page.waitForTimeout(1000); await page.screenshot({ path: outputPath, fullPage: true }); await browser.close(); } /** * Pixel-level comparison using pixelmatch. * Generates a diff image and returns a similarity score. */ async function comparePixels( img1Path: string, img2Path: string, diffOutputPath: string, ): Promise<{ score: number; differences: string[]; pixelStats: { totalPixels: number; diffPixels: number; matchPercent: number } }> { const differences: string[] = []; // Read both images let buf1: Buffer; let buf2: Buffer; try { buf1 = await readFile(img1Path); } catch { return { score: 0, differences: [`Original image not found: ${img1Path}`], pixelStats: { totalPixels: 0, diffPixels: 0, matchPercent: 0 }, }; } try { buf2 = await readFile(img2Path); } catch { return { score: 0, differences: [`Generated image not found: ${img2Path}`], pixelStats: { totalPixels: 0, diffPixels: 0, matchPercent: 0 }, }; } // Decode PNGs let png1: PNG; let png2: PNG; try { png1 = decodePNG(buf1); png2 = decodePNG(buf2); } catch (err) { return { score: 0, differences: [`Failed to decode PNG: ${err}`], pixelStats: { totalPixels: 0, diffPixels: 0, matchPercent: 0 }, }; } // Log original dimensions if (png1.width !== png2.width || png1.height !== png2.height) { differences.push( `Size mismatch: original ${png1.width}x${png1.height} vs generated ${png2.width}x${png2.height} — cropped to common area`, ); } // Use the smaller dimensions so we compare the common area const width = Math.min(png1.width, png2.width); const height = Math.min(png1.height, png2.height); // Resize both to the common dimensions const resized1 = resizePNG(png1, width, height); const resized2 = resizePNG(png2, width, height); // Create diff output image const diff = new PNG({ width, height }); // Run pixelmatch const diffPixels = pixelmatch( resized1.data, resized2.data, diff.data, width, height, { threshold: 0.1, includeAA: false, alpha: 0.1, }, ); // Write diff image const diffBuffer = PNG.sync.write(diff); await writeFile(diffOutputPath, diffBuffer); const totalPixels = width * height; const matchPercent = totalPixels > 0 ? ((1 - diffPixels / totalPixels) * 100) : 0; const score = Math.round(matchPercent); // Add diagnostic differences if (diffPixels > 0) { const diffPercent = ((diffPixels / totalPixels) * 100).toFixed(2); differences.push(`${diffPixels.toLocaleString()} pixels differ (${diffPercent}% of ${totalPixels.toLocaleString()} total)`); } if (score < 60) { differences.push('Major structural differences detected — layout likely diverges significantly'); } else if (score < 85) { differences.push('Moderate differences — colors, spacing, or minor layout issues'); } return { score, differences, pixelStats: { totalPixels, diffPixels, matchPercent: Math.round(matchPercent * 100) / 100 }, }; } async function compare(domain: string): Promise { const baseDir = join(process.cwd(), 'extractions', domain); const comparisonDir = join(baseDir, 'comparison'); const screenshotDir = join(baseDir, 'screenshots'); const outputDir = join(baseDir, 'output'); await mkdir(comparisonDir, { recursive: true }); console.log(`\nšŸ” Comparing results for ${domain}...`); // Check if output exists try { await readdir(outputDir); } catch { console.error(`No output directory found at ${outputDir}`); console.error('Generate code first, then run compare.'); process.exit(1); } // Find HTML files in output const outputFiles = await readdir(outputDir); const htmlFiles = outputFiles.filter((f) => f.endsWith('.html')); if (htmlFiles.length === 0) { console.error('No HTML files found in output directory'); process.exit(1); } const results: ComparisonResult[] = []; for (const htmlFile of htmlFiles) { const htmlPath = join(outputDir, htmlFile); for (const [vpName, vpSize] of Object.entries({ desktop: { width: 1440, height: 900 }, mobile: { width: 390, height: 844 }, })) { const baseName = htmlFile.replace('.html', ''); const generatedScreenshot = join(comparisonDir, `generated-${vpName}-${baseName}.png`); const originalScreenshot = join(screenshotDir, `full-page-${vpName}.png`); const diffImage = join(comparisonDir, `diff-${vpName}.png`); console.log(` šŸ“ø Screenshotting ${htmlFile} (${vpName})...`); await screenshotGenerated(htmlPath, generatedScreenshot, vpSize); // Check if original exists try { await stat(originalScreenshot); } catch { console.log(` āš ļø No original screenshot for ${vpName}, skipping comparison`); continue; } console.log(` šŸ” Comparing ${vpName} (pixel-level)...`); const { score, differences, pixelStats } = await comparePixels( originalScreenshot, generatedScreenshot, diffImage, ); const verdict: ComparisonResult['verdict'] = score >= 85 ? 'PASS' : score >= 60 ? 'NEEDS-REVISION' : 'FAIL'; results.push({ domain, timestamp: new Date().toISOString(), originalScreenshot, generatedScreenshot, diffImage, viewport: vpName, differences, score, verdict, pixelStats, }); const icon = verdict === 'PASS' ? 'āœ…' : verdict === 'NEEDS-REVISION' ? 'āš ļø' : 'āŒ'; console.log(` ${icon} ${vpName}: ${score}/100 — ${verdict}`); if (pixelStats.totalPixels > 0) { console.log(` ${pixelStats.diffPixels.toLocaleString()} differing pixels / ${pixelStats.totalPixels.toLocaleString()} total`); } if (differences.length > 0) { differences.forEach((d) => console.log(` → ${d}`)); } console.log(` Diff image: ${diffImage}`); } } // Save report const overallScore = Math.round( results.reduce((sum, r) => sum + r.score, 0) / Math.max(results.length, 1), ); const overallVerdict: ComparisonResult['verdict'] = results.every((r) => r.verdict === 'PASS') ? 'PASS' : results.some((r) => r.verdict === 'FAIL') ? 'FAIL' : 'NEEDS-REVISION'; const report = { domain, comparedAt: new Date().toISOString(), results, overallScore, overallVerdict, }; await writeFile(join(comparisonDir, 'comparison-report.json'), JSON.stringify(report, null, 2)); console.log(`\nšŸ“Š Overall: ${overallScore}/100 — ${overallVerdict}`); console.log(`šŸ“ Report: ${comparisonDir}/comparison-report.json`); } // ── CLI ── const domain = process.argv[2]; if (!domain) { console.error('Usage: npm run compare -- '); process.exit(1); } compare(domain).catch((err) => { console.error('Error:', err); process.exit(1); });