๐Ÿ“„ troubleshooting.md

โ† Vault

Troubleshooting Reference

> See also: composition.md ยท architecture.md ยท shaders.md ยท scenes.md ยท optimization.md

Quick Diagnostic

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:

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:

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:

SymptomLikely CauseFix
---------------------------
All black outputtonemap gamma too high or no effects renderingLower gamma to 0.5, check scene_fn returns non-zero canvas
Washed out / too brightLinear brightness multiplier instead of tonemapReplace canvas * N with tonemap(canvas, gamma=0.75)
ffmpeg hangs mid-renderstderr=subprocess.PIPE deadlockRedirect stderr to file
"read-only" array errorbroadcast_to view without .copy()Add .copy() after broadcast_to
PicklingErrorLambda or closure in SCENES tableDefine all fx_* at module level
Random dark holes in outputFont missing Unicode glyphsValidate palettes at init
Audio-visual desyncFrame timing accumulationUse integer frame counter, compute t fresh each frame
Single-color flat outputHue field shape mismatchEnsure h,s,v arrays all (rows,cols) before hsv2rgb
Text unreadable over busy bgNo contrast between text and backgroundUse apply_text_backdrop() (composition.md) + reverse_vignette shader (shaders.md)
Text garbled/mirroredKaleidoscope or mirror shader applied to text sceneNever 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
Can PickleCannot Pickle
--------------------------
Module-level functions (def fx_foo():)Lambdas (lambda x: x + 1)
Dicts, lists, numpy arraysClosures (functions defined inside functions)
Class instances (with __reduce__)Instance methods
Strings, numbersFile handles, sockets
PlatformCommon Paths
-----------------------
macOS/System/Library/Fonts/Menlo.ttc, /System/Library/Fonts/Monaco.ttf
Linux/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf
WindowsC:\Windows\Fonts\consola.ttf (Consolas)
ShaderIssueFix
--------------------
wave_distortPer-row Python loopUse vectorized fancy indexing
halftoneTriple-nested loopVectorize with block reduction
matrix rainPer-column per-trail loopAccumulate 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