Ghostty 官网终端动画拆解
2026年07月06日

Ghostty 官网终端动画拆解

拆解 Ghostty 官网首页终端动画的实现:从视频生成字符画帧,再逐帧播放。

之前访问 Ghostty 官网 的时候,我对首页那段终端动画很感兴趣:它看起来像一段在真实终端里播放的字符动画,但又能非常自然地嵌在网页里。

好在 Ghostty 官网的源码也是开源的,所以这篇文章就顺着源码拆一下它的实现思路:动画数据从哪里来、这些 .txt 帧文件是怎么生成的,以及前端组件又是如何把它们播放出来的。

先看一下最终效果:

Ghostty

++++++============++++++
++==**==*%*%********%*%*==**==++
++=====*== ==*=====++
++======+x x+======+x
++====++ o=%$@@@@@@@@@@$%=o ++====++
++==== x%@@@@@@$$$$$$$$$$$$@@@@@@%x ====+x
xx===+ *@@@@$$$$$$$$$$$$$$$$$$$$$$$$@@@@* +===xx
==== +@@@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@@@+ ====
x+==x· +@@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@@+ ·x==++
==++ @@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@@ ++==
xx== x@$$$$@@@@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@x ==xo
++++ +@$$$@@=ox%@@@@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@+ ++++
==+~ @$$$$$ o%@@@@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@ ~+==
== $$$$$$$ x%@@@$$$$$$@@@@@@@@@@@@@@@@@@@$$$$$$ ==
== @$$$$$@@+ *$$$$$$~ %$$$$$@ ==
== ·@$$$$$$$@@@@+ $@$$@ @$$$$@· ==
== ·@$$$$$$@$= ~$$$$$* x$$$$$@· ==
== ·@$$$$$$ o%@@$$$$$@@@$$$$$$$$$$$$$$$@@$$$$$@· ==
== ·@$$$$$$ o*@@@@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$@% ~*@@@@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$$@@@@@@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ·@$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@· ==
== ~$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$~ ==
== @$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$@ ==
==x· @@@$$$$$$$$$@@@@@@@@@$$$$$$$$$$@@@@@@@@@$$$$$$$$$@@@ ·+==
++++ ~%@@@@@@@@@%o =@@@@@@@@@@= o%@@@@@@@@@%~ ++xx
==+x ~~~ ·~~· ~~· x+==
==++ x+%% %%++ +===
++**=%++ooox==*===++**=%++xoox++%=**++===*==xooo++%=**++
++++==++++ ++++====++++ ++++==++++

首页组件

从首页入口开始看,核心逻辑在 HomeContent 里。这里最值得关注的是 AnimatedTerminal 组件,它接收了一组 frames,然后按照固定间隔切换当前帧。

HomeContent.tsx
"use client";

import AnimatedTerminal from "@/components/animated-terminal";
import GridContainer from "@/components/grid-container";
import { ButtonLink } from "@/components/link";
import { P } from "@/components/text";
import type { TerminalFontSize } from "@/components/terminal";
import type { TerminalsMap } from "./terminal-data";
import { useEffect, useState } from "react";
import s from "./home-content.module.css";

interface HomeClientProps {
  terminalData: TerminalsMap;
}

/** Tracks the current viewport size for responsive terminal sizing. */
function useWindowSize() {
  const [width, setWidth] = useState(0);
  const [height, setHeight] = useState(0);
  useEffect(() => {
    function updateSize() {
      setWidth(window.innerWidth);
      setHeight(window.innerHeight);
    }
    window.addEventListener("resize", updateSize);
    updateSize();

    return () => window.removeEventListener("resize", updateSize);
  }, []);
  return [width, height];
}

