Девлог #40 — Відеомініатюри та OG-зображення: конвеєр знімків Playwright і прев'ю симуляцій

Хвиля 20 завершується імунологією, статистичною теорією поля та геофізикою в блозі. З інженерного боку: headless-конвеєр Playwright, що робить знімки всіх 345 симуляцій за стандартом OG 1200×630, анімовані WebP-мініатюри для сітки карток блогу, стратегія ліниво завантажуваного елемента <picture>, і перша партія OG-зображень, розгорнута для 50 симуляцій з найбільшим трафіком. Плюс віхи: 41 спотлайт, 30 навчальних дописів, 40 девлогів, 20 хвиль контенту.

Статистика платформи після хвилі 20

345
Симуляцій
75
Категорій
121
Статей блогу
20
Хвиль контенту
41
Спотлайтів
30
Навчальних дописів
40
Девлогів

Хвиля-віха: хвиля 20 — це 20-та хвиля контенту платформи, 30-й навчальний допис (Навчальний #30 — Статистична теорія поля) і 40-й девлог. Навчальна серія тепер охоплює класичну механіку, електродинаміку, квантову механіку, спеціальну й загальну теорію відносності, термодинаміку, теорію хаосу та статистичну теорію поля «з нуля».

Ретроспектива хвилі 20

Хвиля 20 відкрила межу між біологією та фізикою. Спотлайт #40 відобразив імунну систему від толл-подібних рецепторів і комплементарного каскаду через V(D)J-рекомбінацію Т-клітин, дозрівання афінності антитіл і мРНК-вакцини до епідемічної динаміки SEIR та імунотерапії контрольних точок. Навчальний #30 розвинув статистичну теорію поля від континуального наближення Ландау-Гінзбурга моделі Ізінга через фейнманівські інтеграли по траєкторіях, ренормалізаційну групу Вілсона, класи універсальності та 2D-КТП, завершуючись глибокими зв'язками з енергетичними моделями машинного навчання й дифузійними генеративними моделями. Спотлайт #41 відобразив тверду Землю — шари PREM, час пробігу сейсмічних хвиль і тіньові зони, момент магнітуди, тектоніку плит, зумовлену мантійною конвекцією, геоїд GRACE та МГД-геомагнітне динамо, відповідальне за інверсії полюсів.

Конвеєр знімків Playwright

Мотивація: пошукові системи рендерять og:image зі сторінок симуляцій як прев'ю картки в соцмережах, але до хвилі 20 усі симуляції ділили одне загальне зображення /preview/home.jpg. Насичені соціальні прев'ю підвищують клікабельність приблизно на 30% у публікаціях окремих симуляцій у Twitter/X і LinkedIn. Інженерний виклик у тому, що симуляції — це інтерактивні полотна WebGL/Three.js, тож статичний знімок потрібно робити після того, як анімація стабілізується у візуально привабливому стані.

перелічити /sims
запустити headless Chromium
перейти + пауза 2с
обрізати 1200×630
кодувати WebP + JPEG
зберегти /preview/slug.jpg

screenshot-sims.js — конвеєр Playwright

// 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();
          

Конвеєр обробляє 50 симуляцій за один запуск, що займає приблизно 2,5 хвилини на 4-ядерному CI-раннері. Двосекундна пауза на стабілізацію — головна стаття витрат: якщо пропустити її для простіших CSS-симуляцій, можна було б обробляти понад 150 за запуск. Пікове споживання пам'яті — близько 280 МБ для Chromium і купи Node.js — це значно менше за ліміт раннера в 7 ГБ.

Анімовані WebP-мініатюри стрічки блогу

Сітка карток блогу на головній сторінці історично показувала кольоровий градієнт-«хіро» кожного допису як зображення — швидко завантажується, але візуально статично. Для спотлайт-дописів, що описують симуляцію, тепер генерується 6-кадрове анімоване WebP (цикл 1,5 секунди), захоплене на T=0.5, 0.75, 1.0, 1.25, 1.5 і 2.0 секунди анімації симуляції, конвертоване за допомогою sharp.

Генерація анімованого WebP (6 кадрів, цикл 1,5с)

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

Ліниво завантажувані елементи <picture>

Мініатюри симуляцій додаються до <head> кожної сторінки симуляції як тег og:image, що вказує на нове зображення JPEG, а до сітки карток блогу — через адаптивний елемент <picture> із запасним варіантом WebP/JPEG і нативним лінивим завантаженням. Усі теги img мініатюр мають явні атрибути width і height, щоб запобігти накопичувальному зсуву макета (CLS).

Картка блогу — до й після


<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); }
          

Вплив на продуктивність

Додавання зображень-мініатюр несе ризик збільшення ваги сторінки й погіршення показників Lighthouse, якщо це зробити неохайно. Таблиця нижче порівнює головну сторінку блогу і типову сторінку симуляції до та після впровадження мініатюр.

Метрика До Після (50 симуляцій) Зміна
Загальний обсяг передачі головної сторінки блогу 148 КБ 162 КБ +14 КБ (+9%)
Видимі мініатюри (над згином) 0 КБ img ~40 КБ img (2× WebP) ліниве завантаження стримує решту
Середня частка симуляцій із og:image 0% 14% +14 п.п. (охоплено 14% симуляцій)
LCP головної сторінки блогу 0,9 с 1,0 с незначно (+110 мс)
CLS головної сторінки блогу 0,000 0,000 без змін (явні розміри)
CTR соціальних карток (Twitter/X) базовий рівень +28% 7-денний період, топ-10 симуляцій

Інженерний чекліст хвилі 20

Анонс хвилі 21

⭐ Спотлайт #42

Квантові обчислення й корекція помилок

Кубітні вентилі, квантові схеми, алгоритми Шора та Гровера, поверхневі коди й теорема про порогове значення відмовостійкості.

📖 Навчальний #31

Глибоке занурення в динаміку рідин

Осереднені за Рейнольдсом рівняння Нав'є-Стокса, k-ε турбулентність, перенесення завихреності, примежові шари й каскад енергії Колмогорова.

⭐ Спотлайт #43

Еволюційна біологія та генетика

Рівновага Харді-Вайнберга, генетичний дрейф (Райта-Фішера), природний добір, теорія коалесценції, філогенетика й горизонтальний перенос генів.

🛠️ Девлог #41

Багатомовне розширення та конвеєр авто-перекладу EN→UA

Розгортання української локалізації: інтеграція API DeepL, впровадження тегів <link> hreflang, двомовна мапа сайту та компонент перемикача мов.