// The manifest needs its icons at fixed sizes, and the only artwork there is ships // at 256x256 (public/assets/RecipesCamIcon.png). Skia is already a dependency — the // engine grades through it — so it does the resize here rather than a new image // tool or a runtime fetch of an oversized PNG. One-off, run by hand: the output is // committed, so a build never has to decode and rescale anything. // // node scripts/make-icons.mjs import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js'; const SOURCE = 'public/assets/RecipesCamIcon.png'; const OUT = 'public/icons'; const CanvasKit = await CanvasKitInit({ locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)), }); // Buffers are typed arrays already, but Skia's binding wants one it can read as a // plain byte view, not node's pooled slab. const art = CanvasKit.MakeImageFromEncoded(new Uint8Array(readFileSync(SOURCE))); if (!art) throw new Error(`${SOURCE} is not an image Skia can decode`); // `scale` is the art's share of the square. The plain icons bleed to the edge, // exactly as the source does. The maskable one cannot: the launcher supplies the // mask, and most of them are circles, so the art has to stay inside the 80% // safe zone the spec draws — the background around it is the app's own #0a0a0a // rather than nothing at all, because a masked icon with transparent corners // shows whatever the launcher paints behind it. const icons = [ { file: 'icon-192.png', size: 192, scale: 1, background: false }, { file: 'icon-512.png', size: 512, scale: 1, background: false }, { file: 'maskable-512.png', size: 512, scale: 0.8, background: true }, ]; mkdirSync(OUT, { recursive: true }); for (const { file, size, scale, background } of icons) { const surface = CanvasKit.MakeSurface(size, size); if (!surface) throw new Error(`could not make a ${size}x${size} surface`); const canvas = surface.getCanvas(); if (background) { const paint = new CanvasKit.Paint(); paint.setColor(CanvasKit.Color(10, 10, 10)); canvas.drawPaint(paint); paint.delete(); } const side = size * scale; const offset = (size - side) / 2; // Mitchell cubic (B = C = 1/3): the 256 -> 192 step is a downscale, where // nearest or bilinear would drop detail off the camera's thin outlines. canvas.drawImageRectCubic( art, CanvasKit.XYWHRect(0, 0, art.width(), art.height()), CanvasKit.XYWHRect(offset, offset, side, side), 1 / 3, 1 / 3, null, ); surface.flush(); const png = surface.makeImageSnapshot().encodeToBytes(); surface.dispose(); if (!png) throw new Error(`could not encode ${file}`); writeFileSync(`${OUT}/${file}`, png); console.log(`${OUT}/${file} ${size}x${size} ${png.length} bytes`); } art.delete();