/** Renders the animated terminal hero and action links for the homepage. */
export default function HomeClient({ terminalData }: HomeClientProps) {
  const animationFrames = Object.keys(terminalData)
    .filter((k) => {
      return k.startsWith("home/animation_frames");
    })
    .map((k) => terminalData[k]);

  // Calculate what font size we should use based off of
  // Width & Height considerations. We will pick the smaller
  // of the two values.
  const [windowWidth, windowHeight] = useWindowSize();
  const widthSize =
    windowWidth > 1100 ? "small" : windowWidth > 674 ? "tiny" : "xtiny";
  const heightSize =
    windowHeight > 900 ? "small" : windowHeight > 750 ? "tiny" : "xtiny";
  let fontSize: TerminalFontSize = "small";
  const sizePriority = ["xtiny", "tiny", "small"];
  for (const size of sizePriority) {
    if (widthSize === size || heightSize === size) {
      fontSize = size;
      break;
    }
  }

  return (
    <main className={s.homePage}>
      {/* Don't render the content until the window width has been
        calculated, else there will be a flash from the smallest size
        of the terminal to the true calculated size */}
      {windowWidth > 0 && (
        <>
          <section className={s.terminalWrapper} aria-hidden={true}>
            <AnimatedTerminal
              title={"👻 Ghostty"}
              fontSize={fontSize}
              whitespacePadding={
                windowWidth > 950 ? 20 : windowWidth > 850 ? 10 : 0
              }
              className={s.animatedTerminal}
              columns={100}
              rows={41}
              frames={animationFrames}
              frameLengthMs={31}
            />
          </section>

          <GridContainer>
            <P weight="regular" className={s.tagline}>
              Ghostty is a fast, feature-rich, and cross-platform terminal
              emulator that uses platform-native UI and GPU acceleration.
            </P>
          </GridContainer>

          <GridContainer className={s.buttonsList}>
            <ButtonLink href="/download" text="Download" size="large" />
            <ButtonLink
              href="/docs"
              text="Documentation"
              size="large"
              theme="neutral"
            />
          </GridContainer>
        </>
      )}
    </main>
  );
}

HomeContent 做了两件事:

  • terminalData 中筛出 home/animation_frames 下面的帧数据
  • 根据窗口宽高选择终端字体尺寸,避免终端在小屏幕下撑破布局

真正负责播放动画的是 AnimatedTerminal

AnimatedTerminal.tsx
"use client";

import { useEffect, useState } from "react";
import Terminal, { type TerminalProps } from "../terminal";

// A simple animation frame loop manager that's tied to requestAnimationFrame
// and should always keep frames in lock step with timing updates
class AnimationManager {
  _animation: number | null = null;
  callback: () => void;
  lastFrame = -1;
  frameTime = 1000 / 30; // 30fps

  constructor(callback: () => void, fps = 30) {
    this.callback = callback;
    this.frameTime = 1000 / fps;
  }

  updateFPS(fps: number) {
    this.frameTime = 1000 / fps;
  }

  start() {
    if (this._animation != null) return;
    this._animation = requestAnimationFrame(this.update);
  }

  pause() {
    if (this._animation == null) return;
    this.lastFrame = -1;
    cancelAnimationFrame(this._animation);
    this._animation = null;
  }

  update = (time: number) => {
    const { lastFrame } = this;
    let delta = time - lastFrame;
    if (this.lastFrame === -1) {
      this.lastFrame = time;
    } else {
      while (delta >= this.frameTime) {
        this.callback();
        delta -= this.frameTime;
        this.lastFrame += this.frameTime;
      }
    }
    this._animation = requestAnimationFrame(this.update);
  };
}

const KONAMI_CODE = [
  "arrowup",
  "arrowup",
  "arrowdown",
  "arrowdown",
  "arrowleft",
  "arrowright",
  "arrowleft",
  "arrowright",
  "b",
  "a",
];

export type AnimationFrame = string[];

export type AnimatedTerminalProps = Omit<TerminalProps, "lines"> & {
  frames: AnimationFrame[];
  frameLengthMs: number;
};

export default function AnimatedTerminal({
  className,
  columns,
  rows,
  fontSize,
  title,
  frames,
  whitespacePadding,
  frameLengthMs,
}: AnimatedTerminalProps) {
  const baseFps = 1000 / frameLengthMs;
  const [currentFrame, setCurrentFrame] = useState(16);
  const [animationManager] = useState(
    () =>
      new AnimationManager(() => {
        setCurrentFrame((currentFrame) => (currentFrame + 1) % frames.length);
      }, baseFps),
  );

  useEffect(() => {
    const reducedMotion =
      window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
    if (reducedMotion) {
      return;
    }

    const handleFocus = () => animationManager.start();
    const handleBlur = () => animationManager.pause();
    const codeInProgress: string[] = [];
    const handleKeyUp = (event: KeyboardEvent) => {
      const key = event.key.toLowerCase();
      if (KONAMI_CODE[codeInProgress.length] === key) {
        codeInProgress.push(key);
      } else {
        codeInProgress.length = 0;
      }
      if (codeInProgress.length !== KONAMI_CODE.length) {
        return;
      }
      if (animationManager.frameTime === 1000 / baseFps) {
        animationManager.updateFPS(240);
      } else {
        animationManager.updateFPS(baseFps);
      }
      codeInProgress.length = 0;
    };
    window.addEventListener("focus", handleFocus);
    window.addEventListener("blur", handleBlur);
    window.addEventListener("keyup", handleKeyUp);

    if (document.visibilityState === "visible") {
      animationManager.start();
    }
    return () => {
      window.removeEventListener("focus", handleFocus);
      window.removeEventListener("blur", handleBlur);
      window.removeEventListener("keyup", handleKeyUp);
    };
  }, [animationManager, frames.length, baseFps]);

  return (
    <Terminal
      className={className}
      columns={columns}
      whitespacePadding={whitespacePadding}
      rows={rows}
      title={title}
      fontSize={fontSize}
      lines={frames[currentFrame]}
      disableScrolling={true}
    />
  );
}

