# Shader Pipeline & Composable Effects Post-processing effects applied to the pixel canvas (`numpy uint8 array, shape (H,W,3)`) after character rendering and before encoding. Also covers **pixel-level blend modes**, **feedback buffers**, and the **ShaderChain** compositor. > **See also:** composition.md (blend modes, tonemap) · effects.md · scenes.md · architecture.md · optimization.md · troubleshooting.md > > **Blend modes:** For the 20 pixel blend modes and `blend_canvas()`, see `composition.md`. All blending uses `blend_canvas(base, top, mode, opacity)`. ## Design Philosophy The shader pipeline turns raw ASCII renders into cinematic output. The system is designed for **composability** — every shader, blend mode, and feedback transform is an independent building block. Combining them creates infinite visual variety from a small set of primitives. Choose shaders that reinforce the mood: - **Retro terminal**: CRT + scanlines + grain + green/amber tint - **Clean modern**: light bloom + subtle vignette only - **Glitch art**: heavy chromatic aberration + glitch bands + color wobble + pixel sort - **Cinematic**: bloom + vignette + grain + color grade - **Dreamy**: heavy bloom + soft focus + color wobble + low contrast - **Harsh/industrial**: high contrast + grain + scanlines + no bloom - **Psychedelic**: color wobble + chromatic + kaleidoscope mirror + high saturation + feedback with hue shift - **Data corruption**: pixel sort + data bend + block glitch + posterize - **Recursive/infinite**: feedback buffer with zoom + screen blend + hue shift --- ## Pixel-Level Blend Modes All operate on float32 [0,1] canvases for precision. Use `blend_canvas(base, top, mode, opacity)` which handles uint8 <-> float conversion. ### Available Modes ```python BLEND_MODES = { "normal": lambda a, b: b, "add": lambda a, b: np.clip(a + b, 0, 1), "subtract": lambda a, b: np.clip(a - b, 0, 1), "multiply": lambda a, b: a * b, "screen": lambda a, b: 1 - (1-a)*(1-b), "overlay": # 2*a*b if a<0.5, else 1-2*(1-a)*(1-b) "softlight": lambda a, b: (1-2*b)*a*a + 2*b*a, "hardlight": # like overlay but keyed on b "difference": lambda a, b: abs(a - b), "exclusion": lambda a, b: a + b - 2*a*b, "colordodge": lambda a, b: a / (1-b), "colorburn": lambda a, b: 1 - (1-a)/b, "linearlight": lambda a, b: a + 2*b - 1, "vividlight": # burn if b<0.5, dodge if b>=0.5 "pin_light": # min(a,2b) if b<0.5, max(a,2b-1) if b>=0.5 "hard_mix": lambda a, b: 1 if a+b>=1 else 0, "lighten": lambda a, b: max(a, b), "darken": lambda a, b: min(a, b), "grain_extract": lambda a, b: a - b + 0.5, "grain_merge": lambda a, b: a + b - 0.5, } ``` ### Usage ```python def blend_canvas(base, top, mode="normal", opacity=1.0): """Blend two uint8 canvases (H,W,3) using a named blend mode + opacity.""" af = base.astype(np.float32) / 255.0 bf = top.astype(np.float32) / 255.0 result = BLEND_MODES[mode](af, bf) if opacity < 1.0: result = af * (1-opacity) + result * opacity return np.clip(result * 255, 0, 255).astype(np.uint8) # Multi-layer compositing result = blend_canvas(base, layer_a, "screen", 0.7) result = blend_canvas(result, layer_b, "difference", 0.5) result = blend_canvas(result, layer_c, "multiply", 0.3) ``` ### Creative Combinations - **Feedback + difference** = psychedelic color evolution (each frame XORs with the previous) - **Screen + screen** = additive glow stacking - **Multiply** on two different effects = only shows where both have brightness (intersection) - **Exclusion** between two layers = creates complementary patterns where they differ - **Color dodge/burn** = extreme contrast enhancement at overlap zones - **Hard mix** = reduces everything to pure black/white/color at intersections --- ## Feedback Buffer Recursive temporal effect: frame N-1 feeds back into frame N with decay and optional spatial transform. Creates trails, echoes, smearing, zoom tunnels, rotation feedback, rainbow trails. ```python class FeedbackBuffer: def __init__(self): self.buf = None # previous frame (float32, 0-1) def apply(self, canvas, decay=0.85, blend="screen", opacity=0.5, transform=None, transform_amt=0.02, hue_shift=0.0): """Mix current frame with decayed/transformed previous frame. Args: canvas: current frame (uint8 H,W,3) decay: how fast old frame fades (0=instant, 1=permanent) blend: blend mode for mixing feedback opacity: strength of feedback mix transform: None, "zoom", "shrink", "rotate_cw", "rotate_ccw", "shift_up", "shift_down", "mirror_h" transform_amt: strength of spatial transform per frame hue_shift: rotate hue of feedback buffer each frame (0-1) """ ``` ### Feedback Presets ```python # Infinite zoom tunnel fb_cfg = {"decay": 0.8, "blend": "screen", "opacity": 0.4, "transform": "zoom", "transform_amt": 0.015} # Rainbow trails (psychedelic) fb_cfg = {"decay": 0.7, "blend": "screen", "opacity": 0.3, "transform": "zoom", "transform_amt": 0.01, "hue_shift": 0.02} # Ghostly echo (horror) fb_cfg = {"decay": 0.9, "blend": "add", "opacity": 0.15, "transform": "shift_up", "transform_amt": 0.01} # Kaleidoscopic recursion fb_cfg = {"decay": 0.75, "blend": "screen", "opacity": 0.35, "transform": "rotate_cw", "transform_amt": 0.005, "hue_shift": 0.01} # Color evolution (abstract) fb_cfg = {"decay": 0.8, "blend": "difference", "opacity": 0.4, "hue_shift": 0.03} # Multiplied depth fb_cfg = {"decay": 0.65, "blend": "multiply", "opacity": 0.3, "transform": "mirror_h"} # Rising heat haze fb_cfg = {"decay": 0.5, "blend": "add", "opacity": 0.2, "transform": "shift_up", "transform_amt": 0.02} ``` --- ## ShaderChain Composable shader pipeline. Build chains of named shaders with parameters. Order matters — shaders are applied sequentially to the canvas. ```python class ShaderChain: """Composable shader pipeline. Usage: chain = ShaderChain() chain.add("bloom", thr=120) chain.add("chromatic", amt=5) chain.add("kaleidoscope", folds=6) chain.add("vignette", s=0.2) chain.add("grain", amt=12) canvas = chain.apply(canvas, f=features, t=time) """ def __init__(self): self.steps = [] def add(self, shader_name, **kwargs): self.steps.append((shader_name, kwargs)) return self # chainable def apply(self, canvas, f=None, t=0): if f is None: f = {} for name, kwargs in self.steps: canvas = _apply_shader_step(canvas, name, kwargs, f, t) return canvas ``` ### `_apply_shader_step()` — Full Dispatch Function Routes shader names to implementations. Some shaders have **audio-reactive scaling** — the dispatch function reads `f["bdecay"]` and `f["rms"]` to modulate parameters on the beat. ```python def _apply_shader_step(canvas, name, kwargs, f, t): """Dispatch a single shader by name with kwargs. Args: canvas: uint8 (H,W,3) pixel array name: shader key string (e.g. "bloom", "chromatic") kwargs: dict of shader parameters f: audio features dict (keys: bdecay, rms, sub, etc.) t: current time in seconds (float) Returns: canvas: uint8 (H,W,3) — processed """ bd = f.get("bdecay", 0) # beat decay (0-1, high on beat) rms = f.get("rms", 0.3) # audio energy (0-1) # --- Geometry --- if name == "crt": return sh_crt(canvas, kwargs.get("strength", 0.05)) elif name == "pixelate": return sh_pixelate(canvas, kwargs.get("block", 4)) elif name == "wave_distort": return sh_wave_distort(canvas, t, kwargs.get("freq", 0.02), kwargs.get("amp", 8), kwargs.get("axis", "x")) elif name == "kaleidoscope": return sh_kaleidoscope(canvas.copy(), kwargs.get("folds", 6)) elif name == "mirror_h": return sh_mirror_h(canvas.copy()) elif name == "mirror_v": return sh_mirror_v(canvas.copy()) elif name == "mirror_quad": return sh_mirror_quad(canvas.copy()) elif name == "mirror_diag": return sh_mirror_diag(canvas.copy()) # --- Channel --- elif name == "chromatic": base = kwargs.get("amt", 3) return sh_chromatic(canvas, max(1, int(base * (0.4 + bd * 0.8)))) elif name == "channel_shift": return sh_channel_shift(canvas, kwargs.get("r", (0,0)), kwargs.get("g", (0,0)), kwargs.get("b", (0,0))) elif name == "channel_swap": return sh_channel_swap(canvas, kwargs.get("order", (2,1,0))) elif name == "rgb_split_radial": return sh_rgb_split_radial(canvas, kwargs.get("strength", 5)) # --- Color --- elif name == "invert": return sh_invert(canvas) elif name == "posterize": return sh_posterize(canvas, kwargs.get("levels", 4)) elif name == "threshold": return sh_threshold(canvas, kwargs.get("thr", 128)) elif name == "solarize": return sh_solarize(canvas, kwargs.get("threshold", 128)) elif name == "hue_rotate": return sh_hue_rotate(canvas, kwargs.get("amount", 0.1)) elif name == "saturation": return sh_saturation(canvas, kwargs.get("factor", 1.5)) elif name == "color_grade": return sh_color_grade(canvas, kwargs.get("tint", (1,1,1))) elif name == "color_wobble": return sh_color_wobble(canvas, t, kwargs.get("amt", 0.3) * (0.5 + rms * 0.8)) elif name == "color_ramp": return sh_color_ramp(canvas, kwargs.get("ramp", [(0,0,0),(255,255,255)])) # --- Glow / Blur --- elif name == "bloom": return sh_bloom(canvas, kwargs.get("thr", 130)) elif name == "edge_glow": return sh_edge_glow(canvas, kwargs.get("hue", 0.5)) elif name == "soft_focus": return sh_soft_focus(canvas, kwargs.get("strength", 0.3)) elif name == "radial_blur": return sh_radial_blur(canvas, kwargs.get("strength", 0.03)) # --- Noise --- elif name == "grain": return sh_grain(canvas, int(kwargs.get("amt", 10) * (0.5 + rms * 0.8))) elif name == "static": return sh_static_noise(canvas, kwargs.get("density", 0.05), kwargs.get("color", True)) # --- Lines / Patterns --- elif name == "scanlines": return sh_scanlines(canvas, kwargs.get("intensity", 0.08), kwargs.get("spacing", 3)) elif name == "halftone": return sh_halftone(canvas, kwargs.get("dot_size", 6)) # --- Tone --- elif name == "vignette": return sh_vignette(canvas, kwargs.get("s", 0.22)) elif name == "contrast": return sh_contrast(canvas, kwargs.get("factor", 1.3)) elif name == "gamma": return sh_gamma(canvas, kwargs.get("gamma", 1.5)) elif name == "levels": return sh_levels(canvas, kwargs.get("black", 0), kwargs.get("white", 255), kwargs.get("midtone", 1.0)) elif name == "brightness": return sh_brightness(canvas, kwargs.get("factor", 1.5)) # --- Glitch / Data --- elif name == "glitch_bands": return sh_glitch_bands(canvas, f) elif name == "block_glitch": return sh_block_glitch(canvas, kwargs.get("n_blocks", 8), kwargs.get("max_size", 40)) elif name == "pixel_sort": return sh_pixel_sort(canvas, kwargs.get("threshold", 100), kwargs.get("direction", "h")) elif name == "data_bend": return sh_data_bend(canvas, kwargs.get("offset", 1000), kwargs.get("chunk", 500)) else: return canvas # unknown shader — passthrough ``` ### Audio-Reactive Shaders Three shaders scale their parameters based on audio features: | Shader | Reactive To | Effect | |--------|------------|--------| | `chromatic` | `bdecay` | `amt * (0.4 + bdecay * 0.8)` — aberration kicks on beats | | `color_wobble` | `rms` | `amt * (0.5 + rms * 0.8)` — wobble intensity follows energy | | `grain` | `rms` | `amt * (0.5 + rms * 0.8)` — grain rougher in loud sections | | `glitch_bands` | `bdecay`, `sub` | Number of bands and displacement scale with beat energy | To make any shader beat-reactive, scale its parameter in the dispatch: `base_val * (low + bd * range)`. --- ## Full Shader Catalog ### Geometry Shaders | Shader | Key Params | Description | |--------|-----------|-------------| | `crt` | `strength=0.05` | CRT barrel distortion (cached remap) | | `pixelate` | `block=4` | Reduce effective resolution | | `wave_distort` | `freq, amp, axis` | Sinusoidal row/column displacement | | `kaleidoscope` | `folds=6` | Radial symmetry via polar remapping | | `mirror_h` | — | Horizontal mirror | | `mirror_v` | — | Vertical mirror | | `mirror_quad` | — | 4-fold mirror | | `mirror_diag` | — | Diagonal mirror | ### Channel Manipulation | Shader | Key Params | Description | |--------|-----------|-------------| | `chromatic` | `amt=3` | R/B channel horizontal shift (beat-reactive) | | `channel_shift` | `r=(sx,sy), g, b` | Independent per-channel x,y shifting | | `channel_swap` | `order=(2,1,0)` | Reorder RGB channels (BGR, GRB, etc.) | | `rgb_split_radial` | `strength=5` | Chromatic aberration radiating from center | ### Color Manipulation | Shader | Key Params | Description | |--------|-----------|-------------| | `invert` | — | Negate all colors | | `posterize` | `levels=4` | Reduce color depth to N levels | | `threshold` | `thr=128` | Binary black/white | | `solarize` | `threshold=128` | Invert pixels above threshold | | `hue_rotate` | `amount=0.1` | Rotate all hues by amount (0-1) | | `saturation` | `factor=1.5` | Scale saturation (>1=more, <1=less) | | `color_grade` | `tint=(r,g,b)` | Per-channel multiplier | | `color_wobble` | `amt=0.3` | Time-varying per-channel sine modulation | | `color_ramp` | `ramp=[(R,G,B),...]` | Map luminance to custom color gradient | ### Glow / Blur | Shader | Key Params | Description | |--------|-----------|-------------| | `bloom` | `thr=130` | Bright area glow (4x downsample + box blur) | | `edge_glow` | `hue=0.5` | Detect edges, add colored overlay | | `soft_focus` | `strength=0.3` | Blend with blurred version | | `radial_blur` | `strength=0.03` | Zoom blur from center outward | ### Noise / Grain | Shader | Key Params | Description | |--------|-----------|-------------| | `grain` | `amt=10` | 2x-downsampled film grain (beat-reactive) | | `static` | `density=0.05, color=True` | Random pixel noise (TV static) | ### Lines / Patterns | Shader | Key Params | Description | |--------|-----------|-------------| | `scanlines` | `intensity=0.08, spacing=3` | Darken every Nth row | | `halftone` | `dot_size=6` | Halftone dot pattern overlay | ### Tone | Shader | Key Params | Description | |--------|-----------|-------------| | `vignette` | `s=0.22` | Edge darkening (cached distance field) | | `contrast` | `factor=1.3` | Adjust contrast around midpoint 128 | | `gamma` | `gamma=1.5` | Gamma correction (>1=brighter mids) | | `levels` | `black, white, midtone` | Levels adjustment (Photoshop-style) | | `brightness` | `factor=1.5` | Global brightness multiplier | ### Glitch / Data | Shader | Key Params | Description | |--------|-----------|-------------| | `glitch_bands` | (uses `f`) | Beat-reactive horizontal row displacement | | `block_glitch` | `n_blocks=8, max_size=40` | Random rectangular block displacement | | `pixel_sort` | `threshold=100, direction="h"` | Sort pixels by brightness in rows/columns | | `data_bend` | `offset, chunk` | Raw byte displacement (datamoshing) | --- ## Shader Implementations Every shader function takes a canvas (`uint8 H,W,3`) and returns a canvas of the same shape. The naming convention is `sh_`. Geometry shaders that build coordinate remap tables should **cache** them since the table only depends on resolution + parameters, not on frame content. ### Helpers Shaders that manipulate hue/saturation need vectorized HSV conversion: ```python def rgb2hsv(r, g, b): """Vectorized RGB (0-255 uint8) -> HSV (float32 0-1).""" rf = r.astype(np.float32) / 255.0 gf = g.astype(np.float32) / 255.0 bf = b.astype(np.float32) / 255.0 cmax = np.maximum(np.maximum(rf, gf), bf) cmin = np.minimum(np.minimum(rf, gf), bf) delta = cmax - cmin + 1e-10 h = np.zeros_like(rf) m = cmax == rf; h[m] = ((gf[m] - bf[m]) / delta[m]) % 6 m = cmax == gf; h[m] = (bf[m] - rf[m]) / delta[m] + 2 m = cmax == bf; h[m] = (rf[m] - gf[m]) / delta[m] + 4 h = h / 6.0 % 1.0 s = np.where(cmax > 0, delta / (cmax + 1e-10), 0) return h, s, cmax def hsv2rgb(h, s, v): """Vectorized HSV->RGB. h,s,v are numpy float32 arrays.""" h = h % 1.0 c = v * s; x = c * (1 - np.abs((h * 6) % 2 - 1)); m = v - c r = np.zeros_like(h); g = np.zeros_like(h); b = np.zeros_like(h) mask = h < 1/6; r[mask]=c[mask]; g[mask]=x[mask] mask = (h>=1/6)&(h<2/6); r[mask]=x[mask]; g[mask]=c[mask] mask = (h>=2/6)&(h<3/6); g[mask]=c[mask]; b[mask]=x[mask] mask = (h>=3/6)&(h<4/6); g[mask]=x[mask]; b[mask]=c[mask] mask = (h>=4/6)&(h<5/6); r[mask]=x[mask]; b[mask]=c[mask] mask = h >= 5/6; r[mask]=c[mask]; b[mask]=x[mask] R = np.clip((r+m)*255, 0, 255).astype(np.uint8) G = np.clip((g+m)*255, 0, 255).astype(np.uint8) B = np.clip((b+m)*255, 0, 255).astype(np.uint8) return R, G, B def mkc(R, G, B, rows, cols): """Stack R,G,B uint8 arrays into (rows,cols,3) canvas.""" o = np.zeros((rows, cols, 3), dtype=np.uint8) o[:,:,0] = R; o[:,:,1] = G; o[:,:,2] = B return o ``` --- ### Geometry Shaders #### CRT Barrel Distortion Cache the coordinate remap — it never changes per frame: ```python _crt_cache = {} def sh_crt(c, strength=0.05): k = (c.shape[0], c.shape[1], round(strength, 3)) if k not in _crt_cache: h, w = c.shape[:2]; cy, cx = h/2, w/2 Y = np.arange(h, dtype=np.float32)[:, None] X = np.arange(w, dtype=np.float32)[None, :] ny = (Y - cy) / cy; nx = (X - cx) / cx r2 = nx**2 + ny**2 factor = 1 + strength * r2 sx = np.clip((nx * factor * cx + cx), 0, w-1).astype(np.int32) sy = np.clip((ny * factor * cy + cy), 0, h-1).astype(np.int32) _crt_cache[k] = (sy, sx) sy, sx = _crt_cache[k] return c[sy, sx] ``` #### Pixelate ```python def sh_pixelate(c, block=4): """Reduce effective resolution.""" sm = c[::block, ::block] return np.repeat(np.repeat(sm, block, axis=0), block, axis=1)[:c.shape[0], :c.shape[1]] ``` #### Wave Distort ```python def sh_wave_distort(c, t, freq=0.02, amp=8, axis="x"): """Sinusoidal row/column displacement. Uses time t for animation.""" h, w = c.shape[:2] out = c.copy() if axis == "x": for y in range(h): shift = int(amp * math.sin(y * freq + t * 3)) out[y] = np.roll(c[y], shift, axis=0) else: for x in range(w): shift = int(amp * math.sin(x * freq + t * 3)) out[:, x] = np.roll(c[:, x], shift, axis=0) return out ``` #### Displacement Map ```python def sh_displacement_map(c, dx_map, dy_map, strength=10): """Displace pixels using float32 displacement maps (same HxW as c). dx_map/dy_map: positive = shift right/down.""" h, w = c.shape[:2] Y = np.arange(h)[:, None]; X = np.arange(w)[None, :] ny = np.clip((Y + (dy_map * strength).astype(int)), 0, h-1) nx = np.clip((X + (dx_map * strength).astype(int)), 0, w-1) return c[ny, nx] ``` #### Kaleidoscope ```python def sh_kaleidoscope(c, folds=6): """Radial symmetry by polar coordinate remapping.""" h, w = c.shape[:2]; cy, cx = h//2, w//2 Y = np.arange(h, dtype=np.float32)[:, None] - cy X = np.arange(w, dtype=np.float32)[None, :] - cx angle = np.arctan2(Y, X) dist = np.sqrt(X**2 + Y**2) wedge = 2 * np.pi / folds folded_angle = np.abs((angle % wedge) - wedge/2) ny = np.clip((cy + dist * np.sin(folded_angle)).astype(int), 0, h-1) nx = np.clip((cx + dist * np.cos(folded_angle)).astype(int), 0, w-1) return c[ny, nx] ``` #### Mirror Variants ```python def sh_mirror_h(c): """Horizontal mirror — left half reflected to right.""" w = c.shape[1]; c[:, w//2:] = c[:, :w//2][:, ::-1]; return c def sh_mirror_v(c): """Vertical mirror — top half reflected to bottom.""" h = c.shape[0]; c[h//2:, :] = c[:h//2, :][::-1, :]; return c def sh_mirror_quad(c): """4-fold mirror — top-left quadrant reflected to all four.""" h, w = c.shape[:2]; hh, hw = h//2, w//2 tl = c[:hh, :hw].copy() c[:hh, hw:hw+tl.shape[1]] = tl[:, ::-1] c[hh:hh+tl.shape[0], :hw] = tl[::-1, :] c[hh:hh+tl.shape[0], hw:hw+tl.shape[1]] = tl[::-1, ::-1] return c def sh_mirror_diag(c): """Diagonal mirror — top-left triangle reflected.""" h, w = c.shape[:2] for y in range(h): x_cut = int(w * y / h) if x_cut > 0 and x_cut < w: c[y, x_cut:] = c[y, :x_cut+1][::-1][:w-x_cut] return c ``` > **Note:** Mirror shaders mutate in-place. The dispatch function passes `canvas.copy()` to avoid corrupting the original. --- ### Channel Manipulation Shaders #### Chromatic Aberration ```python def sh_chromatic(c, amt=3): """R/B channel horizontal shift. Beat-reactive in dispatch (amt scaled by bdecay).""" if amt < 1: return c a = int(amt) o = c.copy() o[:, a:, 0] = c[:, :-a, 0] # red shifts right o[:, :-a, 2] = c[:, a:, 2] # blue shifts left return o ``` #### Channel Shift ```python def sh_channel_shift(c, r_shift=(0,0), g_shift=(0,0), b_shift=(0,0)): """Independent per-channel x,y shifting.""" o = c.copy() for ch_i, (sx, sy) in enumerate([r_shift, g_shift, b_shift]): if sx != 0: o[:,:,ch_i] = np.roll(c[:,:,ch_i], sx, axis=1) if sy != 0: o[:,:,ch_i] = np.roll(o[:,:,ch_i], sy, axis=0) return o ``` #### Channel Swap ```python def sh_channel_swap(c, order=(2,1,0)): """Reorder RGB channels. (2,1,0)=BGR, (1,0,2)=GRB, etc.""" return c[:, :, list(order)] ``` #### RGB Split Radial ```python def sh_rgb_split_radial(c, strength=5): """Chromatic aberration radiating from center — stronger at edges.""" h, w = c.shape[:2]; cy, cx = h//2, w//2 Y = np.arange(h, dtype=np.float32)[:, None] X = np.arange(w, dtype=np.float32)[None, :] dist = np.sqrt((Y-cy)**2 + (X-cx)**2) max_dist = np.sqrt(cy**2 + cx**2) factor = dist / max_dist * strength dy = ((Y-cy) / (dist+1) * factor).astype(int) dx = ((X-cx) / (dist+1) * factor).astype(int) out = c.copy() ry = np.clip(Y.astype(int)+dy, 0, h-1); rx = np.clip(X.astype(int)+dx, 0, w-1) out[:,:,0] = c[ry, rx, 0] # red shifts outward by = np.clip(Y.astype(int)-dy, 0, h-1); bx = np.clip(X.astype(int)-dx, 0, w-1) out[:,:,2] = c[by, bx, 2] # blue shifts inward return out ``` --- ### Color Manipulation Shaders #### Invert ```python def sh_invert(c): return 255 - c ``` #### Posterize ```python def sh_posterize(c, levels=4): """Reduce color depth to N levels per channel.""" step = 256.0 / levels return (np.floor(c.astype(np.float32) / step) * step).astype(np.uint8) ``` #### Threshold ```python def sh_threshold(c, thr=128): """Binary black/white at threshold.""" gray = c.astype(np.float32).mean(axis=2) out = np.zeros_like(c); out[gray > thr] = 255 return out ``` #### Solarize ```python def sh_solarize(c, threshold=128): """Invert pixels above threshold — classic darkroom effect.""" o = c.copy(); mask = c > threshold; o[mask] = 255 - c[mask] return o ``` #### Hue Rotate ```python def sh_hue_rotate(c, amount=0.1): """Rotate all hues by amount (0-1).""" h, s, v = rgb2hsv(c[:,:,0], c[:,:,1], c[:,:,2]) h = (h + amount) % 1.0 R, G, B = hsv2rgb(h, s, v) return mkc(R, G, B, c.shape[0], c.shape[1]) ``` #### Saturation ```python def sh_saturation(c, factor=1.5): """Adjust saturation. >1=more saturated, <1=desaturated.""" h, s, v = rgb2hsv(c[:,:,0], c[:,:,1], c[:,:,2]) s = np.clip(s * factor, 0, 1) R, G, B = hsv2rgb(h, s, v) return mkc(R, G, B, c.shape[0], c.shape[1]) ``` #### Color Grade ```python def sh_color_grade(c, tint): """Per-channel multiplier. tint=(r_mul, g_mul, b_mul).""" o = c.astype(np.float32) o[:,:,0] *= tint[0]; o[:,:,1] *= tint[1]; o[:,:,2] *= tint[2] return np.clip(o, 0, 255).astype(np.uint8) ``` #### Color Wobble ```python def sh_color_wobble(c, t, amt=0.3): """Time-varying per-channel sine modulation. Audio-reactive in dispatch (amt scaled by rms).""" o = c.astype(np.float32) o[:,:,0] *= 1.0 + amt * math.sin(t * 5.0) o[:,:,1] *= 1.0 + amt * math.sin(t * 5.0 + 2.09) o[:,:,2] *= 1.0 + amt * math.sin(t * 5.0 + 4.19) return np.clip(o, 0, 255).astype(np.uint8) ``` #### Color Ramp ```python def sh_color_ramp(c, ramp_colors): """Map luminance to a custom color gradient. ramp_colors = list of (R,G,B) tuples, evenly spaced from dark to bright.""" gray = c.astype(np.float32).mean(axis=2) / 255.0 n = len(ramp_colors) idx = np.clip(gray * (n-1), 0, n-1.001) lo = np.floor(idx).astype(int); hi = np.minimum(lo+1, n-1) frac = idx - lo ramp = np.array(ramp_colors, dtype=np.float32) out = ramp[lo] * (1-frac[:,:,None]) + ramp[hi] * frac[:,:,None] return np.clip(out, 0, 255).astype(np.uint8) ``` --- ### Glow / Blur Shaders #### Bloom ```python def sh_bloom(c, thr=130): """Bright-area glow: 4x downsample, threshold, 3-pass box blur, screen blend.""" sm = c[::4, ::4].astype(np.float32) br = np.where(sm > thr, sm, 0) for _ in range(3): p = np.pad(br, ((1,1),(1,1),(0,0)), mode="edge") br = (p[:-2,:-2]+p[:-2,1:-1]+p[:-2,2:]+p[1:-1,:-2]+p[1:-1,1:-1]+ p[1:-1,2:]+p[2:,:-2]+p[2:,1:-1]+p[2:,2:]) / 9.0 bl = np.repeat(np.repeat(br, 4, axis=0), 4, axis=1)[:c.shape[0], :c.shape[1]] return np.clip(c.astype(np.float32) + bl * 0.5, 0, 255).astype(np.uint8) ``` #### Edge Glow ```python def sh_edge_glow(c, hue=0.5): """Detect edges via gradient, add colored overlay.""" gray = c.astype(np.float32).mean(axis=2) gx = np.abs(gray[:, 2:] - gray[:, :-2]) gy = np.abs(gray[2:, :] - gray[:-2, :]) ex = np.zeros_like(gray); ey = np.zeros_like(gray) ex[:, 1:-1] = gx; ey[1:-1, :] = gy edge = np.clip((ex + ey) / 255 * 2, 0, 1) R, G, B = hsv2rgb(np.full_like(edge, hue), np.full_like(edge, 0.8), edge * 0.5) out = c.astype(np.int16).copy() out[:,:,0] = np.clip(out[:,:,0] + R.astype(np.int16), 0, 255) out[:,:,1] = np.clip(out[:,:,1] + G.astype(np.int16), 0, 255) out[:,:,2] = np.clip(out[:,:,2] + B.astype(np.int16), 0, 255) return out.astype(np.uint8) ``` #### Soft Focus ```python def sh_soft_focus(c, strength=0.3): """Blend original with 2x-downsampled box blur.""" sm = c[::2, ::2].astype(np.float32) p = np.pad(sm, ((1,1),(1,1),(0,0)), mode="edge") bl = (p[:-2,:-2]+p[:-2,1:-1]+p[:-2,2:]+p[1:-1,:-2]+p[1:-1,1:-1]+ p[1:-1,2:]+p[2:,:-2]+p[2:,1:-1]+p[2:,2:]) / 9.0 bl = np.repeat(np.repeat(bl, 2, axis=0), 2, axis=1)[:c.shape[0], :c.shape[1]] return np.clip(c * (1-strength) + bl * strength, 0, 255).astype(np.uint8) ``` #### Radial Blur ```python def sh_radial_blur(c, strength=0.03, center=None): """Zoom blur from center — motion blur radiating outward.""" h, w = c.shape[:2] cy, cx = center if center else (h//2, w//2) Y = np.arange(h, dtype=np.float32)[:, None] X = np.arange(w, dtype=np.float32)[None, :] out = c.astype(np.float32) for s in [strength, strength*2]: dy = (Y - cy) * s; dx = (X - cx) * s sy = np.clip((Y + dy).astype(int), 0, h-1) sx = np.clip((X + dx).astype(int), 0, w-1) out += c[sy, sx].astype(np.float32) return np.clip(out / 3, 0, 255).astype(np.uint8) ``` --- ### Noise / Grain Shaders #### Film Grain ```python def sh_grain(c, amt=10): """2x-downsampled film grain. Audio-reactive in dispatch (amt scaled by rms).""" noise = np.random.randint(-amt, amt+1, (c.shape[0]//2, c.shape[1]//2, 1), dtype=np.int16) noise = np.repeat(np.repeat(noise, 2, axis=0), 2, axis=1)[:c.shape[0], :c.shape[1]] return np.clip(c.astype(np.int16) + noise, 0, 255).astype(np.uint8) ``` #### Static Noise ```python def sh_static_noise(c, density=0.05, color=True): """Random pixel noise overlay (TV static).""" mask = np.random.random((c.shape[0]//2, c.shape[1]//2)) < density mask = np.repeat(np.repeat(mask, 2, axis=0), 2, axis=1)[:c.shape[0], :c.shape[1]] out = c.copy() if color: noise = np.random.randint(0, 256, (c.shape[0], c.shape[1], 3), dtype=np.uint8) else: v = np.random.randint(0, 256, (c.shape[0], c.shape[1]), dtype=np.uint8) noise = np.stack([v, v, v], axis=2) out[mask] = noise[mask] return out ``` --- ### Lines / Pattern Shaders #### Scanlines ```python def sh_scanlines(c, intensity=0.08, spacing=3): """Darken every Nth row.""" m = np.ones(c.shape[0], dtype=np.float32) m[::spacing] = 1.0 - intensity return np.clip(c * m[:, None, None], 0, 255).astype(np.uint8) ``` #### Halftone ```python def sh_halftone(c, dot_size=6): """Halftone dot pattern overlay — circular dots sized by local brightness.""" h, w = c.shape[:2] gray = c.astype(np.float32).mean(axis=2) / 255.0 out = np.zeros_like(c) for y in range(0, h, dot_size): for x in range(0, w, dot_size): block = gray[y:y+dot_size, x:x+dot_size] if block.size == 0: continue radius = block.mean() * dot_size * 0.5 cy_b, cx_b = dot_size//2, dot_size//2 for dy in range(min(dot_size, h-y)): for dx in range(min(dot_size, w-x)): if math.sqrt((dy-cy_b)**2 + (dx-cx_b)**2) < radius: out[y+dy, x+dx] = c[y+dy, x+dx] return out ``` > **Performance note:** Halftone is slow due to Python loops. Acceptable for small resolutions or single test frames. For production, consider a vectorized version using precomputed distance masks. --- ### Tone Shaders #### Vignette ```python _vig_cache = {} def sh_vignette(c, s=0.22): """Edge darkening using cached distance field.""" k = (c.shape[0], c.shape[1], round(s, 2)) if k not in _vig_cache: h, w = c.shape[:2] Y = np.linspace(-1, 1, h)[:, None]; X = np.linspace(-1, 1, w)[None, :] _vig_cache[k] = np.clip(1.0 - np.sqrt(X**2 + Y**2) * s, 0.15, 1).astype(np.float32) return np.clip(c * _vig_cache[k][:,:,None], 0, 255).astype(np.uint8) ``` #### Contrast ```python def sh_contrast(c, factor=1.3): """Adjust contrast around midpoint 128.""" return np.clip((c.astype(np.float32) - 128) * factor + 128, 0, 255).astype(np.uint8) ``` #### Gamma ```python def sh_gamma(c, gamma=1.5): """Gamma correction. >1=brighter mids, <1=darker mids.""" return np.clip(((c.astype(np.float32)/255.0) ** (1.0/gamma)) * 255, 0, 255).astype(np.uint8) ``` #### Levels ```python def sh_levels(c, black=0, white=255, midtone=1.0): """Levels adjustment (Photoshop-style). Remap black/white points, apply midtone gamma.""" o = (c.astype(np.float32) - black) / max(1, white - black) o = np.clip(o, 0, 1) ** (1.0 / midtone) return (o * 255).astype(np.uint8) ``` #### Brightness ```python def sh_brightness(c, factor=1.5): """Global brightness multiplier. Prefer tonemap() for scene-level brightness control.""" return np.clip(c.astype(np.float32) * factor, 0, 255).astype(np.uint8) ``` --- ### Glitch / Data Shaders #### Glitch Bands ```python def sh_glitch_bands(c, f): """Beat-reactive horizontal row displacement. f = audio features dict. Uses f["bdecay"] for intensity and f["sub"] for band height.""" n = int(3 + f.get("bdecay", 0) * 10) out = c.copy() for _ in range(n): y = random.randint(0, c.shape[0]-1) h = random.randint(1, max(2, int(4 + f.get("sub", 0.3) * 12))) shift = int((random.random()-0.5) * f.get("bdecay", 0) * 60) if shift != 0 and y+h < c.shape[0]: out[y:y+h] = np.roll(out[y:y+h], shift, axis=1) return out ``` #### Block Glitch ```python def sh_block_glitch(c, n_blocks=8, max_size=40): """Random rectangular block displacement — copy blocks to random positions.""" out = c.copy(); h, w = c.shape[:2] for _ in range(n_blocks): bw = random.randint(10, max_size); bh = random.randint(5, max_size//2) sx = random.randint(0, w-bw-1); sy = random.randint(0, h-bh-1) dx = random.randint(0, w-bw-1); dy = random.randint(0, h-bh-1) out[dy:dy+bh, dx:dx+bw] = c[sy:sy+bh, sx:sx+bw] return out ``` #### Pixel Sort ```python def sh_pixel_sort(c, threshold=100, direction="h"): """Sort pixels by brightness in contiguous bright regions.""" gray = c.astype(np.float32).mean(axis=2) out = c.copy() if direction == "h": for y in range(0, c.shape[0], 3): # every 3rd row for speed row_bright = gray[y] mask = row_bright > threshold regions = np.diff(np.concatenate([[0], mask.astype(int), [0]])) starts = np.where(regions == 1)[0] ends = np.where(regions == -1)[0] for s, e in zip(starts, ends): if e - s > 2: indices = np.argsort(gray[y, s:e]) out[y, s:e] = c[y, s:e][indices] else: for x in range(0, c.shape[1], 3): col_bright = gray[:, x] mask = col_bright > threshold regions = np.diff(np.concatenate([[0], mask.astype(int), [0]])) starts = np.where(regions == 1)[0] ends = np.where(regions == -1)[0] for s, e in zip(starts, ends): if e - s > 2: indices = np.argsort(gray[s:e, x]) out[s:e, x] = c[s:e, x][indices] return out ``` #### Data Bend ```python def sh_data_bend(c, offset=1000, chunk=500): """Treat raw pixel bytes as data, copy a chunk to another offset — datamosh artifacts.""" flat = c.flatten().copy() n = len(flat) src = offset % n; dst = (offset + chunk*3) % n length = min(chunk, n-src, n-dst) if length > 0: flat[dst:dst+length] = flat[src:src+length] return flat.reshape(c.shape) ``` --- ## Tint Presets ```python TINT_WARM = (1.15, 1.0, 0.85) # golden warmth TINT_COOL = (0.85, 0.95, 1.15) # blue cool TINT_MATRIX = (0.7, 1.2, 0.7) # green terminal TINT_AMBER = (1.2, 0.9, 0.6) # amber monitor TINT_SEPIA = (1.2, 1.05, 0.8) # old film TINT_NEON_PINK = (1.3, 0.7, 1.1) # cyberpunk pink TINT_ICE = (0.8, 1.0, 1.3) # frozen TINT_BLOOD = (1.4, 0.7, 0.7) # horror red TINT_FOREST = (0.8, 1.15, 0.75) # natural green TINT_VOID = (0.85, 0.85, 1.1) # deep space TINT_SUNSET = (1.3, 0.85, 0.7) # orange dusk ``` --- ## Transitions > **Note:** These operate on character-level `(chars, colors)` arrays (v1 interface). In v2, transitions between scenes are typically handled by hard cuts at beat boundaries (see `scenes.md`), or by rendering both scenes to canvases and using `blend_canvas()` with a time-varying opacity. The character-level transitions below are still useful for within-scene effects. ### Crossfade ```python def tr_crossfade(ch_a, co_a, ch_b, co_b, blend): co = (co_a.astype(np.float32) * (1-blend) + co_b.astype(np.float32) * blend).astype(np.uint8) mask = np.random.random(ch_a.shape) < blend ch = ch_a.copy(); ch[mask] = ch_b[mask] return ch, co ``` ### v2 Canvas-Level Crossfade ```python def tr_canvas_crossfade(canvas_a, canvas_b, blend): """Smooth pixel crossfade between two canvases.""" return np.clip(canvas_a * (1-blend) + canvas_b * blend, 0, 255).astype(np.uint8) ``` ### Wipe (directional) ```python def tr_wipe(ch_a, co_a, ch_b, co_b, blend, direction="left"): """direction: left, right, up, down, radial, diagonal""" rows, cols = ch_a.shape if direction == "radial": cx, cy = cols/2, rows/2 rr = np.arange(rows)[:, None]; cc = np.arange(cols)[None, :] d = np.sqrt((cc-cx)**2 + (rr-cy)**2) mask = d < blend * np.sqrt(cx**2 + cy**2) ch = ch_a.copy(); co = co_a.copy() ch[mask] = ch_b[mask]; co[mask] = co_b[mask] return ch, co ``` ### Glitch Cut ```python def tr_glitch_cut(ch_a, co_a, ch_b, co_b, blend): if blend < 0.5: ch, co = ch_a.copy(), co_a.copy() else: ch, co = ch_b.copy(), co_b.copy() if 0.3 < blend < 0.7: intensity = 1.0 - abs(blend - 0.5) * 4 for _ in range(int(intensity * 20)): y = random.randint(0, ch.shape[0]-1) shift = int((random.random()-0.5) * 40 * intensity) if shift: ch[y] = np.roll(ch[y], shift); co[y] = np.roll(co[y], shift, axis=0) return ch, co ``` --- ## Output Formats ### MP4 (default) ```python cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(fps), "-i", "pipe:0", "-c:v", "libx264", "-preset", "fast", "-crf", str(crf), "-pix_fmt", "yuv420p", output_path] ``` ### GIF ```python cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(fps), "-i", "pipe:0", "-vf", f"fps={fps},scale={W}:{H}:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0", output_gif] ``` ### PNG Sequence For frame-accurate editing, compositing in external tools (After Effects, Nuke), or lossless archival: ```python import os def output_png_sequence(frames, output_dir, W, H, fps, prefix="frame"): """Write frames as numbered PNGs. frames = iterable of uint8 (H,W,3) arrays.""" os.makedirs(output_dir, exist_ok=True) # Method 1: Direct PIL write (no ffmpeg dependency) from PIL import Image for i, frame in enumerate(frames): img = Image.fromarray(frame) img.save(os.path.join(output_dir, f"{prefix}_{i:06d}.png")) # Method 2: ffmpeg pipe (faster for large sequences) cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(fps), "-i", "pipe:0", os.path.join(output_dir, f"{prefix}_%06d.png")] ``` Reassemble PNG sequence to video: ```bash ffmpeg -framerate 24 -i frame_%06d.png -c:v libx264 -crf 18 -pix_fmt yuv420p output.mp4 ``` ### Alpha Channel / Transparent Background (RGBA) For compositing ASCII art over other video or images. Uses RGBA canvas (4 channels) instead of RGB (3 channels): ```python def create_rgba_canvas(H, W): """Transparent canvas — alpha channel starts at 0 (fully transparent).""" return np.zeros((H, W, 4), dtype=np.uint8) def render_char_rgba(canvas, row, col, char_img, color_rgb, alpha=255): """Render a character with alpha. char_img = PIL glyph mask (grayscale). Alpha comes from the glyph mask — background stays transparent.""" r, g, b = color_rgb y0, x0 = row * cell_h, col * cell_w mask = np.array(char_img) # grayscale 0-255 canvas[y0:y0+cell_h, x0:x0+cell_w, 0] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 0], (mask * r / 255).astype(np.uint8)) canvas[y0:y0+cell_h, x0:x0+cell_w, 1] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 1], (mask * g / 255).astype(np.uint8)) canvas[y0:y0+cell_h, x0:x0+cell_w, 2] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 2], (mask * b / 255).astype(np.uint8)) canvas[y0:y0+cell_h, x0:x0+cell_w, 3] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 3], mask) def blend_onto_background(rgba_canvas, bg_rgb): """Composite RGBA canvas over a solid or image background.""" alpha = rgba_canvas[:, :, 3:4].astype(np.float32) / 255.0 fg = rgba_canvas[:, :, :3].astype(np.float32) bg = bg_rgb.astype(np.float32) result = fg * alpha + bg * (1.0 - alpha) return result.astype(np.uint8) ``` RGBA output via ffmpeg (ProRes 4444 for editing, WebM VP9 for web): ```bash # ProRes 4444 — preserves alpha, widely supported in NLEs ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \ -c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le output.mov # WebM VP9 — alpha support for web/browser compositing ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \ -c:v libvpx-vp9 -pix_fmt yuva420p -crf 30 -b:v 0 output.webm # PNG sequence with alpha (lossless) ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \ frame_%06d.png ``` **Key constraint**: shaders that operate on `(H,W,3)` arrays need adaptation for RGBA. Either apply shaders to the RGB channels only and preserve alpha, or write RGBA-aware versions: ```python def apply_shader_rgba(canvas_rgba, shader_fn, **kwargs): """Apply an RGB shader to the color channels of an RGBA canvas.""" rgb = canvas_rgba[:, :, :3] alpha = canvas_rgba[:, :, 3:4] rgb_out = shader_fn(rgb, **kwargs) return np.concatenate([rgb_out, alpha], axis=2) ``` --- ## Real-Time Terminal Rendering Live ASCII display in the terminal using ANSI escape codes. Useful for previewing scenes during development, live performances, and interactive parameter tuning. ### ANSI Color Escape Codes ```python def rgb_to_ansi(r, g, b): """24-bit true color ANSI escape (supported by most modern terminals).""" return f"\033[38;2;{r};{g};{b}m" ANSI_RESET = "\033[0m" ANSI_CLEAR = "\033[2J\033[H" # clear screen + cursor home ANSI_HIDE_CURSOR = "\033[?25l" ANSI_SHOW_CURSOR = "\033[?25h" ``` ### Frame-to-ANSI Conversion ```python def frame_to_ansi(chars, colors): """Convert char+color arrays to a single ANSI string for terminal output. Args: chars: (rows, cols) array of single characters colors: (rows, cols, 3) uint8 RGB array Returns: str: ANSI-encoded frame ready for sys.stdout.write() """ rows, cols = chars.shape lines = [] for r in range(rows): parts = [] prev_color = None for c in range(cols): rgb = tuple(colors[r, c]) ch = chars[r, c] if ch == " " or rgb == (0, 0, 0): parts.append(" ") else: if rgb != prev_color: parts.append(rgb_to_ansi(*rgb)) prev_color = rgb parts.append(ch) parts.append(ANSI_RESET) lines.append("".join(parts)) return "\n".join(lines) ``` ### Optimized: Delta Updates Only redraw characters that changed since the last frame. Eliminates redundant terminal writes for static regions: ```python def frame_to_ansi_delta(chars, colors, prev_chars, prev_colors): """Emit ANSI escapes only for cells that changed.""" rows, cols = chars.shape parts = [] for r in range(rows): for c in range(cols): if (chars[r, c] != prev_chars[r, c] or not np.array_equal(colors[r, c], prev_colors[r, c])): parts.append(f"\033[{r+1};{c+1}H") # move cursor rgb = tuple(colors[r, c]) parts.append(rgb_to_ansi(*rgb)) parts.append(chars[r, c]) return "".join(parts) ``` ### Live Render Loop ```python import sys import time def render_live(scene_fn, r, fps=24, duration=None): """Render a scene function live in the terminal. Args: scene_fn: v2 scene function (r, f, t, S) -> canvas OR v1-style function that populates a grid r: Renderer instance fps: target frame rate duration: seconds to run (None = run until Ctrl+C) """ frame_time = 1.0 / fps S = {} f = {} # synthesize features or connect to live audio sys.stdout.write(ANSI_HIDE_CURSOR + ANSI_CLEAR) sys.stdout.flush() t0 = time.monotonic() frame_count = 0 try: while True: t = time.monotonic() - t0 if duration and t > duration: break # Synthesize features from time (or connect to live audio via pyaudio) f = synthesize_features(t) # Render scene — for terminal, use a small grid g = r.get_grid("sm") # Option A: v2 scene → extract chars/colors from canvas (reverse render) # Option B: call effect functions directly for chars/colors canvas = scene_fn(r, f, t, S) # For terminal display, render chars+colors directly # (bypassing the pixel canvas — terminal uses character cells) chars, colors = scene_to_terminal(scene_fn, r, f, t, S, g) frame_str = ANSI_CLEAR + frame_to_ansi(chars, colors) sys.stdout.write(frame_str) sys.stdout.flush() # Frame timing elapsed = time.monotonic() - t0 - (frame_count * frame_time) sleep_time = frame_time - elapsed if sleep_time > 0: time.sleep(sleep_time) frame_count += 1 except KeyboardInterrupt: pass finally: sys.stdout.write(ANSI_SHOW_CURSOR + ANSI_RESET + "\n") sys.stdout.flush() def scene_to_terminal(scene_fn, r, f, t, S, g): """Run effect functions and return (chars, colors) for terminal display. For terminal mode, skip the pixel canvas and work with character arrays directly.""" # Effects that return (chars, colors) work directly # For vf-based effects, render the value field + hue field to chars/colors: val = vf_plasma(g, f, t, S) hue = hf_time_cycle(0.08)(g, t) mask = val > 0.03 chars = val2char(val, mask, PAL_DENSE) R, G, B = hsv2rgb(hue, np.full_like(val, 0.8), val) colors = mkc(R, G, B, g.rows, g.cols) return chars, colors ``` ### Curses-Based Rendering (More Robust) For full-featured terminal UIs with proper resize handling and input: ```python import curses def render_curses(scene_fn, r, fps=24): """Curses-based live renderer with resize handling and key input.""" def _main(stdscr): curses.start_color() curses.use_default_colors() curses.curs_set(0) # hide cursor stdscr.nodelay(True) # non-blocking input # Initialize color pairs (curses supports 256 colors) # Map RGB to nearest curses color pair color_cache = {} next_pair = [1] def get_color_pair(r, g, b): key = (r >> 4, g >> 4, b >> 4) # quantize to reduce pairs if key not in color_cache: if next_pair[0] < curses.COLOR_PAIRS - 1: ci = 16 + (r // 51) * 36 + (g // 51) * 6 + (b // 51) # 6x6x6 cube curses.init_pair(next_pair[0], ci, -1) color_cache[key] = next_pair[0] next_pair[0] += 1 else: return 0 return curses.color_pair(color_cache[key]) S = {} f = {} frame_time = 1.0 / fps t0 = time.monotonic() while True: t = time.monotonic() - t0 f = synthesize_features(t) # Adapt grid to terminal size max_y, max_x = stdscr.getmaxyx() g = r.get_grid_for_size(max_x, max_y) # dynamic grid sizing chars, colors = scene_to_terminal(scene_fn, r, f, t, S, g) rows, cols = chars.shape for row in range(min(rows, max_y - 1)): for col in range(min(cols, max_x - 1)): ch = chars[row, col] rgb = tuple(colors[row, col]) try: stdscr.addch(row, col, ch, get_color_pair(*rgb)) except curses.error: pass # ignore writes outside terminal bounds stdscr.refresh() # Handle input key = stdscr.getch() if key == ord('q'): break time.sleep(max(0, frame_time - (time.monotonic() - t0 - t))) curses.wrapper(_main) ``` ### Terminal Rendering Constraints | Constraint | Value | Notes | |-----------|-------|-------| | Max practical grid | ~200x60 | Depends on terminal size | | Color support | 24-bit (modern), 256 (fallback), 16 (minimal) | Check `$COLORTERM` for truecolor | | Frame rate ceiling | ~30 fps | Terminal I/O is the bottleneck | | Delta updates | 2-5x faster | Only worth it when <30% of cells change per frame | | SSH latency | Kills performance | Local terminals only for real-time | **Detect color support:** ```python import os def get_terminal_color_depth(): ct = os.environ.get("COLORTERM", "") if ct in ("truecolor", "24bit"): return 24 term = os.environ.get("TERM", "") if "256color" in term: return 8 # 256 colors return 4 # 16 colors basic ANSI ```