# Troubleshooting Reference > **See also:** composition.md · architecture.md · shaders.md · scenes.md · optimization.md ## Quick Diagnostic | 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 | 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) = 2*a*b` 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(start*FPS), 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 | ### Render Time Scaling If render is taking much longer than expected: 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) --- ## Common Mistakes ### Using `r.S` vs the `S` Parameter The v2 scene protocol passes `S` (the state dict) as an explicit parameter. But `S` IS `r.S` — they're the same object. Both work: ```python def fx_scene(r, f, t, S): S["counter"] = S.get("counter", 0) + 1 # via parameter (preferred) r.S["counter"] = r.S.get("counter", 0) + 1 # via renderer (also works) ``` Use the `S` parameter for clarity. The explicit parameter makes it obvious that the function has persistent state. ### Forgetting to Handle Empty Feature Values Audio features default to 0.0 if the audio is silent. Use `.get()` with sensible defaults: ```python energy = f.get("bass", 0.3) # default to 0.3, not 0 ``` If you default to 0, effects go blank during silence. ### Writing New Files Instead of Editing Existing State A common bug in particle systems: creating new arrays every frame instead of updating persistent state. ```python # WRONG — particles reset every frame S["px"] = [] for _ in range(100): S["px"].append(random.random()) # RIGHT — only initialize once, update each frame if "px" not in S: S["px"] = [] # ... emit new particles based on beats # ... update existing particles ``` ### Not Clipping Value Fields Value fields should be [0, 1]. If they exceed this range, `val2char()` produces index errors: ```python # WRONG — vf_plasma() * 1.5 can exceed 1.0 val = vf_plasma(g, f, t, S) * 1.5 # RIGHT — clip after scaling val = np.clip(vf_plasma(g, f, t, S) * 1.5, 0, 1) ``` The `_render_vf()` helper clips automatically, but if you're building custom scenes, clip explicitly. ## Brightness Best Practices - Dense animated backgrounds — never flat black, always fill the grid - Vignette minimum clamped to 0.15 (not 0.12) - Bloom threshold 130 (not 170) so more pixels contribute to glow - Use `screen` blend mode (not `overlay`) for dark ASCII layers — overlay squares dark values: `2 * 0.12 * 0.12 = 0.03` - FeedbackBuffer decay minimum 0.5 — below that, feedback disappears too fast to see - Value field floor: `vf * 0.8 + 0.05` ensures no cell is truly zero - Per-scene gamma overrides: default 0.75, solarize 0.55, posterize 0.50, bright scenes 0.85 - Test frames early: render single frames at key timestamps before committing to full render **Quick checklist before full render:** 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