mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-07-03 12:32:32 +08:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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 (0–480ms) — 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 (480–1200ms) — 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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user