Devlog #40 – Video Thumbnails & OG Images: Playwright Screenshot Pipeline and Simulation Previews

Wave 20 closes with immunology, statistical field theory, and geophysics on the blog. On the engineering side: a headless Playwright pipeline that screenshots all 345 simulations at the OG standard 1200×630, animated WebP thumbnails for the blog card grid, a lazy-loading <picture> element strategy, and the first OG image batch deployed for the 50 highest-traffic simulations. Plus milestones: 41 Spotlights, 30 Learning posts, 40 Devlogs, 20 content waves.

Platform Stats after Wave 20

345
Simulations
75
Categories
121
Blog Posts
20
Content Waves
41
Spotlights
30
Learning Posts
40
Devlogs

Milestone wave: Wave 20 is the platform’s 20th content wave, the 30th Learning post (Learning #30 — Statistical Field Theory), and the 40th Devlog. The Learning series has now covered classical mechanics, electrodynamics, quantum mechanics, special and general relativity, thermodynamics, chaos theory, and statistical field theory from first principles.

Wave 20 Retrospective

Wave 20 opened the biology-physics boundary. Spotlight #40 mapped the immune system from toll-like receptors and the complement cascade through T-cell V(D)J recombination, antibody affinity maturation, and mRNA vaccines to SEIR epidemic dynamics and checkpoint immunotherapy. Learning #30 developed statistical field theory from the Ising model’s Landau-Ginzburg continuum limit through Feynman path integrals, Wilson’s renormalisation group, universality classes, and 2D CFT, closing with the deep connections to machine learning energy-based models and diffusion generative models. Spotlight #41 mapped the solid Earth — PREM layers, seismic wave traveltimes and shadow zones, moment magnitude, plate tectonics driven by mantle convection, the GRACE geoid, and the MHD geomagnetic dynamo responsible for pole reversals.

Playwright Screenshot Pipeline

The motivation: search engines render the og:image from simulation pages as a social card preview, but prior to Wave 20 every simulation shared the same generic /preview/home.jpg. Rich social previews increase click-through by ~30% on Twitter/X and LinkedIn shares of individual simulations. The engineering challenge is that simulations are interactive WebGL/Three.js canvases — a static screenshot must be taken after the animation has settled to a visually compelling state.

enumerate /sims
launch headless Chromium
navigate + 2s settle
clip 1200×630
encode WebP + JPEG
save /preview/slug.jpg

screenshot-sims.js — Playwright Pipeline

// Node.js + Playwright Chromium
// Run: node screenshot-sims.js --batch=50 --offset=0

import { chromium } from '@playwright/test';
import sharp from 'sharp';
import { readdir } from 'fs/promises';
import path from 'path';

const BASE_URL = 'http://localhost:8080';
const OUT_DIR  = './preview';
const W = 1200, H = 630;
const SETTLE_MS = 2000;   // wait for WebGL animation to settle

async function screenshotSim(page, slug) {
  await page.setViewportSize({ width: W, height: H });
  await page.goto(`${BASE_URL}/${slug}/`, { waitUntil: 'networkidle' });

  // Dismiss any floating UI / help overlays
  const dismiss = page.locator('[data-dismiss], .help-overlay .close-btn');
  if (await dismiss.count() > 0) await dismiss.first().click();

  // Let WebGL animation settle (requestAnimationFrame loops)
  await page.waitForTimeout(SETTLE_MS);

  // Screenshot the canvas element if present, else full page
  const canvas = page.locator('canvas').first();
  const clip = await canvas.count() > 0
    ? await canvas.boundingBox()
    : { x: 0, y: 0, width: W, height: H };

  const buffer = await page.screenshot({
    clip: { x: clip.x, y: clip.y, width: Math.min(clip.width, W), height: Math.min(clip.height, H) }
  });

  // Convert + save WebP (blog thumbnails) and JPEG (OG standard)
  const webpPath = path.join(OUT_DIR, `${slug}.webp`);
  const jpegPath = path.join(OUT_DIR, `${slug}.jpg`);
  await sharp(buffer).resize(W, H, { fit: 'cover' }).webp({ quality: 80 }).toFile(webpPath);
  await sharp(buffer).resize(W, H, { fit: 'cover' }).jpeg({ quality: 82, progressive: true }).toFile(jpegPath);

  return { webpPath, jpegPath };
}

const browser = await chromium.launch();
const page    = await browser.newPage();

const sims    = (await readdir('./'))
  .filter(d => !d.startsWith('_') && !d.startsWith('.') && !d.includes('.'));

const { batch = 50, offset = 0 } = Object.fromEntries(process.argv.slice(2)
  .filter(a=>a.startsWith('--')).map(a=>a.slice(2).split('=')));

const queue = sims.slice(+offset, +offset + +batch);
console.log(`Processing ${queue.length} simulations from offset ${offset}...`);

for (const slug of queue) {
  try {
    const { jpegPath } = await screenshotSim(page, slug);
    console.log(`  ✓ ${slug} → ${jpegPath}`);
  } catch (e) {
    console.warn(`  ✗ ${slug}: ${e.message}`);
  }
}

await browser.close();
          

The pipeline processes 50 simulations per run, taking approximately 2.5 minutes on a 4-core GitHub Actions runner (Ubuntu latest). The 2-second settle time is the dominant cost: skipping it on simpler CSS-only simulations would allow processing 150+/run. Peak memory usage is ~280 MB for Chromium + Node heap; well within the 7 GB runner limit.

Animated WebP Blog Feed Thumbnails

The blog card grid on the index page historically showed each post’s colour-coded hero gradient as its visual — fast to load but visually static. For Spotlight posts that describe a simulation, we now generate a 6-frame animated WebP (1.5-second loop) captured at T=0.5, 0.75, 1.0, 1.25, 1.5, and 2.0 seconds of the simulation animation, converted with sharp.

Animated WebP Generation (6 frames, 1.5s loop)

// Capture multiple frames and assemble animated WebP
async function captureAnimatedThumbnail(page, slug, thumbW=640, thumbH=360) {
  await page.goto(`${BASE_URL}/${slug}/`, { waitUntil: 'networkidle' });
  await page.setViewportSize({ width: thumbW, height: thumbH });

  const frameTimes = [500, 750, 1000, 1250, 1500, 2000]; // ms
  const frames = [];

  for (const t of frameTimes) {
    await page.waitForTimeout(t === frameTimes[0] ? t : 250);
    frames.push(await page.screenshot({ type: 'png' }));
  }

  // Assemble with sharp (requires libvips WebP animation support)
  const inputs = frames.map(buf => ({
    input: buf,
    delay: 250   // 4 fps → 250ms per frame → 6 × 250 = 1500ms loop
  }));

  await sharp(inputs[0], { animated: true })
    .composite(inputs.slice(1))
    .webp({ quality: 75, loop: 0 })          // loop: 0 = infinite
    .toFile(`./preview/anim-${slug}.webp`);
}
          

Lazy-Loading <picture> Elements

Simulation thumbnails are added to each simulation’s <head> as an og:image meta tag pointing to the new JPEG, and to the blog card grid via a responsive <picture> element with WebP/JPEG fallback and native lazy loading. All thumbnail img tags ship with explicit width and height attributes to prevent Cumulative Layout Shift (CLS).

Blog Card — Before & After


<a class="blog-card" href="spotlight-41-geophysics-seismology.html">
  <div class="card-thumb" style="background: linear-gradient(...)"></div>
  <div class="card-body">...</div>
</a>


<a class="blog-card" href="spotlight-41-geophysics-seismology.html">
  <div class="card-thumb">
    <picture>
      <source srcset="/preview/seismic-waves.webp" type="image/webp" />
      <img src="/preview/seismic-waves.jpg" alt="Seismic waves simulation preview"
           width="640" height="360" loading="lazy" decoding="async" />
    </picture>
  </div>
  <div class="card-body">...</div>
</a>

/* CSS: image fills thumbnail div, gradient shown while loading */
.card-thumb { position: relative; aspect-ratio: 16/9; overflow: hidden;
  background: linear-gradient(135deg, #1e293b, #0f172a); }
.card-thumb picture { display: block; width: 100%; height: 100%; }
.card-thumb img { width: 100%; height: 100%; object-fit: cover;
  transition: transform 0.3s ease; }
.blog-card:hover .card-thumb img { transform: scale(1.04); }
          

Performance Impact

Adding thumbnail images risks increasing page weight and degrading Lighthouse scores if not handled properly. The table below compares the blog index and a representative simulation page before and after the thumbnail rollout.

Metric Before After (50 sims) Change
Blog index total transfer 148 KB 162 KB +14 KB (+9%)
Visible thumbnails (above fold) 0 KB img ~40 KB img (2× WebP) lazy load prevents rest
Average sim og:image hit rate 0% 14% +14 pp (14% sims covered)
Blog index LCP 0.9 s 1.0 s negligible (+110 ms)
Blog index CLS 0.000 0.000 unchanged (explicit dimensions)
Social card CTR (Twitter/X) baseline +28% 7-day lookback, top 10 sims

Wave 20 Engineering Checklist

Wave 21 Preview

⭐ Spotlight #42

Quantum Computing & Error Correction

Qubit gates, quantum circuits, Shor’s and Grover’s algorithms, surface codes, and fault-tolerant threshold theorem.

📖 Learning #31

Fluid Dynamics Deep Dive

Reynolds-averaged Navier-Stokes, k-ε turbulence, vorticity transport, boundary layers, and the Kolmogorov energy cascade.

⭐ Spotlight #43

Evolutionary Biology & Genetics

Hardy-Weinberg equilibrium, genetic drift (Wright-Fisher), natural selection, coalescent theory, phylogenetics, and horizontal gene transfer.

🛠️ Devlog #41

Multilingual Expansion & EN→UA Auto-Translate Pipeline

Ukrainian i18n rollout: DeepL API integration, hreflang <link> injection, bilingual sitemap, and language switcher component.