perf: aggressive webp re-compression + max-width 1920 resize
- recompress every webp at q=70-82 depending on size band - resize anything wider than 1920px to 1920 max - convert remaining png/jpg holdouts (mine, thermal, dex3-1 etc) to webp - restore apple-touch-icon.png (iOS requires png) - 86 files recompressed, saved 4.36 MB - public/ images: 11.7 MB -> 6.69 MB (-43%) - total trajectory: 23.3 MB -> 6.69 MB (-71%) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
dda358b59d
commit
5a40f6b733
@@ -0,0 +1,85 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const ROOT = path.resolve('public');
|
||||
const SKIP = new Set(['favicon.ico']);
|
||||
const MAX_W = 1920; // cap any image wider than this
|
||||
|
||||
const exts = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
||||
let convertedCount = 0;
|
||||
let savedBytes = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) await walk(p);
|
||||
else if (exts.has(path.extname(p).toLowerCase())) await compress(p);
|
||||
}
|
||||
}
|
||||
|
||||
function qualityFor(bytes) {
|
||||
if (bytes > 700 * 1024) return 70;
|
||||
if (bytes > 300 * 1024) return 75;
|
||||
if (bytes > 120 * 1024) return 78;
|
||||
return 82;
|
||||
}
|
||||
|
||||
async function compress(input) {
|
||||
const base = path.basename(input);
|
||||
if (SKIP.has(base)) return;
|
||||
const ext = path.extname(input).toLowerCase();
|
||||
const isGif = ext === '.gif';
|
||||
const relRoot = path.relative(process.cwd(), input).split(path.sep).join('/');
|
||||
|
||||
try {
|
||||
const stIn = await fs.stat(input);
|
||||
if (stIn.size < 8 * 1024) {
|
||||
// tiny: leave
|
||||
return;
|
||||
}
|
||||
const target = input.slice(0, -ext.length) + '.webp';
|
||||
// Read into memory first, then close handle
|
||||
const srcBuf = await fs.readFile(input);
|
||||
const meta = await sharp(srcBuf, { animated: isGif }).metadata();
|
||||
let pipeline = sharp(srcBuf, { animated: isGif });
|
||||
if (meta.width && meta.width > MAX_W) {
|
||||
pipeline = pipeline.resize({ width: MAX_W, withoutEnlargement: true });
|
||||
}
|
||||
const q = qualityFor(stIn.size);
|
||||
pipeline = pipeline.webp({
|
||||
quality: q,
|
||||
alphaQuality: 88,
|
||||
effort: 6,
|
||||
smartSubsample: true,
|
||||
...(isGif ? { loop: 0 } : {}),
|
||||
});
|
||||
const buf = await pipeline.toBuffer();
|
||||
if (buf.length >= stIn.size && ext === '.webp') {
|
||||
skippedCount++;
|
||||
return;
|
||||
}
|
||||
// write via temp file then rename for atomicity
|
||||
const tmp = target + '.tmp';
|
||||
await fs.writeFile(tmp, buf);
|
||||
if (target !== input) {
|
||||
try { await fs.unlink(input); } catch {}
|
||||
}
|
||||
await fs.rename(tmp, target);
|
||||
convertedCount++;
|
||||
savedBytes += stIn.size - buf.length;
|
||||
const inKB = (stIn.size / 1024).toFixed(0);
|
||||
const outKB = (buf.length / 1024).toFixed(0);
|
||||
console.log(`OK q=${q} ${relRoot} ${inKB}KB -> ${outKB}KB${meta.width && meta.width > MAX_W ? ' (resized)' : ''}`);
|
||||
} catch (err) {
|
||||
console.log(`FAIL ${relRoot}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await walk(ROOT);
|
||||
const savedMB = (savedBytes / 1024 / 1024).toFixed(2);
|
||||
console.log('\n---');
|
||||
console.log(`compressed: ${convertedCount} skipped: ${skippedCount}`);
|
||||
console.log(`saved: ${savedMB} MB`);
|
||||
Reference in New Issue
Block a user