← Visual Preview Capture Server v1 2 files 🔗 Share Link

🤖 AI Code Review

All source files from Capture Server v1 are listed below as formatted code blocks. This page is designed for another AI agent to read — share the URL and it can review every file, check for issues, and suggest improvements.

📄 2 files 📏 1,011 total lines 📂 Full-page screenshot service — Playwright + HTTP. Dark Framer Motion theme with URL input, capture button, result display with metadata (title, dimensions, panels, file size). Stitches multiple scroll passes until height stabilizes.
📄 ./index.html 274 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Capture — Full Page Screenshot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
  :root{--bg:#0a0a0f;--bg2:#12121a;--bg3:#1a1a2e;--surface:#1e1e32;--border:#2a2a44;--accent:#818cf8;--accent2:#6366f1;--accent3:#4f46e5;--green:#34d399;--amber:#fbbf24;--pink:#f472b6;--text:#e2e8f0;--muted:#8892a4;--radius:12px;--radius-sm:8px}
  html{scroll-behavior:smooth}
  body{font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;overflow-x:hidden;min-height:100vh}

  /* Noise */
  .noise{position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:.02;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}

  /* Animations */
  @keyframes fadeUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
  @keyframes fadeIn{from{opacity:0}to{opacity:1}}
  @keyframes scaleIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
  @keyframes blob{0%,100%{border-radius:60% 40% 30% 70%/60% 30% 70% 40%}50%{border-radius:30% 60% 70% 40%/50% 60% 30% 60%}}
  @keyframes cursor{0%,100%{opacity:1}50%{opacity:0}}
  @keyframes shimmer{to{background-position:200% 0}}
  @keyframes spin{to{transform:rotate(360deg)}}

  .anim-fade{animation:fadeUp .7s ease-out both}
  .anim-fade-d1{animation-delay:.1s}
  .anim-fade-d2{animation-delay:.2s}
  .anim-fade-d3{animation-delay:.3s}
  .anim-scale{animation:scaleIn .6s ease-out both}

  /* Hero */
  .hero{position:relative;padding:80px 24px 60px;text-align:center;overflow:hidden}
  .hero .bg-blobs{position:absolute;inset:0;overflow:hidden}
  .hero .bg-blobs .blob{position:absolute;width:500px;height:500px;border-radius:50%;filter:blur(100px);opacity:.1}
  .hero .bg-blobs .b1{top:-200px;left:-200px;background:var(--accent);animation:blob 8s ease-in-out infinite}
  .hero .bg-blobs .b2{bottom:-300px;right:-200px;background:var(--accent3);animation:blob 10s ease-in-out infinite reverse}
  .hero .bg-blobs .b3{top:50%;left:50%;transform:translate(-50%,-50%);width:700px;height:700px;background:var(--accent2);opacity:.05;animation:blob 12s ease-in-out infinite}
  .hero h1{font-size:clamp(32px,5vw,56px);font-weight:800;letter-spacing:-1.5px;position:relative;z-index:1;line-height:1.08}
  .hero h1 .highlight{background:linear-gradient(135deg,var(--accent),var(--accent2),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
  .hero p{font-size:16px;color:var(--muted);margin-top:12px;position:relative;z-index:1;max-width:500px;margin-left:auto;margin-right:auto}

  /* Input area */
  .capture-section{position:relative;z-index:1;max-width:800px;margin:0 auto;padding:0 24px 60px}
  .input-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:32px;box-shadow:0 4px 24px rgba(0,0,0,.2)}
  .input-label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:6px}
  .input-row{display:flex;gap:10px;align-items:stretch}
  .input-row input[type="text"]{flex:1;padding:12px 16px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;font-family:'Inter',system-ui,sans-serif;outline:none;transition:all .2s}
  .input-row input[type="text"]::placeholder{color:rgba(255,255,255,.2)}
  .input-row input[type="text"]:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(129,140,248,.1)}
  .input-row input[type="text"].error{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
  .capture-btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all .25s;white-space:nowrap;font-family:'Inter',system-ui,sans-serif}
  .capture-btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(99,102,241,.35)}
  .capture-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
  .capture-btn .spinner{display:none;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite}
  .capture-btn.loading .spinner{display:inline-block}
  .capture-btn.loading .btn-text{display:none}

  /* Options row */
  .options-row{display:flex;gap:20px;margin-top:14px;flex-wrap:wrap}
  .opt-group{display:flex;align-items:center;gap:8px}
  .opt-group label{font-size:12px;color:var(--muted)}
  .opt-group select,.opt-group input{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px 8px;color:var(--text);font-size:12px;font-family:'Inter',system-ui,sans-serif;outline:none}
  .opt-group select:focus,.opt-group input:focus{border-color:var(--accent)}
  .opt-group input[type="number"]{width:64px}

  /* Status bar */
  .status-bar{display:none;align-items:center;gap:10px;margin-top:12px;padding:10px 14px;border-radius:var(--radius-sm);font-size:12px}
  .status-bar.show{display:flex}
  .status-bar.info{background:rgba(129,140,248,.08);border:1px solid rgba(129,140,248,.15);color:var(--accent)}
  .status-bar.success{background:rgba(52,211,153,.08);border:1px solid rgba(52,211,153,.15);color:var(--green)}
  .status-bar.error{background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.15);color:#f87171}
  .status-bar .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
  .status-bar.info .status-dot{background:var(--accent);animation:pulse 1.5s ease-in-out infinite}
  .status-bar.success .status-dot{background:var(--green)}
  .status-bar.error .status-dot{background:#ef4444}
  .status-bar .status-close{margin-left:auto;cursor:pointer;opacity:.5;font-size:14px;background:none;border:none;color:inherit;font-family:inherit}
  .status-bar .status-close:hover{opacity:1}

  /* Result area */
  .result-area{display:none;margin-top:24px;animation:fadeUp .5s ease-out}
  .result-area.show{display:block}
  .result-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
  .result-meta{display:flex;gap:16px;padding:14px 20px;border-bottom:1px solid var(--border);flex-wrap:wrap;font-size:12px;color:var(--muted);align-items:center}
  .result-meta .meta-item{display:flex;align-items:center;gap:4px}
  .result-meta .meta-item strong{color:var(--text);font-weight:600}
  .result-meta .spacer{flex:1}
  .result-meta .download-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:6px;background:rgba(129,140,248,.1);color:var(--accent);font-size:11px;font-weight:500;text-decoration:none;border:1px solid rgba(129,140,248,.2);transition:all .15s}
  .result-meta .download-btn:hover{background:rgba(129,140,248,.18)}
  .result-meta .copy-url-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:6px;background:rgba(255,255,255,.04);color:var(--muted);font-size:11px;font-weight:500;border:1px solid var(--border);cursor:pointer;font-family:'Inter',system-ui,sans-serif;transition:all .15s}
  .result-meta .copy-url-btn:hover{color:var(--text);border-color:var(--accent);background:rgba(129,140,248,.08)}
  .result-image-wrap{position:relative;overflow:auto;max-height:80vh;background:repeating-conic-gradient(rgba(255,255,255,.03) 0% 25%,transparent 0% 50%) 0 0/20px 20px}
  .result-image-wrap img{display:block;width:100%;max-width:none}
  .result-image-wrap .loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg2);font-size:13px;color:var(--muted);gap:8px;z-index:2}
  .result-image-wrap .loading-overlay .spinner-s{width:14px;height:14px;border:2px solid rgba(255,255,255,.1);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}

  /* Divider */
  .divider{display:flex;align-items:center;gap:12px;padding:20px 0;color:var(--border);font-size:10px;text-transform:uppercase;letter-spacing:1px}
  .divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}

  /* Footer */
  .footer{text-align:center;padding:40px 24px 60px;font-size:12px;color:var(--muted)}
  .footer a{color:var(--accent);text-decoration:none}
  .footer a:hover{text-decoration:underline}

  /* Responsive */
  @media(max-width:640px){
    .input-row{flex-direction:column}
    .capture-btn{justify-content:center}
    .options-row{flex-direction:column;gap:10px}
  }
