storebound/cli/icons.ts

118 lines
6.5 KiB
TypeScript

// 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:
// <out>/app-store-1024.png App Store icon, rgb24 (Apple rejects an alpha channel)
// <out>/play-512.png Play listing icon
// <web>/icon-512|192|180.png, favicon-64|32.png (256-colour PNGs), maskable-512.webp (or .png without cwebp)
// <capacitorAssets>/icon-only.png, icon-foreground.png, icon-background.png (then capacitorCommand runs)
// <androidRes>/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<void> {
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');
}