#!/usr/bin/env tsx // The Archilyzer Media YouTube channel's assets, rendered from lib/brandMedia.ts. // // pnpm --filter yt-dlp-transcript-common exec tsx bin/brand-media.ts [--out ] // pnpm --filter yt-dlp-transcript-common exec tsx bin/brand-media.ts --video-kit // // Without --video-kit it writes, into --out (default ~/reports/archilyzer-media/brand/), // each PNG beside the SVG it was rasterised from: // // avatar-800.png the channel picture: mark M2, maskable (YouTube crops a circle) // watermark-150.png the Studio video watermark: mark M2, `any` (transparent corners) // banner-2560x1440.png the channel banner, the approved canvas design // banner-safe-1546x423.png the part of the banner every device shows // lockup.svg the lockup on its own (no PNG: it is a source) // INDEX.html the operator's handoff: previews, sizes, the Studio upload steps // // PNGs go through lib/brandIcons.ts renderSvgPng (next/og: satori + resvg), the same // path the site icons take, so nothing here needs a system rasteriser or a font. // // --video-kit instead writes umtool/report-to-video/brands/archilyzer-media.json, the // palette, mark and lockup the report-to-video preset draws with (its scripts are plain // .mjs and cannot import this TypeScript). brandMedia.test.ts fails when it is stale. import { mkdir, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { renderSvgPng } from "../lib/brandIcons"; import { MEDIA_BANNER, MEDIA_PALETTE, mediaBannerSvg, mediaLockupGeometry, mediaLockupSvg, mediaMarkSvg, mediaVideoKit, } from "../lib/brandMedia"; import { PROJECT_URL } from "../lib/project"; import { runIfEntryPoint } from "./_cli"; import { parseFlags } from "./_parseFlags"; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export const VIDEO_KIT_PATH = path.join(REPO, "umtool/report-to-video/brands/archilyzer-media.json"); export const DEFAULT_OUT = path.join(os.homedir(), "reports/archilyzer-media/brand"); // The kit as the file holds it: two-space JSON and a trailing newline. export function videoKitJson(): string { return JSON.stringify(mediaVideoKit(), null, 2) + "\n"; } export type MediaAsset = { png: string; svg: string; width: number; height: number; title: string; studio: string; note: string; render: () => string; }; const S = MEDIA_BANNER.safe; export const MEDIA_ASSETS: ReadonlyArray = [ { png: "avatar-800.png", svg: "avatar.svg", width: 800, height: 800, title: "Channel picture", studio: "Picture", note: "Mark M2 · Signal, maskable: the lines sit inside the circle YouTube crops to.", render: () => mediaMarkSvg("maskable"), }, { png: "watermark-150.png", svg: "watermark.svg", width: 150, height: 150, title: "Video watermark", studio: "Video watermark", note: "Mark M2 · Signal, rounded square; the corners are transparent.", render: () => mediaMarkSvg("any"), }, { png: "banner-2560x1440.png", svg: "banner.svg", width: MEDIA_BANNER.width, height: MEDIA_BANNER.height, title: "Banner image", studio: "Banner image", note: "The whole banner: a TV shows all of it, a desktop the full-width strip through the middle.", render: () => mediaBannerSvg(), }, { png: "banner-safe-1546x423.png", svg: "banner-safe.svg", width: S.width, height: S.height, title: "Banner, safe area", studio: "(not uploaded)", note: "What every device shows, phones included. A preview of the crop, not a separate upload.", render: () => mediaBannerSvg({ view: S }), }, ]; // The lockup at the head of INDEX.html (and lockup.svg), wordmark px. const INDEX_LOCKUP_PX = 64; // YouTube's own limits, checked before anything is written. export const BANNER_MAX_BYTES = 6 * 1024 * 1024; function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function kb(n: number): string { return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(2)} MB` : `${(n / 1024).toFixed(1)} KB`; } export function indexHtml(rows: ReadonlyArray, generatedAt: string): string { const P = MEDIA_PALETTE; const bannerBytes = rows.find((r) => r.png.startsWith("banner-2560"))?.bytes ?? 0; const lockup = mediaLockupGeometry(INDEX_LOCKUP_PX); const card = (r: MediaAsset & { bytes: number }) => { const round = r.png.startsWith("avatar"); return `
${esc(r.title)}
${esc(r.title)} ${esc(r.png)} · ${r.width} × ${r.height} · ${kb(r.bytes)} · source ${esc(r.svg)} ${esc(r.note)}
`; }; return ` Archilyzer Media assets
Archilyzer Media

