← 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')}`);