Created
March 10, 2026 23:01
-
-
Save jwa91/1b55714f4b3f6ac157810bb39fe1ddc4 to your computer and use it in GitHub Desktop.
Ghostty style animation with "Twentse Ros"
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Twentse Ros - Pre-rendered ASCII animation for the terminal. | |
| Ghostty-style smooth animation: pre-rendered frames, two colors, | |
| character density for depth, subtle breathing wave. | |
| Usage: | |
| python3 twentse_ros_anim.py [ros_ascii.txt] | |
| Ctrl+C to quit | |
| """ | |
| import math | |
| import os | |
| import signal | |
| import sys | |
| import time | |
| from collections import deque | |
| # --- Config --- | |
| NUM_FRAMES = 180 # 6-second loop | |
| FPS = 30 | |
| # --- ANSI --- | |
| ESC = "\033" | |
| HIDE_CURSOR = f"{ESC}[?25l" | |
| SHOW_CURSOR = f"{ESC}[?25h" | |
| CLEAR = f"{ESC}[2J" | |
| HOME = f"{ESC}[H" | |
| RESET = f"{ESC}[0m" | |
| # Two colors: default terminal fg + Twente red accent | |
| ACCENT = f"{ESC}[38;2;180;25;25m" | |
| ACCENT_DIM = f"{ESC}[38;2;100;14;14m" | |
| DEFAULT_FG = f"{ESC}[39m" | |
| # --- Characters --- | |
| # Body: ordered light → heavy (18 chars) | |
| BODY_CHARS = "·.,;:-~=+*xo%S#@$W" | |
| # Border: lighter decorative set | |
| BORDER_CHARS = "·.+~*=-:" | |
| # --- Density mapping from original ASCII --- | |
| CHAR_DENSITY = {} | |
| for c in "W": CHAR_DENSITY[c] = 1.0 | |
| for c in "#@": CHAR_DENSITY[c] = 0.9 | |
| for c in "%&$": CHAR_DENSITY[c] = 0.85 | |
| for c in "S": CHAR_DENSITY[c] = 0.75 | |
| for c in "E": CHAR_DENSITY[c] = 0.7 | |
| for c in "N": CHAR_DENSITY[c] = 0.6 | |
| for c in "AHMX": CHAR_DENSITY[c] = 0.55 | |
| for c in "0123456789": CHAR_DENSITY[c] = 0.5 | |
| for c in "abcdefghijklmnopqrstuvwxyz": CHAR_DENSITY[c] = 0.4 | |
| for c in "+=*?": CHAR_DENSITY[c] = 0.35 | |
| for c in "~-:;": CHAR_DENSITY[c] = 0.25 | |
| for c in ".,": CHAR_DENSITY[c] = 0.15 | |
| def density_to_char(d): | |
| d = max(0.0, min(1.0, d)) | |
| return BODY_CHARS[int(d * (len(BODY_CHARS) - 1))] | |
| def load_ascii_art(path): | |
| with open(path) as f: | |
| lines = [l.rstrip('\n') for l in f] | |
| # Find bounding box of content | |
| min_x = min_y = float('inf') | |
| max_x = max_y = 0 | |
| for y, line in enumerate(lines): | |
| for x, ch in enumerate(line): | |
| if ch != ' ': | |
| min_x = min(min_x, x) | |
| max_x = max(max_x, x) | |
| min_y = min(min_y, y) | |
| max_y = max(max_y, y) | |
| if min_x == float('inf'): | |
| print("No content found!") | |
| sys.exit(1) | |
| # Add margin for the border aura | |
| margin = 3 | |
| grid_x0 = max(0, min_x - margin) | |
| grid_y0 = max(0, min_y - margin) | |
| grid_x1 = max_x + margin | |
| grid_y1 = max_y + margin | |
| h = grid_y1 - grid_y0 + 1 | |
| w = grid_x1 - grid_x0 + 1 | |
| grid = [] | |
| for y in range(grid_y0, grid_y1 + 1): | |
| row = [] | |
| for x in range(grid_x0, grid_x1 + 1): | |
| ch = ' ' | |
| if 0 <= y < len(lines) and 0 <= x < len(lines[y]): | |
| ch = lines[y][x] | |
| row.append(ch) | |
| grid.append(row) | |
| return grid, w, h | |
| def compute_outer_aura(grid, h, w, max_dist=2): | |
| """Find empty pixels near the OUTER edge of the horse only. | |
| Flood-fills from the grid border to find all 'outside' empty pixels, | |
| then computes distance from filled pixels for only those outside pixels. | |
| Interior holes (like the eye) are excluded. | |
| """ | |
| # Step 1: flood-fill from edges to mark all "outside" empty pixels | |
| outside = [[False] * w for _ in range(h)] | |
| q = deque() | |
| # Seed from all border cells that are empty | |
| for y in range(h): | |
| for x in range(w): | |
| if (y == 0 or y == h - 1 or x == 0 or x == w - 1) and grid[y][x] == ' ': | |
| outside[y][x] = True | |
| q.append((x, y)) | |
| while q: | |
| px, py = q.popleft() | |
| for dx in (-1, 0, 1): | |
| for dy in (-1, 0, 1): | |
| if dx == 0 and dy == 0: | |
| continue | |
| nx, ny = px + dx, py + dy | |
| if 0 <= nx < w and 0 <= ny < h and not outside[ny][nx] and grid[ny][nx] == ' ': | |
| outside[ny][nx] = True | |
| q.append((nx, ny)) | |
| # Step 2: BFS distance from filled pixels, but only for outside empty pixels | |
| dist = [[999] * w for _ in range(h)] | |
| q = deque() | |
| for y in range(h): | |
| for x in range(w): | |
| if grid[y][x] != ' ': | |
| dist[y][x] = 0 | |
| q.append((x, y)) | |
| while q: | |
| px, py = q.popleft() | |
| for dx in (-1, 0, 1): | |
| for dy in (-1, 0, 1): | |
| if dx == 0 and dy == 0: | |
| continue | |
| nx, ny = px + dx, py + dy | |
| if 0 <= nx < w and 0 <= ny < h: | |
| nd = dist[py][px] + 1 | |
| if nd < dist[ny][nx] and nd <= max_dist and outside[ny][nx]: | |
| dist[ny][nx] = nd | |
| q.append((nx, ny)) | |
| return dist | |
| def generate_frames(grid, dist, h, w): | |
| """Pre-render all animation frames.""" | |
| frames = [] | |
| for f in range(NUM_FRAMES): | |
| phase = f / NUM_FRAMES * 2.0 * math.pi | |
| lines = [] | |
| for y in range(h): | |
| parts = [] | |
| color_state = 'default' | |
| ny = y / h | |
| for x in range(w): | |
| ch = grid[y][x] | |
| pd = dist[y][x] | |
| if ch != ' ': | |
| # --- Body pixel: default terminal color, original char --- | |
| if color_state != 'default': | |
| parts.append(DEFAULT_FG) | |
| color_state = 'default' | |
| parts.append(ch) | |
| elif 1 <= pd <= 2: | |
| # --- Border aura: Twente red accent --- | |
| # Slow traveling shimmer (whole-number multipliers = seamless loop) | |
| s = math.sin(y * 0.15 + x * 0.1 + phase) | |
| s += math.sin(y * 0.08 - x * 0.06 + phase * 2.0) * 0.4 | |
| # Show character when wave crest passes | |
| thresh = 0.1 if pd == 1 else 0.5 | |
| if s > thresh: | |
| want_color = ACCENT if pd == 1 else ACCENT_DIM | |
| if color_state != want_color: | |
| parts.append(want_color) | |
| color_state = want_color | |
| bci = int(abs(s * 5.0 + ny * 3.0)) % len(BORDER_CHARS) | |
| parts.append(BORDER_CHARS[bci]) | |
| else: | |
| if color_state != 'default': | |
| parts.append(DEFAULT_FG) | |
| color_state = 'default' | |
| parts.append(' ') | |
| else: | |
| # --- Empty pixel --- | |
| if color_state != 'default': | |
| parts.append(DEFAULT_FG) | |
| color_state = 'default' | |
| parts.append(' ') | |
| if color_state != 'default': | |
| parts.append(DEFAULT_FG) | |
| lines.append(''.join(parts)) | |
| frames.append(lines) | |
| return frames | |
| def main(): | |
| path = sys.argv[1] if len(sys.argv) > 1 else "ros_ascii.txt" | |
| grid, w, h = load_ascii_art(path) | |
| try: | |
| term_w, term_h = os.get_terminal_size() | |
| except OSError: | |
| term_w, term_h = 120, 50 | |
| offset_x = max(0, (term_w - w) // 2) | |
| offset_y = max(0, (term_h - h) // 2) | |
| dist = compute_outer_aura(grid, h, w, max_dist=2) | |
| sys.stderr.write("Generating frames...") | |
| sys.stderr.flush() | |
| frames = generate_frames(grid, dist, h, w) | |
| sys.stderr.write(f" {NUM_FRAMES} frames ready.\n") | |
| sys.stderr.flush() | |
| # Setup terminal | |
| sys.stdout.write(HIDE_CURSOR + CLEAR) | |
| sys.stdout.flush() | |
| def cleanup(sig=None, _frame=None): | |
| sys.stdout.write(SHOW_CURSOR + RESET + CLEAR + HOME) | |
| sys.stdout.flush() | |
| sys.exit(0) | |
| signal.signal(signal.SIGINT, cleanup) | |
| signal.signal(signal.SIGTERM, cleanup) | |
| frame_idx = 0 | |
| try: | |
| while True: | |
| frame_lines = frames[frame_idx % NUM_FRAMES] | |
| buf = [] | |
| for y, line in enumerate(frame_lines): | |
| row = offset_y + y + 1 | |
| if 1 <= row <= term_h: | |
| buf.append(f"{ESC}[{row};{offset_x + 1}H{line}") | |
| sys.stdout.write(''.join(buf)) | |
| sys.stdout.flush() | |
| frame_idx += 1 | |
| time.sleep(1.0 / FPS) | |
| except KeyboardInterrupt: | |
| cleanup() | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
ros_ascii.txt