Files
cameleer-website/scripts/optimize-product-images.mjs
hsiegeln 183a92123c
All checks were successful
ci / build-test (push) Successful in 4m56s
perf(homepage): serve product screenshots as resized WebP to lift Lighthouse perf above 0.95
The homepage Lighthouse perf score dropped to 0.94 in CI (threshold 0.95)
because Hero and ThreeAmWalkthrough each load a 1920×945 PNG screenshot
straight out of public/product/ — together ~1.2 MiB and the LCP element.
Other pages have no product imagery and pass with 1.0.

Adds a sharp-based generator (scripts/optimize-product-images.mjs, run via
`npm run optimize:images`) that emits 1280w and 1920w WebP variants beside
each source PNG. Lightbox.astro now wraps the trigger in a <picture> with
a WebP <source srcset> auto-derived from the PNG path (PNG kept as
fallback for the ~2% of clients without WebP), exposes a fetchpriority
prop, and points the dialog modal at the 1920w WebP. The Hero passes
fetchpriority="high" on the LCP image; index.astro injects a matching
<link rel="preload" as="image" imagesrcset> via a new BaseLayout head
slot so the WebP is discovered before the body parses.

Effect on the LCP image: 551 KiB PNG → 46 KiB WebP at 1280w (-92%).
Local Lighthouse perf: 0.95 → 1.00 across 3 runs on /index.html;
pricing/imprint stay 1.00; privacy unchanged at 0.98 (pre-existing CLS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:20:29 +02:00

49 lines
1.7 KiB
JavaScript

// Generate WebP variants of source PNGs in public/product/.
// Run after replacing/adding a source PNG; outputs are committed.
//
// For each <name>.png we emit:
// <name>-1280.webp (q=82, used as inline srcset for desktop ≤ ~1280 px)
// <name>-1920.webp (q=80, used as inline srcset for retina/wide viewports
// and as the lightbox-modal full-size source)
//
// The original .png is kept as a <picture> fallback for the rare browser
// without WebP support (~2 % globally).
import { readdir, stat } from 'node:fs/promises';
import { join, parse } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const SRC_DIR = fileURLToPath(new URL('../public/product/', import.meta.url));
const VARIANTS = [
{ width: 1280, quality: 82, suffix: '-1280' },
{ width: 1920, quality: 80, suffix: '-1920' },
];
const entries = await readdir(SRC_DIR);
const pngs = entries.filter((f) => f.toLowerCase().endsWith('.png'));
if (pngs.length === 0) {
console.error(`No PNGs found in ${SRC_DIR}`);
process.exit(1);
}
for (const file of pngs) {
const { name } = parse(file);
const inputPath = join(SRC_DIR, file);
const inputBytes = (await stat(inputPath)).size;
console.log(`\n${file} (${(inputBytes / 1024).toFixed(0)} KiB)`);
for (const v of VARIANTS) {
const outName = `${name}${v.suffix}.webp`;
const outPath = join(SRC_DIR, outName);
const info = await sharp(inputPath)
.resize({ width: v.width, withoutEnlargement: true })
.webp({ quality: v.quality, effort: 6 })
.toFile(outPath);
const pct = ((1 - info.size / inputBytes) * 100).toFixed(0);
console.log(`${outName} ${(info.size / 1024).toFixed(0)} KiB (-${pct}%)`);
}
}