// scripts/measure-image-formats.mjs — from tinytinytools.com // Served verbatim from the working tree. Method: https://tinytinytools.com/guides /** * The measurement behind /guides/png-jpg-or-webp. * * Encodes three deterministic test images to PNG, JPEG and WebP in a real Chromium, using * the same `canvas.toBlob(type, quality)` path the image tools on this site use, and * prints the byte sizes as a JSON block to paste into * `src/lib/guides/image-format-sizes.ts`. * * Why generated images rather than photographs: a reader who wants to check these numbers * can regenerate byte-identical inputs from the seeded generator below and get the same * results on their own machine. "A photo from my phone" is not reproducible, and a * comparison nobody can repeat is worth about as much as one that was made up. * * The generators are deliberately crude models of three content classes rather than * attempts at realism. What is being measured is how each codec responds to smooth tonal * variation, to large areas of flat color with hard edges, and to small text — and those * properties survive the crudeness. Where the model's limits matter, the guide says so. * * Usage: node scripts/measure-image-formats.mjs */ import { chromium } from 'playwright'; /** Mulberry32 — small, seeded, and identical in every JS engine, so the noise repeats. */ const IMAGE_SOURCE = ` function rng(seed) { return function () { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } /** * Photographic: smooth gradients plus multi-octave value noise, with a little per-pixel * grain on top. The octaves matter. An earlier version of this generator used per-pixel * white noise alone, and that one choice inverted a headline result — WebP measured 4.7% * *larger* than JPEG at quality 85, where with spatially correlated detail it comes out * around 50% smaller. White noise is incompressible by construction and photographs are * not, so the first generator was measuring its own defect. The grain amplitude below is * deliberately small for the same reason. Run this script with --grain-sweep to repeat * the comparison across grain amplitudes, so the sensitivity stays visible rather than * being something I found once and then quietly dropped. */ function drawPhotograph(ctx, w, h, grainAmplitude) { const random = rng(1); const field = (size) => { const values = []; for (let i = 0; i < size * size; i++) values.push(random()); return { size, values }; }; const sample = (f, x, y) => { const fx = (x / w) * (f.size - 1), fy = (y / h) * (f.size - 1); const x0 = Math.floor(fx), y0 = Math.floor(fy), tx = fx - x0, ty = fy - y0; const at = (a, b) => f.values[Math.min(f.size - 1, b) * f.size + Math.min(f.size - 1, a)]; return (at(x0, y0) * (1 - tx) + at(x0 + 1, y0) * tx) * (1 - ty) + (at(x0, y0 + 1) * (1 - tx) + at(x0 + 1, y0 + 1) * tx) * ty; }; // Three octaves: broad tonal masses, mid-scale structure, fine detail. const octaves = [{ f: field(16), amp: 54 }, { f: field(64), amp: 26 }, { f: field(192), amp: 12 }]; const image = ctx.createImageData(w, h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = (y * w + x) * 4; const sweep = Math.sin(x / 180) * Math.cos(y / 140); let detail = 0; for (const octave of octaves) detail += (sample(octave.f, x, y) - 0.5) * octave.amp; const grain = (random() - 0.5) * (grainAmplitude === undefined ? 6 : grainAmplitude); const d = detail + grain; image.data[i] = Math.max(0, Math.min(255, 128 + sweep * 92 + d)); image.data[i + 1] = Math.max(0, Math.min(255, 118 + sweep * 60 + (x / w) * 70 + d)); image.data[i + 2] = Math.max(0, Math.min(255, 150 - sweep * 48 + (y / h) * 58 + d)); image.data[i + 3] = 255; } } ctx.putImageData(image, 0, 0); } /** Flat graphic: a handful of colors, hard edges, no texture. A logo or a chart. */ function drawFlatGraphic(ctx, w, h) { ctx.fillStyle = '#f4f7f5'; ctx.fillRect(0, 0, w, h); const palette = ['#0f6d52', '#29c17d', '#f0a202', '#c3423f', '#1d3f73']; for (let i = 0; i < 5; i++) { ctx.fillStyle = palette[i]; ctx.fillRect(90 + i * 150, h - 120 - i * 88, 104, 120 + i * 88); } ctx.strokeStyle = '#0f6d52'; ctx.lineWidth = 8; ctx.beginPath(); ctx.arc(w - 200, 190, 108, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = '#12232e'; ctx.font = '700 58px sans-serif'; ctx.fillText('Quarterly total', 90, 96); } /** Screenshot: small text on panels. Sharp, high-contrast, limited palette. */ function drawScreenshot(ctx, w, h) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); ctx.fillStyle = '#f1f4f8'; ctx.fillRect(0, 0, w, 74); ctx.fillStyle = '#12232e'; ctx.font = '600 26px sans-serif'; ctx.fillText('Settings', 34, 47); for (let row = 0; row < 16; row++) { const y = 120 + row * 52; ctx.fillStyle = row % 2 ? '#fbfcfd' : '#ffffff'; ctx.fillRect(28, y - 30, w - 56, 48); ctx.fillStyle = '#12232e'; ctx.font = '400 21px sans-serif'; ctx.fillText('Configuration option ' + (row + 1), 46, y); ctx.fillStyle = '#5a6b78'; ctx.font = '400 18px sans-serif'; ctx.fillText('Enabled for every document in this workspace', 430, y); } } /** Half opaque, half fully transparent — for the flattening measurement. */ function drawTransparent(ctx, w, h) { ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#29c17d'; ctx.beginPath(); ctx.arc(w / 2, h / 2, Math.min(w, h) / 2.6, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#0f6d52'; ctx.font = '700 64px sans-serif'; ctx.fillText('LOGO', w / 2 - 108, h / 2 + 22); } const DRAW = { photograph: drawPhotograph, 'flat-graphic': drawFlatGraphic, screenshot: drawScreenshot, 'transparent-logo': drawTransparent, }; async function encode(canvas, type, quality) { const blob = await new Promise(resolve => canvas.toBlob(resolve, type, quality)); return blob ? blob.size : null; } /** * The check that caught the mistake: how much of the JPEG-versus-WebP result is really * about the image, and how much is about how grainy the generator made it. */ window.__grainSweep = async function (width, height, amplitudes) { const rows = []; for (const amplitude of amplitudes) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; drawPhotograph(canvas.getContext('2d'), width, height, amplitude); rows.push({ grain: amplitude, png: await encode(canvas, 'image/png'), jpeg85: await encode(canvas, 'image/jpeg', 0.85), webp85: await encode(canvas, 'image/webp', 0.85), }); } return rows; }; window.__measure = async function (width, height) { const results = {}; for (const [name, draw] of Object.entries(DRAW)) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); draw(ctx, width, height); const entry = { png: await encode(canvas, 'image/png') }; for (const q of [60, 75, 85, 95]) entry['jpeg' + q] = await encode(canvas, 'image/jpeg', q / 100); for (const q of [75, 85]) entry['webp' + q] = await encode(canvas, 'image/webp', q / 100); // The round trip people actually hit: save as JPEG, then convert that JPEG to PNG. const jpegBlob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85)); const bitmap = await createImageBitmap(jpegBlob); const second = document.createElement('canvas'); second.width = bitmap.width; second.height = bitmap.height; second.getContext('2d').drawImage(bitmap, 0, 0); entry.jpeg85ThenPng = await encode(second, 'image/png'); results[name] = entry; } return results; }; `; const WIDTH = 1280; const HEIGHT = 960; const browser = await chromium.launch(); const page = await browser.newPage(); await page.setContent('