这个组件本身并不关心帧是怎么来的。它只维护一个 currentFrame,然后在 requestAnimationFrame 驱动的循环里递增索引,最后把当前帧传给 Terminal 组件渲染。

也就是说,前端播放部分其实很克制:它没有在浏览器里做图像处理,也没有动态计算字符画,只是在播放一组已经准备好的文本帧。

帧数据从哪里来

继续往上找,HomeContent 接收的 terminalData 来自首页的 page.tsx

page.tsx
import HomeContent from "./HomeContent";
import { loadAllTerminalFiles } from "./terminal-data";
import type { Metadata } from "next";

/** Defines document metadata for the Ghostty homepage. */
export const metadata: Metadata = {
  title: "Ghostty",
  description:
    "Ghostty is a fast, feature-rich, and cross-platform terminal emulator that uses platform-native UI and GPU acceleration.",
};

/** Loads homepage terminal data and renders the client-side home content. */
export default async function HomePage() {
  const terminalData = await loadAllTerminalFiles("/home");
  return <HomeContent terminalData={terminalData} />;
}

这里调用了 loadAllTerminalFiles('/home'),把 terminals/home 目录下的文本文件都读出来。再看一下 loadAllTerminalFiles 的实现:

terminal-data.tsx
import { promises as fs } from "node:fs";

/** Provides POSIX and path utilities for terminal file traversal. */
const nodePath = require("node:path");

/** Filesystem location for checked-in terminal snapshot data. */
const TERMINALS_DIRECTORY = "./terminals";

/** Extension used by terminal snapshot files. */
const TERMINAL_CONTENT_FILE_EXTENSION = ".txt";

export type TerminalsMap = { [k: string]: string[] };

/** Loads terminal text files and returns them keyed by slug path. */
export async function loadAllTerminalFiles(
  subdirectory?: string,
): Promise<TerminalsMap> {
  const allPaths = (
    await collectAllFilesRecursively(`${TERMINALS_DIRECTORY}${subdirectory}`)
  ).filter((path) => path.endsWith(TERMINAL_CONTENT_FILE_EXTENSION));

  const map: Map<string, Array<string>> = new Map<string, Array<string>>();
  for (const path of allPaths) {
    const slug = nodePath
      .relative(TERMINALS_DIRECTORY, path)
      .split(".")
      .slice(0, -1)
      .join(".");
    let content = (await fs.readFile(path, "utf8")).split(/\n/g);
    if (content[content.length - 1] === "") {
      content = content.slice(0, -1);
    }
    map.set(slug, content);
  }
  return Object.fromEntries(map);
}

/** Walks a directory recursively and returns full file paths. */
async function collectAllFilesRecursively(root: string): Promise<string[]> {
  const files: string[] = [];
  const entries = await fs.readdir(root, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = nodePath.join(root, entry.name);
    if (entry.isDirectory()) {
      files.push(...(await collectAllFilesRecursively(fullPath)));
      continue;
    }
    files.push(fullPath);
  }

  return files;
}

它做了下面几件事:

  1. 递归遍历 terminals 目录
  2. 找出所有 .txt 文件
  3. 把每个文件按行读取成 string[]
  4. 用文件路径生成 slug,最终得到一个 { slug: 文件内容[] } 的映射

所以 AnimatedTerminal 拿到的 frames,本质上就是一组二维文本数据:第一层数组表示动画帧,第二层数组表示这一帧里的每一行。

我们再看一下 terminals 目录下的文件结构

terminals 目录

打开其中一个 .txt 文件,就能看到已经处理好的字符画帧:

frame

