用 Node.js 复刻 Codex Astra 的终端星光

用 Node.js 复刻 Codex Astra 的终端星光

Codex 选中 gpt-6-astra 后,输入区域会出现一些淡淡的光点。它们停在原地,各自亮起,又慢慢隐进背景。

这类动画容易写得太吵:光点不停换位置,整片区域一起闪,或者亮到盖过文字。Astra 的处理很克制。读完实现,会发现这种观感和几个很具体的选择有关。

本文对照 Codex rust-v0.154.0sparkle.rs,用 Node.js 写一个独立演示。保留原版的坐标哈希和明暗公式,使用固定的深色配色,不需要安装第三方依赖。

先看星星本身。原版使用的是八个 Unicode 盲文字符:

text 复制代码
⠁ ⠂ ⠄ ⠈ ⠐ ⠠ ⡀ ⢀

每个字符只有一个点,点在字符格里的位置各不相同。同样占一个格子,它们比 * 小得多,混在一起也不容易看出整齐的网格。

接着要决定在哪里画。

如果每一帧都调用 Math.random() 重新挑位置,上一帧还亮着的地方,下一帧可能已经空了。整个区域会像噪声一样跳动。这里让坐标决定结果:把 (x, y) 传入固定的哈希函数,同一个位置始终得到同一个整数。

这个整数决定该格子有没有星星,也决定用哪个字符、多久闪一次,以及从周期的哪个位置开始。代码里的 h % 5n === 0n 会选出约五分之一的格子作为候选;候选星星多数时候很暗,所以实际看到的会更少。

哈希沿用了原版的 64 位整数运算。JavaScript 的普通位运算会把数字压到 32 位,这里用 BigInt.asUintN(64, ...) 处理溢出。计算只在初始化和窗口缩放时进行。

星星的位置定下来以后,每一帧只需重新算明暗。

先把经过的时间换算成周期内的进度:phase = (time / period + offset) % 1period 控制多久闪一次,offset 错开每颗星的起点。然后计算混色比例:

text 复制代码
alpha = sin(π × phase)¹² × 0.55

进度从 0 走到 1,正弦值会先升到 1,再降回 0。十二次方把亮起来的时间压短了。比如周期走到四分之一时,正弦值约为 0.707,取十二次方再乘 0.55,只剩约 0.0086。它会在接近周期中间时才明显亮起来。

最后的颜色按 RGB 通道计算:背景色 × (1 - alpha) + 前景色 × alpha。这是原版 blend 函数使用的混色方式。光点暗下去时会接近背景的颜色。alpha 小于 0.04 就不画。

候选格子的比例、4~7 秒的周期、十二次方曲线和 0.04 阈值都来自 原版绘制逻辑。下面把它们放进一个可以运行的脚本。

保存为 astra.mjs

js 复制代码
import assert from 'node:assert/strict';
import { writeSync } from 'node:fs';
import { performance } from 'node:perf_hooks';

const CSI = '\x1b[';
const DOTS = ['⠁', '⠂', '⠄', '⠈', '⠐', '⠠', '⡀', '⢀'];
const BG = [18, 20, 28];
const FG = [224, 228, 240];
const write = (text) => writeSync(1, text);

function hash(x, y) {
  let n = BigInt(y) * 65537n + BigInt(x);
  for (let i = 0; i < 2; i++) {
    n = BigInt.asUintN(64, (n ^ (n >> 16n)) * 0x45d9f3bn);
  }
  return n ^ (n >> 16n);
}

function brightness(time, period, offset) {
  const phase = (time / period + offset) % 1;
  return Math.sin(Math.PI * phase) ** 12 * 0.55;
}

function main() {
  const out = process.stdout;
  if (!out.isTTY || out.getColorDepth() < 24) {
    console.error('请在支持 24 位真彩色的终端中运行。');
    process.exitCode = 1;
    return;
  }

  let width = 0;
  let height = 0;
  let stars = [];
  const started = performance.now();
  const background = `${CSI}48;2;${BG.join(';')}m`;

  function render() {
    const time = (performance.now() - started) / 1000;
    const cells = Array.from({ length: height }, () => Array(width).fill(' '));

    for (const star of stars) {
      const alpha = brightness(time, star.period, star.offset);
      if (alpha < 0.04) continue;
      const rgb = BG.map((bg, i) => Math.floor(bg * (1 - alpha) + FG[i] * alpha));
      cells[star.y][star.x] = `${CSI}38;2;${rgb.join(';')}m${star.dot}`;
    }

    // ponytail: 每帧重画至多 96×12 格;大型 TUI 使用已有的差分渲染器。
    let frame = background;
    for (let y = 0; y < height; y++) {
      frame += `${CSI}${y + 3};2H${cells[y].join('')}`;
    }
    write(frame + `${CSI}0m`);
  }

  function resize() {
    const columns = out.columns || 80;
    const rows = out.rows || 24;
    width = Math.max(0, Math.min(96, columns - 2));
    height = Math.max(0, Math.min(12, rows - 4));
    stars = [];

    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const h = hash(x, y);
        if (h % 5n !== 0n) continue;
        stars.push({
          x, y,
          dot: DOTS[Number(h / 161n % 8n)],
          period: 4 + Number(h % 31n) / 10,
          offset: Number(h % 997n) / 997,
        });
      }
    }

    const title = 'Astra sparkle demo | Ctrl+C to quit';
    write(`${background}${CSI}2J${CSI}H${CSI}38;2;${FG.join(';')}m`
      + title.slice(0, Math.max(0, columns - 1)));
    render();
  }

  process.on('exit', () => write(`${CSI}0m${CSI}?25h${CSI}?1049l`));
  process.on('SIGINT', () => process.exit(130));
  process.on('SIGTERM', () => process.exit(143));
  process.on('SIGHUP', () => process.exit(129));
  write(`${CSI}?1049h${CSI}?25l`);
  out.on('resize', resize);
  resize();
  setInterval(render, 150);
}

