Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351// side-stamps.mjs — AC's side-stamp chrome, as a thing you can call.//// Two pals watermarks hug the left and right edges, rotated a quarter turn to// face inward, with the title climbing the margin beside each one. Everything// wiggles, bobs and swivels on a slow loop, and every glyph carries a VHS// chromatic fringe — magenta and cyan ghosts split a few pixels either side of// a bright core, with a glow that breathes on whatever envelope you feed it.//// const stamps = await makeSideStamps({ w: 1080, h: 1920, fps: 30, frames,// title: "prompt.ac", assetsDir });// stamps.draw(ctx, t, env, tint); // env 0..1 — audio or motion, your choice//// `tint` is optional: pass { core, hi } (each an [r,g,b]) to recolor the stamps// per frame — e.g. sampled from the footage and pushed to dayglow, so the chrome// answers the picture instead of ignoring it. Omit it and the defaults stand.//// `env` does more than glow: it bounces. The stamps swell and settle on it, so// they move with the voice rather than on a metronome.//// This treatment was written five times (pop/menuband, pop/momboba, pop/marimba,// pop/big-pictures, marketing/kidlisp-reels) before it was written once. Those// copies still exist and still work; new reels should call this instead, and the// old ones can migrate whenever someone is in there anyway.//// The defaults carry @jeffrey's tuning notes from the kidlisp reels: the stamps// move SLOWLY (speed 0.18 — they read as too fast much above that).
import { spawnSync } from "node:child_process";import { existsSync, mkdirSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { createCanvas, loadImage } from "canvas";
import { prerenderTitleChars, magickRenderText } from "../../pop/lib/preview-shared.mjs";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..");const TAU = Math.PI * 2;
export async function makeSideStamps({ w, h, fps, frames, assetsDir, title = "aesthetic.computer", // A bright core with a hot highlight, plus the two fringe colors. The kidlisp // reels run lime; white reads cleaner over skin and photographic footage. core = [235, 240, 250], coreHi = [255, 255, 255], chromaR = [255, 60, 120], chromaB = [70, 170, 255], chromaPx = 3, speed = 0.18, stampSize = 145, edgeX = 92, titlePx = 72, charScale = 0.62, leftCy = null, // where the title column sits; defaults to low-left / high-right rightCy = null, // Pass a clock to make the climbing text a live readout instead of a fixed // word: { atlas } from makeChromaGlyphs, { textAt(t) } → the string for time t, // plus its own { scale, kern }. When present it replaces `title` on the margins. clock = null,} = {}) { const PALS_HALF = stampSize / 2; const CHARS_EDGE_X = edgeX + 12; const BOUNCE_BUF = 26;
// ── raster the pals ────────────────────────────────────────────────────── // Rasterized at 2× and drawn down, so the stamp stays clean. The blurred copy // is what the chromatic ghosts and the glow are drawn from — a soft source // makes the fringe read as light rather than as a second, offset logo. mkdirSync(assetsDir, { recursive: true }); // rsvg won't create its output dir
const svg = join(REPO, "system", "public", "purple-pals.svg"); const png = join(assetsDir, "pals-watermark.png"); const blurPng = join(assetsDir, "pals-watermark-blur.png"); const size = 212 * 2;
const r = spawnSync("rsvg-convert", ["-w", String(size), "-h", String(size), "-o", png, svg]); if (r.status !== 0 || !existsSync(png)) { throw new Error( `pals watermark failed to rasterize: ${r.error?.message || r.stderr?.toString().trim() || "rsvg-convert not found"}`, ); } const palsImg = await loadImage(png); const rb = spawnSync("magick", [png, "-channel", "A", "-blur", "0x1.5", "+channel", blurPng]); const palsBlur = rb.status === 0 && existsSync(blurPng) ? await loadImage(blurPng) : palsImg;
// With a clock, the climbing text is the ticking readout — skip the static // title, and size the column from a representative clock string so the pals // (which park above and below it) sit in the right place all minute. let chars = []; let CHAR_SPAN; if (clock) { CHAR_SPAN = clock.atlas.measure(clock.sample || "0000.00.00 00:00:00", clock.scale, clock.kern); } else { const t = await prerenderTitleChars({ text: title, ptSize: titlePx, palette: ["#FFFFFF"], shadowColor: null, assetsDir, }); chars = t.chars; CHAR_SPAN = t.totalWidth * charScale; } // Two column layouts, chosen by what the text IS. // // · A short title (the /pop look): the column sits at a fixed height and the // pals badge hugs the far end of it — the arrangement every other AC reel // uses, so a talking-head reel reads as one of the family. // · A long clock: anchor the badge in the corner instead and let the readout // climb inward from it, or a 19-character timestamp shoves the badge clean // off the edge. let LEFT_PALS_CY; let RIGHT_PALS_CY; let LEFT_CHARS_CY; let RIGHT_CHARS_CY; if (clock) { LEFT_PALS_CY = leftCy ?? h - PALS_HALF - 78; RIGHT_PALS_CY = rightCy ?? PALS_HALF + 78; LEFT_CHARS_CY = LEFT_PALS_CY - PALS_HALF - BOUNCE_BUF - CHAR_SPAN / 2; RIGHT_CHARS_CY = RIGHT_PALS_CY + PALS_HALF + BOUNCE_BUF + CHAR_SPAN / 2; } else { LEFT_CHARS_CY = leftCy ?? h * 0.84; RIGHT_CHARS_CY = rightCy ?? h * 0.16; LEFT_PALS_CY = LEFT_CHARS_CY - CHAR_SPAN / 2 - BOUNCE_BUF - PALS_HALF; RIGHT_PALS_CY = RIGHT_CHARS_CY + CHAR_SPAN / 2 + BOUNCE_BUF + PALS_HALF; }
// Scratch canvases for tinting. Reused rather than reallocated — this runs // per glyph per frame, and at 1774 frames the garbage adds up. const wm = createCanvas(8, 8); const wmCtx = wm.getContext("2d"); const glyph = createCanvas(2, 2); const glyphCtx = glyph.getContext("2d");
// Recolor an alpha mask by filling through it — `source-in` keeps the source's // alpha and takes the fill's color, which is how a white raster becomes any // color we like without shipping five copies of the asset. const tint = (canvas, ctx2, src, c) => { canvas.width = src.width; canvas.height = src.height; ctx2.globalCompositeOperation = "source-over"; ctx2.clearRect(0, 0, src.width, src.height); ctx2.drawImage(src, 0, 0); ctx2.globalCompositeOperation = "source-in"; ctx2.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`; ctx2.fillRect(0, 0, src.width, src.height); ctx2.globalCompositeOperation = "source-over"; return canvas; }; const tintPals = (src, c) => tint(wm, wmCtx, src, c); const tintGlyph = (src, c) => tint(glyph, glyphCtx, src, c);
// The fringe is atmosphere; the core is the thing. So the ghosts are drawn from // the BLURRED raster and kept faint, and the core is drawn from the SHARP one // at full opacity, twice — the fringe reads as light bleeding around a mark // that is itself crisp. Earlier this drew the core at 0.6 alpha over its own // ghosts, which is what made the stamps look soft and washed instead of inked. const drawChroma = (ctx, tintFn, sharp, soft, dx, dy, dw, dh, env, col, colHi) => { ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 0.30; ctx.drawImage(tintFn(sharp, [8, 10, 6]), dx + 2.5, dy + 3, dw, dh);
ctx.globalCompositeOperation = "screen"; ctx.globalAlpha = 0.22; ctx.drawImage(tintFn(soft, chromaR), dx - chromaPx, dy, dw, dh); ctx.globalAlpha = 0.22; ctx.drawImage(tintFn(soft, chromaB), dx + chromaPx, dy, dw, dh); if (env > 0.02) { ctx.globalAlpha = 0.10 + 0.26 * env; // the bloom breathes with the voice ctx.drawImage(tintFn(soft, colHi), dx, dy, dw, dh); }
// Opaque, on top, sharp — this is the pass that gives it its edge. ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1; ctx.drawImage(tintFn(sharp, col), dx, dy, dw, dh); ctx.globalAlpha = 0.35 + 0.35 * env; // a second hit to bite, harder when loud ctx.drawImage(tintFn(sharp, colHi), dx, dy, dw, dh); ctx.globalAlpha = 1; };
// A spring, so the bounce overshoots and settles instead of tracking the // envelope rigidly — the envelope is what he's doing, this is how the stamp // *reacts* to it, and the lag between the two is the whole charm. let bounce = 0; let bounceV = 0;
function draw(ctx, t, env = 0, tint = null) { const u = (t * fps) / frames; const col = tint?.core || core; const colHi = tint?.hi || coreHi;
const dt = 1 / fps; bounceV += (34 * (env - bounce) - 9 * bounceV) * dt; bounce += bounceV * dt; const pop = 1 + 0.22 * bounce; // swell on the beat of his voice
// Layered sines at unrelated frequencies, so the loop never lands on an // obvious beat — it reads as drift rather than as animation. const wig = 13 * Math.sin(TAU * 24 * speed * u + 0.7) + 4 * Math.sin(TAU * 9 * speed * u); const bob = 3 * Math.sin(TAU * 17 * speed * u + 2.1) + 1.5 * Math.sin(TAU * 31 * speed * u); const swiv = 0.05 * Math.sin(TAU * 19 * speed * u) + 0.025 * Math.sin(TAU * 38 * speed * u + 1.4); const kick = 14 * bounce; // and shove away from the edge as they swell
const s = stampSize * pop; for (const spot of [ { cx: edgeX - wig + kick, cy: LEFT_PALS_CY + bob, rot: Math.PI / 2 + swiv }, { cx: w - edgeX + wig - kick, cy: RIGHT_PALS_CY - bob, rot: -Math.PI / 2 - swiv }, ]) { ctx.save(); ctx.translate(spot.cx, spot.cy); ctx.rotate(spot.rot + bounce * 0.04); drawChroma(ctx, tintPals, palsImg, palsBlur, -s / 2, -s / 2, s, s, env, col, colHi); ctx.restore(); }
// The title climbs each margin, facing inward — and every letter is its own // creature. Each one gets its own phase, so it swells, leans and drifts on a // schedule the letter beside it knows nothing about. The word never pumps as // a block; it ripples, and the ripple runs along the word as he speaks. // // The list of letters is resolved per frame, so the climbing text can be a // running clock as easily as a fixed word — the ticking seconds get the same // bounce and fringe as everything else, which is the point. const [glyphList, span, glyphScale] = clock ? layoutClock(clock.textAt(t)) : [chars, CHAR_SPAN, charScale];
const twig = 11 * Math.sin(TAU * 30 * speed * u + 3.6) + 4 * Math.sin(TAU * 12 * speed * u + 1.1); for (const spot of [ { cx: CHARS_EDGE_X - twig + kick * 0.6, cy: LEFT_CHARS_CY, rot: Math.PI / 2 }, { cx: w - CHARS_EDGE_X + twig - kick * 0.6, cy: RIGHT_CHARS_CY, rot: -Math.PI / 2 }, ]) { ctx.save(); ctx.translate(spot.cx, spot.cy); ctx.rotate(spot.rot); const startX = -span / 2;
for (const [i, ch] of glyphList.entries()) { if (!ch.img) continue; // Phase offset per letter: the wave travels down the word instead of // hitting it all at once. const ph = TAU * (t * 1.1 - i * 0.13); const swell = 1 + (0.10 + 0.20 * bounce) * Math.sin(ph); const lean = 0.10 * Math.sin(ph + 1.2) * (0.4 + bounce); const drift = 4 * Math.sin(ph * 0.7 + i);
const dw = ch.img.width * glyphScale; const dh = ch.img.height * glyphScale; const cx = startX + ch.prefixWidth * glyphScale + dw / 2;
ctx.save(); ctx.translate(cx, drift); // each letter rides its own little wave ctx.rotate(lean); ctx.scale(swell, swell); drawChroma(ctx, tintGlyph, ch.img, ch.img, -dw / 2, -dh / 2, dw, dh, env, col, colHi); ctx.restore(); } ctx.restore(); }
ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1; }
// Lay a clock string out on the fly: cumulative x offsets in unscaled px, the // same shape the prerendered title chars have, so the draw loop treats both the // same way. function layoutClock(text) { const list = []; let prefix = 0; for (const ch of text) { const g = clock.atlas.glyphs.get(ch); if (g) list.push({ img: g, prefixWidth: prefix }); prefix += (g ? g.width : clock.atlas.spaceW) + clock.kern; } return [list, prefix - clock.kern, clock.scale]; }
return { draw, drawChroma, tintGlyph };}
// The same inked, fringed, per-character treatment — but for text that CHANGES,// like a clock. Prerendering every string a ticking timestamp passes through// would mean hundreds of magick calls; prerendering the dozen characters it is// made of means twelve. Glyphs are composed at draw time.export async function makeChromaGlyphs({ charset = "0123456789.: ", ptSize = 96, assetsDir, font = null,} = {}) { mkdirSync(assetsDir, { recursive: true }); const glyphs = new Map();
for (const ch of charset) { if (ch === " ") { glyphs.set(ch, null); // a space is a width, not a picture continue; } const safe = ch.charCodeAt(0).toString(16); glyphs.set(ch, await magickRenderText(ch, { ptSize, font, fill: "#FFFFFF", outPath: join(assetsDir, `g-${safe}.png`), })); }
const spaceW = ptSize * 0.32; const measure = (text, scale = 1, kern = 0) => { let width = 0; for (const ch of text) { const g = glyphs.get(ch); width += (g ? g.width * scale : spaceW * scale) + kern; } return width; };
return { glyphs, measure, spaceW };}
// Take a color the camera actually saw and push it until it fluoresces: keep the// hue, throw saturation and value to the top. The picture stays photographic and// the chrome laid over it goes highlighter-bright, which is the whole trick —// they agree on hue, so it reads as belonging, and disagree on everything else,// so it reads as ink.export function dayglow([r, g, b], { value = 255 } = {}) { const max = Math.max(r, g, b); const min = Math.min(r, g, b); const d = max - min;
let hue = 0; if (d > 0) { if (max === r) hue = ((g - b) / d + 6) % 6; else if (max === g) hue = (b - r) / d + 2; else hue = (r - g) / d + 4; hue /= 6; }
// Full saturation, full value — hsv(h, 1, 1) back to rgb. const i = Math.floor(hue * 6) % 6; const f = hue * 6 - Math.floor(hue * 6); const q = Math.round(value * (1 - f)); const tt = Math.round(value * f); const v = Math.round(value); const table = [ [v, tt, 0], [q, v, 0], [0, v, tt], [0, q, v], [tt, 0, v], [v, 0, q], ]; return table[i];}