</style>
</head>
<body>
<div class="noise"></div>

<div class="hero anim-fade">
  <div class="bg-blobs">
    <div class="blob b1"></div>
    <div class="blob b2"></div>
    <div class="blob b3"></div>
  </div>
  <h1><span class="highlight">Capture</span></h1>
  <p>Full-page screenshots of any URL — scroll, stitch, done.</p>
</div>

<div class="capture-section anim-fade anim-fade-d1">
  <div class="input-card">
    <div class="input-label">🔗 URL to capture</div>
    <div class="input-row">
      <input type="text" id="urlInput" placeholder="https://example.com" onkeydown="if(event.key==='Enter') startCapture()">
      <button class="capture-btn" id="captureBtn" onclick="startCapture()">
        <span class="spinner"></span>
        <span class="btn-text">📸 Capture</span>
      </button>
    </div>
    <div class="options-row">
      <div class="opt-group">
        <label>Width</label>
        <select id="widthSelect">
          <option value="1440">1440px (Desktop)</option>
          <option value="1280">1280px</option>
          <option value="1024">1024px (Tablet)</option>
          <option value="768">768px</option>
          <option value="414">414px (Mobile)</option>
        </select>
      </div>
      <div class="opt-group">
        <label>Wait</label>
        <select id="waitSelect">
          <option value="500">500ms</option>
          <option value="1500" selected>1.5s</option>
          <option value="3000">3s</option>
          <option value="5000">5s</option>
        </select>
      </div>
    </div>

    <div class="status-bar" id="statusBar">
      <span class="status-dot"></span>
      <span class="status-text" id="statusText">Ready</span>
      <button class="status-close" onclick="hideStatus()">✕</button>
    </div>
  </div>

  <div class="result-area" id="resultArea">
    <div class="result-card">
      <div class="result-meta" id="resultMeta"></div>
      <div class="result-image-wrap" id="resultImageWrap">
        <div class="loading-overlay" id="imgLoading">
          <span class="spinner-s"></span>
          Loading image...
        </div>
        <img id="resultImage" src="" alt="Screenshot" onload="document.getElementById('imgLoading').style.display='none'">
      </div>
    </div>
  </div>
