feat(banner): video-frame ASCII renderer with three-act choreography

Generator switched from a single Jimp-rendered logo to pre-extracted
video frames concatenated with \x01 separators and gzip-deflated, ported
from ghostty's boo wire format. Renderer rewritten around three acts
(ignite → stagger bloom → text reveal + breathe) with adaptive sizing,
radial gradient, and diff-based redraw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-30 15:33:26 -07:00
parent f735da3a2b
commit 8e4480151e
4 changed files with 279 additions and 346 deletions

View File

@@ -1,132 +1,151 @@
#!/usr/bin/env node
// Renders pre-extracted video frames as ASCII art using a luminosity ramp.
// Frames are concatenated with \x01 separators and gzip-deflated, matching
// ghostty's boo wire format so the runtime player can be a near-direct port.
//
// Source frames are produced from the webm via:
// ffmpeg -y -i <video> -vf "scale=320:180" /tmp/cmem-banner-frames/frame_%04d.png
import { Jimp } from 'jimp';
import { writeFileSync } from 'fs';
import { writeFileSync, readdirSync, existsSync } from 'fs';
import { deflateRawSync } from 'zlib';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, '..');
const SRC = join(repoRoot, 'assets/cmem-logo.png');
const FRAMES_DIR = process.env.FRAMES_DIR || '/tmp/cmem-banner-frames';
const OUT = join(repoRoot, 'src/npx-cli/banner-frames.ts');
const ALPHA_THRESHOLD = 110;
const BLOOM_RAYS = 12;
const DISC_RADIUS_FRACTION = 0.15;
const COLS = 128;
// Match 16:9 source aspect with 2:1 cell aspect: 128 * 9 / 16 / 2 = 36.
const VIDEO_ROWS = Math.round(COLS * (9 / 16) / 2); // 36
const ROWS = VIDEO_ROWS;
const TOP_PAD = 0;
const BOTTOM_PAD = 0;
const TIERS = [
{ name: 'small', cols: 40, rows: 20 },
{ name: 'medium', cols: 60, rows: 30 },
{ name: 'hero', cols: 80, rows: 40 },
];
const RAMP = ' .·~+=*x%$@#';
// Aggressive clip + steeper curve = high contrast. Background falls to space,
// bright cells push into the dense end of the ramp.
const BLACK_FLOOR = 50;
const WHITE_CEIL = 160;
const HALO_MIN = 70;
const HALO_MAX = 175;
function sectorMask(img, visibleSectors, discRadius) {
const result = img.clone();
const cx = result.bitmap.width / 2;
const cy = result.bitmap.height / 2;
for (let y = 0; y < result.bitmap.height; y++) {
for (let x = 0; x < result.bitmap.width; x++) {
const dx = x - cx, dy = y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= discRadius) continue;
// angle: 0 = top (12 o'clock), clockwise
const angle = ((Math.atan2(dx, -dy) * 180 / Math.PI) + 360) % 360;
const sector = Math.floor(angle / (360 / BLOOM_RAYS));
if (!visibleSectors.includes(sector)) {
const idx = (y * result.bitmap.width + x) * 4;
result.bitmap.data[idx + 3] = 0;
}
function rasterize(img, gridW, gridH) {
// Resize to the terminal grid and read luminance (Y) per cell.
// Video is full-color, so we use perceptual luminance rather than alpha.
const resized = img.clone().resize({ w: gridW, h: gridH });
const data = resized.bitmap.data;
const density = new Float32Array(gridW * gridH);
for (let cy = 0; cy < gridH; cy++) {
for (let cx = 0; cx < gridW; cx++) {
const idx = (cy * gridW + cx) * 4;
const r = data[idx], g = data[idx + 1], b = data[idx + 2];
density[cy * gridW + cx] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
}
return result;
return density;
}
function imgToFrameString(resizedImg, cols, rows) {
const pixelW = cols;
const cells = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const topIdx = (row * 2 * pixelW + col) * 4;
const botIdx = ((row * 2 + 1) * pixelW + col) * 4;
const topA = resizedImg.bitmap.data[topIdx + 3];
const botA = resizedImg.bitmap.data[botIdx + 3];
const top = topA > ALPHA_THRESHOLD ? 1 : 0;
const bot = botA > ALPHA_THRESHOLD ? 1 : 0;
cells.push((top << 1) | bot);
function densityToChar(d) {
if (d <= BLACK_FLOOR) return ' ';
const range = WHITE_CEIL - BLACK_FLOOR;
const norm = Math.min(1, (d - BLACK_FLOOR) / range);
const t = Math.pow(norm, 1.3);
const idx = Math.min(RAMP.length - 1, Math.max(1, Math.round(t * (RAMP.length - 1))));
return RAMP[idx];
}
function renderASCII(density, w, h) {
const lines = [];
for (let y = 0; y < h; y++) {
let line = '';
let inSpan = false;
for (let x = 0; x < w; x++) {
const i = y * w + x;
const d = density[i];
const ch = densityToChar(d);
const wantSpan = d > HALO_MIN && d < HALO_MAX && ch !== ' ';
if (wantSpan && !inSpan) { line += '<span>'; inSpan = true; }
if (!wantSpan && inSpan) { line += '</span>'; inSpan = false; }
line += ch;
}
if (inSpan) line += '</span>';
lines.push(line);
}
return cells.join('');
return lines.join('\n');
}
async function main() {
const img = await Jimp.read(SRC);
const size = Math.max(img.bitmap.width, img.bitmap.height);
const square = new Jimp({ width: size, height: size, color: 0x00000000 });
square.composite(
img,
Math.floor((size - img.bitmap.width) / 2),
Math.floor((size - img.bitmap.height) / 2),
);
if (!existsSync(FRAMES_DIR)) {
throw new Error(`Frames directory not found: ${FRAMES_DIR}\n` +
`Run: ffmpeg -y -i <video> -vf "scale=320:180" ${FRAMES_DIR}/frame_%04d.png`);
}
const files = readdirSync(FRAMES_DIR)
.filter((f) => f.endsWith('.png'))
.sort();
if (files.length === 0) {
throw new Error(`No PNG frames found in ${FRAMES_DIR}`);
}
const tierResults = {};
const blankLine = ' '.repeat(COLS);
const topPadding = Array(TOP_PAD).fill(blankLine).join('\n');
const bottomPadding = Array(BOTTOM_PAD).fill(blankLine).join('\n');
for (const tier of TIERS) {
const pixelW = tier.cols;
const pixelH = tier.rows * 2;
const discRadius = (size / 2) * DISC_RADIUS_FRACTION;
// Generate finalFrame (full logo, no masking)
const full = square.clone().resize({ w: pixelW, h: pixelH });
const finalFrame = imgToFrameString(full, tier.cols, tier.rows);
// Generate 12 bloom frames
const bloomFrames = [];
for (let i = 0; i < BLOOM_RAYS; i++) {
const visibleSectors = Array.from({ length: i + 1 }, (_, k) => k);
const masked = sectorMask(square, visibleSectors, discRadius);
const resized = masked.resize({ w: pixelW, h: pixelH });
bloomFrames.push(imgToFrameString(resized, tier.cols, tier.rows));
const frameStrings = [];
for (let i = 0; i < files.length; i++) {
const img = await Jimp.read(join(FRAMES_DIR, files[i]));
const density = rasterize(img, COLS, VIDEO_ROWS);
const body = renderASCII(density, COLS, VIDEO_ROWS);
const padded = [topPadding, body, bottomPadding].filter(Boolean).join('\n');
frameStrings.push(padded);
if ((i + 1) % 32 === 0 || i === files.length - 1) {
process.stdout.write(` rasterized ${i + 1}/${files.length}\r`);
}
tierResults[tier.name] = { cols: tier.cols, rows: tier.rows, bloomFrames, finalFrame };
console.log(`✓ Generated tier: ${tier.name} (${tier.cols}×${tier.rows}, ${bloomFrames.length + 1} frames)`);
}
process.stdout.write('\n');
// Build TypeScript output
const lines = [
'// Auto-generated by scripts/generate-banner-frames.mjs — do not edit by hand.',
'',
'export interface TierFrames {',
' cols: number;',
' rows: number;',
' /** 12 progressive bloom frames, each = cols×rows cells encoded as 0-3 ASCII digits */',
' bloomFrames: string[];',
' /** Final fully-revealed frame */',
' finalFrame: string;',
'}',
'',
'export const BANNER_TIERS: Record<\'small\' | \'medium\' | \'hero\', TierFrames> = {',
];
const joined = frameStrings.join('\x01');
const compressed = deflateRawSync(Buffer.from(joined, 'utf8'), { level: 9 });
const b64 = compressed.toString('base64');
for (const [i, tier] of TIERS.entries()) {
const r = tierResults[tier.name];
const bloomArrayStr = r.bloomFrames.map(f => ` ${JSON.stringify(f)}`).join(',\n');
lines.push(` ${tier.name}: {`);
lines.push(` cols: ${r.cols},`);
lines.push(` rows: ${r.rows},`);
lines.push(` bloomFrames: [`);
lines.push(bloomArrayStr);
lines.push(` ],`);
lines.push(` finalFrame: ${JSON.stringify(r.finalFrame)},`);
lines.push(` }${i < TIERS.length - 1 ? ',' : ''}`);
}
const FRAME_DELAY = 22; // ~45 fps — sped up ~12% from prior 25ms
lines.push('};');
lines.push('');
const ts = `// Auto-generated by scripts/generate-banner-frames.mjs — do not edit by hand.
// Frames are gzip-deflated, base64-encoded, separated by \\x01.
// Source: webm video rasterized to ASCII via luminance ramp.
writeFileSync(OUT, lines.join('\n'));
console.log(`✓ Generated tiers: small (40×20, 13 frames), medium (60×30, 13 frames), hero (80×40, 13 frames)`);
export interface BannerData {
/** Base64-encoded raw deflate of all frames joined by \\x01 */
compressed: string;
frameCount: number;
width: number;
height: number;
/** Milliseconds per frame */
frameDelay: number;
}
export const BANNER: BannerData = {
compressed: ${JSON.stringify(b64)},
frameCount: ${files.length},
width: ${COLS},
height: ${ROWS},
frameDelay: ${FRAME_DELAY},
};
`;
writeFileSync(OUT, ts);
console.log(`✓ Generated ${files.length} ASCII frames at ${COLS}×${ROWS}`);
console.log(` Raw size: ${joined.length} bytes`);
console.log(` Compressed: ${compressed.length} bytes (${((compressed.length / joined.length) * 100).toFixed(1)}%)`);
console.log(` Base64: ${b64.length} bytes`);
console.log(` Written to: ${OUT}`);
if (process.env.PREVIEW) {
console.log('\n--- final frame preview ---');
console.log(frameStrings[frameStrings.length - 1].replace(/<\/?span>/g, ''));
}
}
main().catch((err) => {

View File

@@ -1,15 +1,7 @@
import { playBanner } from './banner.js';
const TIERS = [
{ cols: 100, rows: 30, label: 'small' },
{ cols: 140, rows: 40, label: 'medium' },
{ cols: 180, rows: 50, label: 'hero' },
];
const tier = process.argv[2] ?? 'small';
const t = TIERS.find(x => x.label === tier) ?? TIERS[0];
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
process.stdout.columns = t.cols;
process.stdout.rows = t.rows;
console.log(`--- Preview: ${t.label} tier (${t.cols}×${t.rows}) ---`);
process.stdout.columns = process.stdout.columns ?? 140;
process.stdout.rows = process.stdout.rows ?? 50;
process.env.COLORTERM = process.env.COLORTERM ?? 'truecolor';
playBanner().then(() => process.exit(0));

File diff suppressed because one or more lines are too long

View File

@@ -1,142 +1,103 @@
import { BANNER_TIERS, type TierFrames } from './banner-frames.js';
type Tier = 'small' | 'medium' | 'hero';
type Cell = string;
import { inflateRawSync } from 'zlib';
import { BANNER } from './banner-frames.js';
const HIDE_CURSOR = '\x1b[?25l';
const SHOW_CURSOR = '\x1b[?25h';
const CURSOR_HOME = '\x1b[H';
const CLEAR_DOWN = '\x1b[J';
const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
const RESET = '\x1b[0m';
let canvas: Cell[][] = [];
const FRAME_SEP = '\x01';
function detectTier(): Tier | null {
const cols = process.stdout.columns ?? 0;
if (cols >= 160) return 'hero';
if (cols >= 120) return 'medium';
if (cols >= 80) return 'small';
return null;
// Brand colors. Primary = the orange logo body. Accent = outline highlight
// applied to <span>-tagged cells (the high-gradient edges).
function primaryColor(truecolor: boolean, brightness: number = 1.0): string {
if (!truecolor) return '\x1b[38;5;208m';
const r = Math.min(255, Math.round(230 * brightness));
const g = Math.min(255, Math.round(115 * brightness));
const b = Math.min(255, Math.round(70 * brightness));
return `\x1b[38;2;${r};${g};${b}m`;
}
function accentColor(truecolor: boolean, brightness: number = 1.0): string {
if (!truecolor) return '\x1b[38;5;215m';
const r = Math.min(255, Math.round(255 * brightness));
const g = Math.min(255, Math.round(180 * brightness));
const b = Math.min(255, Math.round(122 * brightness));
return `\x1b[38;2;${r};${g};${b}m`;
}
let frames: string[] | null = null;
function getFrames(): string[] {
if (frames) return frames;
const raw = inflateRawSync(Buffer.from(BANNER.compressed, 'base64')).toString('utf8');
frames = raw.split(FRAME_SEP);
return frames;
}
// Render one frame: walks the text, switching ANSI styles on <span>...</span>
// tags. Mirrors the state machine in ghostty's src/cli/boo.zig updateFrame().
function styleFrame(
frame: string,
truecolor: boolean,
brightness: number = 1.0,
): string {
const primary = primaryColor(truecolor, brightness);
const accent = accentColor(truecolor, brightness);
let out = primary;
let i = 0;
let inSpan = false;
while (i < frame.length) {
const ch = frame[i];
if (ch === '<') {
// Skip until '>'. Track whether we're entering or leaving a span.
const isClosing = frame[i + 1] === '/';
while (i < frame.length && frame[i] !== '>') i++;
i++; // skip '>'
inSpan = !isClosing;
out += inSpan ? accent : primary;
continue;
}
out += ch;
i++;
}
return out + RESET;
}
function detectTruecolor(): boolean {
return process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit';
}
function cellColor(
col: number,
row: number,
tierCols: number,
tierRows: number,
truecolor: boolean,
brightness: number = 1.0,
): string {
const cx = tierCols / 2;
const cy = tierRows / 2;
const dx = col - cx;
const dy = row - cy;
const dist = Math.sqrt(dx * dx + dy * dy) / Math.sqrt(cx * cx + cy * cy);
// lerp #FFB47A → #C04A30
let r = Math.round(255 * (1 - dist) + 192 * dist);
let g = Math.round(180 * (1 - dist) + 74 * dist);
let b = Math.round(122 * (1 - dist) + 48 * dist);
r = Math.min(255, Math.round(r * brightness));
g = Math.min(255, Math.round(g * brightness));
b = Math.min(255, Math.round(b * brightness));
if (truecolor) return `\x1b[38;2;${r};${g};${b}m`;
return '\x1b[38;5;208m';
// figlet -f standard "claude-mem" — geometric ASCII letters, 5 rows × 62 cols.
const WORDMARK_BUBBLE: readonly string[] = [
" _ _ ",
" ___| | __ _ _ _ __| | ___ _ __ ___ ___ _ __ ___ ",
" / __| |/ _` | | | |/ _` |/ _ \\_____| '_ ` _ \\ / _ \\ '_ ` _ \\ ",
"| (__| | (_| | |_| | (_| | __/_____| | | | | | __/ | | | | |",
" \\___|_|\\__,_|\\__,_|\\__,_|\\___| |_| |_| |_|\\___|_| |_| |_|",
] as const;
const BUBBLE_HEIGHT = WORDMARK_BUBBLE.length;
const BUBBLE_WIDTH = WORDMARK_BUBBLE[0].length;
// Reserve canvas: animation + wordmark (5 rows) + 1 blank gap + 1 tagline row.
const TAGLINE_GAP = 1;
const TOTAL_ROWS = BANNER.height + BUBBLE_HEIGHT + TAGLINE_GAP + 1;
function writeBubbleRow(rowIdx: number, colsRevealed: number): string {
// Reveal left-to-right by column. Hidden cols become spaces so the row width
// is always BUBBLE_WIDTH and centering stays stable.
const src = WORDMARK_BUBBLE[rowIdx];
const W = BANNER.width;
const visible = src.slice(0, Math.min(BUBBLE_WIDTH, colsRevealed)).padEnd(BUBBLE_WIDTH, ' ');
const pad = Math.max(0, Math.floor((W - BUBBLE_WIDTH) / 2));
return ' '.repeat(pad) + `\x1b[1;97m${visible}\x1b[0m` + ' '.repeat(Math.max(0, W - pad - BUBBLE_WIDTH));
}
function renderFrame(
frameStr: string,
tier: TierFrames,
truecolor: boolean,
brightness: number = 1.0,
): Cell[][] {
const { cols, rows } = tier;
const newCanvas: Cell[][] = [];
const CHARS = [' ', '▄', '▀', '█'];
for (let row = 0; row < rows; row++) {
newCanvas[row] = [];
for (let col = 0; col < cols; col++) {
const idx = row * cols + col;
const val = parseInt(frameStr[idx] ?? '0', 10);
if (val === 0) {
newCanvas[row][col] = ' ';
continue;
}
let isTip = false;
outer: for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = row + dr;
const nc = col + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) {
isTip = true;
break outer;
}
if (parseInt(frameStr[nr * cols + nc] ?? '0', 10) === 0) {
isTip = true;
break outer;
}
}
}
const b = isTip ? Math.min(1.5, brightness * 1.2) : brightness;
const color = cellColor(col, row, cols, rows, truecolor, b);
newCanvas[row][col] = color + CHARS[val] + '\x1b[0m';
}
}
return newCanvas;
}
function diffWrite(newCanvas: Cell[][], first: boolean): void {
if (first) {
process.stdout.write('\x1b[s');
for (const row of newCanvas) {
process.stdout.write(row.join('') + '\n');
}
canvas = newCanvas.map((r) => [...r]);
return;
}
for (let row = 0; row < newCanvas.length; row++) {
const rowDirty = newCanvas[row].some((cell, col) => cell !== canvas[row]?.[col]);
if (!rowDirty) continue;
process.stdout.write('\x1b[u');
if (row > 0) process.stdout.write(`\x1b[${row}B`);
process.stdout.write('\x1b[1G');
process.stdout.write(newCanvas[row].join(''));
}
canvas = newCanvas.map((r) => [...r]);
}
function buildDiscFrame(tier: TierFrames, fraction: number): string {
const { cols, rows, finalFrame } = tier;
const cx = cols / 2;
const cy = rows / 2;
const maxDiscRadius = Math.min(cols, rows) * 0.15;
const radius = maxDiscRadius * fraction;
let result = '';
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const dx = col - cx;
const dy = row - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
result += dist <= radius ? finalFrame[row * cols + col] : '0';
}
}
return result;
}
function addWordmark(c: Cell[][], tier: TierFrames, wordmark: string, tagline: string): void {
const { cols, rows } = tier;
const midY = Math.floor(rows / 2);
if (wordmark) {
c[midY - 1] = c[midY - 1] ?? [];
while (c[midY - 1].length < cols + 2) c[midY - 1].push(' ');
c[midY - 1].push(`\x1b[1;37m${wordmark}\x1b[0m`);
}
if (tagline) {
c[midY] = c[midY] ?? [];
while (c[midY].length < cols + 2) c[midY].push(' ');
c[midY].push(`\x1b[2;37m${tagline}\x1b[0m`);
}
function writeTaglineRow(text: string): string {
const W = BANNER.width;
const pad = Math.max(0, Math.floor((W - text.length) / 2));
return ' '.repeat(pad) + `\x1b[2;37m${text}\x1b[0m` + ' '.repeat(Math.max(0, W - pad - text.length));
}
export function isBannerEnabled(): boolean {
@@ -144,77 +105,87 @@ export function isBannerEnabled(): boolean {
if (process.env.CI) return false;
if (process.env.CLAUDE_MEM_NO_BANNER) return false;
if (process.env.NO_COLOR) return false;
return detectTier() !== null;
const cols = process.stdout.columns ?? 0;
return cols >= BANNER.width;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function playBanner(): Promise<void> {
if (!isBannerEnabled()) return;
const tierName = detectTier()!;
const tier = BANNER_TIERS[tierName];
const truecolor = detectTruecolor();
const allFrames = getFrames();
let aborted = false;
const onResize = () => {
aborted = true;
};
const onResize = () => { aborted = true; };
process.stdout.on('resize', onResize);
process.stdout.write(CLEAR_SCREEN);
process.stdout.write(HIDE_CURSOR);
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
canvas = [];
let first = true;
// Reserve vertical space (logo + 2 wordmark rows) so we have a stable
// canvas to redraw on. Save cursor at the top of the canvas.
process.stdout.write('\n'.repeat(TOTAL_ROWS));
process.stdout.write(`\x1b[${TOTAL_ROWS}A`);
process.stdout.write('\x1b[s');
const blankRow = ' '.repeat(BANNER.width);
const writeFrame = (frameText: string, colsRevealed: number, tagline: string, brightness: number = 1.0) => {
process.stdout.write('\x1b[u');
process.stdout.write(styleFrame(frameText, truecolor, brightness));
// Frames don't end with \n on the last line, so add one before drawing wordmark rows
process.stdout.write('\n');
for (let i = 0; i < BUBBLE_HEIGHT; i++) {
process.stdout.write(writeBubbleRow(i, colsRevealed));
process.stdout.write('\n');
}
for (let g = 0; g < TAGLINE_GAP; g++) {
process.stdout.write(blankRow);
process.stdout.write('\n');
}
process.stdout.write(writeTaglineRow(tagline));
};
try {
// ACT 1: Ignition (0480ms) — center disc grows, 8 steps × 60ms
for (let step = 1; step <= 8; step++) {
// ACT 1+2: play the prerendered animation
for (let i = 0; i < allFrames.length; i++) {
if (aborted) return;
const discFrame = buildDiscFrame(tier, step / 8);
const nc = renderFrame(discFrame, tier, truecolor);
diffWrite(nc, first);
first = false;
await sleep(60);
writeFrame(allFrames[i], 0, '');
await sleep(BANNER.frameDelay);
}
// ACT 2: Bloom (4801200ms) — 12 rays × 60ms = 720ms
for (let i = 0; i < 12; i++) {
if (aborted) return;
const nc = renderFrame(tier.bloomFrames[i], tier, truecolor);
diffWrite(nc, false);
await sleep(60);
}
// ACT 3a: Type-on wordmark (350ms, 10 chars × 35ms)
const WORDMARK = 'claude-mem';
for (let c = 1; c <= WORDMARK.length; c++) {
if (aborted) return;
const nc = renderFrame(tier.finalFrame, tier, truecolor);
addWordmark(nc, tier, WORDMARK.slice(0, c), '');
diffWrite(nc, false);
await sleep(35);
}
// ACT 3b: Tagline fade-in (200ms, 6 steps)
// ACT 3: reveal bubble wordmark letter-by-letter, then tagline
const finalFrame = allFrames[allFrames.length - 1];
const TAGLINE = 'persistent memory across sessions';
// Sweep reveal across all columns
const REVEAL_STEPS = 14;
for (let s = 1; s <= REVEAL_STEPS; s++) {
if (aborted) return;
const cols = Math.ceil(BUBBLE_WIDTH * (s / REVEAL_STEPS));
writeFrame(finalFrame, cols, '');
await sleep(45);
}
for (let s = 1; s <= 6; s++) {
if (aborted) return;
const chars = Math.ceil(TAGLINE.length * (s / 6));
const nc = renderFrame(tier.finalFrame, tier, truecolor);
addWordmark(nc, tier, WORDMARK, TAGLINE.slice(0, chars));
diffWrite(nc, false);
writeFrame(finalFrame, BUBBLE_WIDTH, TAGLINE.slice(0, chars));
await sleep(33);
}
// ACT 3c: Breathe (300ms)
for (const brightness of [0.9, 0.95, 1.0]) {
// Brief breathe pulse on the final pose
for (const brightness of [0.85, 0.95, 1.0]) {
if (aborted) return;
const nc = renderFrame(tier.finalFrame, tier, truecolor, brightness);
addWordmark(nc, tier, WORDMARK, TAGLINE);
diffWrite(nc, false);
writeFrame(finalFrame, BUBBLE_WIDTH, TAGLINE, brightness);
await sleep(100);
}
// Hold 200ms
await sleep(200);
await sleep(150);
} finally {
process.stdout.off('resize', onResize);
process.stdout.write(RESET);
process.stdout.write(SHOW_CURSOR);
// Move cursor below the banner so subsequent output starts on a fresh line.
process.stdout.write('\n');
}
}