From c1295e8c81f598af7089f86f8f39bbaf5e319278 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 2 Apr 2026 15:40:18 -0700 Subject: [PATCH] Merge server changes: fixed-width glyph cells, Web Worker animation Incorporates uncommitted server-side improvements: - VARIABLE_WIDTH_PALETTES + fixed-width cells for grid alignment - Web Worker-based animation timer (keeps running in background tabs) - Passes fix_cells through animate_image() for animation frames Co-Authored-By: Claude Opus 4.6 --- render.py | 37 +++++++++++++++++++++++------ static/app.js | 60 ++++++++++++++++++++++++++++++++---------------- static/style.css | 8 +++++++ 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/render.py b/render.py index 7f19118..a727721 100644 --- a/render.py +++ b/render.py @@ -64,6 +64,15 @@ WIDE_PALETTES = { "hieroglyph", "cuneiform", "alchemical", } +# Palettes whose glyphs aren't reliably monospaced in JetBrains Mono. +# These need fixed-width cells in HTML output to maintain grid alignment. +VARIABLE_WIDTH_PALETTES = WIDE_PALETTES | { + "wingdings", "zodiac", "chess", "arrows", "music", "cosmic", "mystic", + "runes", "geometric", "kanji", "thai", "arabic", "devanagari", "ethiopic", + "georgian", "tibetan", "hieroglyph", "cuneiform", "alchemical", "dingbats", + "yijing", "math", "flora", "weather", +} + def is_wide_palette(name: str) -> bool: """Check if a palette uses double-width (emoji/supplementary) characters.""" @@ -128,6 +137,7 @@ def _render_frame( double_width: bool = False, dither: bool = False, output_format: str = "ansi", + fixed_width_cells: bool = False, ) -> str: """Render a single prepared (resized RGB) frame to art string.""" width, height = img.size @@ -159,13 +169,18 @@ def _render_frame( char = chars[idx] if double_width: char = char + " " + escaped = html_escape(char) + if fixed_width_cells: + # Wrap each char in a fixed-width cell so non-monospace + # glyphs don't break the grid alignment + escaped = f'{escaped}' color = f"{r},{g},{b}" if color == prev_color: - run_chars.append(html_escape(char)) + run_chars.append(escaped) else: if run_chars: line_parts.append(f'{"".join(run_chars)}') - run_chars = [html_escape(char)] + run_chars = [escaped] prev_color = color if run_chars: line_parts.append(f'{"".join(run_chars)}') @@ -216,7 +231,8 @@ def image_to_art( wide = is_wide_palette(palette_name) img = _prepare_frame(img, width, bg, wide_chars=wide) chars = PALETTES.get(palette_name, PALETTES["wingdings"]) - return _render_frame(img, chars, double_width, dither, output_format) + fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html" + return _render_frame(img, chars, double_width, dither, output_format, fixed_width_cells=fix_cells) def gif_to_art( @@ -232,6 +248,7 @@ def gif_to_art( img = Image.open(image_path) wide = is_wide_palette(palette_name) chars = PALETTES.get(palette_name, PALETTES["wingdings"]) + fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html" frames = [] frame_list = list(ImageSequence.Iterator(img)) @@ -245,7 +262,7 @@ def gif_to_art( for frame in frame_list: duration = frame.info.get("duration", 100) prepared = _prepare_frame(frame.copy(), width, bg, wide_chars=wide) - art = _render_frame(prepared, chars, double_width, dither, output_format) + art = _render_frame(prepared, chars, double_width, dither, output_format, fixed_width_cells=fix_cells) frames.append({"art": art, "duration": max(duration, 20)}) return frames @@ -264,7 +281,8 @@ def render_from_pil( wide = is_wide_palette(palette_name) prepared = _prepare_frame(img, width, bg, wide_chars=wide) chars = PALETTES.get(palette_name, PALETTES["wingdings"]) - return _render_frame(prepared, chars, double_width, dither, output_format) + fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html" + return _render_frame(prepared, chars, double_width, dither, output_format, fixed_width_cells=fix_cells) def is_animated_gif(image_path: str) -> bool: @@ -428,12 +446,13 @@ def animate_image( prepared = _prepare_frame(img, width, bg, wide_chars=wide) chars = PALETTES.get(palette_name, PALETTES["wingdings"]) effect_fn = _EFFECTS.get(effect, _effect_color_cycle) + fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html" frames = [] for i in range(num_frames): t = i / num_frames effected = effect_fn(prepared, t) - art = _render_frame(effected, chars, double_width, dither, output_format) + art = _render_frame(effected, chars, double_width, dither, output_format, fixed_width_cells=fix_cells) frame = {"art": art, "duration": frame_duration} if return_pil: frame["pil_frame"] = effected @@ -459,6 +478,7 @@ def generate_pattern( chars = PALETTES.get(palette_name, PALETTES["wingdings"]) num_chars = len(chars) + fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html" # Random parameters for variety freq1 = rng.uniform(0.02, 0.12) @@ -627,7 +647,10 @@ def generate_pattern( char = chars[idx] if output_format == "html": - line.append(f'{html_escape(char)}') + escaped = html_escape(char) + if fix_cells: + escaped = f'{escaped}' + line.append(f'{escaped}') elif output_format == "ansi": line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m") else: diff --git a/static/app.js b/static/app.js index 8108e45..f90f5e0 100644 --- a/static/app.js +++ b/static/app.js @@ -257,9 +257,40 @@ async function generatePattern() { randomBtn.disabled = false; } -// ── GIF Animation ────────────────────────────── +// ── GIF Animation (runs even when tab is inactive) ────────────────────────────── let animFrames = null; +let animationWorker = null; + +function createAnimationWorker() { + const blob = new Blob([` + let timerId = null; + self.onmessage = function(e) { + if (e.data.cmd === 'start') { + if (timerId) clearTimeout(timerId); + timerId = setTimeout(() => self.postMessage('tick'), e.data.delay); + } else if (e.data.cmd === 'stop') { + if (timerId) { clearTimeout(timerId); timerId = null; } + } + }; + `], { type: 'application/javascript' }); + return new Worker(URL.createObjectURL(blob)); +} + +function scheduleNextFrame(delay) { + if (animationWorker) { + animationWorker.postMessage({ cmd: 'start', delay }); + } +} + +function advanceFrame() { + if (!animationPlaying || !animFrames) return; + animFrames[currentFrameIdx].style.display = 'none'; + currentFrameIdx = (currentFrameIdx + 1) % animFrames.length; + animFrames[currentFrameIdx].style.display = 'block'; + updateFrameCounter(); + scheduleNextFrame(parseInt(animFrames[currentFrameIdx].dataset.duration) || 100); +} function startAnimation(frames) { animFrames = frames; @@ -271,18 +302,15 @@ function startAnimation(frames) { playPauseBtn.innerHTML = '⏸'; updateFrameCounter(); - function nextFrame() { - if (!animationPlaying) return; - frames[currentFrameIdx].style.display = 'none'; - currentFrameIdx = (currentFrameIdx + 1) % frames.length; - frames[currentFrameIdx].style.display = 'block'; - updateFrameCounter(); - animationInterval = setTimeout(nextFrame, parseInt(frames[currentFrameIdx].dataset.duration) || 100); - } - animationInterval = setTimeout(nextFrame, parseInt(frames[0].dataset.duration) || 100); + if (animationWorker) animationWorker.terminate(); + animationWorker = createAnimationWorker(); + animationWorker.onmessage = advanceFrame; + + scheduleNextFrame(parseInt(frames[0].dataset.duration) || 100); } function stopAnimation() { + if (animationWorker) { animationWorker.postMessage({ cmd: 'stop' }); animationWorker.terminate(); animationWorker = null; } if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; } animationPlaying = false; animFrames = null; @@ -298,21 +326,13 @@ playPauseBtn.addEventListener('click', () => { if (animationPlaying) { // Pause animationPlaying = false; - if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; } + if (animationWorker) animationWorker.postMessage({ cmd: 'stop' }); playPauseBtn.innerHTML = '▶'; } else { // Resume animationPlaying = true; playPauseBtn.innerHTML = '⏸'; - function nextFrame() { - if (!animationPlaying || !animFrames) return; - animFrames[currentFrameIdx].style.display = 'none'; - currentFrameIdx = (currentFrameIdx + 1) % animFrames.length; - animFrames[currentFrameIdx].style.display = 'block'; - updateFrameCounter(); - animationInterval = setTimeout(nextFrame, parseInt(animFrames[currentFrameIdx].dataset.duration) || 100); - } - animationInterval = setTimeout(nextFrame, parseInt(animFrames[currentFrameIdx].dataset.duration) || 100); + scheduleNextFrame(parseInt(animFrames[currentFrameIdx].dataset.duration) || 100); } }); diff --git a/static/style.css b/static/style.css index 1f2b89a..13aedc0 100644 --- a/static/style.css +++ b/static/style.css @@ -419,6 +419,14 @@ header { position: relative; } +/* Fixed-width character cells for non-monospace glyphs */ +.preview-area .c { + display: inline-block; + width: 1ch; + text-align: center; + overflow: hidden; +} + .placeholder { position: absolute; top: 50%;