Devlog #40 – Miniatury wideo i obrazy OG: potok zrzutów ekranu Playwright i podglądy symulacji

Fala 20 zamyka się immunologią, statystyczną teorią pola i geofizyką na blogu. Od strony inżynierskiej: headless'owy potok Playwright, który wykonuje zrzuty ekranu wszystkich 345 symulacji w standardzie OG 1200×630, animowane miniatury WebP dla siatki kart bloga, strategia leniwie ładowanych elementów <picture> oraz pierwsza partia obrazów OG wdrożona dla 50 symulacji o najwyższym ruchu. Do tego kamienie milowe: 41 Spotlight, 30 wpisów Learning, 40 Devlogów, 20 fal treści.

Statystyki platformy po Fali 20

345
Symulacje
75
Kategorie
121
Wpisy na blogu
20
Fale treści
41
Spotlight
30
Wpisy Learning
40
Devlogi

Fala kamieni milowych: Fala 20 to 20. fala treści platformy, 30. wpis Learning (Learning #30 — Statystyczna teoria pola) i 40. Devlog. Seria Learning objęła już mechanikę klasyczną, elektrodynamikę, mechanikę kwantową, szczególną i ogólną teorię względności, termodynamikę, teorię chaosu i statystyczną teorię pola od podstaw.

Podsumowanie Fali 20

Fala 20 otworzyła granicę biologii i fizyki. Spotlight #40 zmapował układ odpornościowy — od receptorów Toll-podobnych i kaskady dopełniacza przez rekombinację V(D)J limfocytów T, dojrzewanie powinowactwa przeciwciał i szczepionki mRNA po dynamikę epidemii SEIR i immunoterapię punktów kontrolnych. Learning #30 rozwinął statystyczną teorię pola od kontinualnej granicy Landaua-Ginzburga modelu Isinga przez całki po trajektoriach Feynmana, grupę renormalizacji Wilsona, klasy uniwersalności i dwuwymiarową CFT, kończąc na głębokich powiązaniach z modelami energetycznymi uczenia maszynowego i generatywnymi modelami dyfuzyjnymi. Spotlight #41 zmapował wnętrze Ziemi — warstwy PREM, czasy przebiegu fal sejsmicznych i strefy cienia, momentową magnitudę, tektonikę płyt napędzaną konwekcją płaszcza, geoidę GRACE oraz magnetohydrodynamiczne dynamo geomagnetyczne odpowiedzialne za odwrócenia biegunów.

Potok zrzutów ekranu Playwright

Motywacja: wyszukiwarki renderują og:image ze stron symulacji jako podgląd karty społecznościowej, ale przed Falą 20 każda symulacja dzieliła ten sam ogólny obraz /preview/home.jpg. Bogate podglądy społecznościowe zwiększają współczynnik klikalności o ~30% w udostępnieniach poszczególnych symulacji na Twitterze/X i LinkedIn. Wyzwanie inżynierskie polega na tym, że symulacje to interaktywne płótna WebGL/Three.js — statyczny zrzut ekranu musi zostać wykonany po ustabilizowaniu się animacji w wizualnie atrakcyjnym stanie.

enumeracja /sims
uruchom headless Chromium
nawigacja + 2s ustabilizowania
przycięcie 1200×630
kodowanie WebP + JPEG
zapis /preview/slug.jpg

screenshot-sims.js — potok Playwright

// Node.js + Playwright Chromium
// Uruchomienie: 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;   // czekaj, aż animacja WebGL się ustabilizuje

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

  // Zamknij ewentualne pływające UI / nakładki pomocy
  const dismiss = page.locator('[data-dismiss], .help-overlay .close-btn');
  if (await dismiss.count() > 0) await dismiss.first().click();

  // Poczekaj, aż animacja WebGL się ustabilizuje (pętle requestAnimationFrame)
  await page.waitForTimeout(SETTLE_MS);

  // Zrzut ekranu elementu canvas, jeśli istnieje, w przeciwnym razie całej strony
  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) }
  });

  // Konwersja + zapis WebP (miniatury bloga) i JPEG (standard OG)
  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(`Przetwarzanie ${queue.length} symulacji od offsetu ${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();
          

Potok przetwarza 50 symulacji na uruchomienie, co zajmuje około 2,5 minuty na 4-rdzeniowym runnerze CI. Dominującym kosztem jest 2-sekundowy czas ustabilizowania — pominięcie go w prostszych symulacjach opartych wyłącznie na CSS pozwoliłoby przetworzyć 150+ na uruchomienie. Szczytowe zużycie pamięci wynosi ~280 MB dla Chromium + stosu Node; z dużym zapasem w limicie 7 GB runnera.

Animowane miniatury WebP dla kanału bloga

Siatka kart bloga na stronie indeksu historycznie pokazywała kolorowy gradient bohatera każdego wpisu jako element wizualny — szybki do załadowania, ale statyczny wizualnie. Dla wpisów Spotlight opisujących symulację generujemy teraz 6-klatkowy animowany WebP (pętla 1,5 s) przechwycony w chwilach T=0,5, 0,75, 1,0, 1,25, 1,5 i 2,0 s animacji symulacji, konwertowany za pomocą sharp.

Generowanie animowanego WebP (6 klatek, pętla 1,5 s)

// Przechwycenie wielu klatek i złożenie animowanego 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' }));
  }

  // Złożenie za pomocą sharp (wymaga obsługi animacji WebP w libvips)
  const inputs = frames.map(buf => ({
    input: buf,
    delay: 250   // 4 fps → 250ms na klatkę → 6 × 250 = 1500ms pętli
  }));

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

Leniwie ładowane elementy <picture>

Miniatury symulacji dodawane są do <head> każdej strony symulacji jako znacznik meta og:image wskazujący na nowy JPEG oraz do siatki kart bloga poprzez responsywny element <picture> z fallbackiem WebP/JPEG i natywnym leniwym ładowaniem. Wszystkie znaczniki img miniatur mają jawne atrybuty width i height, aby zapobiec skumulowanemu przesunięciu layoutu (CLS).

Karta bloga — przed i po


<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="Podgląd symulacji fal sejsmicznych"
           width="640" height="360" loading="lazy" decoding="async" />
    </picture>
  </div>
  <div class="card-body">...</div>
</a>

/* CSS: obraz wypełnia div miniatury, gradient widoczny podczas ładowania */
.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); }
          

Wpływ na wydajność

Dodanie obrazów miniatur niesie ryzyko zwiększenia wagi strony i pogorszenia wyników Lighthouse, jeśli nie zostanie odpowiednio obsłużone. Poniższa tabela porównuje indeks bloga i reprezentatywną stronę symulacji przed i po wdrożeniu miniatur.

Metryka Przed Po (50 symulacji) Zmiana
Łączny transfer indeksu bloga 148 KB 162 KB +14 KB (+9%)
Widoczne miniatury (powyżej zakrętu) 0 KB obrazu ~40 KB obrazu (2× WebP) leniwe ładowanie zapobiega reszcie
Średni wskaźnik trafień og:image symulacji 0% 14% +14 pp (14% symulacji objęte)
LCP indeksu bloga 0,9 s 1,0 s pomijalne (+110 ms)
CLS indeksu bloga 0,000 0,000 bez zmian (jawne wymiary)
CTR karty społecznościowej (Twitter/X) wartość bazowa +28% 7-dniowy przegląd, top 10 symulacji

Lista kontrolna inżynierska Fali 20

Zapowiedź Fali 21

⭐ Spotlight #42

Obliczenia kwantowe i korekcja błędów

Bramki kubitowe, obwody kwantowe, algorytmy Shora i Grovera, kody powierzchniowe i twierdzenie o progu odporności na błędy.

📖 Learning #31

Głębokie zanurzenie w dynamikę płynów

Równania Naviera-Stokesa uśrednione Reynoldsem, turbulencja k-ε, transport wirowości, warstwy przyścienne i kaskada energii Kołmogorowa.

⭐ Spotlight #43

Biologia ewolucyjna i genetyka

Równowaga Hardy'ego-Weinberga, dryf genetyczny (Wright-Fisher), dobór naturalny, teoria koalescencji, filogenetyka i horyzontalny transfer genów.

🛠️ Devlog #41

Ekspansja wielojęzyczna i potok automatycznego tłumaczenia EN→UA

Wdrożenie i18n dla ukraińskiego: integracja API DeepL, wstrzykiwanie znaczników <link> hreflang, dwujęzyczna mapa witryny i komponent przełącznika języka.