diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 891e77e..d7cb7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Until the first release, changes are recorded under [Unreleased]. ### Added +- The website's build writes `sitemap.xml` (the home page, the examples page, each example and its slides) and a `robots.txt` that names it, when given `--url`, as the deploy is. +- `.gitattributes`: shell scripts (`*.sh`) check out with LF line endings on every platform. +- Video chapters: a whole-cut MP4 or WebM from `motion.mjs` carries one chapter per scene, named by its label (scenes in a row with the same label make one), and `-motion.vtt` beside it holds the same chapters. `export.mjs` takes them as `--chapters `. The example videos carry them, and the site's example pages play it in their own player: its scrubber has a segment per chapter (click, drag, arrow keys, Page Up and Page Down by chapter) with the chapter's name on hover, plus play, mute and full screen, and the chapters are listed under it. Without JavaScript the browser's own controls stay. Tests in `dev/test/motion.test.mjs` and `dev/test/site.test.mjs`. - Brands: a per-repo `brand.json` next to `house-style.md` (colours by role in light and dark, display, body and mono fonts with a stylesheet, and a logo) that the post, the motion cut and the deck follow. `scripts/brand.mjs` checks it (unknown keys, hex colours, and a contrast warning below 4.5:1), derives the roles it implies, and writes it into a page as a style block after the template's own, so pages without a brand are unchanged. `viz.mjs build`, `motion.mjs` and `deck.mjs` apply it; `motion.json` takes `"brand"` overrides, and `--brand`/`--no-brand` pick another file or none. `workdir.mjs` prints its path. Docs in `references/visuals.md#brand`, `motion.md`, `deck.md` and `SKILL.md`; tests in `dev/test/brand.test.mjs`. - The buildnotes skill (named build-story until 2026-10-15): `SKILL.md` and the references in `references/` (brief, evidence, story, visuals, modules, craft). It turns issues, pull requests, commits, reviews, and screenshots into a post, in three flavors (release story, build story, and technical deep dive), and it says when a change is only a note. - `scripts/gather.mjs`, which writes `dossier.md` and `dossier.json` from `git` and `gh`: issues, pull requests, commits, review threads, nearby work, and prior work. diff --git a/dev/test/motion.test.mjs b/dev/test/motion.test.mjs index 1827506..fdb88f8 100644 --- a/dev/test/motion.test.mjs +++ b/dev/test/motion.test.mjs @@ -6,7 +6,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { check, build, clipSprite, exportPlan, imageSize, readingTime } from "../../skills/buildnotes/scripts/motion.mjs"; +import { check, build, chaptersVtt, clipSprite, exportPlan, imageSize, readingTime } from "../../skills/buildnotes/scripts/motion.mjs"; import { spawnSync } from "node:child_process"; import { chromePath, openPage } from "../../skills/buildnotes/scripts/chrome.mjs"; @@ -112,6 +112,20 @@ test("a clip is one scene: it starts where the scene starts and stops before its assert.throws(() => exportPlan(cut, { size: 20 }), /a width from 160/); }); +test("the whole reel has a chapter per scene, named by its label, starting where the scene starts", () => { + const { cut } = check(example, base); + const { chapters, seconds } = exportPlan(cut); + const starts = cut.scenes.map((d, i) => Math.round(cut.scenes.slice(0, i).reduce((a, x) => a + x.dur, 0) * 10) / 10); + // Scenes 5 and 6 share "02 · What counts as impact": one chapter, starting at the first. + assert.equal(chapters.length, cut.scenes.length - 1); + assert.deepEqual(chapters.map((c) => c.start), starts.filter((_, i) => i !== 5)); + assert.deepEqual(chapters.map((c) => c.title), cut.scenes.filter((_, i) => i !== 5).map((d) => d.label)); + assert.equal(exportPlan(cut, { scene: 2 }).chapters, undefined, "a one-scene clip has no chapters"); + const vtt = chaptersVtt(chapters, seconds); + assert.match(vtt, /^WEBVTT\n\n1\n00:00:00\.000 --> /); + assert.ok(vtt.trimEnd().endsWith(`--> 00:00:${seconds.toFixed(3)}\nTry it`), "the last cue ends with the video"); +}); + test("the page embeds the images, carries the script safely and drops the authoring notes", () => { const { cut, images } = check({ ...example, title: "Ends here & now" }, base); const html = build(cut, images); diff --git a/dev/test/site.test.mjs b/dev/test/site.test.mjs index bd680e5..11462af 100644 --- a/dev/test/site.test.mjs +++ b/dev/test/site.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { build, SHARE, EXAMPLES, wrapPost, footer, exampleCard, siteFonts } from "../../site/build.mjs"; +import { build, SHARE, EXAMPLES, videoChapters, wrapPost, footer, exampleCard, siteFonts } from "../../site/build.mjs"; import { drawing, NAMES } from "../../site/iso.mjs"; test("the site builds: markers filled, files copied, every command copyable", () => { @@ -41,7 +41,18 @@ test("the site builds: markers filled, files copied, every command copyable", () assert.equal(post.match(//g).length, 1); assert.match(post, /<header class="site-bar site-top">/); // the video sits under the headline, and the rail lists the examples and every chapter - assert.match(post, /<\/header>\s*<aside class="site-bar site-watch" id="site-video"[^>]*>\s*<video src="motion\.mp4"/); + assert.match(post, /<\/header>\s*<aside class="site-bar site-watch" id="site-video"[^>]*>\s*<div class="site-player"[^>]*>\s*<video src="motion\.mp4"[^>]* controls /); + // its chapters: a track for the player, and a segment and a list entry per cue in motion.vtt + assert.ok(files.includes(`examples/${ex.slug}/motion.vtt`), ex.slug); + assert.match(post, /<track kind="chapters" src="motion\.vtt"/); + const cues = videoChapters(ex.slug).length; + assert.ok(cues >= 5, ex.slug); + // the player's own controls (shown by its script; the browser's stay without it): a scrubber with a segment + // per chapter, play, mute and full screen, and the chapters listed under the player + assert.match(post, /<div class="site-controls" hidden>/); + assert.equal(post.match(/<div class="site-scrub" role="slider"[\s\S]*?<\/div>/)[0].match(/class="sp-seg"/g).length, cues, ex.slug); + for (const b of ["sp-play", "sp-mute", "sp-full"]) assert.match(post, new RegExp(`<button type="button" class="${b}" aria-label="[^"]+">`), b); + assert.equal(post.match(/<nav class="site-chapters"[\s\S]*?<\/nav>/)[0].match(/<li>/g).length, cues, ex.slug); assert.match(post, new RegExp(`<a href="\\.\\./${ex.slug}/" aria-current="page">`), ex.slug); assert.equal((post.match(/<nav class="site-bar site-rail"[\s\S]*?<\/nav>/)?.[0].match(/href="#c\d+"/g) ?? []).length, (post.match(/<section class="ch" id="c\d+">/g) ?? []).length, ex.slug); for (const o of EXAMPLES) assert.match(post, new RegExp(`href="\\.\\./\\.\\./examples/${o.slug}/"`), `${ex.slug} links ${o.slug}`); @@ -135,8 +146,20 @@ test("link previews: description, Open Graph and Twitter tags, favicon", () => { assert.match(html, /<link rel="icon" href="assets\/favicon\.svg" type="image\/svg\+xml">/); assert.ok(files.includes("assets/og.png") && files.includes("assets/favicon.svg")); assert.ok(existsSync(join(out, "assets/og.png"))); + assert.ok(!existsSync(join(out, "sitemap.xml")) && !existsSync(join(out, "robots.txt")), "no sitemap without --url"); // with --url: canonical, og:url, and an absolute image inside the site ({ html } = build({ out, url: "https://example.com/buildnotes" })); + // with --url: a sitemap of every page, under that address, and a robots.txt naming it + const map = readFileSync(join(out, "sitemap.xml"), "utf8"); + const locs = [...map.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]); + assert.equal(locs[0], "https://example.com/buildnotes/"); + assert.ok(locs.includes("https://example.com/buildnotes/examples/")); + for (const ex of EXAMPLES) assert.ok(locs.includes(`https://example.com/buildnotes/examples/${ex.slug}/`), ex.slug); + for (const l of locs) { + const path = l.slice("https://example.com/buildnotes/".length); + assert.ok(existsSync(join(out, path.endsWith("/") || !path ? path + "index.html" : path)), `${l} is a built page`); + } + assert.match(readFileSync(join(out, "robots.txt"), "utf8"), /^Sitemap: https:\/\/example\.com\/buildnotes\/sitemap\.xml$/m); assert.match(html, /<link rel="canonical" href="https:\/\/example\.com\/buildnotes\/">/); assert.match(readFileSync(join(out, "examples/index.html"), "utf8"), /<link rel="canonical" href="https:\/\/example\.com\/buildnotes\/examples\/">/); assert.equal(meta(html, "property", "og:url"), "https://example.com/buildnotes/"); diff --git a/docs/developing.md b/docs/developing.md index 2169118..9eb7574 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -30,7 +30,7 @@ To keep a public run as a committed example, check it, then copy its page, plan ```bash node site/build.mjs # or: npm run site -node site/build.mjs --url https://buildnotes.releases.sh # canonical link, og:url, og:image there +node site/build.mjs --url https://buildnotes.releases.sh # canonical link, og:url, og:image there, sitemap.xml and robots.txt node site/card.mjs # re-render the link-preview images in site/assets (needs Chrome) open site/dist/index.html npm run site:dev # build, then serve it as the Worker does at http://localhost:8787 diff --git a/docs/scripts.md b/docs/scripts.md index 8956349..1ad88b3 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -19,7 +19,7 @@ The skill's scripts live in [`scripts/`](../skills/buildnotes/scripts). Each one node skills/buildnotes/scripts/gather.mjs --out <work-dir>/evidence --pr 120,131,140 --download node skills/buildnotes/scripts/lint.mjs <work-dir>/post.html --flavor build --product "Release Notes Index,Releases" --avoid "codename" node skills/buildnotes/scripts/export.mjs <work-dir>/post.html --figure 4 --gif -node skills/buildnotes/scripts/motion.mjs examples/releases-weekly-digests/motion.json --mp4 # the whole cut → export/<story>-motion.mp4 +node skills/buildnotes/scripts/motion.mjs examples/releases-weekly-digests/motion.json --mp4 # the whole cut → export/<story>-motion.mp4, with chapters, and <story>-motion.vtt node skills/buildnotes/scripts/motion.mjs examples/releases-weekly-digests/motion.json --gif --scene 5 --size 720 # one scene → export/<story>-motion-scene-5.gif node skills/buildnotes/scripts/deck.mjs examples/sunny-walkthrough-videos/motion.json --pdf # slides → deck.html, export/<story>-deck.pdf ``` diff --git a/examples/releases-weekly-digests/motion.mp4 b/examples/releases-weekly-digests/motion.mp4 index b25c3f0..a420505 100644 Binary files a/examples/releases-weekly-digests/motion.mp4 and b/examples/releases-weekly-digests/motion.mp4 differ diff --git a/examples/releases-weekly-digests/motion.vtt b/examples/releases-weekly-digests/motion.vtt new file mode 100644 index 0000000..2d69df6 --- /dev/null +++ b/examples/releases-weekly-digests/motion.vtt @@ -0,0 +1,33 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:04.500 +Build notes + +2 +00:00:04.500 --> 00:00:10.300 +The problem + +3 +00:00:10.300 --> 00:00:17.700 +What we added + +4 +00:00:17.700 --> 00:00:25.900 +01 · The week at a glance + +5 +00:00:25.900 --> 00:00:39.100 +02 · What counts as impact + +6 +00:00:39.100 --> 00:00:45.900 +03 · Playing the week back + +7 +00:00:45.900 --> 00:00:54.100 +04 · One email per reader per week + +8 +00:00:54.100 --> 00:00:59.900 +Try it diff --git a/examples/sunny-walkthrough-videos/motion.mp4 b/examples/sunny-walkthrough-videos/motion.mp4 index ecf2288..65fba95 100644 Binary files a/examples/sunny-walkthrough-videos/motion.mp4 and b/examples/sunny-walkthrough-videos/motion.mp4 differ diff --git a/examples/sunny-walkthrough-videos/motion.vtt b/examples/sunny-walkthrough-videos/motion.vtt new file mode 100644 index 0000000..1898322 --- /dev/null +++ b/examples/sunny-walkthrough-videos/motion.vtt @@ -0,0 +1,29 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:04.500 +Build notes + +2 +00:00:04.500 --> 00:00:10.600 +The problem + +3 +00:00:10.600 --> 00:00:23.000 +01 · A camera that plans its own shots + +4 +00:00:23.000 --> 00:00:32.700 +02 · The plan in the corner + +5 +00:00:32.700 --> 00:00:39.400 +03 · Floors and projects + +6 +00:00:39.400 --> 00:00:46.000 +04 · Nothing in the way + +7 +00:00:46.000 --> 00:00:52.000 +Try it diff --git a/examples/uploads-before-after/motion.mp4 b/examples/uploads-before-after/motion.mp4 index b934bdb..78ed1db 100644 Binary files a/examples/uploads-before-after/motion.mp4 and b/examples/uploads-before-after/motion.mp4 differ diff --git a/examples/uploads-before-after/motion.vtt b/examples/uploads-before-after/motion.vtt new file mode 100644 index 0000000..1b6d2f5 --- /dev/null +++ b/examples/uploads-before-after/motion.vtt @@ -0,0 +1,29 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:04.500 +Build notes + +2 +00:00:04.500 --> 00:00:10.300 +The problem + +3 +00:00:10.300 --> 00:00:19.700 +01 · Pairs that find each other + +4 +00:00:19.700 --> 00:00:26.300 +02 · A divider instead of thumbnails + +5 +00:00:26.300 --> 00:00:33.300 +03 · Two shots that line up + +6 +00:00:33.300 --> 00:00:41.400 +04 · The pair that overwrote itself + +7 +00:00:41.400 --> 00:00:47.100 +Try it diff --git a/site/build.mjs b/site/build.mjs index d911cd4..48fa501 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -4,7 +4,8 @@ // Usage: // node site/build.mjs build into site/dist // node site/build.mjs --out <dir> build somewhere else -// node site/build.mjs --url <url> where it will be served: sets the canonical link, og:url and an og:image there +// node site/build.mjs --url <url> where it will be served: sets the canonical link, og:url and an og:image there, +// and writes sitemap.xml and a robots.txt that names it // // site/src/index.html is the page. The build fills in its markers (<!-- share --> and <!-- description --> // become the link-preview tags, from SHARE below), including these: @@ -18,8 +19,10 @@ // examples/<slug>/, with its video beside it. Its post is not copied as is: the committed posts are written // for the Artifact viewer, which wraps them in a document, so the build wraps each one itself (doctype, // charset, viewport) and adds a site shell around it: a bar naming the example with a way back, and a -// footer with the example's video, its plan, and the other examples. examples/index.html lists them all, -// built from site/src/examples.html with the home page's stylesheet. Nothing to install; Node 22 or later. +// footer with the example's video, its plan, and the other examples. The video's chapters come from the +// motion.vtt beside it: the player has its own controls, with a segment per chapter on its scrubber, and +// lists them under it. examples/index.html lists them all, built from site/src/examples.html with the home +// page's stylesheet. Nothing to install; Node 22 or later. import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, existsSync, readdirSync } from "node:fs"; import { dirname, join, resolve, basename } from "node:path"; @@ -95,6 +98,38 @@ const SHELL_CSS = ` /* The video, in a box under the post's headline. */ .site-watch { display: grid; gap: 0; border: 1px solid #e3e3dd; border-radius: 6px; background: #f5f5f1; padding: 6px; } .site-watch video { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 3px; display: block; } +/* The player: the video with its own controls (the page's script swaps out the browser's), whose scrubber is + one segment per chapter, and the chapters as a list under it. */ +.site-player { position: relative; border-radius: 3px; overflow: hidden; background: #000; } +.site-player video { border-radius: 0; } +.site-player:fullscreen { display: flex; align-items: center; border-radius: 0; } +.site-player:fullscreen video { height: 100%; aspect-ratio: auto; object-fit: contain; } +.site-controls { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 10px; padding: 32px 12px 8px; background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.62)); color: #fff; transition: opacity 200ms ease; } +.site-controls[hidden] { display: none; } +.site-player.sp-idle .site-controls { opacity: 0; } +.site-player.sp-idle { cursor: none; } +.site-controls button { all: unset; flex: none; width: 30px; height: 30px; display: grid; place-items: center; cursor: pointer; border-radius: 3px; } +.site-controls button:focus-visible, .site-scrub:focus-visible { outline: 2px solid #fff; outline-offset: 1px; } +.site-controls svg { width: 20px; height: 20px; fill: currentColor; } +.site-player[data-paused] .sp-i-pause, .site-player:not([data-paused]) .sp-i-play, +.site-player[data-muted] .sp-i-sound, .site-player:not([data-muted]) .sp-i-muted, +.site-player[data-full] .sp-i-full, .site-player:not([data-full]) .sp-i-exit { display: none; } +.site-scrub { flex: 1; min-width: 0; position: relative; display: flex; gap: 3px; align-items: center; height: 22px; cursor: pointer; touch-action: none; border-radius: 3px; } +.sp-seg { flex: 1 1 0; min-width: 3px; height: 4px; border-radius: 2px; background: linear-gradient(#fff, #fff) no-repeat 0 0 / var(--played, 0%) 100%, rgba(255, 255, 255, 0.32); transition: height 120ms ease; } +.site-scrub:hover .sp-seg, .site-scrub.sp-drag .sp-seg { height: 6px; } +.site-scrub .sp-seg.sp-hover { height: 8px; } +.sp-tip { position: absolute; bottom: 26px; left: 0; max-width: 18rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 8px; border-radius: 3px; background: rgba(24, 24, 22, 0.92); color: #fff; font-size: 0.75rem; line-height: 1.3; pointer-events: none; } +.sp-tip[hidden] { display: none; } +.site-player .sp-tip b { font-family: "Martian Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 400; font-size: 0.6875rem; margin-right: 8px; color: #fff; } +.sp-time { flex: none; font-family: "Martian Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.6875rem; font-stretch: 87.5%; font-variant-numeric: tabular-nums; white-space: nowrap; } +@media (max-width: 30rem) { .sp-time { display: none; } } +.site-chapters ol { list-style: none; margin: 0; padding: 10px 6px 2px; columns: 2 15rem; column-gap: 16px; } +.site-chapters li { break-inside: avoid; } +.site-chapters button { all: unset; box-sizing: border-box; display: flex; gap: 10px; align-items: baseline; width: 100%; padding: 3px 0; cursor: pointer; color: #64645f; line-height: 1.35; } +.site-chapters button:hover { color: #1f1f1d; } +.site-chapters button:focus-visible { outline: 2px solid #3358d4; outline-offset: 2px; border-radius: 2px; } +.site-chapters button[aria-current] { color: #1f1f1d; font-weight: 600; } +.site-chapters .site-label { flex: none; min-width: 2.6em; font-weight: 400; font-variant-numeric: tabular-nums; } .site-watch .site-watch-bar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 4px 16px; padding: 10px 6px 4px; } .site-watch b { color: #1f1f1d; font-weight: 600; } .site-watch .site-links { display: flex; flex-wrap: wrap; gap: 4px 16px; } @@ -126,6 +161,49 @@ function footerCss() { .replace(/^(\s*)footer\b/gm, "$1.site-footer footer"); } +// The video's chapters, from the motion.vtt beside it (motion.mjs writes one with each export): [{ start, end, title }]. +export function videoChapters(slug) { + const file = join(root, "examples", slug, "motion.vtt"); + if (!existsSync(file)) return []; + const secs = (t) => t.split(":").reduce((a, x) => a * 60 + Number(x), 0); + return [...readFileSync(file, "utf8").matchAll(/^([\d:.]+) --> ([\d:.]+)\n(.+)$/gm)].map(([, a, b, title]) => ({ start: secs(a), end: secs(b), title })); +} + +const clock = (t) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; +const ICON = (cls, d) => `<svg class="${cls}" viewBox="0 0 20 20" aria-hidden="true">${d}</svg>`; +const SPEAKER = `<path d="M3 7.5h3l4.5-3.5v12L6 12.5H3z"/>`; +const ICONS = { + play: ICON("sp-i-play", `<path d="M6 4.2v11.6L15.5 10z"/>`), + pause: ICON("sp-i-pause", `<path d="M5.5 4h3v12h-3zM11.5 4h3v12h-3z"/>`), + sound: ICON("sp-i-sound", `${SPEAKER}<path d="M13.2 7.2a4 4 0 0 1 0 5.6M15.4 5a7 7 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>`), + muted: ICON("sp-i-muted", `${SPEAKER}<path d="M13.5 8l4 4M17.5 8l-4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>`), + full: ICON("sp-i-full", `<path d="M3.5 8V3.5H8M12 3.5h4.5V8M16.5 12v4.5H12M8 16.5H3.5V12" fill="none" stroke="currentColor" stroke-width="1.8"/>`), + exit: ICON("sp-i-exit", `<path d="M8 3.5V8H3.5M16.5 8H12V3.5M12 16.5V12h4.5M3.5 12H8v4.5" fill="none" stroke="currentColor" stroke-width="1.8"/>`), +}; + +// The video and its controls. With chapters (the motion.vtt beside it), the page's script replaces the +// browser's controls with its own, whose scrubber has one segment per chapter, filled as the video plays; +// the chapters are also listed under it. Without the script, the browser's controls stay. The cue text is +// already escaped for HTML (WebVTT escapes the same way). +function player(marks) { + const video = (track) => `<video src="motion.mp4" autoplay muted loop playsinline controls preload="metadata" aria-label="The video cut of this post.">${track}</video>`; + if (!marks.length) return video(""); + const total = marks.at(-1).end; + return `<div class="site-player" data-paused data-muted> + ${video(`<track kind="chapters" src="motion.vtt" srclang="en" label="Chapters" default>`)} + <div class="site-controls" hidden> + <button type="button" class="sp-play" aria-label="Play">${ICONS.play}${ICONS.pause}</button> + <div class="site-scrub" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="0" aria-valuetext="0:00">${marks.map((c) => `<span class="sp-seg" data-at="${c.start}" data-title="${c.title}" style="flex-grow: ${(c.end - c.start).toFixed(2)}"></span>`).join("")}<span class="sp-tip" hidden></span></div> + <span class="sp-time"><span class="sp-now">0:00</span> / ${clock(total)}</span> + <button type="button" class="sp-mute" aria-label="Unmute">${ICONS.sound}${ICONS.muted}</button> + <button type="button" class="sp-full" aria-label="Full screen">${ICONS.full}${ICONS.exit}</button> + </div> + </div> + <nav class="site-chapters" aria-label="Chapters"> + <ol>${marks.map((c) => `<li><button type="button" data-at="${c.start}"><span class="site-label">${clock(c.start)}</span>${c.title}</button></li>`).join("")}</ol> + </nav>`; +} + export function wrapPost(slug, post, url) { const i = EXAMPLES.findIndex((e) => e.slug === slug); if (i < 0) throw new Error(`examples/${slug} is not in EXAMPLES (site/build.mjs)`); @@ -138,9 +216,10 @@ export function wrapPost(slug, post, url) { // The video goes in a box right under the post's headline. const mast = body.indexOf("</header>"); if (mast < 0) throw new Error(`examples/${slug}: no </header> to put the video under`); + const marks = videoChapters(slug); const watch = ` <aside class="site-bar site-watch" id="site-video" aria-label="The video"> - <video src="motion.mp4" autoplay muted loop playsinline controls preload="metadata" aria-label="The video cut of this post."></video> + ${player(marks)} <div class="site-watch-bar"><b>The same story as a video</b><span class="site-links"><a href="motion.mp4" download>Download</a>${ex.deck ? `<a href="deck.html">Slides</a>` : ""}<a href="${REPO}/blob/main/examples/${ex.slug}/plan.md">The plan behind it</a></span></div> </aside>`; body = body.slice(0, mast + 9) + watch + body.slice(mast + 9); @@ -202,6 +281,150 @@ ${footer("../../")} for (const a of links) { const t = document.getElementById(a.hash.slice(1)); if (t) io.observe(t); } if (matchMedia("(prefers-reduced-motion: reduce)").matches) document.querySelector(".site-watch video")?.pause(); })(); +// The player's own controls: a scrubber with a segment per chapter (click, drag, or the arrow keys; Page Up and +// Page Down move a chapter), play, mute and full screen. They fade while the video plays and the pointer rests. +(() => { + const box = document.querySelector(".site-player"); + if (!box) return; + const video = box.querySelector("video"); + const bar = box.querySelector(".site-controls"); + const scrub = bar.querySelector(".site-scrub"); + const tip = scrub.querySelector(".sp-tip"); + const now = bar.querySelector(".sp-now"); + const [play, mute, full] = [".sp-play", ".sp-mute", ".sp-full"].map((s) => bar.querySelector(s)); + const segs = [...scrub.querySelectorAll(".sp-seg")]; + const items = [...document.querySelectorAll(".site-chapters button[data-at]")]; + const starts = segs.map((s) => Number(s.dataset.at)); + const titles = segs.map((s) => s.dataset.title); + const total = () => video.duration || Number(scrub.getAttribute("aria-valuemax")); + const end = (i) => starts[i + 1] ?? total(); + const clamp = (x, a, b) => Math.min(b, Math.max(a, x)); + const clock = (t) => Math.floor(t / 60) + ":" + String(Math.floor(t % 60)).padStart(2, "0"); + const chapterAt = (t) => Math.max(0, starts.findLastIndex((a) => a <= t + 0.05)); + video.controls = false; + bar.hidden = false; + + let current = -1; + const draw = () => { + const t = video.currentTime; + segs.forEach((s, i) => s.style.setProperty("--played", clamp(((t - starts[i]) / (end(i) - starts[i])) * 100, 0, 100) + "%")); + now.textContent = clock(t); + const on = chapterAt(t); + scrub.setAttribute("aria-valuenow", t.toFixed(1)); + scrub.setAttribute("aria-valuetext", clock(t) + ", " + titles[on]); + if (on !== current) { items[current]?.removeAttribute("aria-current"); items[on]?.setAttribute("aria-current", "true"); current = on; } + }; + const state = () => { + box.toggleAttribute("data-paused", video.paused); + box.toggleAttribute("data-muted", video.muted); + play.setAttribute("aria-label", video.paused ? "Play" : "Pause"); + mute.setAttribute("aria-label", video.muted ? "Unmute" : "Mute"); + }; + let raf = 0; + const tick = () => { draw(); raf = video.paused ? 0 : requestAnimationFrame(tick); }; + video.addEventListener("play", () => { state(); wake(); if (!raf) raf = requestAnimationFrame(tick); }); + video.addEventListener("pause", () => { state(); wake(); }); + video.addEventListener("volumechange", state); + for (const e of ["seeked", "timeupdate", "loadedmetadata"]) video.addEventListener(e, draw); + const toggle = () => (video.paused ? video.play().catch(() => {}) : video.pause()); + const seekTo = (t) => { video.currentTime = clamp(t, 0, total() - 0.05); draw(); }; + + // A pointer's place on the scrubber, as a time: the segment under it (or the nearest), and how far into it. + const timeAt = (x) => { + let i = 0, best = Infinity, frac = 0; + segs.forEach((s, j) => { + const r = s.getBoundingClientRect(); + const d = x < r.left ? r.left - x : x > r.right ? x - r.right : 0; + if (d < best) { best = d; i = j; frac = clamp((x - r.left) / r.width, 0, 1); } + }); + return { i, t: starts[i] + frac * (end(i) - starts[i]) }; + }; + const showTip = (x) => { + const { i, t } = timeAt(x); + tip.replaceChildren(Object.assign(document.createElement("b"), { textContent: clock(t) }), titles[i]); + tip.hidden = false; + const r = scrub.getBoundingClientRect(); + tip.style.left = clamp(x - r.left - tip.offsetWidth / 2, 0, r.width - tip.offsetWidth) + "px"; + segs.forEach((s, j) => s.classList.toggle("sp-hover", j === i)); + }; + const hideTip = () => { tip.hidden = true; segs.forEach((s) => s.classList.remove("sp-hover")); }; + let dragging = false; + let resume = false; + scrub.addEventListener("pointerdown", (e) => { + if (e.button) return; + dragging = true; + resume = !video.paused; + scrub.setPointerCapture(e.pointerId); + scrub.classList.add("sp-drag"); + video.pause(); + seekTo(timeAt(e.clientX).t); + showTip(e.clientX); + }); + scrub.addEventListener("pointermove", (e) => { + if (dragging) seekTo(timeAt(e.clientX).t); + if (dragging || e.pointerType === "mouse") showTip(e.clientX); + }); + const stop = (e) => { + if (!dragging) return; + dragging = false; + scrub.classList.remove("sp-drag"); + if (resume) video.play().catch(() => {}); + if (e.pointerType !== "mouse") hideTip(); + }; + scrub.addEventListener("pointerup", stop); + scrub.addEventListener("pointercancel", stop); + scrub.addEventListener("pointerleave", () => { if (!dragging) hideTip(); }); + scrub.addEventListener("keydown", (e) => { + const t = video.currentTime; + const i = chapterAt(t); + const to = { + ArrowLeft: t - 5, ArrowDown: t - 5, ArrowRight: t + 5, ArrowUp: t + 5, + PageDown: t - starts[i] > 1 ? starts[i] : starts[Math.max(0, i - 1)], + PageUp: starts[Math.min(starts.length - 1, i + 1)], + Home: 0, End: starts.at(-1), + }[e.key]; + if (to !== undefined) { e.preventDefault(); seekTo(to); } + else if (e.key === " " || e.key === "Enter") { e.preventDefault(); toggle(); } + }); + + play.addEventListener("click", toggle); + mute.addEventListener("click", () => { video.muted = !video.muted; }); + const fullscreen = () => document.fullscreenElement || document.webkitFullscreenElement; + full.addEventListener("click", () => { + if (fullscreen()) (document.exitFullscreen || document.webkitExitFullscreen).call(document); + else if (box.requestFullscreen) box.requestFullscreen().catch(() => video.webkitEnterFullscreen?.()); + else if (box.webkitRequestFullscreen) box.webkitRequestFullscreen(); + else video.webkitEnterFullscreen?.(); + }); + const onFull = () => { box.toggleAttribute("data-full", !!fullscreen()); full.setAttribute("aria-label", fullscreen() ? "Exit full screen" : "Full screen"); }; + document.addEventListener("fullscreenchange", onFull); + document.addEventListener("webkitfullscreenchange", onFull); + + // The controls fade after a moment of play with the pointer still, and come back on any move, tap or key. + // A tap on a faded video brings the controls back rather than pausing it. + let idle = 0; + let wasIdle = false; + function wake() { + box.classList.remove("sp-idle"); + clearTimeout(idle); + idle = setTimeout(() => { if (!video.paused && !dragging && !bar.contains(document.activeElement)) box.classList.add("sp-idle"); }, 2500); + } + box.addEventListener("pointerdown", () => { wasIdle = box.classList.contains("sp-idle"); wake(); }, true); + box.addEventListener("pointermove", wake); + box.addEventListener("focusin", wake); + box.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse" && !video.paused && !dragging) box.classList.add("sp-idle"); }); + video.addEventListener("click", (e) => { if (e.pointerType !== "mouse" && wasIdle) return; toggle(); }); + video.addEventListener("dblclick", () => full.click()); + box.addEventListener("keydown", (e) => { + if (e.target.closest("button, .site-scrub")) return; + if (e.key === "k" || e.key === " ") { e.preventDefault(); toggle(); } + }); + + for (const b of items) b.addEventListener("click", () => { seekTo(Number(b.dataset.at)); if (video.paused) video.play().catch(() => {}); }); + state(); + draw(); + wake(); +})(); </script> </body> </html> @@ -352,6 +575,20 @@ function examplesPage(homeHtml, url) { return page; } +// The site's pages, as paths from its root: the folders end in a slash, as auto-trailing-slash serves them. +export function sitePages() { + return ["", "examples/", ...EXAMPLES.flatMap((ex) => [`examples/${ex.slug}/`, ...(ex.deck ? [`examples/${ex.slug}/deck.html`] : [])])]; +} + +export function sitemap(url, pages) { + const loc = (p) => new URL(p, url).href.replace(/&/g, "&").replace(/</g, "<"); + return `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +${pages.map((p) => ` <url><loc>${loc(p)}</loc></url>`).join("\n")} +</urlset> +`; +} + // url: where site/dist will be served, e.g. https://example.com/buildnotes/ (a trailing slash is added). export function build({ out = join(here, "dist"), url } = {}) { if (url) { @@ -394,6 +631,7 @@ export function build({ out = join(here, "dist"), url } = {}) { for (const ex of EXAMPLES) { files.set(`examples/${ex.slug}/index.html`, join(root, "examples", ex.slug, ex.file)); files.set(`examples/${ex.slug}/motion.mp4`, join(root, "examples", ex.slug, "motion.mp4")); + if (videoChapters(ex.slug).length) files.set(`examples/${ex.slug}/motion.vtt`, join(root, "examples", ex.slug, "motion.vtt")); if (ex.deck) for (const f of ["deck.html", "deck.pdf"]) files.set(`examples/${ex.slug}/${f}`, join(root, "examples", ex.slug, f)); files.set(exampleCard(ex), join(here, exampleCard(ex))); // its link-preview card } @@ -412,6 +650,14 @@ export function build({ out = join(here, "dist"), url } = {}) { } writeFileSync(join(out, "examples/index.html"), siteFonts(examples, "../", "src/examples.html")); files.set("examples/index.html", null); + // With --url, a sitemap of every page (the home page, the examples page, each example and its slides) and a + // robots.txt that points to it. A sitemap needs absolute URLs, so a build without --url writes neither. + if (url) { + writeFileSync(join(out, "sitemap.xml"), sitemap(url, sitePages())); + writeFileSync(join(out, "robots.txt"), `User-agent: *\nAllow: /\n\nSitemap: ${new URL("sitemap.xml", url).href}\n`); + files.set("sitemap.xml", null); + files.set("robots.txt", null); + } return { html, examples, files: [...files.keys()], warnings, out }; } diff --git a/skills/buildnotes/references/motion.md b/skills/buildnotes/references/motion.md index 89d31c2..f0d17d1 100644 --- a/skills/buildnotes/references/motion.md +++ b/skills/buildnotes/references/motion.md @@ -101,7 +101,7 @@ node <skill-dir>/scripts/motion.mjs <work-dir>/motion.json --gif --scene 5 --siz Building writes `motion.html` with the images embedded (a `clip`'s footage becomes one sheet of frames, which needs `ffmpeg`), then opens it in headless Chrome to check that every scene's text fits the frame. A headline shrinks to fit before it is reported; a reported scene needs fewer words. -`--mp4` (or `--webm`, or `--gif` at half size) writes `<work-dir>/export/<story>-motion.mp4` at 1920×1080 and 30 fps. `<story>` is the work dir's name without its date (`walkthrough-videos`), or the cut's title when the dir has a generic name; `--name <slug>` sets it, so exports dragged out of the folder still say which story they belong to. `--poster` writes a still from the end of the title scene, for a thumbnail; `--at <seconds>` picks another moment. The video opens on that same frame, held for one frame, because GitHub and most players show the first frame before play and ignore a poster; the MP4 also carries it as cover art for Finder and QuickTime. `--theme dark` exports the dark theme. Export needs Chrome and `ffmpeg` and takes a couple of minutes for a one-minute cut. +`--mp4` (or `--webm`, or `--gif` at half size) writes `<work-dir>/export/<story>-motion.mp4` at 1920×1080 and 30 fps. `<story>` is the work dir's name without its date (`walkthrough-videos`), or the cut's title when the dir has a generic name; `--name <slug>` sets it, so exports dragged out of the folder still say which story they belong to. `--poster` writes a still from the end of the title scene, for a thumbnail; `--at <seconds>` picks another moment. The video opens on that same frame, held for one frame, because GitHub and most players show the first frame before play and ignore a poster; the MP4 also carries it as cover art for Finder and QuickTime. A whole-reel MP4 or WebM carries one chapter per scene, named by its `label` (else its headline), so QuickTime, VLC and IINA can jump between them; scenes in a row with the same label make one chapter. The same chapters go to `<story>-motion.vtt` beside it, for a web page's `<track kind="chapters">`: browsers' own video controls don't show chapters, so a page that wants them draws them from that file. `--theme dark` exports the dark theme. Export needs Chrome and `ffmpeg` and takes a couple of minutes for a one-minute cut. `--scene <n>` exports one scene alone, counted from 1, as `export/<story>-motion-scene-<n>.<ext>`. It stops just before the scene fades out, so a GIF loops on the scene. A clip is the right size for a README, a reply in a thread or a changelog entry; the `morph` and `flow` scenes make the best ones. `--size <px>` sets the width (default 1920, or 960 for a GIF): `--gif --scene 5 --size 720` gives a clip of a few hundred kilobytes. With `--poster`, `--scene` gives that scene's last frame. diff --git a/skills/buildnotes/scripts/export.mjs b/skills/buildnotes/scripts/export.mjs index 1e7f9a9..52c0cff 100644 --- a/skills/buildnotes/scripts/export.mjs +++ b/skills/buildnotes/scripts/export.mjs @@ -29,6 +29,8 @@ // --pad <px> page background around the figure; default 16 // --transparent for --svg: no background rectangle // --frames for GIF and video: keep the PNG frames next to the output +// --chapters <file> for video: a JSON list of { "start": <s>, "title" } from the start of the loop, written into +// the file as chapters (QuickTime, VLC and IINA list them; browsers' own controls don't) // // GIF and video need ffmpeg. Frames are stepped, not recorded: SVG and CSS motion is paused and moved // to each frame's time, and the page's scripts run on a virtual clock (requestAnimationFrame, @@ -38,7 +40,7 @@ // --seconds. Needs Chrome (set CHROME_PATH if it isn't found). import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync, existsSync, renameSync } from "node:fs"; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync, renameSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -178,12 +180,25 @@ try { copyFileSync(name(cover), name(0)); } const input = ["-framerate", String(fps), "-i", join(frames, "frame-%04d.png")]; - const art = cover ? ["-i", join(frames, "cover.png"), "-map", "0", "-map", "1", "-c:v:1", "png", "-disposition:v:1", "attached_pic"] : []; + // Chapters go in as an ffmetadata file, each one ending where the next starts. + let marks = []; + let toc = []; + if (opt("chapters") && format !== "gif") { + const list = JSON.parse(readFileSync(opt("chapters"), "utf8")).filter((c) => c.start >= 0 && c.start < seconds).sort((a, b) => a.start - b.start); + const esc = (t) => String(t).replace(/[=;#\\\n]/g, (m) => `\\${m}`); + const meta = list.map((c, i) => `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${Math.round(c.start * 1000)}\nEND=${Math.round((list[i + 1]?.start ?? seconds) * 1000)}\ntitle=${esc(c.title)}\n`).join(""); + writeFileSync(join(frames, "chapters.txt"), `;FFMETADATA1\n${meta}`); + marks = ["-f", "ffmetadata", "-i", join(frames, "chapters.txt")]; + toc = ["-map_chapters", "1"]; + } + // The cover art is the next input after the frames and any chapters; inputs come before output options. + const pic = toc.length ? 2 : 1; + const art = cover ? ["-i", join(frames, "cover.png"), "-map", "0", "-map", String(pic), "-c:v:1", "png", "-disposition:v:1", "attached_pic"] : []; const even = "pad=ceil(iw/2)*2:ceil(ih/2)*2"; const args = { gif: [...input, "-vf", "split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle", "-loop", "0"], - mp4: [...input, ...art, "-filter:v:0", even, "-c:v:0", "libx264", "-pix_fmt:v:0", "yuv420p", "-crf", "18", "-movflags", "+faststart"], - webm: [...input, "-vf", even, "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-pix_fmt", "yuv420p"], + mp4: [...input, ...marks, ...art, ...toc, "-filter:v:0", even, "-c:v:0", "libx264", "-pix_fmt:v:0", "yuv420p", "-crf", "18", "-movflags", "+faststart"], + webm: [...input, ...marks, ...toc, "-vf", even, "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-pix_fmt", "yuv420p"], }[format]; const run = spawnSync("ffmpeg", ["-y", "-loglevel", "error", ...args, out], { encoding: "utf8" }); if (run.status !== 0) { rmSync(frames, { recursive: true, force: true }); await done(1, `ffmpeg failed: ${run.stderr.trim().split("\n").pop()}`); } diff --git a/skills/buildnotes/scripts/motion.mjs b/skills/buildnotes/scripts/motion.mjs index 002fdfa..e0a64a4 100644 --- a/skills/buildnotes/scripts/motion.mjs +++ b/skills/buildnotes/scripts/motion.mjs @@ -32,7 +32,7 @@ // in. Video is 1920×1080: export.mjs steps the stage frame by frame on a virtual clock, so it needs Chrome // and ffmpeg. Warnings go to stderr. -import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; import { basename, dirname, extname, join, resolve } from "node:path"; @@ -258,12 +258,13 @@ export function build(cut, images, { brand = null, template = readFileSync(TEMPL } // What to export: the whole reel, or one scene as a clip; the clip's place on the timeline, and the scale -// that gives the asked-for width. Scene numbers count from 1. +// that gives the asked-for width. Scene numbers count from 1. The whole reel also gets chapters, one per +// scene, named by its label (else its headline), which a video carries for the players that show them. export function exportPlan(cut, { scene, size, gif = false } = {}) { const width = Number(size ?? (gif ? 960 : 1920)); if (!(width >= 160 && width <= 3840)) throw new Error(`--size ${size}: a width from 160 to 3840`); const plan = { scale: (EXPORT.scale * width) / 1920, from: 0, seconds: cut.total, at: round(cut.scenes[0].dur - 0.5), suffix: "" }; - if (scene == null) return plan; + if (scene == null) return { ...plan, chapters: chapters(cut) }; const n = Number(scene); if (!Number.isInteger(n) || n < 1 || n > cut.scenes.length) throw new Error(`--scene ${scene}: a scene from 1 to ${cut.scenes.length}`); const from = cut.scenes.slice(0, n - 1).reduce((a, d) => a + d.dur, 0); @@ -272,6 +273,25 @@ export function exportPlan(cut, { scene, size, gif = false } = {}) { return { ...plan, from: round(from), seconds, at: round(from + seconds - 0.1), suffix: `-scene-${n}` }; } +// Scenes in a row with the same label (a chapter told in two scenes) make one chapter. +export function chapters(cut) { + const list = []; + let at = 0; + for (const d of cut.scenes) { + const title = String(d.label || d.headline || d.kind).replace(/\*/g, "").trim(); + if (list.at(-1)?.title !== title) list.push({ start: round(at), title }); + at += d.dur; + } + return list; +} + +// The chapters as WebVTT, for a <track kind="chapters"> beside the video: each cue ends where the next starts. +export function chaptersVtt(list, total) { + const ts = (t) => new Date(Math.round(t * 1000)).toISOString().slice(11, 23); + const text = (t) => t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + return `WEBVTT\n\n${list.map((c, i) => `${i + 1}\n${ts(c.start)} --> ${ts(list[i + 1]?.start ?? total)}\n${text(c.title)}\n`).join("\n")}`; +} + // A clip's footage as one sprite sheet: its frames from "from" to "to" at its own rate up to 15 a second, at most // 640 px wide, tiled left to right and top to bottom, in one JPEG. Returns the sheet's file and how to step through it. export function clipSprite(d) { @@ -350,8 +370,9 @@ async function main(argv) { let plan; try { plan = exportPlan(cut, { scene: opt("scene"), size: set("size"), gif: video === "gif" }); } catch (e) { console.error(`error: ${e.message}`); return 2; } const stem = exportStem({ name: set("name"), dir: base, title: cut.title }); + const outFor = (fmt) => join(dirname(page), "export", exportName(stem, `motion${plan.suffix}`, fmt)); const exportOne = (fmt, extra) => { - const out = join(dirname(page), "export", exportName(stem, `motion${plan.suffix}`, fmt)); + const out = outFor(fmt); const args = [join(here, "export.mjs"), page, "--select", "#stage", `--${fmt}`, "--width", String(EXPORT.width), "--pad", "0", "--theme", theme, "--out", out, ...extra]; const r = spawnSync(process.execPath, args, { stdio: ["ignore", "inherit", "inherit"] }); return r.status ?? 1; @@ -359,8 +380,13 @@ async function main(argv) { if (video) { // A video opens on the poster frame, since GitHub and most players show frame 0 before play. // A GIF at full size is tens of megabytes; half size at 15 fps keeps it shareable. - const status = exportOne(video, ["--scale", String(plan.scale), "--fps", String(set("fps") ?? (video === "gif" ? 15 : 30)), "--from", String(plan.from), "--seconds", String(plan.seconds), ...(video === "gif" ? [] : ["--cover", opt("at") ?? String(plan.at)])]); + const marks = plan.chapters && video !== "gif" ? join(mkdtempSync(join(tmpdir(), "buildnotes-chapters-")), "chapters.json") : null; + if (marks) writeFileSync(marks, JSON.stringify(plan.chapters)); + const status = exportOne(video, ["--scale", String(plan.scale), "--fps", String(set("fps") ?? (video === "gif" ? 15 : 30)), "--from", String(plan.from), "--seconds", String(plan.seconds), ...(video === "gif" ? [] : ["--cover", opt("at") ?? String(plan.at)]), ...(marks ? ["--chapters", marks] : [])]); + if (marks) rmSync(dirname(marks), { recursive: true, force: true }); if (status) return status; + // The same chapters as WebVTT next to the video, for a web player to list and jump between. + if (marks) { writeFileSync(outFor("vtt"), chaptersVtt(plan.chapters, plan.seconds)); console.log(`wrote ${outFor("vtt")} (${plan.chapters.length} chapters)`); } } if (has("poster")) { const status = exportOne("png", ["--scale", String(plan.scale), "--at", opt("at") ?? String(plan.at)]);