diff --git a/.gitignore b/.gitignore index a20575a..d93c40d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ # Build output dist/ +tools/cards/out/ .astro/ tsconfig.tsbuildinfo diff --git a/flake.nix b/flake.nix index a095481..dbe226d 100644 --- a/flake.nix +++ b/flake.nix @@ -31,23 +31,41 @@ assert pkgs.lib.assertMsg (pkgs.lib.hasPrefix "FROM oven/bun:${pkgs.bun.version}-slim AS base\n" (builtins.readFile ./Dockerfile)) "Update Dockerfile to match Nix Bun ${pkgs.bun.version}"; - pkgs.mkShell { - packages = with pkgs; [ - # Everything in the justfile runs through bun: astro, vite, biome, - # tsc, and wrangler are all `bun run` or `bunx`. - # - # Keep package.json and Dockerfile aligned when nixpkgs changes - # Bun. CI reads package.json and evaluates the assertions above. - bun + pkgs.mkShell ( + { + packages = with pkgs; [ + # Everything in the justfile runs through bun: astro, vite, biome, + # tsc, and wrangler are all `bun run` or `bunx`. + # + # Keep package.json and Dockerfile aligned when nixpkgs changes + # Bun. CI reads package.json and evaluates the assertions above. + bun - # Astro and Vite target node, and parts of their toolchains shell - # out to it rather than to bun. - nodejs_24 + # Astro and Vite target node, and parts of their toolchains shell + # out to it rather than to bun. + nodejs_24 - # The task runner every recipe in the justfile is written for. - just - ]; - }; + # The task runner every recipe in the justfile is written for. + just + ]; + + # `just cards` imports Playwright from here rather than node_modules, + # so the library and its browsers come from the same nixpkgs pin. + PLAYWRIGHT_NODE_PATH = "${pkgs.playwright-test}/lib/node_modules"; + PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers; + } + # Chromium only, and skip Playwright's host check: it ldd-checks + # the host even when the Nix browser runs fine. Not + # `browsers-chromium`, which drops chromium-headless-shell, the + # binary headless launches resolve to. + // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers.override { + withFirefox = false; + withWebkit = false; + }; + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true"; + } + ); formatter = pkgs.nixfmt-tree; } diff --git a/justfile b/justfile index 7e043de..d3b3388 100644 --- a/justfile +++ b/justfile @@ -29,6 +29,14 @@ test: # Run the JS tests via node. bun test +# Render the social announcement cards (tools/cards/cards.ts) to PNG with the +# devshell's Playwright Chromium: `just cards` for all, `just cards ...` +# for some, `--html` to keep the page for tweaking. Output lands in +# tools/cards/out/, which is ignored; the PNGs are posted, not committed. +cards *args: + bun i + bun tools/cards/render.ts {{args}} + # Upgrade any tooling upgrade: # Update the NPM dependencies diff --git a/tools/cards/README.md b/tools/cards/README.md new file mode 100644 index 0000000..c0046f1 --- /dev/null +++ b/tools/cards/README.md @@ -0,0 +1,23 @@ +# Announcement cards + +Social cards for new open-source features, in the style Bun uses for its "in +the next version" posts: one headline, one code block, 1200x675, posted as an +image with a one-line caption. CDN announcements live in moq.pro's copy of this +tool, which shares the look. + +| file | what it is | +| --- | --- | +| `cards.ts` | The cards. Add one entry per post; the comment at the top documents the fields and the inline markup. | +| `render.ts` | Renders each card to `out/.png` with the devshell's Playwright Chromium. Fails on a card whose content overflows rather than cropping it. | +| `underline.svg`, `jetbrains-mono-latin.woff2` | The hand-drawn underline and code font, copied from moq.pro's splash page. The wordmark is `public/home/logo.svg`. | + +```sh +just cards # every card +just cards e2ee # one or more slugs +just cards --html # also write out/.html, to tweak the layout in a browser +``` + +Change the look in `render.ts`, not per card. + +`out/` is ignored. A card is retired from `cards.ts` once posted; the git +history keeps it. diff --git a/tools/cards/cards.ts b/tools/cards/cards.ts new file mode 100644 index 0000000..05fd7eb --- /dev/null +++ b/tools/cards/cards.ts @@ -0,0 +1,173 @@ +// The announcement cards for the open-source stack (crates + npm packages), one +// entry per post. `just cards` turns each into a 1200x675 PNG for +// X/Bluesky/Discord (the format Bun uses for its "in the next version" posts). +// CDN announcements live in moq.pro's copy of this tool. Copy conventions: +// +// - `title` is the headline. `**word**` gets the hand-drawn green underline +// from the splash, so save it for the one phrase the post is about. +// - `sub` accepts `` `mono` `` and `**green**`. `code` is syntax-highlighted as +// `lang` (shell by default), where a line starting with `$ ` is a prompt. +// - `eyebrow` (top right) names the release: a crate + version. `note` (bottom right) is the one-line kicker, if any. +// +// Keep cards factual: a version, a rate, a measured number. Retire a card once +// it has been posted; the git history is the archive. + +export type Lang = "shell" | "rust" | "toml" | "ts"; + +export type Card = { + slug: string; + eyebrow?: string; + title: string; + sub?: string; + code?: string; + lang?: Lang; + note?: string; +}; + +export const CARDS: Card[] = [ + { + slug: "e2ee", + eyebrow: "new crate: moq-e2ee 0.0.1", + title: "End-to-end **encrypted** media.", + sub: "Relays forward ciphertext. Content keys never enter `moq-net`. AES-128-GCM inline: about 1.3 µs per 1 KiB frame, 440 ns per Opus datagram.", + code: "$ cargo add moq-e2ee", + note: "draft-lcurley-moq-e2ee, profile moq-e2ee-01", + }, + { + slug: "uring", + eyebrow: "new crate: moq-uring 0.0.1", + title: "io_uring, **thread per core**.", + sub: "One ring per worker: multishot `recvmsg` from a provided-buffer ring, `UDP_GRO` in, `UDP_SEGMENT` out, timers on the ring, futex parking. QUIC on top, no tokio in the hot path.", + code: [ + "[runtime]", + "workers = 8 # one QUIC worker per core, SO_REUSEPORT steered by connection id", + "pin = true", + "io_uring = true # Linux 6.12+; older kernels keep the tokio stack", + ].join("\n"), + lang: "toml", + note: "moq-relay 0.15", + }, + { + slug: "noq", + eyebrow: "moq-tokio 0.19", + title: "**noq** is the default QUIC stack.", + sub: "Every native MoQ binary now dials with our own QUIC implementation. quinn and quiche stay one feature flag away.", + code: [ + "[dependencies]", + 'moq-tokio = "0.19" # noq', + 'moq-tokio = { version = "0.19", features = ["quinn"] }', + 'moq-tokio = { version = "0.19", features = ["quiche"] }', + ].join("\n"), + lang: "toml", + }, + { + slug: "binary", + eyebrow: "new crate: moq-binary 0.1.0", + title: "Binary tracks: **snapshot** or **stream**.", + sub: "`snapshot` is lossy: one value over time, consumers get the latest. `stream` is lossless: an ordered append-log, nothing superseded. Same DEFLATE framing as `moq-json`, so the two agree on the wire.", + code: [ + "// a poster image: whoever joins late gets the current one", + "let poster = snapshot::Producer::new(track, snapshot::ProducerConfig::default());", + "", + "// an event log: every payload, in order", + "let events = stream::Producer::new(track, stream::ProducerConfig::default());", + ].join("\n"), + lang: "rust", + }, + { + slug: "auth", + eyebrow: "new crate: moq-auth 0.1.0", + title: "One **auth contract** for every relay.", + sub: "The request a relay sends per session, the grant an auth server answers with, the lease a session holds, and the JWT a client presents. Paths are patterns: `foo` is one broadcast, `foo/**` a subtree.", + code: [ + "$ moq auth generate --out key.jwk", + "$ moq auth sign --key key.jwk --root demo --publish 'bbb/**' > token.jwt", + "$ moq auth verify --key key.jwk < token.jwt", + ].join("\n"), + }, + { + slug: "room", + eyebrow: "@moq/room 0.2", + title: "A video call is **a path prefix**.", + sub: "Members are discovered from announcements. Camera, mic, and screenshare built in. No room server: joining is a token for the prefix. Native twin: `moq-room`.", + code: [ + 'const url = new URL("https://relay.example.com/meet/demo?jwt=...");', + "const connection = new Connection({ url });", + 'const identity = Path.from("alice");', + "", + "new Local({ connection, identity }).cameraEnabled.set(true);", + "const room = new Room({ connection, identity });", + ].join("\n"), + lang: "ts", + }, + { + slug: "play", + eyebrow: "moq-cli 0.12", + title: "Watch without **a browser**.", + sub: "`moq play` decodes H.264, H.265, AV1, Opus, and AAC with the platform's hardware decoder, into a native window synced to the speaker.", + code: [ + "$ cargo install moq-cli --features play", + "$ moq --connect https://relay.example.com/anon --broadcast my-stream.hang play", + "", + "# trade latency for a jittery link", + "$ moq ... play --delay 500ms", + ].join("\n"), + }, + { + slug: "lan", + eyebrow: "moq-cli 0.12", + title: "Mesh the LAN with **zero config**.", + sub: "`--cluster-lan` finds every MoQ process on the network over mDNS and meshes with it. No relay, no internet, no certificates. Relays join the same mesh with `[cluster.lan]`.", + code: [ + "# on the camera box", + "$ moq --cluster-lan --broadcast cam.hang import capture", + "", + "# anywhere else on the network", + "$ moq --cluster-lan --broadcast cam.hang play", + ].join("\n"), + note: "--cluster-lan-secret to keep strangers out", + }, + { + slug: "media", + eyebrow: "moq-video + moq-audio", + title: "Native media, **no ffmpeg**.", + sub: "`getUserMedia` and WebCodecs for Rust. Camera, screen, and mic capture. Hardware codecs on VideoToolbox, Media Foundation, NVENC, VAAPI, V4L2, and MediaCodec. wgpu rendering and echo cancellation.", + code: "$ cargo add moq-video --features capture,render\n$ cargo add moq-audio --features capture,playback", + note: "no system codecs to install", + }, + { + slug: "languages", + eyebrow: "8 languages, 1 wire", + title: "MoQ in **your language**.", + sub: "Rust and TypeScript implementations, plus Python, Kotlin, Swift, Go, Dart, and C over the same Rust core. A publisher in Python plays in Swift.", + code: [ + "$ cargo add moq-net", + "$ bun add @moq/net", + "$ pip install moq-rs", + "$ go get moq.dev/moq", + "$ dart pub add moq", + "# Kotlin: dev.moq:moq Swift: moq-dev/moq-swift C: libmoq", + ].join("\n"), + }, + { + slug: "gateway", + eyebrow: "moq-cli 0.12", + title: "Bridge **every protocol**.", + sub: "RTMP, SRT, WebRTC (WHIP/WHEP), and HLS in and out of MoQ, as either the server or the client. Chain stages over one connection.", + code: [ + "$ moq --connect https://relay.example.com/anon \\", + " import --broadcast event.hang srt --listen 0.0.0.0:9000 \\", + " -- export --broadcast event.hang hls --listen 0.0.0.0:8080", + ].join("\n"), + }, + { + slug: "transcode", + eyebrow: "moq-cli 0.12", + title: "Transcode **on demand**.", + sub: "`moq transcode` publishes an ABR ladder next to any broadcast. A rung is only decoded and encoded while someone watches it, and on NVIDIA the whole pipeline stays on the GPU.", + code: [ + "$ moq --connect https://relay.example.com/anon --broadcast cam.hang transcode \\", + " --rung 720:2500000 --rung 360:600000 --encoder nvenc --decoder nvdec", + ].join("\n"), + }, +]; diff --git a/tools/cards/jetbrains-mono-latin.woff2 b/tools/cards/jetbrains-mono-latin.woff2 new file mode 100644 index 0000000..2ca6ac6 Binary files /dev/null and b/tools/cards/jetbrains-mono-latin.woff2 differ diff --git a/tools/cards/render.ts b/tools/cards/render.ts new file mode 100644 index 0000000..7d9f255 --- /dev/null +++ b/tools/cards/render.ts @@ -0,0 +1,282 @@ +#!/usr/bin/env bun +// Renders the announcement cards in cards.ts to PNG with the devshell's +// Playwright Chromium. Run from the devshell: +// +// just cards # every card -> tools/cards/out/.png +// just cards e2ee # one or more slugs +// just cards --html # also keep the HTML, to tweak in a browser +// +// The look is moq.pro's splash page, shared with moq.pro's copy of this tool: +// slate grid wash, bold system-font headline with the hand-drawn green +// underline, JetBrains Mono for code. + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import hljs from "highlight.js/lib/core"; +import bash from "highlight.js/lib/languages/bash"; +import ini from "highlight.js/lib/languages/ini"; +import rust from "highlight.js/lib/languages/rust"; +import typescript from "highlight.js/lib/languages/typescript"; +import { CARDS, type Card, type Lang } from "./cards.ts"; + +// X renders summary_large_image at 1200x675; every other network is happy with +// 16:9 too. 2x device pixels so the text stays crisp when the network re-encodes. +const WIDTH = 1200; +const HEIGHT = 675; +const SCALE = 2; + +const HERE = dirname(new URL(import.meta.url).pathname); +const ROOT = join(HERE, "..", ".."); +const OUT = join(HERE, "out"); + +// Static assets are inlined as data URLs so the page is one self-contained +// string: no server, and `--html` output opens from anywhere. +function dataUrl(path: string, type: string): string { + return `data:${type};base64,${readFileSync(path).toString("base64")}`; +} + +const LOGO = dataUrl(join(ROOT, "public/home/logo.svg"), "image/svg+xml"); +const FONT = dataUrl(join(HERE, "jetbrains-mono-latin.woff2"), "font/woff2"); +// Inlined rather than an so it can stretch: the drawing is scaled to the +// underlined phrase's width and a fixed height, which an image's aspect ratio +// would refuse. +const UNDERLINE = readFileSync(join(HERE, "underline.svg"), "utf8") + .trim() + .replace(/^[\s\S]*? = { shell: "bash", rust: "rust", toml: "ini", ts: "typescript" }; + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +// The inline markup cards.ts documents: `mono` and **green**. In the title the +// bold form is the underlined phrase instead. +function inline(text: string, strong: string): string { + return escapeHtml(text) + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, `<${strong}>$1`); +} + +function title(text: string): string { + return inline(text, "em").replace(/(.*?)<\/em>/g, `$1${UNDERLINE}`); +} + +function codeLine(line: string, lang: Lang): string { + const prompt = lang === "shell" && line.startsWith("$ "); + const body = prompt ? line.slice(2) : line; + const highlighted = hljs.highlight(body, { language: GRAMMAR[lang] }).value; + return prompt ? `$ ${highlighted}` : highlighted; +} + +function html(card: Card): string { + // Longer headlines step down so a two-line title never collides with the + // code block; the breakpoints are the card lengths that fit at each size. + const plain = card.title.replace(/\*\*/g, ""); + const titleSize = plain.length <= 24 ? 72 : plain.length <= 36 ? 60 : 50; + const codeLines = card.code?.split("\n") ?? []; + const longest = Math.max(0, ...codeLines.map((l) => l.length)); + const codeSize = longest > 72 || codeLines.length > 7 ? 17 : longest > 56 ? 19 : 22; + + return ` + + + + + + +
+
+
+ + ${card.eyebrow ? `
${escapeHtml(card.eyebrow)}
` : ""} +
+
+

