Files
au_whisper_web/scripts/generate-icons.ts
T
Lucas WintherandClaude Fable 5 9f39a82982 WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper
- M3: hand-rolled service worker (precache + runtime /ort caching), precache
  manifest injection, SW registration/update toast, manifest + generated icons,
  production build script, offline indicator in ModelPicker (D-005/D-007)
- M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind
  connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving,
  Bun.password Argon2id, 38 auth tests
- M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware
  header, anonymous-first fail-silent offline handling, 18 client tests
- D-013: server engine (M5) will use faster-whisper via Python sidecar;
  browser side stays Transformers.js (CTranslate2 has no WASM build)
- bun test: 83 pass / tsc --noEmit clean
- Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in
  flight from a background agent; follow-up commit will finish docs

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-10 04:02:28 +02:00

115 lines
3.9 KiB
TypeScript

/**
* PWA icon generator — writes simple solid-background PNGs (192 & 512 px)
* with a hand-rolled PNG encoder (no image dependencies, D-001). The glyph —
* a ring on the app's dark background — stays well inside the maskable safe
* zone, so the icons declare `purpose: "any maskable"` in the manifest.
*
* Run directly (`bun scripts/generate-icons.ts`) or via `ensureIcons()` from
* src/index.ts (dev) and scripts/build.ts (production).
*/
import { deflateSync } from "node:zlib";
import { join } from "node:path";
export const ICON_SIZES = [192, 512] as const;
// App theme (src/index.css / manifest.webmanifest).
const BACKGROUND: readonly [number, number, number] = [0x16, 0x18, 0x1d];
const ACCENT: readonly [number, number, number] = [0x7a, 0xa2, 0xf7];
// --- Minimal PNG encoding (8-bit RGBA, filter 0) -------------------------
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Uint8Array): Uint8Array {
const chunk = new Uint8Array(12 + data.length);
const view = new DataView(chunk.buffer);
view.setUint32(0, data.length);
for (let i = 0; i < 4; i++) chunk[4 + i] = type.charCodeAt(i);
chunk.set(data, 8);
view.setUint32(8 + data.length, crc32(chunk.subarray(4, 8 + data.length)));
return chunk;
}
/** Encode an RGBA pixel buffer as a PNG file. */
function encodePng(size: number, rgba: Uint8Array): Uint8Array {
const ihdr = new Uint8Array(13);
const ihdrView = new DataView(ihdr.buffer);
ihdrView.setUint32(0, size);
ihdrView.setUint32(4, size);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
// compression 0, filter 0, interlace 0
// Scanlines, each prefixed with filter byte 0.
const raw = new Uint8Array(size * (size * 4 + 1));
for (let y = 0; y < size; y++) {
raw.set(rgba.subarray(y * size * 4, (y + 1) * size * 4), y * (size * 4 + 1) + 1);
}
const signature = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const idat = new Uint8Array(deflateSync(raw));
return Uint8Array.from([
...signature,
...pngChunk("IHDR", ihdr),
...pngChunk("IDAT", idat),
...pngChunk("IEND", new Uint8Array(0)),
]);
}
// --- Icon artwork ---------------------------------------------------------
/** 0→1 coverage across a ~1px band, for cheap antialiasing. */
function edge(distance: number): number {
return Math.min(1, Math.max(0, distance + 0.5));
}
export function encodeIcon(size: number): Uint8Array {
const rgba = new Uint8Array(size * size * 4);
const center = (size - 1) / 2;
const outer = size * 0.3;
const inner = size * 0.17;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const d = Math.hypot(x - center, y - center);
// Ring: inside outer radius but outside inner radius.
const cover = edge(outer - d) * edge(d - inner);
const offset = (y * size + x) * 4;
for (let c = 0; c < 3; c++) {
rgba[offset + c] = Math.round(
BACKGROUND[c]! + (ACCENT[c]! - BACKGROUND[c]!) * cover,
);
}
rgba[offset + 3] = 255; // opaque — maskable icons need a full background
}
}
return encodePng(size, rgba);
}
/** Directory the manifest's icon URLs are served from in dev. */
export const ICONS_DIR = join(import.meta.dir, "../src/pwa/icons");
/** Generate any missing icons into `dir` (idempotent). */
export async function ensureIcons(dir: string = ICONS_DIR): Promise<void> {
for (const size of ICON_SIZES) {
const path = join(dir, `icon-${size}.png`);
if (await Bun.file(path).exists()) continue;
await Bun.write(path, encodeIcon(size));
console.log(`Wrote ${path}`);
}
}
if (import.meta.main) {
await ensureIcons();
}