if (process.argv.includes('--check')) {
  assert.equal(hash(37, 11), 12395833206921308040n);
  assert.equal(brightness(0, 4, 0), 0);
  assert.equal(brightness(2, 4, 0), 0.55);
  assert.ok(brightness(1, 4, 0) < 0.04);
  assert.ok(Math.abs(brightness(0.7, 4, 0.13) - brightness(4.7, 4, 0.13)) < 1e-12);
  assert.notEqual(brightness(0.7, 4, 0), brightness(0.7, 4, 0.5));
  console.log('check passed');
} else {
  main();
}

运行:

bash 复制代码
node astra.mjs

Ctrl+C 退出。脚本会恢复光标并切回原来的屏幕。只想检查哈希和明暗计算,可以运行:

bash 复制代码
node astra.mjs --check

这个演示需要支持 24 位真彩色和盲文字符的终端。代码用 isTTY 排除文件重定向,再用 getColorDepth() 判断颜色能力;后者依赖运行环境信息,检测结果可能有误差。Node.js TTY 文档说明了这些限制。如果光点显示成方框,检查终端字体是否包含这些字符。

代码里以 \x1b[ 开头的字符串是终端控制序列。38;2;R;G;Bm 设置文字颜色,48;2;R;G;Bm 设置背景色,行;列H 移动光标,坐标从 1 开始。演示还用了备用屏幕和隐藏光标的控制序列,具体定义可查 XTerm Control Sequences

这里有一个容易漏掉的地方:星星不再显示时,要把上一帧留下的字符擦掉。

示例每帧先创建一块全是空格的区域,再填入当前可见的星星,最后一次写出。暗下去的位置自然会被空格覆盖。每帧最多重画 96×12 个格子;清屏只发生在初始化和窗口缩放时。

刷新间隔设为 150 毫秒,与原版请求下一帧的间隔一致。动画使用实际经过的时间计算进度,因此偶尔少画一帧,也不会把闪烁周期拖长。想让渐变更细腻,可以把间隔改成 50 毫秒,输出量也会随之增加。

如果要把它接进已有的输入框,先让输入框完成正常绘制,再给空白格子补上星星。原版还会跳过光标、选中样式以及宽字符占据的后续格子,弹出菜单时暂停这层装饰。官方测试覆盖了这些行为。中文和 emoji 可能占用多个终端列,接入时应使用现有 TUI 的宽度计算与屏幕缓冲区,别用字符串的 length 判断哪里有空位。

至于风格调整,先动这几个参数就够了:

参数 当前取值 调整后的变化
候选格子 h % 5n === 0n 改成 % 8n,星星更稀疏
周期 4~7 秒 增大后,每颗星闪得更慢
指数 12 改成 6,每次亮起来会持续更久
最大混色比例 0.55 增大后,星星更接近前景色
刷新间隔 150 毫秒 改成 50 毫秒,明暗变化的采样更密

如果是给输入框加背景,我会先保留这组偏淡的参数。输入几行字,打开补全菜单,再选中一段文本。星星有没有妨碍这些操作,比空白画面里闪得好不好看更值得检查。

相关推荐
海上彼尚2 小时前
Cursor 模型的强度实测排行
前端·人工智能·后端
默_笙2 小时前
💫 给金鱼建图书馆(下):Milvus 向量数据库,让 AI 拥有长期记忆
前端·javascript
罗西的思考2 小时前
[Agent Memory / 强化学习] MemPO源码学习笔记 ---(1)--- 总体
人工智能·算法·机器学习
liangshanbo12153 小时前
手写 Function.prototype.call 与 Function.prototype.apply 面试题满分答案
开发语言·javascript·原型模式
lvwangshu3 小时前
图论:LCA、树的直径、树的重心、二分图与 Tarjan 缩点
算法·图论
EatFan3 小时前
HTML前端利用flex解决input定位的问题
前端·css·html·css3
CoderYanger4 小时前
前端基础——JavaScript(WebAPI)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
302wanger4 小时前
干与湿:AI 拿走脑力之后,人剩下什么
算法
kyriewen4 小时前
AI 改祖传模块后,代码评审总绕不开两个问题
前端·程序员·ai编程