diff --git a/marketing/animations/.gitignore b/marketing/animations/.gitignore new file mode 100644 index 00000000..b665b878 --- /dev/null +++ b/marketing/animations/.gitignore @@ -0,0 +1,4 @@ +frames/ +# and as a symlink: a worktree that shares the primary checkout's install has +# node_modules as a link, which `node_modules/` does not match. +node_modules diff --git a/marketing/animations/artifactory.html b/marketing/animations/artifactory.html new file mode 100644 index 00000000..9ad4dcc2 --- /dev/null +++ b/marketing/animations/artifactory.html @@ -0,0 +1,268 @@ + + + + +GraphCode — a note for whoever comes next + + + +
+
graphcode · artifactory
+ + + +
Fixer
+
Reviewer
+
Auditor
+ +
node send
+
+
three days later
+ +
+
Artifactory
+
+
+
+
+ +
+
+
+
+ + + diff --git a/marketing/animations/build.sh b/marketing/animations/build.sh new file mode 100755 index 00000000..39ba2765 --- /dev/null +++ b/marketing/animations/build.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Renders every animation to a GIF (for X) and an MP4 (for anywhere that takes video). +# +# ./build.sh all figures +# ./build.sh pass-one-vs-pass-two +# +# Frames are stepped through window.__seek, so a re-run of an unchanged file produces +# byte-identical output — the GIF can never drift from the HTML it came from. +set -euo pipefail +cd "$(dirname "$0")" + +[ -d node_modules/playwright-core ] || npm i --silent playwright-core + +CAPTURE_FPS=25 # what the frames are stepped at, and the MP4's rate +GIF_FPS=12.5 # halved for the GIF: same motion, half the palette work +WIDTH=1200 +HEIGHT=675 + +figures=("$@") +if [ ${#figures[@]} -eq 0 ]; then + figures=(loop-learns loops-talk bundle-travels fresh-identity) +fi + +mkdir -p out +for name in "${figures[@]}"; do + name="${name%.html}" + echo "── $name" + node render.mjs "$name.html" "frames/$name" "$CAPTURE_FPS" "$WIDTH" "$HEIGHT" + + ffmpeg -loglevel error -y -framerate "$CAPTURE_FPS" -i "frames/$name/f%04d.png" \ + -c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart \ + -vf "scale=1600:-2:flags=lanczos" "out/$name.mp4" + + # Two passes: a palette built from the whole clip, then applied. One-pass GIFs band + # badly on these flat dark panels. + ffmpeg -loglevel error -y -framerate "$CAPTURE_FPS" -i "frames/$name/f%04d.png" \ + -vf "fps=$GIF_FPS,scale=1100:-1:flags=lanczos,palettegen=max_colors=192:stats_mode=diff" \ + -y "frames/$name/palette.png" + ffmpeg -loglevel error -y -framerate "$CAPTURE_FPS" -i "frames/$name/f%04d.png" \ + -i "frames/$name/palette.png" \ + -lavfi "fps=$GIF_FPS,scale=1100:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle" \ + -loop 0 "out/$name.gif" + + # A still for anywhere that won't take motion, taken from the held final frame. + cp "frames/$name/$(ls "frames/$name" | grep '^f' | tail -1)" "out/$name.png" + + printf ' %-34s %s\n' "$name.gif" "$(du -h "out/$name.gif" | cut -f1)" + printf ' %-34s %s\n' "$name.mp4" "$(du -h "out/$name.mp4" | cut -f1)" +done diff --git a/marketing/animations/out/artifactory.gif b/marketing/animations/out/artifactory.gif new file mode 100644 index 00000000..9d2fcde2 Binary files /dev/null and b/marketing/animations/out/artifactory.gif differ diff --git a/marketing/animations/out/artifactory.mp4 b/marketing/animations/out/artifactory.mp4 new file mode 100644 index 00000000..2c2ef4f6 Binary files /dev/null and b/marketing/animations/out/artifactory.mp4 differ diff --git a/marketing/animations/out/artifactory.png b/marketing/animations/out/artifactory.png new file mode 100644 index 00000000..3e925c0c Binary files /dev/null and b/marketing/animations/out/artifactory.png differ diff --git a/marketing/animations/package-lock.json b/marketing/animations/package-lock.json new file mode 100644 index 00000000..230946d3 --- /dev/null +++ b/marketing/animations/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "animations", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "animations", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "playwright-core": "^1.62.1" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/marketing/animations/package.json b/marketing/animations/package.json new file mode 100644 index 00000000..a8cb721d --- /dev/null +++ b/marketing/animations/package.json @@ -0,0 +1,16 @@ +{ + "name": "animations", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "playwright-core": "^1.62.1" + } +} diff --git a/marketing/animations/probe.mjs b/marketing/animations/probe.mjs new file mode 100644 index 00000000..c86daa58 --- /dev/null +++ b/marketing/animations/probe.mjs @@ -0,0 +1,17 @@ +import { chromium } from 'playwright-core'; +import { resolve } from 'node:path'; +const [file, ...times] = process.argv.slice(2); +const browser = await chromium.launch({ channel: 'chrome' }); +const page = await browser.newPage({ viewport:{width:1200,height:675}, deviceScaleFactor:2, colorScheme:'dark' }); +const errs = []; +page.on('pageerror', e => errs.push(String(e))); +page.on('console', m => { if (m.type()==='error') errs.push(m.text()); }); +await page.goto('file://' + resolve(file)); +await page.waitForFunction(() => typeof window.__seek === 'function'); +await page.evaluate(() => window.__pause()); +for (const t of times) { + await page.evaluate(v => window.__seek(v), Number(t)); + await page.screenshot({ path: `probe-${t}.png` }); +} +await browser.close(); +console.log(errs.length ? 'ERRORS:\n' + errs.join('\n') : 'no console errors'); diff --git a/marketing/animations/render.mjs b/marketing/animations/render.mjs new file mode 100644 index 00000000..7dfbbe27 --- /dev/null +++ b/marketing/animations/render.mjs @@ -0,0 +1,42 @@ +// Frame-capture for the article animations. +// +// node render.mjs [fps] [width] [height] +// +// Each animation exposes `window.__seek(t)` and `window.__duration`, so frames are +// stepped rather than recorded in real time: the capture is deterministic and the GIF +// can never drift from the HTML it came from. +import { chromium } from 'playwright-core'; +import { mkdir, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const [file, outDir, fpsArg, wArg, hArg] = process.argv.slice(2); +if (!file || !outDir) { + console.error('usage: node render.mjs [fps] [width] [height]'); + process.exit(1); +} + +const fps = Number(fpsArg ?? 25); +const width = Number(wArg ?? 1200); +const height = Number(hArg ?? 760); + +await rm(outDir, { recursive: true, force: true }); +await mkdir(outDir, { recursive: true }); + +const browser = await chromium.launch({ channel: 'chrome' }); +const page = await browser.newPage({ + viewport: { width, height }, + deviceScaleFactor: 2, + colorScheme: 'dark', +}); +await page.goto('file://' + resolve(file)); +await page.waitForFunction(() => typeof window.__seek === 'function'); +await page.evaluate(() => window.__pause()); + +const duration = await page.evaluate(() => window.__duration); +const frames = Math.round(duration * fps); +for (let i = 0; i < frames; i++) { + await page.evaluate(t => window.__seek(t), i / fps); + await page.screenshot({ path: `${outDir}/f${String(i).padStart(4, '0')}.png` }); +} +await browser.close(); +console.log(`${frames} frames @ ${fps}fps (${duration}s) → ${outDir}`);