</div>

<div class="divider anim-fade anim-fade-d2">powered by Playwright</div>

<div class="footer anim-fade anim-fade-d2">
  <a href="https://preview.aivibehosting.com">Preview Dashboard</a> &middot;
  <a href="https://pcm.aivibehosting.com">PCM</a>
</div>

<script>
function showStatus(msg, type) {
  const bar = document.getElementById('statusBar');
  const text = document.getElementById('statusText');
  bar.className = 'status-bar show ' + type;
  text.textContent = msg;
}

function hideStatus() {
  document.getElementById('statusBar').classList.remove('show');
}

function copyUrl(filename) {
  const url = window.location.origin + '/screenshots/' + filename;
  navigator.clipboard.writeText(url).then(() => {
    showStatus('🔗 URL copied to clipboard!', 'success');
  }).catch(() => {
    // Fallback
    const ta = document.createElement('textarea');
    ta.value = url;
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    document.body.removeChild(ta);
    showStatus('🔗 URL copied!', 'success');
  });
}

async function startCapture() {
  const url = document.getElementById('urlInput').value.trim();
  const btn = document.getElementById('captureBtn');
  const width = parseInt(document.getElementById('widthSelect').value);
  const waitMs = parseInt(document.getElementById('waitSelect').value);
  const input = document.getElementById('urlInput');

  if (!url) {
    input.classList.add('error');
    showStatus('Please enter a URL', 'error');
    setTimeout(() => input.classList.remove('error'), 2000);
    return;
  }

  // Basic URL validation
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
    input.classList.add('error');
    showStatus('URL must start with http:// or https://', 'error');
    setTimeout(() => input.classList.remove('error'), 2000);
    return;
  }

  btn.classList.add('loading');
  btn.disabled = true;
  showStatus('Capturing ' + url + ' ...', 'info');

  try {
    const res = await fetch('/api/capture', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url, width, wait_ms: waitMs }),
    });

    const data = await res.json();

    if (!data.success) {
      showStatus(data.error || 'Capture failed', 'error');
      btn.classList.remove('loading');
      btn.disabled = false;
      return;
    }

    // Show result
    const resultArea = document.getElementById('resultArea');
    const resultMeta = document.getElementById('resultMeta');
    const resultImage = document.getElementById('resultImage');
    const imgLoading = document.getElementById('imgLoading');

    resultMeta.innerHTML = `
      <span class="meta-item">📄 <strong>\${data.title}</strong></span>
      <span class="meta-item">📐 \${data.scroll_height}px × \${data.viewport_width}px</span>
      <span class="meta-item">🧩 \${data.stitched} panels</span>
      <span class="meta-item">💾 \${data.file_size_kb}KB</span>
      <span class="spacer"></span>
      <button class="copy-url-btn" onclick="copyUrl('\${data.filename}')">🔗 Copy URL</button>
      <a class="download-btn" href="/screenshots/\${data.filename}" download="\${data.filename}">⬇ Download PNG</a>
    \
📄 ./server.js 716 lines
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const { chromium } = require('playwright');

const PORT = 3005;
const SCREENSHOT_DIR = '/var/www/capture/screenshots';

if (!fs.existsSync(SCREENSHOT_DIR)) {
  fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}

// Auto-clean screenshots older than 24 hours to prevent disk exhaustion
setInterval(() => {
  fs.readdir(SCREENSHOT_DIR, (err, files) => {
    if (err) return;
    const now = Date.now();
    for (const file of files) {
      const fp = path.join(SCREENSHOT_DIR, file);
      try {
        const stat = fs.statSync(fp);
        if (stat.isFile() && now - stat.mtimeMs > 24 * 60 * 60 * 1000) {
          fs.unlinkSync(fp);
        }
      } catch {}
    }
  });
}, 60 * 60 * 1000); // Run once per hour

// ── MIME types ────────────────────────────────────────────────────────────

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.woff2': 'font/woff2',
  '.woff': 'font/woff',
  '.ttf': 'font/ttf',
};

function sendFile(res, filePath) {
  const ext = path.extname(filePath).toLowerCase();
  const contentType = MIME[ext] || 'application/octet-stream';
  const stream = fs.createReadStream(filePath);
  stream.on('open', () => {
    res.writeHead(200, {
      'Content-Type': contentType,
      'Cache-Control': 'no-cache, no-store, must-revalidate',
    });
    stream.pipe(res);
  });
  stream.on('error', () => {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('Not found');
  });
}

function sendJSON(res, data, status = 200) {
  res.writeHead(status, {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Origin': '*',
  });
  res.end(JSON.stringify(data));
}

function sendHTML(res, html, status = 200) {
  res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end(html);
}

// ── Capture via Playwright ────────────────────────────────────────────────

// Safe evaluate wrapper: retries if navigation destroys the execution context
async function safeEvaluate(page, fn, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await page.evaluate(fn);
    } catch (err) {
      const msg = (err && err.message) || '';
      if (msg.includes('Execution context was destroyed') || msg.includes('Target closed')) {
        if (attempt < maxRetries - 1) {
          // Page navigated — wait for new page to stabilize, then retry
          await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
          await page.waitForTimeout(500);
          continue;
        }
      }
      throw err; // Non-navigation error or exhausted retries — let caller decide
    }
  }
}

async function capturePage(targetUrl, width = 1440, waitMs = 1500) {
  const safeName = `capture_${Date.now()}`;
  const filename = `${safeName}.png`;
  const outputPath = path.join(SCREENSHOT_DIR, filename);

  // Flexible Chrome path: fallback to Playwright default binary if missing
  const customChromePath = '/usr/bin/google-chrome-stable';
  const launchOptions = {
    headless: true,
    args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
  };
  if (fs.existsSync(customChromePath)) {
    launchOptions.executablePath = customChromePath;
  }

  const browser = await chromium.launch(launchOptions);

  try {
    const context = await browser.newContext({
      viewport: { width, height: 900 },
      deviceScaleFactor: 1,
      userAgent:
        'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
    });

    const page = await context.newPage();

    // ── Step 1: Navigate and wait for FULL load ──
    await page.goto(targetUrl, { waitUntil: 'load', timeout: 60000 });
    await page.waitForFunction(() => document.readyState === 'complete', { timeout: 15000 }).catch(() => {});

    // ── Step 2: Wait for initial images to finish loading ──
    await page.waitForFunction(() => {
      const imgs = Array.from(document.images);
      return imgs.every(img => img.complete);
    }, { timeout: 15000 }).catch(() => {});

    // ── Step 3: Force all lazy images to load before scrolling ──
    // WordPress uses native loading="lazy" which doesn't trigger reliably
    // in headless Chrome during fast scroll passes.
    await page.evaluate(() => {
      document.querySelectorAll('img[loading="lazy"], img[data-lazy], iframe[loading="lazy"]').forEach(el => {
        el.setAttribute('loading', 'eager');
        // Force loading if the image hasn't started yet
        if (el.tagName === 'IMG' && !el.complete) {
          const src = el.getAttribute('src');
          if (src) {
            el.src = ''; // Clear to force re-request
            el.src = src; // Re-set to trigger immediate load
          }
        }
      });
    });

    // Wait for images to actually start loading after removing lazy
    await page.waitForFunction(() => {
      const imgs = Array.from(document.images);
      return imgs.length > 0 && imgs.some(img => !img.complete) === false;
    }, { timeout: 30000 }).catch(() => {});

    // ── Step 4: Force page builder animations to reveal hidden content ──
    // Elementor and other page builders hide content with CSS classes
    // (elementor-invisible, animated-slow, etc.) and reveal it via
    // scroll-triggered JavaScript. In headless Chrome these don't fire
    // reliably, so we force all hidden content visible.
    await page.evaluate(() => {
      // Remove Elementor invisibility
      document.querySelectorAll('.elementor-invisible').forEach(el => {
        el.classList.remove('elementor-invisible');
        el.style.visibility = 'visible';
        el.style.opacity = '1';
      });

      // Force all animated elements to final state
      document.querySelectorAll('[class*="animated"], [data-settings*="animation"]').forEach(el => {
        el.classList.remove('animated-slow');
        el.style.animation = 'none';
        el.style.opacity = '1';
        el.style.visibility = 'visible';
      });

      // Force WPBakery / other builders
      document.querySelectorAll('.vc_hidden, .wpb_animate_when_almost_visible').forEach(el => {
        el.classList.remove('vc_hidden', 'wpb_animate_when_almost_visible');
        el.style.visibility = 'visible';
        el.style.opacity = '1';
      });

      // Unhide any hidden parent containers that might trap content
      document.querySelectorAll('[style*="display: none"], [style*="display:none"]').forEach(el => {
        el.style.display = '';
      });

      // Force zero-height containers (common in Elementor footer animations)
      // to auto-height so their content renders
      // First, force a reflow so measurements are accurate
      document.body.offsetHeight;

      // Directly target footer and its children
      const footer = document.querySelector('footer');
      if (footer) {
        footer.style.height = 'auto';
        footer.style.minHeight = 'auto';
        footer.style.overflow = 'visible';
      }

      // Then sweep all elements for any remaining zero-height containers
      document.querySelectorAll('*').forEach(el => {
        if (el.offsetHeight === 0 && el.scrollHeight > 0) {
          el.style.height = 'auto';
          el.style.minHeight = 'auto';
          el.style.overflow = 'visible';
        }
      });

      // Force another reflow
      document.body.offsetHeight;
    });

    await page.waitForTimeout(500);

    // ── Step 5: Force re-layout after revealing hidden content ──
    await page.evaluate(() => {
      // Trigger a layout recalculation
      document.body.offsetHeight;
      // Dispatch scroll event to trigger any remaining IntersectionObservers
      window.dispatchEvent(new Event('scroll'));
    });
    await page.waitForTimeout(300);

    // ── Step 6: Slow scroll — pause at every viewport until stable ──
    // Instead of fast-scrolling, we pause at each scroll position and wait
    // for DOM mutations to stop. This lets Elementor animations, lazy images,
    // and JavaScript widgets fully render at each section before moving on.
    await page.evaluate(() => {
      return new Promise((resolve) => {
        const viewportH = window.innerHeight;
        const MAX_STEPS = 20;
        let step = 0;

        // Wait-for-stable helper: resolves after `ms` of no DOM changes
        const waitForStable = (ms = 3000) => {
          return new Promise((stableResolve) => {
            let lastChange = Date.now();
            const observer = new MutationObserver(() => { lastChange = Date.now(); });
            observer.observe(document.body, { childList: true, subtree: true, attributes: true,
              attributeFilter: ['class', 'style', 'src', 'loading'] });

            const check = setInterval(() => {
              if (Date.now() - lastChange >= ms) {
                observer.disconnect();
                clearInterval(check);
                stableResolve();
              }
            }, 250);

            // Safety: always proceed after 10s at this position
            setTimeout(() => { observer.disconnect(); clearInterval(check); stableResolve(); }, 10000);
          });
        };

        const doStep = async () => {
          if (step >= MAX_STEPS) return resolve();

          const targetY = step * viewportH;
          const totalHeight = Math.max(
            document.body ? document.body.scrollHeight : 0,
            document.documentElement ? document.documentElement.scrollHeight : 0,
          );

          // Stop if we've scrolled past total content
          if (targetY > totalHeight) {
            window.scrollTo(0, totalHeight);
            await waitForStable(3000);
            return resolve();
          }

          window.scrollTo(0, targetY);
          await waitForStable(3000);

          step++;
          setTimeout(() => doStep(), 100);
        };

        window.scrollTo(0, 0);
        setTimeout(() => doStep(), 100);
      });
    });

    // Final settle at the very bottom
    await page.evaluate(() => {
      const totalHeight = Math.max(
        document.body ? document.body.scrollHeight : 0,
        document.documentElement ? document.documentElement.scrollHeight : 0,
      );
      window.scrollTo(0, totalHeight);
    });
    await page.waitForTimeout(500);

    // ── Step 7: Measure final dimensions (after everything rendered) ──
    const pageInfo = await safeEvaluate(page, () => ({
      title: document.title,
      scrollHeight: Math.max(
        document.body ? document.body.scrollHeight : 0,
        document.documentElement ? document.documentElement.scrollHeight : 0,
        (document.scrollingElement || document.documentElement || {}).scrollHeight || 0,
      ),
      viewportHeight: window.innerHeight,
    })).catch(() => ({ title: targetUrl, scrollHeight: 1080, viewportHeight: 900 }));

    await page.screenshot({ path: outputPath, fullPage: true });

    const fileSizeKb = Math.round(fs.statSync(outputPath).size / 1024);

    // ── Quality check: compare HTML content density vs screenshot ──
    const pageSummary = await safeEvaluate(page, () => {
      const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6').length;
      const paragraphs = document.querySelectorAll('p').length;
      const images = document.querySelectorAll('img').length;
      const links = document.querySelectorAll('a').length;
      const textLen = (document.body ? document.body.innerText || '' : '').length;
      return { headings, paragraphs, images, links, textLen };
    }).catch(() => ({ headings: 0, paragraphs: 0, images: 0, links: 0, textLen: 0 }));

    // Calculate density: KB per 1000px of scroll height
    const densityKbPer1000px = pageInfo.scrollHeight > 0
      ? (fileSizeKb / pageInfo.scrollHeight) * 1000
      : 0;

    const quality = { densityKbPer1000px: Math.round(densityKbPer1000px * 10) / 10 };

    // Flag suspicious captures: content-rich HTML but blank/small screenshot
    const hasRichHtml = pageSummary.textLen > 500 || pageSummary.headings > 3 || pageSummary.images > 5;
    const seemsBlank = densityKbPer1000px < 50 && pageInfo.scrollHeight > 1500;

    if (hasRichHtml && seemsBlank) {
      quality.warning = 'HIGH';
      quality.reason = `Page has ${pageSummary.headings} headings, ${pageSummary.images} images, ${(pageSummary.textLen / 1000).toFixed(1)}K chars but screenshot is only ${fileSizeKb}KB for ${pageInfo.scrollHeight}px — lazy content may not have rendered. Try increasing wait time.`;
    } else if (seemsBlank) {
      quality.warning = 'MEDIUM';
      quality.reason = `Screenshot is ${fileSizeKb}KB for ${pageInfo.scrollHeight}px (${densityKbPer1000px.toFixed(1)} KB/1000px) — page may be sparse or capture may have missed content.`;
    } else {
      quality.warning = 'NONE';
    }

    return {
      filename,
      outputPath,
      fileSizeKb,
      title: pageInfo.title,
      scrollHeight: pageInfo.scrollHeight,
      viewportHeight: pageInfo.viewportHeight,
      stitched: Math.ceil(pageInfo.scrollHeight / pageInfo.viewportHeight),
      quality,
    };
  } finally {
    // Guaranteed cleanup — prevents zombie Chrome processes
    await browser.close();
  }
}

// ── HTML Page ─────────────────────────────────────────────────────────────

const HTML_PAGE = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Capture — Full Page Screenshot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
  :root{--bg:#0a0a0f;--bg2:#12121a;--bg3:#1a1a2e;--surface:#1e1e32;--border:#2a2a44;--accent:#818cf8;--accent2:#6366f1;--accent3:#4f46e5;--green:#34d399;--amber:#fbbf24;--pink:#f472b6;--text:#e2e8f0;--muted:#8892a4;--radius:12px;--radius-sm:8px}
  html{scroll-behavior:smooth}
  body{font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;overflow-x:hidden;min-height:100vh}

  /* Noise */
  .noise{position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:.02;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}

  /* Animations */
  @keyframes fadeUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
  @keyframes fadeIn{from{opacity:0}to{opacity:1}}
  @keyframes scaleIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
  @keyframes blob{0%,100%{border-radius:60% 40% 30% 70%/60% 30% 70% 40%}50%{border-radius:30% 60% 70% 40%/50% 60% 30% 60%}}
  @keyframes cursor{0%,100%{opacity:1}50%{opacity:0}}
  @keyframes shimmer{to{background-position:200% 0}}
  @keyframes spin{to{transform:rotate(360deg)}}

  .anim-fade{animation:fadeUp .7s ease-out both}
  .anim-fade-d1{animation-delay:.1s}
  .anim-fade-d2{animation-delay:.2s}
  .anim-fade-d3{animation-delay:.3s}
  .anim-scale{animation:scaleIn .6s ease-out both}

  /* Hero */
  .hero{position:relative;padding:80px 24px 60px;text-align:center;overflow:hidden}
  .hero .bg-blobs{position:absolute;inset:0;overflow:hidden}
  .hero .bg-blobs .blob{position:absolute;width:500px;height:500px;border-radius:50%;filter:blur(100px);opacity:.1}
  .hero .bg-blobs .b1{top:-200px;left:-200px;background:var(--accent);animation:blob 8s ease-in-out infinite}
  .hero .bg-blobs .b2{bottom:-300px;right:-200px;background:var(--accent3);animation:blob 10s ease-in-out infinite reverse}
  .hero .bg-blobs .b3{top:50%;left:50%;transform:translate(-50%,-50%);width:700px;height:700px;background:var(--accent2);opacity:.05;animation:blob 12s ease-in-out infinite}
  .hero h1{font-size:clamp(32px,5vw,56px);font-weight:800;letter-spacing:-1.5px;position:relative;z-index:1;line-height:1.08}
  .hero h1 .highlight{background:linear-gradient(135deg,var(--accent),var(--accent2),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
  .hero p{font-size:16px;color:var(--muted);margin-top:12px;position:relative;z-index:1;max-width:500px;margin-left:auto;margin-right:auto}

  /* Input area */
  .capture-section{position:relative;z-index:1;max-width:800px;margin:0 auto;padding:0 24px 60px}
  .input-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:32px;box-shadow:0 4px 24px rgba(0,0,0,.2)}
  .input-label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:6px}
  .input-row{display:flex;gap:10px;align-items:stretch}
  .input-row input[type="text"]{flex:1;padding:12px 16px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;font-family:'Inter',system-ui,sans-serif;outline:none;transition:all .2s}
  .input-row input[type="text"]::placeholder{color:rgba(255,255,255,.2)}
  .input-row input[type="text"]:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(129,140,248,.1)}
  .input-row input[type="text"].error{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
  .capture-btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;font-size:14px;font-weight:600;border:none;cursor:pointer;transition:all .25s;white-space:nowrap;font-family:'Inter',system-ui,sans-serif}
  .capture-btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(99,102,241,.35)}
  .capture-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
  .capture-btn .spinner{display:none;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite}
  .capture-btn.loading .spinner{display:inline-block}
  .capture-btn.loading .btn-text{display:none}

  /* Options row */
  .options-row{display:flex;gap:20px;margin-top:14px;flex-wrap:wrap}
  .opt-group{display:flex;align-items:center;gap:8px}
  .opt-group label{font-size:12px;color:var(--muted)}
  .opt-group select,.opt-group input{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px 8px;color:var(--text);font-size:12px;font-family:'Inter',system-ui,sans-serif;outline:none}
  .opt-group select:focus,.opt-group input:focus{border-color:var(--accent)}
  .opt-group input[type="number"]{width:64px}

  /* Status bar */
  .status-bar{display:none;align-items:center;gap:10px;margin-top:12px;padding:10px 14px;border-radius:var(--radius-sm);font-size:12px}
  .status-bar.show{display:flex}
  .status-bar.info{background:rgba(129,140,248,.08);border:1px solid rgba(129,140,248,.15);color:var(--accent)}
  .status-bar.success{background:rgba(52,211,153,.08);border:1px solid rgba(52,211,153,.15);color:var(--green)}
  .status-bar.error{background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.15);color:#f87171}
  .status-bar .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
  .status-bar.info .status-dot{background:var(--accent);animation:pulse 1.5s ease-in-out infinite}
  .status-bar.success .status-dot{background:var(--green)}
  .status-bar.error .status-dot{background:#ef4444}
  .status-bar .status-close{margin-left:auto;cursor:pointer;opacity:.5;font-size:14px;background:none;border:none;color:inherit;font-family:inherit}
  .status-bar .status-close:hover{opacity:1}

  /* Result area */
  .result-area{display:none;margin-top:24px;animation:fadeUp .5s ease-out}
  .result-area.show{display:block}
  .result-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
  .result-meta{display:flex;gap:16px;padding:14px 20px;border-bottom:1px solid var(--border);flex-wrap:wrap;font-size:12px;color:var(--muted);align-items:center}
  .result-meta .meta-item{display:flex;align-items:center;gap:4px}
  .result-meta .meta-item strong{color:var(--text);font-weight:600}
  .result-meta .spacer{flex:1}
  .result-meta .download-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:6px;background:rgba(129,140,248,.1);color:var(--accent);font-size:11px;font-weight:500;text-decoration:none;border:1px solid rgba(129,140,248,.2);transition:all .15s}
  .result-meta .download-btn:hover{background:rgba(129,140,248,.18)}
  .result-meta .copy-url-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:6px;background:rgba(255,255,255,.04);color:var(--muted);font-size:11px;font-weight:500;border:1px solid var(--border);cursor:pointer;font-family:'Inter',system-ui,sans-serif;transition:all .15s}
  .result-meta .copy-url-btn:hover{color:var(--text);border-color:var(--accent);background:rgba(129,140,248,.08)}
  .result-image-wrap{position:relative;overflow:auto;max-height:80vh;background:repeating-conic-gradient(rgba(255,255,255,.03) 0% 25%,transparent 0% 50%) 0 0/20px 20px}
  .result-image-wrap img{display:block;width:100%;max-width:none}
  .result-image-wrap .loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg2);font-size:13px;color:var(--muted);gap:8px;z-index:2}
  .result-image-wrap .loading-overlay .spinner-s{width:14px;height:14px;border:2px solid rgba(255,255,255,.1);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}

  /* Divider */
  .divider{display:flex;align-items:center;gap:12px;padding:20px 0;color:var(--border);font-size:10px;text-transform:uppercase;letter-spacing:1px}
  .divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}

  /* Footer */
  .footer{text-align:center;padding:40px 24px 60px;font-size:12px;color:var(--muted)}
  .footer a{color:var(--accent);text-decoration:none}
  .footer a:hover{text-decoration:underline}

  /* Responsive */
  @media(max-width:640px){
    .input-row{flex-direction:column}
    .capture-btn{justify-content:center}
    .options-row{flex-direction:column;gap:10px}
  }
</style>
</head>
<body>
<div class="noise"></div>

<div class="hero anim-fade">
  <div class="bg-blobs">
    <div class="blob b1"></div>
    <div class="blob b2"></div>
    <div class="blob b3"></div>
  </div>
  <h1><span class="highlight">Capture</span></h1>
  <p>Full-page screenshots of any URL — scroll, stitch, done.</p>
</div>

<div class="capture-section anim-fade anim-fade-d1">
  <div class="input-card">
    <div class="input-label">🔗 URL to capture</div>
    <div class="input-row">
      <input type="text" id="urlInput" placeholder="https://example.com" onkeydown="if(event.key==='Enter') startCapture()">
      <button class="capture-btn" id="captureBtn" onclick="startCapture()">
        <span class="spinner"></span>
        <span class="btn-text">📸 Capture</span>
      </button>
    </div>
    <div class="options-row">
      <div class="opt-group">
        <label>Width</label>
        <select id="widthSelect">
          <option value="1440">1440px (Desktop)</option>
          <option value="1280">1280px</option>
          <option value="1024">1024px (Tablet)</option>
          <option value="768">768px</option>
          <option value="414">414px (Mobile)</option>
        </select>
      </div>
      <div class="opt-group">
        <label>Wait</label>
        <select id="waitSelect">
          <option value="500">500ms</option>
          <option value="1500" selected>1.5s</option>
          <option value="3000">3s</option>
          <option value="5000">5s</option>
        </select>
      </div>
    </div>

    <div class="status-bar" id="statusBar">
      <span class="status-dot"></span>
      <span class="status-text" id="statusText">Ready</span>
      <button class="status-close" onclick="hideStatus()">✕</button>
    </div>
  </div>

  <div class="result-area" id="resultArea">
    <div class="result-card">
      <div class="result-meta" id="resultMeta"></div>
      <div class="result-image-wrap" id="resultImageWrap">
        <div class="loading-overlay" id="imgLoading">
          <span class="spinner-s"></span>
          Loading image...
        </div>
        <img id="resultImage" src="" alt="Screenshot" onload="document.getElementById('imgLoading').style.display='none'">
      </div>
    </div>
  </div>
</div>

<div class="divider anim-fade anim-fade-d2">powered by Playwright</div>

<div class="footer anim-fade anim-fade-d2">
  <a href="https://preview.aivibehosting.com">Preview Dashboard</a> &middot;
  <a href="https://pcm.aivibehosting.com">PCM</a>
</div>

<script>
function showStatus(msg, type) {
  const bar = document.getElementById('statusBar');
  const text = document.getElementById('statusText');
  bar.className = 'status-bar show ' + type;
  text.textContent = msg;
}

function hideStatus() {
  document.getElementById('statusBar').classList.remove('show');
}

function copyUrl(filename) {
  const url = window.location.origin + '/screenshots/' + filename;
  navigator.clipboard.writeText(url).then(() => {
    showStatus('🔗 URL copied to clipboard!', 'success');
  }).catch(() => {
    // Fallback
    const ta = document.createElement('textarea');
    ta.value = url;
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    document.body.removeChild(ta);
    showStatus('🔗 URL copied!', 'success');
  });
}

async function startCapture() {
  const url = document.getElementById('urlInput').value.trim();
  const btn = document.getElementById('captureBtn');
  const width = parseInt(document.getElementById('widthSelect').value);
  const waitMs = parseInt(document.getElementById('waitSelect').value);
  const input = document.getElementById('urlInput');

  if (!url) {
    input.classList.add('error');
    showStatus('Please enter a URL', 'error');
    setTimeout(() => input.classList.remove('error'), 2000);
    return;
  }

  // Basic URL validation
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
    input.classList.add('error');
    showStatus('URL must start with http:// or https://', 'error');
    setTimeout(() => input.classList.remove('error'), 2000);
    return;
  }

  btn.classList.add('loading');
  btn.disabled = true;
  showStatus('Capturing ' + url + ' ...', 'info');

  try {
    const res = await fetch('/api/capture', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url, width, wait_ms: waitMs }),
    });

    const data = await res.json();

    if (!data.success) {
      showStatus(data.error || 'Capture failed', 'error');
      btn.classList.remove('loading');
      btn.disabled = false;
      return;
    }

    // Show result
    const resultArea = document.getElementById('resultArea');
    const resultMeta = document.getElementById('resultMeta');
    const resultImage = document.getElementById('resultImage');
    const imgLoading = document.getElementById('imgLoading');

    resultMeta.innerHTML = \`
      <span class="meta-item">📄 <strong>\${data.title}</strong></span>
      <span class="meta-item">📐 \${data.scroll_height}px × \${data.viewport_width}px</span>
      <span class="meta-item">🧩 \${data.stitched} panels</span>
      <span class="meta-item">💾 \${data.file_size_kb}KB</span>
      <span class="spacer"></span>
      <button class="copy-url-btn" onclick="copyUrl('\${data.filename}')">🔗 Copy URL</button>
      <a class="download-btn" href="/screenshots/\${data.filename}" download="\${data.filename}">⬇ Download PNG</a>
    \`;

    imgLoading.style.display = 'flex';
    resultImage.src = '/screenshots/' + data.filename + '?t=' + Date.now();
    resultArea.classList.add('show');

    showStatus('✅ Captured! Scroll height: ' + data.scroll_height + 'px', 'success');
  } catch (err) {
    showStatus('Error: ' + err.message, 'error');
  }

  btn.classList.remove('loading');
  btn.disabled = false;
}
</script>
</body>
</html>`;

