clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / android / art / render.mjs
render.mjs 3.6K
1// Renders the Android TV app's banner, launcher icon and setup wordmark from2// the HTML sources next to this file, into android/app/src/main/res, and the3// web app's icons into frontend/dist/icons. They share4// the Steam art's look (build/linux/steam-art: art.css, art.js, the app's fonts).5//6// node android/art/render.mjs [banner|mark|icon ...] (needs the playwright package resolvable)7// PLAYWRIGHT=/path/to/node_modules/playwright/index.mjs node android/art/render.mjs8//9// Serves the repo over a local HTTP server so the pages can load the app's fonts and art.js.1011import { createServer } from "node:http";12import { mkdir, readFile } from "node:fs/promises";13import { dirname, extname, join, normalize } from "node:path";14import { fileURLToPath, pathToFileURL } from "node:url";1516const here = dirname(fileURLToPath(import.meta.url));17const root = join(here, "..", "..");18const res = join(here, "..", "app", "src", "main", "res");19const spec = process.env.PLAYWRIGHT;20const { chromium } = await import(spec ? pathToFileURL(spec).href : "playwright");2122// The launcher icon's foreground layer is 108dp square, per density.23const ICON = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };2425// name: [page, width, height, transparent, output]; width/height null = size of #art26const ART = {27 banner: ["banner.html", 640, 360, false, "drawable-xhdpi/banner.png"],28 mark: ["mark.html", null, null, true, "drawable-nodpi/mark.png"],29 ...Object.fromEntries(Object.entries(ICON).map(([d, px]) =>30 [`icon-${d}`, [`icon.html?px=${px}`, px, px, true, `mipmap-${d}/ic_launcher_foreground.png`]])),31 // The web app's: the W on its white tile, edge to edge, which a phone32 // masks or rounds to its own shape (outputs from the repo's root).33 ...Object.fromEntries([[192, "icon-192"], [512, "icon-512"], [180, "apple-touch-icon"]].map(([px, n]) =>34 [`web-${n}`, [`icon.html?px=${px}`, px, px, false, `/frontend/dist/icons/${n}.png`]])),35};3637const TYPES = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript", ".woff2": "font/woff2", ".png": "image/png" };38const server = createServer(async (req, res) => {39 const path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname));40 try {41 const body = await readFile(join(root, path));42 res.writeHead(200, { "content-type": TYPES[extname(path)] ?? "application/octet-stream" });43 res.end(body);44 } catch {45 res.writeHead(404).end();46 }47});48await new Promise((ok) => server.listen(0, "127.0.0.1", ok));49const base = `http://127.0.0.1:${server.address().port}/android/art/`;5051const only = process.argv.slice(2);52const browser = await chromium.launch();53try {54 for (const [name, [page, w, h, transparent, out]] of Object.entries(ART)) {55 if (only.length && !only.some((o) => name === o || name.startsWith(`${o}-`))) continue;56 const ctx = await browser.newContext({ viewport: { width: w ?? 2400, height: h ?? 800 } });57 const tab = await ctx.newPage();58 tab.on("pageerror", (e) => console.error(`${name}: ${e.message}`));59 tab.on("requestfailed", (r) => console.error(`${name}: failed ${r.url()}`));60 await tab.goto(base + page);61 await tab.waitForSelector("body[data-ready]", { timeout: 15000 });62 const file = out.startsWith("/") ? join(root, out) : join(res, out);63 await mkdir(dirname(file), { recursive: true });64 await tab.locator("#art").screenshot({ path: file, omitBackground: transparent });65 const box = await tab.locator("#art").boundingBox();66 console.log(`${out} ${Math.round(box.width)}x${Math.round(box.height)}`);67 await ctx.close();68 }69} finally {70 await browser.close();71 server.close();72}