到这里,网页播放链路已经清楚了:

帧文件里并不是纯文本,蓝色部分会被包进 <span class="b">...</span>。后续 Terminal 渲染时会把 .b 这类片段高亮成品牌色,于是终端里的 Ghostty 图案就有了分层。

mp4 -> 一组 txt 帧 -> terminalData -> AnimatedTerminal -> Terminal

接下来的问题是:这些 .txt 帧文件是怎么从视频生成出来的?

视频转终端帧

bin/video-to-terminal 目录下,可以看到一个 video-to-terminal.sh 脚本和一个 mp4 文件:

bin 目录

脚本内容如下:

video-to-terminal.sh
#!/usr/bin/env bash

# The ratio between the width : height of the end-rendering font
FONT_RATIO=".44"
VIDEO_FORMATS=("mp4" "mkv")
OUTPUT_FPS=24
OUTPUT_COLUMNS=100

# Colors Used
BLUE="0,0,230"
BLUE_DISTANCE_TOLERANCE=90
BLUE_MIN_LUMINANCE=10
BLUE_MAX_LUMINANCE=21

WHITE="215,215,215"
WHITE_DISTANCE_TOLERANCE=140
WHITE_MIN_LUMINANCE="165"
WHITE_MAX_LUMINANCE="255"

#
# Outputs the distance between two colors to stdout
#
# @param $1: r,g,b color #1
# @param $2: r,g,b color #2
#
color_distance_from() {
  awk -v c1="$1" -v c2="$2" '
    BEGIN {
      split(c1, a, ",");
      split(c2, b, ",");
      print abs(a[1] - b[1]) + abs(a[2] - b[2]) + abs(a[3] - b[3]);
    }
    function abs(x) { return ((x < 0) ? -x : x) }
  '
}

#
# Outputs the calculated pixel for the color to stdout
#
# @param $1: The r,g,b Pixel
#
pixel_for() {
  local r="$(echo "$1" | cut -f1 -d ',')"
  local g="$(echo "$1" | cut -f2 -d ',')"
  local b="$(echo "$1" | cut -f3 -d ',')"

  # https://en.wikipedia.org/wiki/Relative_luminance
  # Relative luminance scaled from 0-9
  # We'll use this to determine the Pixel to render, (. o x X E H), etc.
  # TODO: I might want to grab a _very specific_ slice of luminance (e.g from 200 -> 220)
  # TODO: Only call this if it hits a pixel
  #local scaled_luminance="$(echo "(0.2126 * $r + 0.7152 * $g + 0.0722 * $b) * 9 / 255" | bc)"
  local luminance=$(awk -v r="$r" -v g="$g" -v b="$b" 'BEGIN{print int((0.2126 * r + 0.7152 * g + 0.0722 * b) / 1)}')

  local blue_distance="$(color_distance_from "$BLUE" "$1")"
  local white_distance="$(color_distance_from "$WHITE" "$1")"

  if [[ $blue_distance -lt $BLUE_DISTANCE_TOLERANCE ]]; then
    local scaled_luminance=$(awk -v luminance="$luminance" -v min="$BLUE_MIN_LUMINANCE" -v max="$BLUE_MAX_LUMINANCE" 'BEGIN{print int((luminance - min) * 9 / (max - min))}')
    echo "B$scaled_luminance"
  elif [[ $white_distance -lt $WHITE_DISTANCE_TOLERANCE ]]; then
    local scaled_luminance=$(awk -v luminance="$luminance" -v min="$WHITE_MIN_LUMINANCE" -v max="$WHITE_MAX_LUMINANCE" 'BEGIN{print int((luminance - min) * 9 / (max - min))}')
    echo "W$scaled_luminance"
  else
    echo " "
  fi
}

