Add S-expression based video effects pipeline with modular effect definitions, constructs, and recipe files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
206 lines
6.6 KiB
Python
206 lines
6.6 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["numpy", "opencv-python"]
|
|
# ///
|
|
"""
|
|
@effect ascii_art
|
|
@version 1.0.0
|
|
@author artdag
|
|
|
|
@description
|
|
ASCII art effect - converts video to ASCII character representation.
|
|
Renders the video using ASCII characters based on brightness,
|
|
creating a retro terminal aesthetic.
|
|
|
|
@param char_size int
|
|
@range 4 32
|
|
@default 8
|
|
Size of each character cell in pixels. Sync to bass for reactive sizing.
|
|
|
|
@param alphabet string
|
|
@enum standard blocks cyrillic greek japanese braille
|
|
@default standard
|
|
Character set to use for rendering. Each has different visual density.
|
|
|
|
@param color_mode string
|
|
@enum mono color invert
|
|
@default color
|
|
Color rendering mode:
|
|
- mono: white on black
|
|
- color: preserve source colors
|
|
- invert: dark text on colored background
|
|
|
|
@param contrast_boost float
|
|
@range 1 3
|
|
@default 1.5
|
|
Enhance contrast for better character separation.
|
|
|
|
@param background list
|
|
@default [0, 0, 0]
|
|
Background color RGB.
|
|
|
|
@example
|
|
(effect ascii_art :char_size 8 :color_mode "color")
|
|
|
|
@example
|
|
;; Japanese characters, reactive sizing
|
|
(effect ascii_art :alphabet "japanese" :char_size (bind bass :range [6 16]))
|
|
|
|
@example
|
|
;; Braille pattern for high detail
|
|
(effect ascii_art :alphabet "braille" :char_size 4)
|
|
"""
|
|
|
|
import numpy as np
|
|
import cv2
|
|
|
|
|
|
# Character sets ordered by visual density (light to dark)
|
|
ALPHABETS = {
|
|
# Classic ASCII gradient
|
|
"standard": " .`'^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
|
|
|
|
# Unicode block elements - naturally ordered by fill
|
|
"blocks": " ░▒▓█",
|
|
|
|
# Cyrillic - ordered by visual complexity
|
|
"cyrillic": " ·гтпрсьоеаилнкчуцбдвжзмъыюяфщшэ",
|
|
|
|
# Greek - ordered by visual weight
|
|
"greek": " ·ιτορεαηυικλνσςπμβγδζθξφψωΣΩΨΦ",
|
|
|
|
# Japanese Katakana - ordered by stroke complexity
|
|
"japanese": " ·ノ一ヘイコニハヒフホメヨワヲンリルレロカキクケサシスセソタチツテト",
|
|
|
|
# Braille patterns - high detail
|
|
"braille": " ⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿",
|
|
}
|
|
|
|
|
|
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
|
|
"""
|
|
Apply ASCII art effect to a video frame.
|
|
|
|
Args:
|
|
frame: Input frame as numpy array (H, W, 3) RGB uint8
|
|
params: Effect parameters
|
|
- char_size: character cell size (default 8)
|
|
- alphabet: character set name (default "standard")
|
|
- color_mode: mono/color/invert (default "color")
|
|
- contrast_boost: enhance contrast (default 1.5)
|
|
- background: RGB tuple (default [0,0,0])
|
|
state: Persistent state dict (used for caching)
|
|
|
|
Returns:
|
|
Tuple of (processed_frame, new_state)
|
|
"""
|
|
char_size = max(4, min(int(params.get("char_size", 8)), 32))
|
|
alphabet_name = params.get("alphabet", "standard")
|
|
color_mode = params.get("color_mode", "color")
|
|
contrast_boost = max(1.0, min(params.get("contrast_boost", 1.5), 3.0))
|
|
background = params.get("background", [0, 0, 0])
|
|
|
|
if state is None:
|
|
state = {}
|
|
|
|
# Get character set
|
|
char_set = ALPHABETS.get(alphabet_name, ALPHABETS["standard"])
|
|
|
|
# Get or create character atlas cache
|
|
cache_key = f"{char_size}_{alphabet_name}"
|
|
if "atlas_cache" not in state or state.get("cache_key") != cache_key:
|
|
state["atlas"] = _create_char_atlas(char_size, char_set)
|
|
state["cache_key"] = cache_key
|
|
|
|
atlas = state["atlas"]
|
|
|
|
h, w = frame.shape[:2]
|
|
cols = w // char_size
|
|
rows = h // char_size
|
|
|
|
if cols < 1 or rows < 1:
|
|
return frame, state
|
|
|
|
# Crop frame to fit grid
|
|
grid_h, grid_w = rows * char_size, cols * char_size
|
|
frame_cropped = frame[:grid_h, :grid_w]
|
|
|
|
# Downsample to get average color per cell
|
|
reshaped = frame_cropped.reshape(rows, char_size, cols, char_size, 3)
|
|
cell_colors = reshaped.mean(axis=(1, 3)).astype(np.uint8)
|
|
|
|
# Convert to grayscale for brightness mapping
|
|
cell_gray = 0.299 * cell_colors[:,:,0] + 0.587 * cell_colors[:,:,1] + 0.114 * cell_colors[:,:,2]
|
|
|
|
# Apply contrast boost
|
|
if contrast_boost > 1:
|
|
cell_gray = (cell_gray - 128) * contrast_boost + 128
|
|
cell_gray = np.clip(cell_gray, 0, 255)
|
|
|
|
# Map brightness to character indices
|
|
char_indices = ((cell_gray / 255) * (len(char_set) - 1)).astype(np.int32)
|
|
char_indices = np.clip(char_indices, 0, len(char_set) - 1)
|
|
|
|
# Create output frame
|
|
if isinstance(background, (list, tuple)) and len(background) >= 3:
|
|
bg = background[:3]
|
|
else:
|
|
bg = [0, 0, 0]
|
|
result = np.full((grid_h, grid_w, 3), bg, dtype=np.uint8)
|
|
|
|
# Render characters
|
|
for row in range(rows):
|
|
for col in range(cols):
|
|
char_idx = char_indices[row, col]
|
|
char = char_set[char_idx]
|
|
char_mask = atlas.get(char)
|
|
|
|
if char_mask is None:
|
|
continue
|
|
|
|
y1, x1 = row * char_size, col * char_size
|
|
|
|
if color_mode == "mono":
|
|
color = np.array([255, 255, 255], dtype=np.uint8)
|
|
elif color_mode == "invert":
|
|
# Colored background, dark text
|
|
result[y1:y1+char_size, x1:x1+char_size] = cell_colors[row, col]
|
|
color = np.array([0, 0, 0], dtype=np.uint8)
|
|
else: # color
|
|
color = cell_colors[row, col]
|
|
|
|
# Apply character mask
|
|
if char != ' ':
|
|
mask = char_mask > 0
|
|
result[y1:y1+char_size, x1:x1+char_size][mask] = color
|
|
|
|
# Pad to original size if needed
|
|
if result.shape[0] < h or result.shape[1] < w:
|
|
padded = np.full((h, w, 3), bg, dtype=np.uint8)
|
|
padded[:grid_h, :grid_w] = result
|
|
result = padded
|
|
|
|
return result, state
|
|
|
|
|
|
def _create_char_atlas(char_size: int, char_set: str) -> dict:
|
|
"""Pre-render all characters as masks."""
|
|
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
font_scale = char_size / 20.0
|
|
thickness = max(1, int(char_size / 10))
|
|
|
|
atlas = {}
|
|
for char in char_set:
|
|
char_img = np.zeros((char_size, char_size), dtype=np.uint8)
|
|
if char != ' ':
|
|
try:
|
|
(text_w, text_h), baseline = cv2.getTextSize(char, font, font_scale, thickness)
|
|
text_x = (char_size - text_w) // 2
|
|
text_y = (char_size + text_h) // 2
|
|
cv2.putText(char_img, char, (text_x, text_y), font, font_scale, 255, thickness, cv2.LINE_AA)
|
|
except:
|
|
pass
|
|
atlas[char] = char_img
|
|
|
|
return atlas
|