Composition & Brightness Reference
The composable system is the core of visual complexity. It operates at three levels: pixel-level blend modes, multi-grid composition, and adaptive brightness management. This document covers all three, plus the masking/stencil system for spatial control.
> See also: architecture.md ยท effects.md ยท scenes.md ยท shaders.md ยท troubleshooting.md
Pixel-Level Blend Modes
The blend_canvas() Function
All blending operates on full pixel canvases (uint8 H,W,3). Internally converts to float32 [0,1] for precision, blends, lerps by opacity, converts back.
`python
def blend_canvas(base, top, mode="normal", opacity=1.0):
af = base.astype(np.float32) / 255.0
bf = top.astype(np.float32) / 255.0
fn = BLEND_MODES.get(mode, BLEND_MODES["normal"])
result = fn(af, bf)
if opacity < 1.0:
result = af (1 - opacity) + result opacity
return np.clip(result * 255, 0, 255).astype(np.uint8)
`
20 Blend Modes
`python
BLEND_MODES = {
# Basic arithmetic
"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),
# Contrast
"overlay": lambda a, b: np.where(a < 0.5, 2ab, 1 - 2(1-a)(1-b)),
"softlight": lambda a, b: (1 - 2b)aa + 2b*a,
"hardlight": lambda a, b: np.where(b < 0.5, 2ab, 1 - 2(1-a)(1-b)),
# Difference
"difference": lambda a, b: np.abs(a - b),
"exclusion": lambda a, b: a + b - 2ab,
# Dodge / burn
"colordodge": lambda a, b: np.clip(a / (1 - b + 1e-6), 0, 1),
"colorburn": lambda a, b: np.clip(1 - (1 - a) / (b + 1e-6), 0, 1),
# Light
"linearlight": lambda a, b: np.clip(a + 2*b - 1, 0, 1),
"vividlight": lambda a, b: np.where(b < 0.5,
np.clip(1 - (1-a)/(2*b + 1e-6), 0, 1),
np.clip(a / (2*(1-b) + 1e-6), 0, 1)),
"pin_light": lambda a, b: np.where(b < 0.5,
np.minimum(a, 2b), np.maximum(a, 2b - 1)),
"hard_mix": lambda a, b: np.where(a + b >= 1.0, 1.0, 0.0),
# Compare
"lighten": lambda a, b: np.maximum(a, b),
"darken": lambda a, b: np.minimum(a, b),
# Grain
"grain_extract": lambda a, b: np.clip(a - b + 0.5, 0, 1),
"grain_merge": lambda a, b: np.clip(a + b - 0.5, 0, 1),
}
`
Blend Mode Selection Guide
Modes that brighten (safe for dark inputs):
- -
screenโ always brightens. Two 50% gray layers screen to 75%. The go-to safe blend. - -
addโ simple addition, clips at white. Good for sparkles, glows, particle overlays. - -
colordodgeโ extreme brightening at overlap zones. Can blow out. Use low opacity (0.3-0.5). - -
linearlightโ aggressive brightening. Similar to add but with offset. - -
multiplyโ darkens everything. Only use when both layers are already bright. - -
overlayโ darkens when base < 0.5, brightens when base > 0.5. Crushes dark inputs:2 0.12 0.12 = 0.03. Usescreeninstead for dark material. - -
colorburnโ extreme darkening at overlap zones. - -
softlightโ gentle contrast. Good for subtle texture overlay. - -
hardlightโ strong contrast. Like overlay but keyed on the top layer. - -
vividlightโ very aggressive contrast. Use sparingly. - -
differenceโ XOR-like patterns. Two identical layers difference to black; offset layers create wild colors. Great for psychedelic looks. - -
exclusionโ softer version of difference. Creates complementary color patterns. - -
hard_mixโ posterizes to pure black/white/saturated color at intersections. - -
grain_extract/grain_mergeโ extract a texture from one layer, apply it to another. - -
smgrid (10pt font): 320x83 characters. Fine detail, dense texture. - -
mdgrid (16pt): 192x56 characters. Medium density. - -
lggrid (20pt): 160x45 characters. Coarse, chunky characters. - - Mean frame brightness is inherently low (often 5-30 out of 255)
- - Different effect combinations produce wildly different brightness levels
- - A spiral scene might be 50 mean, while a fire scene is 9 mean
- - Linear multipliers (e.g.,
canvas * 2.0) either leave dark scenes dark or blow out bright scenes - - Feedback operates on normalized data (consistent behavior regardless of scene brightness)
- - Shaders like solarize, posterize, contrast operate on properly-ranged data
- - The brightness shader in the chain is no longer needed (tonemap handles it)
- - Quiet/ambient scenes: mean 30-60
- - Active scenes: mean 40-100
- - Climax/peak scenes: mean 60-150
- - If mean < 20: gamma is too high or a shader is destroying brightness
- - If mean > 180: gamma is too low or add is stacking too much
- - Text that dynamically reflows around moving objects
- - Per-glyph animation (reveal, scatter, physics)
- - Variable typography that needs precise measurement
- - Any case where Python's Pillow text layout is insufficient
- - Static centered text (just use PIL
draw.text()directly) - - Text that only fades in/out without spatial animation
- - Simple typewriter effects (handle in Python with a character counter)
Modes that darken (avoid with dark inputs):
Modes that create contrast:
Modes that create color effects:
Modes for texture blending:
Multi-Layer Chaining
`python
Pattern: render layers -> blend sequentially
canvas_a = _render_vf(r, "md", vf_plasma, hf_angle(0.0), PAL_DENSE, f, t, S)
canvas_b = _render_vf(r, "sm", vf_vortex, hf_time_cycle(0.1), PAL_RUNE, f, t, S)
canvas_c = _render_vf(r, "lg", vf_rings, hf_distance(), PAL_BLOCKS, f, t, S)
result = blend_canvas(canvas_a, canvas_b, "screen", 0.8)
result = blend_canvas(result, canvas_c, "difference", 0.6)
`
Order matters: screen(A, B) is commutative, but difference(screen(A,B), C) differs from difference(A, screen(B,C)).
Linear-Light Blend Modes
Standard blend_canvas() operates in sRGB space โ the raw byte values. This is fine for most uses, but sRGB is perceptually non-linear: blending in sRGB darkens midtones and shifts hues slightly. For physically accurate blending (matching how light actually combines), convert to linear light first.
Uses srgb_to_linear() / linear_to_srgb() from architecture.md ยง OKLAB Color System.
`python
def blend_canvas_linear(base, top, mode="normal", opacity=1.0):
"""Blend in linear light space for physically accurate results.
Identical API to blend_canvas(), but converts sRGB โ linear before
blending and linear โ sRGB after. More expensive (~2x) due to the
gamma conversions, but produces correct results for additive blending,
screen, and any mode where brightness matters.
"""
af = srgb_to_linear(base.astype(np.float32) / 255.0)
bf = srgb_to_linear(top.astype(np.float32) / 255.0)
fn = BLEND_MODES.get(mode, BLEND_MODES["normal"])
result = fn(af, bf)
if opacity < 1.0:
result = af (1 - opacity) + result opacity
result = linear_to_srgb(np.clip(result, 0, 1))
return np.clip(result * 255, 0, 255).astype(np.uint8)
`
When to use blend_canvas_linear() vs blend_canvas():
| Scenario | Use | Why |
| ---------- | ----- | ----- |
| Screen-blending two bright layers | linear | sRGB screen over-brightens highlights |
| Add mode for glow/bloom effects | linear | Additive light follows linear physics |
| Blending text overlay at low opacity | srgb | Perceptual blending looks more natural for text |
| Multiply for shadow/darkening | srgb | Differences are minimal for darken ops |
| Color-critical work (matching reference) | linear | Avoids sRGB hue shifts in midtones |
| Performance-critical inner loop | srgb | ~2x faster, good enough for most ASCII art |
| Combination | Effect | Good For |
| ------------- | -------- | ---------- |
sm + lg | Maximum contrast between fine detail and chunky blocks | Bold, graphic looks |
sm + md | Subtle texture layering, similar scales | Organic, flowing looks |
md + lg + xs | Three-scale interference, maximum complexity | Psychedelic, dense |
sm + sm (different effects) | Same scale, pattern interference only | Moire, interference |
| Scene Type | Recommended Gamma | Why |
| ------------ | ------------------- | ----- |
| Standard effects | 0.75 | Default, works for most scenes |
| Solarize post-process | 0.50-0.60 | Solarize inverts bright pixels, reducing overall brightness |
| Posterize post-process | 0.50-0.55 | Posterize quantizes, often crushing mid-values to black |
| Heavy difference blending | 0.60-0.70 | Difference mode creates many near-zero pixels |
| Already bright scenes | 0.85-1.0 | Don't over-boost scenes that are naturally bright |
| Preset | Config | Visual Effect |
| -------- | -------- | --------------- |
| Infinite zoom tunnel | decay=0.8, blend="screen", transform="zoom", transform_amt=0.015 | Expanding ring patterns |
| Rainbow trails | decay=0.7, blend="screen", transform="zoom", transform_amt=0.01, hue_shift=0.02 | Psychedelic color trails |
| Ghostly echo | decay=0.9, blend="add", opacity=0.15, transform="shift_up", transform_amt=0.01 | Faint upward smearing |
| Kaleidoscopic recursion | decay=0.75, blend="screen", transform="rotate_cw", transform_amt=0.005, hue_shift=0.01 | Rotating mandala feedback |
| Color evolution | decay=0.8, blend="difference", opacity=0.4, hue_shift=0.03 | Frame-to-frame color XOR |
| Rising heat haze | decay=0.5, blend="add", opacity=0.2, transform="shift_up", transform_amt=0.02 | Hot air shimmer |
Masking / Stencil System
Masks are float32 arrays (rows, cols) or (VH, VW) in range [0, 1]. They control where effects are visible: 1.0 = fully visible, 0.0 = fully hidden. Use masks to create figure/ground relationships, focal points, and shaped reveals.
Shape Masks
`python
def mask_circle(g, cx_frac=0.5, cy_frac=0.5, radius=0.3, feather=0.05):
"""Circular mask centered at (cx_frac, cy_frac) in normalized coords.
feather: width of soft edge (0 = hard cutoff)."""
asp = g.cw / g.ch if hasattr(g, 'cw') else 1.0
dx = (g.cc / g.cols - cx_frac)
dy = (g.rr / g.rows - cy_frac) * asp
d = np.sqrt(dx2 + dy2)
if feather > 0:
return np.clip(1.0 - (d - radius) / feather, 0, 1)
return (d <= radius).astype(np.float32)
def mask_rect(g, x0=0.2, y0=0.2, x1=0.8, y1=0.8, feather=0.03):
"""Rectangular mask. Coordinates in [0,1] normalized."""
dx = np.maximum(x0 - g.cc / g.cols, g.cc / g.cols - x1)
dy = np.maximum(y0 - g.rr / g.rows, g.rr / g.rows - y1)
d = np.maximum(dx, dy)
if feather > 0:
return np.clip(1.0 - d / feather, 0, 1)
return (d <= 0).astype(np.float32)
def mask_ring(g, cx_frac=0.5, cy_frac=0.5, inner_r=0.15, outer_r=0.35,
feather=0.03):
"""Ring / annulus mask."""
inner = mask_circle(g, cx_frac, cy_frac, inner_r, feather)
outer = mask_circle(g, cx_frac, cy_frac, outer_r, feather)
return outer - inner
def mask_gradient_h(g, start=0.0, end=1.0):
"""Left-to-right gradient mask."""
return np.clip((g.cc / g.cols - start) / (end - start + 1e-10), 0, 1).astype(np.float32)
def mask_gradient_v(g, start=0.0, end=1.0):
"""Top-to-bottom gradient mask."""
return np.clip((g.rr / g.rows - start) / (end - start + 1e-10), 0, 1).astype(np.float32)
def mask_gradient_radial(g, cx_frac=0.5, cy_frac=0.5, inner=0.0, outer=0.5):
"""Radial gradient mask โ bright at center, dark at edges."""
d = np.sqrt((g.cc / g.cols - cx_frac)2 + (g.rr / g.rows - cy_frac)2)
return np.clip(1.0 - (d - inner) / (outer - inner + 1e-10), 0, 1)
`
Value Field as Mask
Use any vf_* function's output as a spatial mask:
`python
def mask_from_vf(vf_result, threshold=0.5, feather=0.1):
"""Convert a value field to a mask by thresholding.
feather: smooth edge width around threshold."""
if feather > 0:
return np.clip((vf_result - threshold + feather) / (2 * feather), 0, 1)
return (vf_result > threshold).astype(np.float32)
def mask_select(mask, vf_a, vf_b):
"""Spatial conditional: show vf_a where mask is 1, vf_b where mask is 0.
mask: float32 [0,1] array. Intermediate values blend."""
return vf_a mask + vf_b (1 - mask)
`
Text Stencil
Render text to a mask. Effects are visible only through the letterforms:
`python
def mask_text(grid, text, row_frac=0.5, font=None, font_size=None):
"""Render text string as a float32 mask [0,1] at grid resolution.
Characters = 1.0, background = 0.0.
row_frac: vertical position as fraction of grid height.
font: PIL ImageFont (defaults to grid's font if None).
font_size: override font size for the mask text (for larger stencil text).
"""
from PIL import Image, ImageDraw, ImageFont
f = font or grid.font
if font_size and font != grid.font:
f = ImageFont.truetype(font.path, font_size)
# Render text to image at pixel resolution, then downsample to grid
img = Image.new("L", (grid.cols * grid.cw, grid.ch), 0)
draw = ImageDraw.Draw(img)
bbox = draw.textbbox((0, 0), text, font=f)
tw = bbox[2] - bbox[0]
x = (grid.cols * grid.cw - tw) // 2
draw.text((x, 0), text, fill=255, font=f)
row_mask = np.array(img, dtype=np.float32) / 255.0
# Place in full grid mask
mask = np.zeros((grid.rows, grid.cols), dtype=np.float32)
target_row = int(grid.rows * row_frac)
# Downsample rendered text to grid cells
for c in range(grid.cols):
px = c * grid.cw
if px + grid.cw <= row_mask.shape[1]:
cell = row_mask[:, px:px + grid.cw]
if cell.mean() > 0.1:
mask[target_row, c] = cell.mean()
return mask
def mask_text_block(grid, lines, start_row_frac=0.3, font=None):
"""Multi-line text stencil. Returns full grid mask."""
mask = np.zeros((grid.rows, grid.cols), dtype=np.float32)
for i, line in enumerate(lines):
row_frac = start_row_frac + i / grid.rows
line_mask = mask_text(grid, line, row_frac, font)
mask = np.maximum(mask, line_mask)
return mask
`
Animated Masks
Masks that change over time for reveals, wipes, and morphing:
`python
def mask_iris(g, t, t_start, t_end, cx_frac=0.5, cy_frac=0.5,
max_radius=0.7, ease_fn=None):
"""Iris open/close: circle that grows from 0 to max_radius.
ease_fn: easing function (default: ease_in_out_cubic from effects.md)."""
if ease_fn is None:
ease_fn = lambda x: x x (3 - 2 * x) # smoothstep fallback
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
radius = ease_fn(progress) * max_radius
return mask_circle(g, cx_frac, cy_frac, radius, feather=0.03)
def mask_wipe_h(g, t, t_start, t_end, direction="right"):
"""Horizontal wipe reveal."""
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
if direction == "left":
progress = 1 - progress
return mask_gradient_h(g, start=progress - 0.05, end=progress + 0.05)
def mask_wipe_v(g, t, t_start, t_end, direction="down"):
"""Vertical wipe reveal."""
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
if direction == "up":
progress = 1 - progress
return mask_gradient_v(g, start=progress - 0.05, end=progress + 0.05)
def mask_dissolve(g, t, t_start, t_end, seed=42):
"""Random pixel dissolve โ noise threshold sweeps from 0 to 1."""
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
rng = np.random.RandomState(seed)
noise = rng.random((g.rows, g.cols)).astype(np.float32)
return (noise < progress).astype(np.float32)
`
Mask Boolean Operations
`python
def mask_union(a, b):
"""OR โ visible where either mask is active."""
return np.maximum(a, b)
def mask_intersect(a, b):
"""AND โ visible only where both masks are active."""
return np.minimum(a, b)
def mask_subtract(a, b):
"""A minus B โ visible where A is active but B is not."""
return np.clip(a - b, 0, 1)
def mask_invert(m):
"""NOT โ flip mask."""
return 1.0 - m
`
Applying Masks to Canvases
`python
def apply_mask_canvas(canvas, mask, bg_canvas=None):
"""Apply a grid-resolution mask to a pixel canvas.
Expands mask from (rows, cols) to (VH, VW) via nearest-neighbor.
canvas: uint8 (VH, VW, 3)
mask: float32 (rows, cols) [0,1]
bg_canvas: what shows through where mask=0. None = black.
"""
# Expand mask to pixel resolution
mask_px = np.repeat(np.repeat(mask, canvas.shape[0] // mask.shape[0] + 1, axis=0),
canvas.shape[1] // mask.shape[1] + 1, axis=1)
mask_px = mask_px[:canvas.shape[0], :canvas.shape[1]]
if bg_canvas is not None:
return np.clip(canvas * mask_px[:, :, None] +
bg_canvas * (1 - mask_px[:, :, None]), 0, 255).astype(np.uint8)
return np.clip(canvas * mask_px[:, :, None], 0, 255).astype(np.uint8)
def apply_mask_vf(vf_a, vf_b, mask):
"""Apply mask at value-field level โ blend two value fields spatially.
All arrays are (rows, cols) float32."""
return vf_a mask + vf_b (1 - mask)
`
PixelBlendStack
Higher-level wrapper for multi-layer compositing:
`python
class PixelBlendStack:
def __init__(self):
self.layers = []
def add(self, canvas, mode="normal", opacity=1.0):
self.layers.append((canvas, mode, opacity))
return self
def composite(self):
if not self.layers:
return np.zeros((VH, VW, 3), dtype=np.uint8)
result = self.layers[0][0]
for canvas, mode, opacity in self.layers[1:]:
result = blend_canvas(result, canvas, mode, opacity)
return result
`
Text Backdrop (Readability Mask)
When placing readable text over busy multi-grid ASCII backgrounds, the text will blend into the background and become illegible. Always apply a dark backdrop behind text regions.
The technique: compute the bounding box of all text glyphs, create a gaussian-blurred dark mask covering that area with padding, and multiply the background by (1 - mask * darkness) before rendering text on top.
`python
from scipy.ndimage import gaussian_filter
def apply_text_backdrop(canvas, glyphs, padding=80, darkness=0.75):
"""Darken the background behind text for readability.
Call AFTER rendering background, BEFORE rendering text.
Args:
canvas: (VH, VW, 3) uint8 background
glyphs: list of {"x": float, "y": float, ...} glyph positions
padding: pixel padding around text bounding box
darkness: 0.0 = no darkening, 1.0 = fully black
Returns:
darkened canvas (uint8)
"""
if not glyphs:
return canvas
xs = [g['x'] for g in glyphs]
ys = [g['y'] for g in glyphs]
x0 = max(0, int(min(xs)) - padding)
y0 = max(0, int(min(ys)) - padding)
x1 = min(VW, int(max(xs)) + padding + 50) # extra for char width
y1 = min(VH, int(max(ys)) + padding + 60) # extra for char height
# Soft dark mask with gaussian blur for feathered edges
mask = np.zeros((VH, VW), dtype=np.float32)
mask[y0:y1, x0:x1] = 1.0
mask = gaussian_filter(mask, sigma=padding * 0.6)
factor = 1.0 - mask * darkness
return (canvas.astype(np.float32) * factor[:, :, np.newaxis]).astype(np.uint8)
`
Usage in render pipeline
Insert between background rendering and text rendering:
`python
1. Render background (multi-grid ASCII effects)
bg = render_background(cfg, t)
2. Darken behind text region
bg = apply_text_backdrop(bg, frame_glyphs, padding=80, darkness=0.75)
3. Render text on top (now readable against dark backdrop)
bg = text_renderer.render(bg, frame_glyphs, color=(255, 255, 255))
`
Combine with reverse vignette (see shaders.md) for scenes where text is always centered โ the reverse vignette provides a persistent center-dark zone, while the backdrop handles per-frame glyph positions.
External Layout Oracle Pattern
For text-heavy videos where text needs to dynamically reflow around obstacles (shapes, icons, other text), use an external layout engine to pre-compute glyph positions and feed them into the Python renderer via JSON.
Architecture
`
Layout Engine (browser/Node.js) โ layouts.json โ Python ASCII Renderer
โ โ
Computes per-frame Reads glyph positions,
glyph (x,y) positions renders as ASCII chars
with obstacle-aware reflow with full effect pipeline
`
JSON interchange format
`json
{
"meta": {
"canvas_width": 1080, "canvas_height": 1080,
"fps": 24, "total_frames": 1248,
"fonts": {
"body": {"charW": 12.04, "charH": 24, "fontSize": 20},
"hero": {"charW": 24.08, "charH": 48, "fontSize": 40}
}
},
"scenes": [
{
"id": "scene_name",
"start_frame": 0, "end_frame": 96,
"frames": {
"0": {
"glyphs": [
{"char": "H", "x": 287.1, "y": 400.0, "alpha": 1.0},
{"char": "e", "x": 311.2, "y": 400.0, "alpha": 1.0}
],
"obstacles": [
{"type": "circle", "cx": 540, "cy": 540, "r": 80},
{"type": "rect", "x": 300, "y": 500, "w": 120, "h": 80}
]
}
}
}
]
}
`
When to use
When NOT to use
Running the oracle
Use Playwright to run the layout engine in a headless browser:
`javascript
// extract.mjs
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(file://${oraclePath});
await page.waitForFunction(() => window.__ORACLE_DONE__ === true, null, { timeout: 60000 });
const result = await page.evaluate(() => window.__ORACLE_RESULT__);
writeFileSync('layouts.json', JSON.stringify(result));
await browser.close();
`
Consuming in Python
`python
In the renderer, map pixel positions to the canvas:
for glyph in frame_data['glyphs']:
char, px, py = glyph['char'], glyph['x'], glyph['y']
alpha = glyph.get('alpha', 1.0)
# Render using PIL draw.text() at exact pixel position
draw.text((px, py), char, fill=(int(255alpha),)3, font=font)
`
Obstacles from the JSON can also be rendered as glowing ASCII shapes (circles, rectangles) to visualize the reflow zones.