#
# @param $1: The video file
# @param $2: The directory to place the frame images
#
generate_frame_images() {
  local video_file="$1"
  local working_dir="$2"

  local frame_images_dir="$working_dir/frame_images"
  mkdir "$frame_images_dir"

  # Outputs a png file for each frame
  ffmpeg \
    -loglevel error \
    -i "$video_file" \
    -vf "scale=$OUTPUT_COLUMNS:-2,fps=$OUTPUT_FPS" \
    "$frame_images_dir/frame_%04d.png"

  for f in $(find "$frame_images_dir" -name '*.png' | sort); do
    # We need to squish the image, as the terminal "pixels" will not be 1:1,
    local squished_image_file="$(echo "$f" | sed 's/\.png$/_squished\.png/g')"
    local image_height="$(magick identify -ping -format '%h' "$f")"
    local new_height=$(echo "$FONT_RATIO * $image_height" | bc | jq '.|ceil')

    magick "$f" -resize "x$new_height"'!' "$squished_image_file"
    rm "$f"
    mv "$squished_image_file" "$f"

    # Generate a parsable .txt file for each frame
    local imagemagick_text_file="$(echo "$f" | sed 's/\.png$/_im\.txt/g')"
    local output_text_file="$(echo "$f" | sed 's/\.png$/\.txt/g')"
    magick "$f" "$imagemagick_text_file"
    cat "$imagemagick_text_file" | tail -n +2 | while read line; do
      # Read / parse each line
      local xy="$(echo "$line" | cut -f1 -d ' ' | sed 's/://g')"
      local column=$(echo "$xy" | sed 's/\,.*//g')
      local row=$(echo "$xy" | sed 's/.*\,//g')

      # Echo out a new line on each new row
      if [[ "$column" = "0" ]] && [[ "$row" != "0" ]]; then
        echo "" >> "$output_text_file"
      fi

      local rgb="$(echo "$line" | cut -f2 -d ' ' | cut -d "(" -f 2 | cut -d ")" -f1)"
      local pixel="$(pixel_for "$rgb")"
      echo -n "$pixel" >> "$output_text_file"
    done

    cat "$output_text_file" \
      | perl -pe 's/(B[0-9](?:B[0-9])*)/<span class="b">\1<\/span>/g' \
      | sed 's/B//g' \
      | sed 's/W//g' \
      | sed 's/0/·/g' \
      | sed 's/1/~/g' \
      | sed 's/2/o/g' \
      | sed 's/3/x/g' \
      | sed 's/4/+/g' \
      | sed 's/5/=/g' \
      | sed 's/6/*/g' \
      | sed 's/7/%/g' \
      | sed 's/8/$/g' \
      | sed 's/9/@/g' \
      > "$output_text_file.tmp"
    mv "$output_text_file.tmp" "$output_text_file"
    rm "$imagemagick_text_file"
    echo "Processed $f"
    rm "$f"
    sleep 1 # Make it easy to cancel at this breakpoint for testing
  done
}

#
# @param $1: The video file
#
video_to_terminal() {
  local video_file="$1"
  if [[ ! -f "$video_file" ]]; then
    >&2 echo "Input file '$1' does not exist"
    return 1
  fi

  local file_extension="$(echo "${video_file##*.}" | awk '{print tolower($0)}')"
  if [[ "$(echo ${VIDEO_FORMATS[@]} | grep -ow "$file_extension")" == "" ]]; then
    >&2 echo "Does not support '$file_extension' files. Use one of: ${VIDEO_FORMATS[@]}"
  fi

  local working_dir="./$(uuidgen)"
  mkdir "$working_dir"

  generate_frame_images "$video_file" "$working_dir"
}

video_to_terminal "$1"

这个脚本的作用可以概括为一句话:把视频拆成图片帧,再把每一张图片转换成终端可渲染的字符画。

拆开看,它主要分成四步。

配置输出尺寸和颜色范围

脚本开头定义了一组常量:

FONT_RATIO=".44"
OUTPUT_FPS=24
OUTPUT_COLUMNS=100

OUTPUT_FPS 决定输出帧率,OUTPUT_COLUMNS 决定最终字符画的宽度。这里宽度固定为 100 列,也正好对应前端里 AnimatedTerminalcolumns={100}

FONT_RATIO 则用来处理一个容易忽略的问题:终端里的字符不是正方形像素。一个字符单元通常更高、更窄,如果直接把图片按像素转字符,画面会被纵向拉伸。所以脚本会先把图片高度压缩到原来的 0.44,让最终在终端字体里看起来更接近原始比例。

后面还定义了蓝色和白色的 RGB 参考值,以及颜色距离、亮度范围:

BLUE="0,0,230"
WHITE="215,215,215"

这说明脚本并不是把所有像素都转成字符,而是只关心接近蓝色和白色的部分。其他颜色会被当作空白处理。

用 ffmpeg 拆出图片帧

generate_frame_images 里先调用 ffmpeg

ffmpeg \
  -loglevel error \
  -i "$video_file" \
  -vf "scale=$OUTPUT_COLUMNS:-2,fps=$OUTPUT_FPS" \
  "$frame_images_dir/frame_%04d.png"

这一步会把输入视频拆成一组 png 图片,并且在拆帧时完成两件事:

  • scale=$OUTPUT_COLUMNS:-2:把宽度缩放到 100 像素,高度按比例自动计算
  • fps=$OUTPUT_FPS:按 24fps 输出图片帧

也就是说,视频在这里已经变成了 frame_0001.pngframe_0002.png 这样的图片序列。

用 ImageMagick 读取像素

接着脚本会遍历每一张 png。前面提到过,终端字符不是正方形,所以它先用 ImageMagick 调整高度:

magick "$f" -resize "x$new_height"'!' "$squished_image_file"

然后再把图片导出成 ImageMagick 的文本格式:

magick "$f" "$imagemagick_text_file"

这个文本文件会按行描述每个像素的位置和颜色。脚本随后逐行读取它,解析出坐标和 RGB:

local xy="$(echo "$line" | cut -f1 -d ' ' | sed 's/://g')"
local rgb="$(echo "$line" | cut -f2 -d ' ' | cut -d "(" -f 2 | cut -d ")" -f1)"

column 回到 0row 不是 0 时,就说明进入了新的一行,于是往输出 .txt 文件里写一个换行。这样图片里的二维像素矩阵,就被转换成了文本里的行列结构。

把像素映射成字符

最核心的是 pixel_for 函数。它会先计算当前像素的亮度:

local luminance=$(awk -v r="$r" -v g="$g" -v b="$b" 'BEGIN{print int((0.2126 * r + 0.7152 * g + 0.0722 * b) / 1)}')

这里用的是相对亮度公式:绿色权重最高,红色其次,蓝色最低。这比简单地 (r + g + b) / 3 更符合人眼对亮度的感知。

然后脚本计算当前像素到蓝色、白色参考值的距离:

local blue_distance="$(color_distance_from "$BLUE" "$1")"
local white_distance="$(color_distance_from "$WHITE" "$1")"

如果像素接近蓝色,就输出 B0B9;如果接近白色,就输出 W0W9;否则输出空格。

这里的 BW 只是中间标记,后面的数字才表示亮度等级。亮度越高,数字越大。

最后,脚本用一串 sed 把数字替换成不同密度的字符:

sed 's/0/·/g' \
| sed 's/1/~/g' \
| sed 's/2/o/g' \
| sed 's/3/x/g' \
| sed 's/4/+/g' \
| sed 's/5/=/g' \
| sed 's/6/*/g' \
| sed 's/7/%/g' \
| sed 's/8/$/g' \
| sed 's/9/@/g'

这组字符从稀疏到密集排列,正好可以模拟不同亮度的像素。比如 · 很轻,@ 很重,于是字符画就有了明暗层次。

蓝色区域还会先被 Perl 包成 span:

perl -pe 's/(B[0-9](?:B[0-9])*)/<span class="b">\1<\/span>/g'

所以前面看到的帧文件里才会出现 <span class="b">...</span>。这一步把“颜色信息”保留下来,而不是在转成字符后完全丢掉。

前端播放逻辑

经过脚本处理后,每一帧都已经是前端可以直接渲染的文本。AnimatedTerminal 要做的事就很简单了:

  1. requestAnimationFrame 建一个稳定的动画循环
  2. 根据 frameLengthMs 控制实际更新频率
  3. 每次更新时把 currentFrame 加一
  4. 用取模让动画循环播放

这里没有使用 setInterval,而是自己封装了一个 AnimationManager。好处是动画循环仍然挂在浏览器的 requestAnimationFrame 上,但帧更新可以按指定 FPS 节流,不需要每个浏览器渲染帧都切换一次文本内容。

组件里还有一个小彩蛋:输入 Konami Code(上、上、下、下、左、右、左、右、B、A) 后,会把动画 FPS 切到 240,再输入一次切回来。

小结

Ghostty 官网这个效果看起来像是“网页里跑了一个终端动画引擎”,但拆开以后会发现,它的思路非常朴素:

  • 离线阶段:用 ffmpeg 和 ImageMagick 把视频转成字符画帧
  • 构建阶段:读取 .txt 文件,整理成帧数组
  • 运行阶段:React 只负责按时间切换当前帧

这个实现最巧妙的地方在于,它把视频到字符画的转换放在离线阶段完成,浏览器端只需要像播放胶片一样逐帧切换文本。最终呈现出来的是一个很有质感的终端动画,但运行时成本其实很低。