Archilyzer Media: channel assets

Generated ${esc(generatedAt)} by common/bin/brand-media.ts from common/lib/brandMedia.ts (mark M2 · Signal, lockup "flush · caps matched"). Re-run it to regenerate; never edit these files by hand.

Upload in YouTube Studio

  1. Open YouTube Studio signed in as the channel, then Customization → Profile (formerly Branding). The picture, the banner and the watermark are all on that page (YouTube Help: Manage your channel branding).
  2. Picture → Change (Upload if the channel has none yet) → avatar-800.png. Keep the crop at the whole square; YouTube shows it as a circle.
  3. Banner image → Change (or Upload) → banner-2560x1440.png (${kb(bannerBytes)}; YouTube takes up to 6 MB). Leave the crop at the default: the device preview should show the lockup centred, as in the safe-area picture below.
  4. Video watermark → Change (or Upload) → watermark-150.png. Display time: Entire video.
  5. Publish (top right). Changes can take a few minutes to show on the channel page.

Assets

${rows.map(card).join("\n")}

In the videos

The report-to-video preset render.brand: "archilyzer-media" in a umtool manifest draws the title card, the mark in the clip header, the end card (right half clear for YouTube's end-screen elements) and the thumbnail in the same palette. See umtool/report-to-video/README.md, "The Archilyzer Media preset". The site is ${esc(PROJECT_URL.replace(/^https?:\/\//, ""))}.

`; } export async function writeMediaAssets(outDir: string): Promise> { await mkdir(outDir, { recursive: true }); const rows: Array = []; for (const a of MEDIA_ASSETS) { const svg = a.render(); const png = await renderSvgPng(svg, { width: a.width, height: a.height }); if (a.png.startsWith("banner-2560") && png.byteLength > BANNER_MAX_BYTES) { throw new Error(`${a.png} is ${png.byteLength} bytes; YouTube's banner limit is ${BANNER_MAX_BYTES}`); } await writeFile(path.join(outDir, a.svg), svg, "utf8"); await writeFile(path.join(outDir, a.png), png); rows.push({ ...a, bytes: png.byteLength, path: path.join(outDir, a.png) }); } await writeFile(path.join(outDir, "lockup.svg"), mediaLockupSvg({ px: INDEX_LOCKUP_PX }), "utf8"); const stamp = `${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC`; await writeFile(path.join(outDir, "INDEX.html"), indexHtml(rows, stamp), "utf8"); return rows; } export async function main(argv: string[] = process.argv.slice(2)): Promise { const flags = parseFlags(argv); if (flags.help) { console.log("usage: brand-media.ts [--out ] | --video-kit"); return 0; } if (flags["video-kit"]) { await mkdir(path.dirname(VIDEO_KIT_PATH), { recursive: true }); await writeFile(VIDEO_KIT_PATH, videoKitJson(), "utf8"); console.log(`wrote ${path.relative(REPO, VIDEO_KIT_PATH)}`); return 0; } const out = path.resolve(flags.out && flags.out !== "true" ? flags.out.replace(/^~(?=\/)/, os.homedir()) : DEFAULT_OUT); const rows = await writeMediaAssets(out); for (const r of rows) console.log(`${r.path} ${r.width}x${r.height} ${r.bytes} B`); console.log(`${path.join(out, "INDEX.html")}`); return 0; } runIfEntryPoint(import.meta.url, main);