← back to Wallpaper Tour

tour.mjs

186 lines

#!/usr/bin/env node
// Wallpaper portfolio tour — sequential Playwright video capture
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Use the playwright already installed under ~/Projects/animals (CJS bridge)
import { createRequire } from 'node:module';
const require = createRequire('/Users/macstudio3/Projects/animals/');
const { chromium } = require('playwright');

const live = fs.readFileSync(path.join(__dirname, 'live-urls.txt'), 'utf8')
  .split('\n').map(s => s.trim()).filter(Boolean)
  .map(line => {
    const [name, url] = line.split('\t');
    return { name, url };
  });

const CLIPS_DIR = path.join(__dirname, 'clips');
fs.mkdirSync(CLIPS_DIR, { recursive: true });

const VIEWPORT = { width: 1600, height: 900 };
const NAV_TIMEOUT_MS = 20000;
const HARD_FIRST_PAINT_MS = 25000;
const HERO_SETTLE_MS = 1000;
const FRONT_PAGE_DWELL_MS = 3500;
const POST_CLICK_DWELL_MS = 2500;
const RUN_BUDGET_MS = 60 * 60 * 1000; // 60 minutes (resume budget)

const startTime = Date.now();
const report = [];

function safeName(s) {
  return s.replace(/[^a-z0-9-]/gi, '_');
}

async function launchBrowserWithRetry() {
  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      return await chromium.launch({ headless: true });
    } catch (e) {
      console.log(`  Playwright launch failed (attempt ${attempt}): ${e.message}`);
      if (attempt === 2) throw e;
      await new Promise(r => setTimeout(r, 5000));
    }
  }
}

async function captureSite(browser, site, index) {
  const t0 = Date.now();
  const tag = `${String(index).padStart(2, '0')}-${safeName(site.name)}`;
  const finalClip = path.join(CLIPS_DIR, `${tag}.webm`);
  // Resume: if final clip already exists with reasonable size, skip
  if (fs.existsSync(finalClip) && fs.statSync(finalClip).size > 100_000) {
    return { index, name: site.name, url: site.url, clip: finalClip, error: null, ms: 0, resumed: true };
  }
  const siteClipDir = path.join(CLIPS_DIR, `_raw-${tag}`);
  // Clean any prior _raw dir from interrupted runs
  try { fs.rmSync(siteClipDir, { recursive: true, force: true }); } catch {}
  fs.mkdirSync(siteClipDir, { recursive: true });

  let ctx, page;
  const result = { index, name: site.name, url: site.url, clip: null, error: null, ms: 0 };
  try {
    ctx = await browser.newContext({
      viewport: VIEWPORT,
      deviceScaleFactor: 2,
      recordVideo: { dir: siteClipDir, size: VIEWPORT },
      userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    });
    page = await ctx.newPage();

    // Hard cap on first paint
    const navPromise = page.goto(site.url, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
    const hardCap = new Promise((_, rej) => setTimeout(() => rej(new Error('hard 30s cap')), HARD_FIRST_PAINT_MS));
    await Promise.race([navPromise, hardCap]);

    // Hero settle
    await page.waitForTimeout(HERO_SETTLE_MS);

    // Stay on front page
    await page.waitForTimeout(FRONT_PAGE_DWELL_MS);

    // Try to find a clickable interesting element (don't navigate away to a 3rd party)
    let clicked = false;
    const selectors = [
      'main a[href]:not([href^="#"]):not([href^="mailto"]):not([href^="tel"]) img',
      '.product-card a, .product a, [class*="product"] a img',
      'a.product-link, a[href*="/products/"]',
      'main img + *, main a img',
      'button:has-text("View Collection")',
      'a:has-text("View Collection")',
      'a:has-text("Shop")',
      'a:has-text("Explore")',
      'main a[href^="/"]:not([href="/"])',
    ];

    for (const sel of selectors) {
      try {
        const el = await page.locator(sel).first();
        if (await el.count() > 0 && await el.isVisible({ timeout: 1000 }).catch(() => false)) {
          await el.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
          await el.click({ timeout: 3000, noWaitAfter: true });
          clicked = true;
          break;
        }
      } catch (_) { /* try next selector */ }
    }

    if (!clicked) {
      // Fallback: scroll down 600px so the video shows more of the page
      await page.evaluate(() => window.scrollBy({ top: 600, behavior: 'smooth' })).catch(() => {});
    }

    await page.waitForTimeout(POST_CLICK_DWELL_MS);

    // If a click navigated, dwell a bit more on the destination
    if (clicked) {
      try { await page.waitForLoadState('domcontentloaded', { timeout: 3000 }); } catch (_) {}
      await page.waitForTimeout(1500);
    }

    await page.close();
    await ctx.close();

    // Find the saved video file in siteClipDir (Playwright names it randomly)
    const files = fs.readdirSync(siteClipDir).filter(f => f.endsWith('.webm'));
    if (files.length > 0) {
      const src = path.join(siteClipDir, files[0]);
      const dst = path.join(CLIPS_DIR, `${tag}.webm`);
      fs.renameSync(src, dst);
      result.clip = dst;
    }
    // Clean up the per-site dir
    try { fs.rmSync(siteClipDir, { recursive: true, force: true }); } catch (_) {}
  } catch (e) {
    result.error = e.message;
    try { if (page) await page.close(); } catch (_) {}
    try { if (ctx) await ctx.close(); } catch (_) {}
    // Save any partial clip
    try {
      const files = fs.existsSync(siteClipDir) ? fs.readdirSync(siteClipDir).filter(f => f.endsWith('.webm')) : [];
      if (files.length > 0) {
        const src = path.join(siteClipDir, files[0]);
        const stat = fs.statSync(src);
        if (stat.size > 50_000) {
          const dst = path.join(CLIPS_DIR, `${tag}.webm`);
          fs.renameSync(src, dst);
          result.clip = dst;
        }
      }
    } catch (_) {}
    try { fs.rmSync(siteClipDir, { recursive: true, force: true }); } catch (_) {}
  }
  result.ms = Date.now() - t0;
  return result;
}

const browser = await launchBrowserWithRetry();
console.log(`Playwright launched. ${live.length} sites to capture.\n`);

for (let i = 0; i < live.length; i++) {
  if (Date.now() - startTime > RUN_BUDGET_MS) {
    console.log(`\n!! 90m budget exceeded — stopping at ${i}/${live.length}`);
    break;
  }
  const site = live[i];
  process.stdout.write(`[${i + 1}/${live.length}] ${site.name} ... `);
  const r = await captureSite(browser, site, i + 1);
  report.push(r);
  if (r.error) console.log(`FAIL ${r.ms}ms (${r.error.slice(0, 80)})${r.clip ? ' [partial saved]' : ''}`);
  else console.log(`OK ${r.ms}ms ${r.clip ? path.basename(r.clip) : '(no clip)'}`);

  // tiny breather to be gentle on overloaded box
  await new Promise(r => setTimeout(r, 250));
}

await browser.close();

fs.writeFileSync(path.join(__dirname, 'tour-report.json'), JSON.stringify(report, null, 2));
const ok = report.filter(r => r.clip);
console.log(`\nCAPTURED ${ok.length}/${report.length} clips`);
console.log(`Report: ${path.join(__dirname, 'tour-report.json')}`);