// One artwork -> every icon the stores, the native shells and the web build need. Needs ffmpeg (cwebp optional). // storebound icons [--dry] // In (icons in storebound.config.ts): composite (1024 opaque full icon), foreground (1024 RGBA subject on transparent) // and background (1024 opaque) for the adaptive / maskable variants. // Out: // /app-store-1024.png App Store icon, rgb24 (Apple rejects an alpha channel) // /play-512.png Play listing icon // /icon-512|192|180.png, favicon-64|32.png (256-colour PNGs), maskable-512.webp (or .png without cwebp) // /icon-only.png, icon-foreground.png, icon-background.png (then capacitorCommand runs) // /mipmap-*/ic_launcher{,_round}.png redrawn from the adaptive layers (capacitor-assets crops the full // square artwork into the legacy icons, which cuts off anything near the corners) import { mkdirSync, rmSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import type { Ctx } from './lib/ctx'; import { need } from './lib/ctx'; import { done, log } from './lib/log'; /** The anydpi-v26 adaptive icon xml insets both layers 16.7 %, so the launcher mask (often a circle) sees the * foreground's middle ~66 %: at ~0.7 the subject's corners sit just inside a circle mask. */ export const ADAPTIVE_FG = 0.7; /** Web maskable icon: the subject inside the 80 % safe zone. */ export const MASKABLE_FG = 0.8; export const LEGACY_SIZES = [['mdpi', 48], ['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]] as const; const has = (bin: string): boolean => spawnSync('sh', ['-c', `command -v ${bin}`]).status === 0; /** geq expressions for 4x supersampled masks: circle, and rounded square with radius 22 %. */ export function maskExpr(shape: 'circle' | 'squircle', S: number): string { const h = S / 2; if (shape === 'circle') return `if(lte(hypot(X-${h}+0.5,Y-${h}+0.5),${h}),255,0)`; const r = S * 0.22, e = h - r; return `if(lte(hypot(max(abs(X-${h}+0.5)-${e},0),max(abs(Y-${h}+0.5)-${e},0)),${r}),255,0)`; } export async function run(ctx: Ctx): Promise { const { config, resolve, root } = ctx.loaded; const ic = need(config.icons, 'icons'); if (!has('ffmpeg')) throw new Error('ffmpeg is required (brew install ffmpeg / apt install ffmpeg)'); const dry = ctx.args.has('dry'); const outs: string[] = []; const ff = (args: string[], out: string): void => { outs.push(out); if (dry) return; mkdirSync(join(out, '..'), { recursive: true }); const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args, out], { encoding: 'utf8' }); if (r.status !== 0) throw new Error(`ffmpeg failed for ${out}\n${r.stderr}`); }; const composite = resolve(ic.composite); const fg = ic.foreground ? resolve(ic.foreground) : ''; const bg = ic.background ? resolve(ic.background) : ''; const adaptiveK = ic.adaptiveScale ?? ADAPTIVE_FG; const tmp = join(root, '.storebound', 'tmp-icons'); mkdirSync(tmp, { recursive: true }); const opaque = (size: number, out: string, palette = false): void => { const scale = `scale=${size}:${size}:flags=lanczos`; if (!palette) ff(['-i', composite, '-vf', scale, '-pix_fmt', 'rgb24'], out); else ff(['-i', composite, '-vf', `${scale},split[a][b];[a]palettegen=max_colors=256:stats_mode=full[p];[b][p]paletteuse=dither=sierra2_4a`, '-frames:v', '1'], out); }; /** foreground scaled to k, centred on the background (opaque) or on transparency. */ const inset = (k: number, out: string, onBackground: boolean): void => { const s = Math.round(1024 * k), o = Math.round((1024 - s) / 2); const base = onBackground ? ['-i', bg] : ['-f', 'lavfi', '-i', 'color=c=black@0.0:s=1024x1024,format=rgba']; ff([...base, '-i', fg, '-filter_complex', `[1:v]scale=${s}:${s}:flags=lanczos[f];[0:v][f]overlay=${o}:${o}:format=auto`, '-frames:v', '1', '-pix_fmt', onBackground ? 'rgb24' : 'rgba'], out); }; const out = resolve(ic.out); opaque(1024, join(out, 'app-store-1024.png')); opaque(512, join(out, 'play-512.png')); if (ic.web) { const web = resolve(ic.web); for (const n of [512, 192, 180]) opaque(n, join(web, `icon-${n}.png`), true); for (const n of [64, 32]) opaque(n, join(web, `favicon-${n}.png`), true); if (fg && bg) { inset(ic.maskableScale ?? MASKABLE_FG, join(tmp, 'maskable-1024.png'), true); const png512 = has('cwebp') ? join(tmp, 'maskable-512.png') : join(web, 'maskable-512.png'); ff(['-i', join(tmp, 'maskable-1024.png'), '-vf', 'scale=512:512:flags=lanczos'], png512); if (has('cwebp')) { outs.push(join(web, 'maskable-512.webp')); if (!dry && spawnSync('cwebp', ['-quiet', '-q', '86', png512, '-o', join(web, 'maskable-512.webp')]).status !== 0) throw new Error('cwebp failed'); } } } if (ic.capacitorAssets) { if (!fg || !bg) throw new Error('icons.capacitorAssets needs icons.foreground and icons.background'); const cap = resolve(ic.capacitorAssets); opaque(1024, join(cap, 'icon-only.png')); ff(['-i', bg, '-vf', 'scale=1024:1024:flags=lanczos', '-pix_fmt', 'rgb24'], join(cap, 'icon-background.png')); inset(adaptiveK, join(cap, 'icon-foreground.png'), false); if (ic.capacitorCommand && !dry) { log('icons', `running: ${ic.capacitorCommand}`); const r = spawnSync('sh', ['-c', ic.capacitorCommand], { cwd: root, encoding: 'utf8', stdio: 'inherit', env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' } }); if (r.status !== 0) throw new Error('capacitorCommand failed'); } } // after capacitor-assets, so these legacy icons win if (ic.androidRes) { const res = resolve(ic.androidRes); const adaptive = join(tmp, 'adaptive.png'); inset(adaptiveK, adaptive, true); for (const [dpi, n] of LEGACY_SIZES) { const S = n * 4; for (const [name, shape] of [['ic_launcher_round', 'circle'], ['ic_launcher', 'squircle']] as const) ff(['-i', adaptive, '-f', 'lavfi', '-i', `color=c=black:s=${S}x${S},format=gray,geq=lum='${maskExpr(shape, S)}'`, '-filter_complex', `[0:v]scale=${S}:${S}:flags=lanczos,format=rgba[i];[i][1:v]alphamerge,scale=${n}:${n}:flags=area`, '-frames:v', '1', '-pix_fmt', 'rgba'], join(res, `mipmap-${dpi}`, `${name}.png`)); } } rmSync(tmp, { recursive: true, force: true }); for (const f of outs.filter(f => !f.startsWith(tmp))) log('icons', dry ? `would write ${f.slice(root.length + 1)}` : `${f.slice(root.length + 1).padEnd(60)} ${(statSync(f).size / 1024).toFixed(1)} KB`); done('icons', dry ? 'icons planned (dry run)' : 'icons done'); }