skills/creative/ascii-video/references/troubleshooting.md
See also: composition.md · architecture.md · shaders.md · scenes.md · optimization.md
| 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.
broadcast_to().copy() TrapHue 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():
h = np.broadcast_to(h, (g.rows, g.cols)).copy()
This is especially important in _render_vf() where hue arrays flow through hsv2rgb().
+= vs + TrapBroadcasting also fails with in-place operators when operand shapes don't match exactly:
# 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.
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.
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(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(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(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.
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 <function <lambda> 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.
On macOS, multiprocessing defaults to spawn (full serialization). On Linux, it defaults to fork (copy-on-write). This means:
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.
Each worker creates its own:
Renderer instance (with fresh grid cache)FeedbackBuffer (feedback doesn't cross scene boundaries)random.seed(hash(seg_id) + 42))This means:
np.random state is NOT seeded by random.seed() — they use separate RNGsFix for deterministic noise: Use np.random.RandomState(seed) explicitly:
rng = np.random.RandomState(hash(seg_id) + 42)
noise = rng.random((rows, cols))
If a scene is still dark after tonemap, check:
blend_canvas(..., "overlay", ...) with dark layers, switch to screen.python reel.py --test-frame 10.0
# Output: Mean brightness: 44.3, max: 255
If mean < 20, the scene needs attention. Common fixes:
vf_plasma(...) * 1.5)The old pattern used a linear multiplier:
# OLD — don't use
canvas = np.clip(canvas.astype(np.float32) * 2.0, 0, 255).astype(np.uint8)
This fails because:
8 * 2.0 = 16 — still dark130 * 2.0 = 255 — clipped, lost detailUse tonemap() instead. See composition.md § Adaptive Tone Mapping.
The #1 production bug. If you use stderr=subprocess.PIPE:
# 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:
stderr_fh = open(err_path, "w")
pipe = subprocess.Popen(cmd, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=stderr_fh)
If the number of frames written to the pipe doesn't match what ffmpeg expects (based on -r and duration), the output may have:
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 @ ...] Unsafe file name
Fix: Always use -safe 0:
["ffmpeg", "-f", "concat", "-safe", "0", "-i", concat_path, ...]
textbbox() and getbbox() return incorrect heights on some macOS Pillow versions. Use getmetrics():
ascent, descent = font.getmetrics()
cell_height = ascent + descent # correct
# NOT: font.getbbox("M")[3] # wrong on some versions
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:
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 | 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.
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 |
If render is taking much longer than expected:
r.S vs the S ParameterThe v2 scene protocol passes S (the state dict) as an explicit parameter. But S IS r.S — they're the same object. Both work:
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.
Audio features default to 0.0 if the audio is silent. Use .get() with sensible defaults:
energy = f.get("bass", 0.3) # default to 0.3, not 0
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.
# 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
Value fields should be [0, 1]. If they exceed this range, val2char() produces index errors:
# 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.
screen blend mode (not overlay) for dark ASCII layers — overlay squares dark values: 2 * 0.12 * 0.12 = 0.03vf * 0.8 + 0.05 ensures no cell is truly zeroQuick checklist before full render:
canvas.mean() > 8 after tonemap