| Symptom | Likely Cause | Fix |
| --------- | ------------- | ----- |
| All black output | tonemap gamma too high or no effects rendering | Lower gamma to 0.5, check scene_fn returns non-zero canvas |
| Washed out / too bright | Linear brightness multiplier instead of tonemap | Replace canvas * N with tonemap(canvas, gamma=0.75) |
| ffmpeg hangs mid-render | stderr=subprocess.PIPE deadlock | Redirect stderr to file |
| "read-only" array error | broadcast_to view without .copy() | Add .copy() after broadcast_to |
| PicklingError | Lambda or closure in SCENES table | Define all fx_* at module level |
| Random dark holes in output | Font missing Unicode glyphs | Validate palettes at init |
| Audio-visual desync | Frame timing accumulation | Use integer frame counter, compute t fresh each frame |
| Single-color flat output | Hue field shape mismatch | Ensure h,s,v arrays all (rows,cols) before hsv2rgb |
| Text unreadable over busy bg | No contrast between text and background | Use apply_text_backdrop() (composition.md) + reverse_vignette shader (shaders.md) |
| Text garbled/mirrored | Kaleidoscope or mirror shader applied to text scene | Never apply kaleidoscope, mirror_h/v/quad/diag to scenes with readable text โ radial folding destroys legibility. Apply these only to background layers or text-free scenes |
Common bugs, gotchas, and platform-specific issues encountered during ASCII video development.
NumPy Broadcasting
The broadcast_to().copy() Trap
Hue field generators often return arrays that are broadcast views โ they have shape (1, cols) or (rows, 1) that numpy broadcasts to (rows, cols). These views are read-only. If any downstream code tries to modify them in-place (e.g., h %= 1.0), numpy raises:
`
ValueError: output array is read-only
`
Fix: Always .copy() after broadcast_to():
`python
h = np.broadcast_to(h, (g.rows, g.cols)).copy()
`
This is especially important in _render_vf() where hue arrays flow through hsv2rgb().
The += vs + Trap
Broadcasting also fails with in-place operators when operand shapes don't match exactly:
`python
FAILS if result is (rows,1) and operand is (rows, cols)
val += np.sin(g.cc 0.02 + t 0.3) * 0.5
WORKS โ creates a new array
val = val + np.sin(g.cc 0.02 + t 0.3) * 0.5
`
The vf_plasma() function had this bug. Use + instead of += when mixing different-shaped arrays.
Shape Mismatch in hsv2rgb()
hsv2rgb(h, s, v) requires all three arrays to have identical shapes. If h is (1, cols) and s is (rows, cols), the function crashes or produces wrong output.
Fix: Ensure all inputs are broadcast and copied to (rows, cols) before calling.
Blend Mode Pitfalls
Overlay Crushes Dark Inputs
overlay(a, b) = 2ab when a < 0.5. Two values of 0.12 produce 2 0.12 0.12 = 0.03. The result is darker than either input.
Impact: If both layers are dark (which ASCII art usually is), overlay produces near-black output.
Fix: Use screen for dark source material. Screen always brightens: 1 - (1-a)*(1-b).
Colordodge Division by Zero
colordodge(a, b) = a / (1 - b). When b = 1.0 (pure white pixels), this divides by zero.
Fix: Add epsilon: a / (1 - b + 1e-6). The implementation in BLEND_MODES should include this.
Colorburn Division by Zero
colorburn(a, b) = 1 - (1-a) / b. When b = 0 (pure black pixels), this divides by zero.
Fix: Add epsilon: 1 - (1-a) / (b + 1e-6).
Multiply Always Darkens
multiply(a, b) = a * b. Since both operands are [0,1], the result is always <= min(a,b). Never use multiply as a feedback blend mode โ the frame goes black within a few frames.
Fix: Use screen for feedback, or add with low opacity.
Multiprocessing
Pickling Constraints
ProcessPoolExecutor serializes function arguments via pickle. This constrains what you can pass to workers:
| Can Pickle | Cannot Pickle |
| ----------- | --------------- |
Module-level functions (def fx_foo():) | Lambdas (lambda x: x + 1) |
| Dicts, lists, numpy arrays | Closures (functions defined inside functions) |
Class instances (with __reduce__) | Instance methods |
| Strings, numbers | File handles, sockets |
Impact: All scene functions referenced in the SCENES table must be defined at module level with def. If you use a lambda or closure, you get:
`
_pickle.PicklingError: Can't pickle at 0x...>
`
Fix: Define all scene functions at module top level. Lambdas used inside _render_vf() as val_fn/hue_fn are fine because they execute within the worker process โ they're not pickled across process boundaries.
macOS spawn vs Linux fork
On macOS, multiprocessing defaults to spawn (full serialization). On Linux, it defaults to fork (copy-on-write). This means:
- - macOS: Feature arrays are serialized per worker (~57KB for 30s video, but scales with duration). Each worker re-imports the entire module.
- - Linux: Feature arrays are shared via COW. Workers inherit the parent's memory.
Impact: On macOS, module-level code (like detect_hardware()) runs in every worker process. If it has side effects (e.g., subprocess calls), those happen N+1 times.
Per-Worker State Isolation
Each worker creates its own:
- -
Renderer instance (with fresh grid cache)
- -
FeedbackBuffer (feedback doesn't cross scene boundaries)
- - Random seed (
random.seed(hash(seg_id) + 42)) This means:
- - Particle state doesn't carry between scenes (expected)
- - Feedback trails reset at scene cuts (expected)
- -
np.random state is NOT seeded by random.seed() โ they use separate RNGs Fix for deterministic noise: Use np.random.RandomState(seed) explicitly:
`python
rng = np.random.RandomState(hash(seg_id) + 42)
noise = rng.random((rows, cols))
`
Brightness Issues
Dark Scenes After Tonemap
If a scene is still dark after tonemap, check:
1. Gamma too high: Lower gamma (0.5-0.6) for scenes with destructive post-processing
2. Shader destroying brightness: Solarize, posterize, or contrast adjustments in the shader chain can undo tonemap's work. Move destructive shaders earlier in the chain, or increase gamma to compensate.
3. Feedback with multiply: Multiply feedback darkens every frame. Switch to screen or add.
4. Overlay blend in scene: If the scene function uses blend_canvas(..., "overlay", ...) with dark layers, switch to screen.
Diagnostic: Test-Frame Brightness
`bash
python reel.py --test-frame 10.0
Output: Mean brightness: 44.3, max: 255
`
If mean < 20, the scene needs attention. Common fixes:
- - Lower gamma in the SCENES entry
- - Change internal blend modes from overlay/multiply to screen/add
- - Increase value field multipliers (e.g.,
vf_plasma(...) * 1.5)
- - Check that the shader chain doesn't have an aggressive solarize or threshold
v1 Brightness Pattern (Deprecated)
The old pattern used a linear multiplier:
`python
OLD โ don't use
canvas = np.clip(canvas.astype(np.float32) * 2.0, 0, 255).astype(np.uint8)
`
This fails because:
- - Dark scenes (mean 8):
8 * 2.0 = 16 โ still dark
- - Bright scenes (mean 130):
130 * 2.0 = 255 โ clipped, lost detail Use tonemap() instead. See composition.md ยง Adaptive Tone Mapping.
ffmpeg Issues
Pipe Deadlock
The #1 production bug. If you use stderr=subprocess.PIPE:
`python
DEADLOCK โ stderr buffer fills at 64KB, blocks ffmpeg, blocks your writes
pipe = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
`
Fix: Always redirect stderr to a file:
`python
stderr_fh = open(err_path, "w")
pipe = subprocess.Popen(cmd, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=stderr_fh)
`
Frame Count Mismatch
If the number of frames written to the pipe doesn't match what ffmpeg expects (based on -r and duration), the output may have:
- - Missing frames at the end
- - Incorrect duration
- - Audio-video desync
Fix: Calculate frame count explicitly: n_frames = int(duration FPS). Don't use range(int(startFPS), int(end*FPS)) without verifying the total matches.
Concat Fails with "unsafe file name"
`
[concat @ ...] Unsafe file name
`
Fix: Always use -safe 0:
`python
["ffmpeg", "-f", "concat", "-safe", "0", "-i", concat_path, ...]
`
Font Issues
Cell Height (macOS Pillow)
textbbox() and getbbox() return incorrect heights on some macOS Pillow versions. Use getmetrics():
`python
ascent, descent = font.getmetrics()
cell_height = ascent + descent # correct
NOT: font.getbbox("M")[3] # wrong on some versions
`
Missing Unicode Glyphs
Not all fonts render all Unicode characters. If a palette character isn't in the font, the glyph renders as a blank or tofu box, appearing as a dark hole in the output.
Fix: Validate at init:
`python
all_chars = set()
for pal in [PAL_DEFAULT, PAL_DENSE, PAL_RUNE, ...]:
all_chars.update(pal)
valid_chars = set()
for c in all_chars:
if c == " ":
valid_chars.add(c)
continue
img = Image.new("L", (20, 20), 0)
ImageDraw.Draw(img).text((0, 0), c, fill=255, font=font)
if np.array(img).max() > 0:
valid_chars.add(c)
else:
log(f"WARNING: '{c}' (U+{ord(c):04X}) missing from font")
`
Platform Font Paths
| Platform | Common Paths |
| ---------- | ------------- |
| macOS | /System/Library/Fonts/Menlo.ttc, /System/Library/Fonts/Monaco.ttf |
| Linux | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf |
| Windows | C:\Windows\Fonts\consola.ttf (Consolas) |
Always probe multiple paths and fall back gracefully. See architecture.md ยง Font Selection.
Performance
Slow Shaders
Some shaders use Python loops and are very slow at 1080p:
| Shader | Issue | Fix |
| -------- | ------- | ----- |
wave_distort | Per-row Python loop | Use vectorized fancy indexing |
halftone | Triple-nested loop | Vectorize with block reduction |
matrix rain | Per-column per-trail loop | Accumulate index arrays, bulk assign |
1. Check grid count โ each extra grid adds ~100-150ms/frame for init
2. Check particle count โ cap at quality-appropriate limits
3. Check shader count โ each shader adds 2-25ms
4. Check for accidental Python loops in effects (should be numpy only)
Audio features default to 0.0 if the audio is silent. Use .get() with sensible defaults:
If you default to 0, effects go blank during silence.
A common bug in particle systems: creating new arrays every frame instead of updating persistent state.
Value fields should be [0, 1]. If they exceed this range, val2char() produces index errors:
1. Render 3 test frames (start, middle, end)
2. Check canvas.mean() > 8 after tonemap
3. Check no scene is visually flat black
4. Verify per-section variation (different bg/palette/color per scene)
5. Confirm shader chain includes bloom (threshold 130)
6. Confirm vignette strength โค 0.25