${title(card.title)}

+ ${card.sub ? `

${inline(card.sub, "strong")}

` : ""} + ${codeLines.length ? `
${codeLines.map((l) => codeLine(l, card.lang ?? "shell")).join("\n")}
` : ""} +
+
+
moq.dev
+ ${card.note ? `
${escapeHtml(card.note)}
` : ""} +
+
+ +`; +} + +async function main() { + const args = process.argv.slice(2); + const flags = args.filter((a) => a.startsWith("-")); + const bad = flags.find((f) => f !== "--html"); + if (bad) throw new Error(`unknown flag ${bad}; the only flag is --html`); + const keepHtml = flags.includes("--html"); + const slugs = args.filter((a) => !a.startsWith("-")); + const cards = slugs.length ? slugs.map((s) => CARDS.find((c) => c.slug === s) ?? unknown(s)) : CARDS; + + // The devshell's Playwright, not a node_modules copy, so the library and its + // Chromium come from the same nixpkgs pin. + const nodePath = process.env.PLAYWRIGHT_NODE_PATH; + if (!nodePath) throw new Error("PLAYWRIGHT_NODE_PATH is unset; run inside the devshell (nix develop)"); + const { chromium } = await import(join(nodePath, "playwright")); + + mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch(); + try { + const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT }, deviceScaleFactor: SCALE }); + for (const card of cards) { + const doc = html(card); + if (keepHtml) writeFileSync(join(OUT, `${card.slug}.html`), doc); + await page.setContent(doc, { waitUntil: "load" }); + await page.evaluate(() => document.fonts.ready); + // The card is sized to the viewport, so anything past its edge is a + // layout bug (a title that wrapped into the code block, a nowrap + // eyebrow or note wider than the card, a code line wider than the + // block), not a crop. Both axes: the nowrap runs overflow sideways. + const overflow = await page.evaluate(() => { + const over = (el: Element) => el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight; + const pre = document.querySelector("pre"); + return over(document.body) || (pre !== null && over(pre)); + }); + if (overflow) throw new Error(`${card.slug}: content overflows the ${WIDTH}x${HEIGHT} card; shorten it`); + const path = join(OUT, `${card.slug}.png`); + await page.screenshot({ path }); + console.log(path); + } + } finally { + await browser.close(); + } +} + +function unknown(slug: string): never { + throw new Error(`no card named ${slug}; see tools/cards/cards.ts`); +} + +await main(); diff --git a/tools/cards/underline.svg b/tools/cards/underline.svg new file mode 100644 index 0000000..c33f03a --- /dev/null +++ b/tools/cards/underline.svg @@ -0,0 +1,18 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + +