Rust TUI 스크린샷과 데모 GIF 만들기: ratatui와 Alternate Screen의 함정

Rust TUI 앱의 스크린샷과 데모 GIF를 만들려다 vhs와 asciinema가 모두 먹통이 되는 경험. 원인은 alternate screen buffer였고, TestBackend로 SVG를 직접 생성하는 접근과 alternate screen을 끄는 모드를 추가해 GIF 녹화를 살리는 두 가지 해결책을 정리합니다.

1. 문제 상황

Rust로 TUI(Terminal User Interface) 앱을 만들면 필연적으로 부딪히는 고민이 있습니다.

어떻게 스크린샷과 데모 GIF를 만들어서 README에 넣지?

웹 앱이면 브라우저 개발자 도구로 스크린샷을 찍으면 됩니다. CLI 도구면 출력을 복붙해도 되고요. 그런데 TUI는 다릅니다. 실시간 상호작용, 색상, 유니코드 문자, 커서 위치 같은 것들이 어우러진 화면을 정적 이미지나 GIF로 남기려면 별도 도구가 필요합니다.

저는 duru라는 작은 Rust TUI 프로젝트를 만들면서 이 문제를 정면으로 마주했습니다. ratatui 0.29를 기반으로 만든 3-pane Miller Columns 레이아웃을 README에 보여주고 싶었죠.

처음엔 간단할 줄 알았습니다. "vhs 쓰면 되지" 하고요. 그런데 brew install vhs부터 GIF가 나오기까지, 막히는 지점이 한두 개가 아니었습니다.

첫 번째 벽: vhs가 TUI를 캡처 못 함

charmbracelet의 vhs는 테이프 스크립트로 터미널 세션을 녹화하는 도구입니다. 공식 데모도 화려해서 기대했는데, duru를 녹화하니 이런 결과가 나왔습니다.

vhs demo.tape 2>&1

나온 GIF는 쉘 프롬프트와 ./target/release/duru 명령만 보이고, 실제 TUI 화면은 까맣게 비어 있었습니다. 마치 앱이 실행조차 안 된 것처럼요.

두 번째 벽: asciinema도 헤드리스에서는 무력함

vhs 대신 asciinema를 써보기로 했습니다.

asciinema rec demo.cast --command "./target/release/duru"

Claude Code 같은 환경에서 실행하면 ::: TTY not available, recording in headless mode 메시지가 뜨고, 녹화된 cast 파일을 agg로 변환해도 역시 첫 프롬프트만 보이고 TUI는 안 나왔습니다.

세 번째 벽: expect와의 조합도 실패

완전 자동화를 위해 expect 스크립트로 키 입력을 시뮬레이션하는 방법을 시도했습니다.

asciinema rec demo.cast --command "
expect -c '
  spawn ./target/release/duru
  sleep 2
  send \"\\033\\[B\"  # Down
  ...
'"

결과는 같았습니다. GIF에는 spawn ./target/release/duru만 떠 있고, 그 아래는 텅 비어 있었습니다.

여기까지 오면 한 가지가 명확해집니다. 단순한 녹화 도구 선택 문제가 아니라 TUI 앱의 렌더링 방식 자체에 문제가 있다는 것이죠.

2. 원인 분석

2.1 Alternate Screen Buffer란 무엇인가

terminal에는 두 종류의 "화면"이 있습니다.

  1. Main screen buffer: 일반적인 쉘 출력이 스크롤되는 화면
  2. Alternate screen buffer: TUI 앱이 사용하는 별도 화면

vim, htop, tmux 같은 풀스크린 터미널 앱은 alternate screen을 씁니다. 앱을 종료하면 원래 쉘 내용이 그대로 다시 나타나는 걸 본 적이 있으실 텐데, 그게 alternate screen 덕분입니다. TUI는 별도 화면에서 마음대로 그리고, 끝나면 그 화면이 사라지면서 원래 화면이 복구되는 구조입니다.

crossterm에서는 EnterAlternateScreenLeaveAlternateScreen 명령으로 이 버퍼에 진입/퇴출합니다. ratatui 앱의 표준 초기화 코드는 이렇게 생겼습니다.

use crossterm::{
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, enable_raw_mode, disable_raw_mode},
};

fn main() -> io::Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;  // ← 별도 화면으로 진입
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // ... TUI 렌더링 ...

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    Ok(())
}

2.2 녹화 도구가 alternate screen을 못 보는 이유

대부분의 터미널 녹화 도구는 ANSI 이스케이프 시퀀스의 스트림을 저장합니다. asciinema의 .cast 파일도 결국 stdin/stdout을 타임스탬프와 함께 녹음한 것이고, agg는 그걸 가상 터미널로 재생해서 GIF로 변환합니다.

이론적으로는 alternate screen 관련 이스케이프 시퀀스(\x1b[?1049h 등)도 같이 녹화되니 재생 시 정상적으로 표시되어야 합니다. 그런데 실제로는 이런 문제들이 겹칩니다.

  1. Headless 모드: asciinema는 실제 TTY가 없으면 "headless" 모드로 동작하는데, 이 모드에서는 pty가 온전히 alternate screen 제어 시퀀스를 전달하지 못하는 경우가 있습니다.
  2. expect의 pty: expect는 자체 pty를 만들어 자식 프로세스를 실행하지만, 이 pty 설정이 alternate screen을 제대로 처리하지 못할 수 있습니다.
  3. crossterm의 TTY 감지: ratatui/crossterm은 실제 터미널이 아니면 "Device not configured" 같은 에러를 내고 조기 종료하기도 합니다.

특히 저처럼 Claude Code 같은 AI 에이전트 환경에서 자동화하려는 경우, TTY가 없어서 처음부터 TUI가 실행 불가능한 경우도 많습니다.

2.3 검증: TUI가 실행되긴 하는가

원인을 좁히기 위해 실제로 TUI가 실행되는지부터 확인해봤습니다.

./target/release/duru 2>&1
# Error: Os { code: 6, kind: Uncategorized, message: "Device not configured" }

Headless 환경에서는 crossterm이 TTY를 못 찾아서 TUI 자체가 뜨지 않습니다. 즉, 녹화 도구 문제 이전에 TUI가 실행조차 안 되는 환경이라는 것이죠.

2.4 두 가지 방향의 해결책

여기서 결론이 나왔습니다.

  • 방향 A (SVG): 아예 TUI를 실행하지 않고, ratatui의 TestBackend로 프레임 한 장을 렌더링한 뒤 SVG로 직접 변환하자. 완전히 헤드리스.
  • 방향 B (GIF): Alternate screen을 끄고 main screen에 TUI를 그리면, asciinema가 main screen 출력을 녹화할 수 있다. 사용자가 실제 터미널에서 녹화한다는 전제.

둘 다 일리가 있어서 두 방향 모두 구현했습니다.

3. 해결 방법

3.1 Approach A: TestBackend로 SVG 생성

ratatui에는 TestBackend라는 테스트용 백엔드가 있습니다. 실제 터미널 없이 메모리 버퍼에 프레임을 그려주는 백엔드죠. 원래는 유닛 테스트용이지만, 스크린샷 생성에도 활용할 수 있습니다.

examples/screenshot.rs를 만들고, 프로젝트 모듈을 #[path]로 포함했습니다.

//! Generate SVG screenshot of duru TUI with demo data.
//! Usage: cargo run --example screenshot 2>/dev/null > screenshot.svg

use ratatui::{Terminal, backend::TestBackend, style::Color};

#[path = "../src/app.rs"]
mod app;
#[path = "../src/scan.rs"]
mod scan;
#[path = "../src/theme.rs"]
mod theme;
#[path = "../src/ui.rs"]
mod ui;

fn main() {
    let projects = scan::demo_projects();
    let theme = theme::Theme::dark();

    let mut app = app::App::new(projects);
    app.project_index = 2;
    app.file_index = 0;
    app.focus = app::Pane::Files;
    app.load_content();

    let backend = TestBackend::new(120, 30);
    let mut terminal = Terminal::new(backend).unwrap();

    terminal
        .draw(|frame| ui::render(frame, &app, &theme))
        .unwrap();

    let buffer = terminal.backend().buffer().clone();
    print!("{}", buffer_to_svg(&buffer, 120, 30, &theme));
}

핵심은 세 가지입니다.

  1. TestBackend 사용: TestBackend::new(120, 30)으로 120x30 크기의 가상 터미널 생성
  2. App 상태 설정: 스크린샷에 보여주고 싶은 상태를 직접 세팅(특정 프로젝트 선택, 특정 패널 포커스 등)
  3. 한 프레임 렌더링: terminal.draw()로 한 번 그리고, backend().buffer()로 결과 버퍼 획득

이제 Buffer에서 SVG를 만들어내면 됩니다.

3.2 Buffer를 SVG로 변환

ratatui의 Buffer는 각 셀이 Cell 구조체로 표현되어 있고, 각 Cell은 symbol, fg, bg, modifier(bold 등)를 가집니다.

fn buffer_to_svg(
    buffer: &ratatui::buffer::Buffer,
    width: u16,
    height: u16,
    theme: &theme::Theme,
) -> String {
    let char_w: f64 = 8.4;  // 모노스페이스 문자 폭
    let char_h: f64 = 17.0; // 행 높이
    let pad: f64 = 16.0;
    let corner: f64 = 10.0;

    let svg_w = (width as f64) * char_w + pad * 2.0;
    let svg_h = (height as f64) * char_h + pad * 2.0 + 32.0;

    let base_hex = color_to_hex(theme.base);
    let mut svg = String::new();

    // 배경
    svg.push_str(&format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{svg_w}\" height=\"{svg_h}\">\n"
    ));
    svg.push_str(&format!(
        "<rect width=\"{svg_w}\" height=\"{svg_h}\" rx=\"{corner}\" fill=\"{base_hex}\"/>\n"
    ));

    // macOS 스타일 타이틀 바 (빨강/노랑/초록 점)
    svg.push_str(&format!("<g transform=\"translate({pad}, 12)\">\n"));
    svg.push_str("  <circle cx=\"8\" cy=\"8\" r=\"5.5\" fill=\"#eb6f92\"/>\n");
    svg.push_str("  <circle cx=\"26\" cy=\"8\" r=\"5.5\" fill=\"#f6c177\"/>\n");
    svg.push_str("  <circle cx=\"44\" cy=\"8\" r=\"5.5\" fill=\"#31748f\"/>\n");
    svg.push_str("</g>\n");

    // 실제 텍스트 렌더링
    svg.push_str(&format!(
        "<g transform=\"translate({pad}, {})\" font-family=\"SF Mono,Menlo,monospace\" font-size=\"13\">\n",
        pad + 32.0
    ));

    for y in 0..height {
        for x in 0..width {
            let cell = &buffer[(x, y)];
            let symbol = cell.symbol();

            // 빈 셀 건너뛰기
            if symbol == " " || symbol.is_empty() {
                let bg = color_to_hex(cell.bg);
                if bg != base_hex && cell.bg != Color::Reset {
                    svg.push_str(&format!(
                        r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
                        (x as f64) * char_w,
                        (y as f64) * char_h,
                        char_w + 0.5,
                        char_h,
                        bg
                    ));
                }
                continue;
            }

            let fg = color_to_hex(cell.fg);
            let bg = color_to_hex(cell.bg);

            // 배경색이 있으면 사각형 먼저 그리기
            if bg != base_hex && cell.bg != Color::Reset {
                svg.push_str(&format!(
                    r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
                    (x as f64) * char_w,
                    (y as f64) * char_h,
                    char_w + 0.5,
                    char_h,
                    bg
                ));
            }

            // XML 특수문자 이스케이프
            let escaped = symbol
                .replace('&', "&amp;")
                .replace('<', "&lt;")
                .replace('>', "&gt;");

            let bold = if cell.modifier.contains(ratatui::style::Modifier::BOLD) {
                r#" font-weight="bold""#
            } else {
                ""
            };

            svg.push_str(&format!(
                r#"<text x="{}" y="{}" fill="{}"{bold}>{escaped}</text>"#,
                (x as f64) * char_w,
                (y as f64) * char_h + char_h * 0.75,
                fg
            ));
        }
    }

    svg.push_str("</g>\n</svg>\n");
    svg
}

fn color_to_hex(color: Color) -> String {
    match color {
        Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
        Color::Reset => "#191724".to_string(),
        _ => "#191724".to_string(),
    }
}

핵심 아이디어를 정리하면 이렇습니다.

  • 각 셀 = SVG 요소 한 개 또는 두 개: 배경색 있는 셀은 <rect>, 글자는 <text>
  • 배경이 기본색(base)이면 <rect> 생략: SVG 크기 줄이기 위해 기본 배경은 한 번만 그림
  • 모노스페이스 가정: char_w는 대략적인 문자 폭. 대부분 모노스페이스 폰트에서 예쁘게 정렬됨
  • bold는 font-weight 속성: ratatui의 Modifier::BOLD를 SVG 속성으로 매핑
  • XML 이스케이프: &, <, >는 반드시 이스케이프. 그렇지 않으면 SVG가 깨짐

3.3 SVG 생성 실행의 함정: stdout/stderr 분리

SVG를 파일로 저장하려고 처음엔 이렇게 실행했습니다.

cargo run --example screenshot > screenshot.svg

열어보니 브라우저에서 "Document is empty" 에러가 떴습니다. 파일 내용을 보니 맨 앞에 이런 게 들어가 있더군요.

warning: unused variable: `content`
   --> examples/../src/scan.rs:204:61
    |
204 |     let demo_file = |kind: FileKind, name: &str, size: u64, content: &str| MemoryFile {
    |                                                             ^^^^^^^ ...

cargo의 경고 메시지가 SVG 파일 앞에 섞여 들어간 것이었습니다. > 리다이렉트는 기본적으로 stdout만 파일로 보내는데, cargo는 컴파일 경고를 stderr로 출력합니다. 그런데 왜 섞였을까요?

원인은 쉘의 buffer 동작이었습니다. 정확히는, 제가 실행한 방식이 command > file 2>&1 비슷한 형태로 두 스트림이 섞인 것이었죠. 어쨌든 해결은 간단합니다. stderr를 명시적으로 다른 곳으로 보내면 됩니다.

# Wrong: cargo warnings가 SVG에 섞임
cargo run --example screenshot > screenshot.svg

# Correct: stderr 분리
cargo run --example screenshot 2>/dev/null > screenshot.svg

2>/dev/null로 stderr를 버리면 순수한 SVG만 파일로 들어갑니다.

이 경험에서 얻은 교훈은 프로그램 출력을 파이프에 넘길 때는 항상 stderr를 어디로 보낼지 명시하라는 점입니다. 특히 cargo처럼 경고를 stderr로 쏟는 빌드 도구가 앞단에 있으면 더더욱 그렇습니다.

3.4 Approach B: DURU_NO_ALT_SCREEN으로 GIF 녹화 지원

SVG는 정적 이미지에 적합하지만, 네비게이션을 보여주는 데모는 GIF가 필요합니다. GIF 녹화를 포기하지 않으려면 alternate screen 문제를 우회해야 합니다.

답은 alternate screen을 쓰지 않는 모드를 만드는 것입니다. 환경 변수로 제어하기로 했습니다.

// main.rs
let use_alt_screen = std::env::var("DURU_NO_ALT_SCREEN").is_err();

enable_raw_mode()?;
let mut stdout = io::stdout();
if use_alt_screen {
    execute!(stdout, EnterAlternateScreen)?;
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;  // main screen에서도 지저분한 내용을 덮어쓰기 위해

let mut app = App::new(projects);
let result = run_app(&mut terminal, &mut app, &theme);

disable_raw_mode()?;
if use_alt_screen {
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
}

result

평상시 사용자는 이 환경 변수를 모르고, TUI는 기존처럼 alternate screen에서 동작합니다. 그런데 녹화 목적으로 실행할 때는 이렇게 합니다.

DURU_NO_ALT_SCREEN=1 asciinema rec demo.cast \
  --cols 100 --rows 20 \
  --overwrite \
  --command "./target/release/duru --demo --theme light"

Alternate screen을 건너뛰니 TUI가 main screen에 그려지고, asciinema는 이걸 깔끔하게 녹화합니다. 녹화 종료 후 agg로 GIF 변환:

agg demo.cast demo.gif --font-size 14 --idle-time-limit 1.5

--idle-time-limit 1.5는 사용자가 중간에 생각하느라 1.5초 이상 멈춰 있어도 1.5초로 줄이는 옵션입니다. 녹화 중 실수로 멈춰도 GIF가 어색하지 않습니다.

3.5 Approach C: --demo 플래그로 더미 데이터

스크린샷과 GIF 모두 실제 사용자 데이터를 보여주면 안 됩니다. 개인 정보가 섞이기 쉽고, 화면에 뭐가 나올지 예측하기도 어렵죠. 그래서 --demo 플래그를 추가해서 예쁜 더미 데이터로 전환할 수 있게 했습니다.

// main.rs
#[derive(Parser)]
struct Cli {
    #[arg(long)]
    theme: Option<String>,

    #[arg(long)]
    path: Option<PathBuf>,

    /// Use demo data (for screenshots and testing)
    #[arg(long)]
    demo: bool,
}

fn main() -> io::Result<()> {
    let cli = Cli::parse();

    let projects = if cli.demo {
        demo_projects()
    } else {
        // 실제 ~/.claude/ 스캔
        scan_claude_dir(&claude_dir)
    };

    // ... TUI 실행 ...
}

scan.rs에는 demo_projects() 함수를 추가해서, 하드코딩된 샘플 프로젝트들을 반환합니다. 프로젝트 이름, 파일 크기, 콘텐츠 모두 "보여주기 좋은" 값으로 세팅했습니다.

pub fn demo_projects() -> Vec<Project> {
    let demo_file = |kind: FileKind, name: &str, size: u64| MemoryFile {
        kind,
        path: PathBuf::from(format!("/tmp/duru-demo/{name}")),
        name: name.to_string(),
        size,
    };

    // 프리뷰 패널에서 실제로 읽을 수 있도록 /tmp에 콘텐츠 파일도 생성
    let demo_dir = Path::new("/tmp/duru-demo");
    let _ = fs::create_dir_all(demo_dir);

    let files_data = [
        ("CLAUDE-global.md", "# Claude Code Workflow Rules\n\n..."),
        ("CLAUDE-project.md", "# my-webapp\n\nA Next.js web app...\n"),
        // ...
    ];

    for (name, content) in &files_data {
        let _ = fs::write(demo_dir.join(name), content);
    }

    vec![
        Project {
            name: "GLOBAL".to_string(),
            files: vec![demo_file(FileKind::GlobalClaudeMd, "CLAUDE-global.md", 2480)],
            ..
        },
        // ... my-webapp, rust-analyzer, design-system ...
    ]
}

이렇게 하면 duru --demo만 실행해도 네 개의 가상 프로젝트와 해당 메모리 파일들이 보입니다. 스크린샷 예제(screenshot.rs)와 GIF 녹화 스크립트(record-demo.sh) 모두 이 플래그를 사용합니다.

3.6 전체 파이프라인 정리

최종적으로 녹화 파이프라인은 이렇게 정리됩니다.

SVG 스크린샷 (완전 자동화):

# 한 번 실행하면 assets/screenshot.svg 생성
cargo build --example screenshot
cargo run --example screenshot 2>/dev/null > assets/screenshot.svg

GIF 데모 (실제 TTY 필요):

# 터미널에서 직접 실행
cargo build --release

DURU_NO_ALT_SCREEN=1 asciinema rec demo.cast \
  --cols 100 --rows 20 \
  --overwrite \
  --command "./target/release/duru --demo --theme light"

# 녹화 중 키보드로 탐색 후 q로 종료

agg demo.cast demo.gif --font-size 14 --idle-time-limit 1.5

그리고 README에는 이렇게 넣습니다.

<p align="center">
  <img src="assets/screenshot.svg" alt="duru screenshot">
</p>

<p align="center">
  <img src="demo.gif" alt="duru demo">
</p>

GitHub의 README 컨테이너 폭을 꽉 채우려면 width 속성을 빼면 됩니다.

4. 핵심 개념 정리

개념 설명
Alternate Screen Buffer TUI 앱이 쓰는 별도 화면 버퍼. 앱 종료 시 원래 쉘 내용이 복구됨
EnterAlternateScreen crossterm에서 alternate screen 진입을 요청하는 명령
TestBackend ratatui의 가상 백엔드. 실제 터미널 없이 메모리에 프레임 렌더링
Buffer (ratatui) 렌더링 결과가 저장되는 셀 그리드. 각 셀은 symbol/fg/bg/modifier 포함
asciinema .cast 타임스탬프와 함께 stdout을 JSON으로 녹음한 포맷
agg asciinema cast 파일을 GIF로 변환하는 Rust 도구
expect TCL 기반 자동화 도구. 키 입력을 프로그래밍적으로 보냄
--idle-time-limit agg 옵션. 녹화 중 멈춘 시간을 최대 N초로 제한

5. 베스트 프랙티스

TUI 앱의 스크린샷/GIF 만들기에서 반복하기 쉬운 실수를 피하는 체크리스트입니다.

5.1 스크린샷 생성

  • [ ] TestBackend로 SVG를 직접 생성하면 헤드리스 환경에서도 동작
  • [ ] 실제 데이터 대신 --demo 플래그로 더미 데이터 사용
  • [ ] stderr를 2>/dev/null로 분리해서 빌드 경고가 섞이는 것 방지
  • [ ] SVG의 <text> 요소에 XML 특수문자 이스케이프(&, <, >)
  • [ ] 모노스페이스 폰트 크기 추정: char_w ≈ 8.4, char_h ≈ 17.0 (font-size 13 기준)
  • [ ] 배경색이 base와 같으면 <rect> 생략해서 SVG 크기 최적화

5.2 GIF 녹화

  • [ ] Alternate screen을 끄는 모드(환경 변수 또는 플래그) 제공
  • [ ] asciinema가 실제 TTY에서 실행되는지 확인 (TTY not available 메시지 주의)
  • [ ] --cols, --rows로 녹화 크기 명시해서 화면 크기 일관성 유지
  • [ ] agg --idle-time-limit으로 죽은 시간 제거
  • [ ] record-demo.sh 같은 재현 가능한 스크립트로 저장
  • [ ] 녹화 후 .cast 파일은 .gitignore에 추가하고 .gif만 커밋

5.3 데모 데이터 설계

  • [ ] 프로젝트 이름은 현실적이지만 가상의 것(my-webapp, design-system 등)
  • [ ] 파일 콘텐츠는 실제로 읽어도 의미 있는 더미 마크다운
  • [ ] 개인정보, API 키, 토큰 같은 것은 절대 포함하지 말 것
  • [ ] 프리뷰 패널이 실제로 동작하도록 파일을 임시 디렉토리에 쓰기
  • [ ] 특정 상태(선택된 프로젝트, 포커스 패널)를 미리 설정

5.4 README 배치

  • [ ] 상단 hero 이미지로 데모 GIF 또는 SVG 배치
  • [ ] <p align="center">로 중앙 정렬
  • [ ] width 속성 빼면 컨테이너 폭에 맞춰 확장(GitHub 기본 ~800px)
  • [ ] 다크/라이트 테마 모두에서 보이는지 확인
  • [ ] SVG는 텍스트 기반이라 검색 엔진도 인덱싱 가능

6. FAQ

Q: SVG 대신 PNG로 저장하면 안 되나요?

A: SVG의 장점이 분명합니다. 첫째, 텍스트 기반이라 용량이 작고 Git diff로 확인 가능합니다. 둘째, 어떤 해상도로 확대해도 깨지지 않습니다. 셋째, grep으로 스크린샷 안에 어떤 텍스트가 있는지 검색할 수 있습니다. PNG가 필요하면 브라우저에서 SVG를 띄우고 스크린샷 찍으면 되고, 자동화가 필요하면 rsvg-convert 같은 도구로 변환할 수 있습니다.

Q: vhs가 정말 안 되나요?

A: vhs 자체는 훌륭한 도구이고, 많은 Go 기반 TUI 앱과는 잘 맞습니다. 제 경우 ratatui + crossterm 조합의 alternate screen 처리가 vhs의 내부 pty와 잘 맞지 않아 실패했습니다. 같은 환경에서 다른 사용자는 성공할 수도 있고, 특정 버전에서 해결될 수도 있습니다. 저는 asciinema + agg가 더 안정적이었습니다.

Q: --demo 플래그 말고 환경 변수로 관리하면 안 되나요?

A: 가능합니다. 선호의 차이인데, 저는 사용자에게 노출되는 기능(더미 데이터)은 CLI 플래그로, 내부 구현 세부(alternate screen 끄기)는 환경 변수로 나누는 게 깔끔하다고 봤습니다. 플래그는 --help에 자동으로 문서화되지만 환경 변수는 그렇지 않거든요. 대신 환경 변수는 녹화 스크립트에서 export로 손쉽게 설정할 수 있어서 "녹화 모드"처럼 내부 토글에 적합합니다.

Q: TestBackend로 여러 프레임을 렌더링하면 GIF도 만들 수 있지 않나요?

A: 이론적으로는 가능합니다. 각 상태별로 TestBackend로 프레임을 렌더링하고, 여러 SVG를 하나의 APNG나 GIF로 만드는 거죠. 하지만 상호작용의 부드러움, 커서 움직임 같은 걸 표현하기는 어렵습니다. 저는 정적 스크린샷은 TestBackend, 상호작용 데모는 asciinema로 역할을 나누는 게 실용적이라고 판단했습니다.

Q: 녹화 후 demo.cast 파일은 어떻게 처리하나요?

A: .cast 파일은 녹화 원본이어서 repo에 넣을 필요가 없습니다. .gitignoredemo.cast를 추가하고, 최종 결과물인 demo.gif만 커밋합니다. 필요하면 언제든 같은 스크립트로 재생성할 수 있어서 원본을 보관할 이유가 없습니다.

Q: GIF 파일 크기가 커지면 어떻게 하나요?

A: agg의 몇 가지 옵션으로 줄일 수 있습니다. --font-size를 낮추면 프레임 크기가 줄고, --idle-time-limit으로 멈춤 시간을 짧게 자르면 프레임 수가 줄어듭니다. 그래도 너무 크면 gifsicle --optimize=3 --lossy=80 demo.gif -o demo-opt.gif로 추가 최적화할 수 있습니다. 1~3 MB 정도가 README에 적당한 사이즈입니다.

7. 참고 자료