// ── HTTP Server ───────────────────────────────────────────────────────────

const server = http.createServer(async (req, res) => {
  const parsed = url.parse(req.url, true);
  const pathname = parsed.pathname;

  // ── POST /api/capture ──────────────────────────────────────
  if (pathname === '/api/capture' && req.method === 'POST') {
    let body = '';
    req.on('data', c => (body += c));
    req.on('end', async () => {
      try {
        const data = JSON.parse(body);
        if (!data.url) {
          return sendJSON(res, { success: false, error: 'Missing url' }, 400);
        }

        // SSRF guard: block internal/private network targets
        const lowerUrl = data.url.toLowerCase();
        const blockedPatterns = ['localhost', '127.0.0.1', '169.254.169.254', '0.0.0.0', '10.', '172.16.', '192.168.'];
        if (blockedPatterns.some(p => lowerUrl.includes(p))) {
          return sendJSON(res, { success: false, error: 'Internal URL not allowed' }, 403);
        }

        const result = await capturePage(
          data.url,
          data.width || 1440,
          data.wait_ms || 1500
        );

        sendJSON(res, {
          success: true,
          filename: result.filename,
          title: result.title,
          scroll_height: result.scrollHeight,
          viewport_width: data.width || 1440,
          stitched: result.stitched,
          file_size_kb: result.fileSizeKb,
          image_url: '/screenshots/' + result.filename,
          quality: result.quality,
        });
      } catch (e) {
        sendJSON(res, { success: false, error: e.message }, 500);
      }
    });
    return;
  }

  // ── Serve screenshots ──────────────────────────────────────
  if (pathname.startsWith('/screenshots/')) {
    const filePath = path.join(SCREENSHOT_DIR, pathname.replace('/screenshots/', ''));
    if (filePath.startsWith(SCREENSHOT_DIR) && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
      return sendFile(res, filePath);
    }
    return sendJSON(res, { error: 'Not found' }, 404);
  }

  // ── Serve main page ────────────────────────────────────────
  sendHTML(res, HTML_PAGE);
});

server.listen(PORT, () => {
  console.error(`Capture server running on port ${PORT}`);
});