Merge server changes: fixed-width glyph cells, Web Worker animation

Incorporates uncommitted server-side improvements:
- VARIABLE_WIDTH_PALETTES + fixed-width <span class="c"> 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-02 15:40:18 -07:00
parent 588fe0028c
commit c1295e8c81
3 changed files with 78 additions and 27 deletions

View File

@ -64,6 +64,15 @@ WIDE_PALETTES = {
"hieroglyph", "cuneiform", "alchemical", "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: def is_wide_palette(name: str) -> bool:
"""Check if a palette uses double-width (emoji/supplementary) characters.""" """Check if a palette uses double-width (emoji/supplementary) characters."""
@ -128,6 +137,7 @@ def _render_frame(
double_width: bool = False, double_width: bool = False,
dither: bool = False, dither: bool = False,
output_format: str = "ansi", output_format: str = "ansi",
fixed_width_cells: bool = False,
) -> str: ) -> str:
"""Render a single prepared (resized RGB) frame to art string.""" """Render a single prepared (resized RGB) frame to art string."""
width, height = img.size width, height = img.size
@ -159,13 +169,18 @@ def _render_frame(
char = chars[idx] char = chars[idx]
if double_width: if double_width:
char = char + " " 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'<span class="c">{escaped}</span>'
color = f"{r},{g},{b}" color = f"{r},{g},{b}"
if color == prev_color: if color == prev_color:
run_chars.append(html_escape(char)) run_chars.append(escaped)
else: else:
if run_chars: if run_chars:
line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>') line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>')
run_chars = [html_escape(char)] run_chars = [escaped]
prev_color = color prev_color = color
if run_chars: if run_chars:
line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>') line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>')
@ -216,7 +231,8 @@ def image_to_art(
wide = is_wide_palette(palette_name) wide = is_wide_palette(palette_name)
img = _prepare_frame(img, width, bg, wide_chars=wide) img = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"]) 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( def gif_to_art(
@ -232,6 +248,7 @@ def gif_to_art(
img = Image.open(image_path) img = Image.open(image_path)
wide = is_wide_palette(palette_name) wide = is_wide_palette(palette_name)
chars = PALETTES.get(palette_name, PALETTES["wingdings"]) chars = PALETTES.get(palette_name, PALETTES["wingdings"])
fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
frames = [] frames = []
frame_list = list(ImageSequence.Iterator(img)) frame_list = list(ImageSequence.Iterator(img))
@ -245,7 +262,7 @@ def gif_to_art(
for frame in frame_list: for frame in frame_list:
duration = frame.info.get("duration", 100) duration = frame.info.get("duration", 100)
prepared = _prepare_frame(frame.copy(), width, bg, wide_chars=wide) 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)}) frames.append({"art": art, "duration": max(duration, 20)})
return frames return frames
@ -264,7 +281,8 @@ def render_from_pil(
wide = is_wide_palette(palette_name) wide = is_wide_palette(palette_name)
prepared = _prepare_frame(img, width, bg, wide_chars=wide) prepared = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"]) 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: def is_animated_gif(image_path: str) -> bool:
@ -428,12 +446,13 @@ def animate_image(
prepared = _prepare_frame(img, width, bg, wide_chars=wide) prepared = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"]) chars = PALETTES.get(palette_name, PALETTES["wingdings"])
effect_fn = _EFFECTS.get(effect, _effect_color_cycle) effect_fn = _EFFECTS.get(effect, _effect_color_cycle)
fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
frames = [] frames = []
for i in range(num_frames): for i in range(num_frames):
t = i / num_frames t = i / num_frames
effected = effect_fn(prepared, t) 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} frame = {"art": art, "duration": frame_duration}
if return_pil: if return_pil:
frame["pil_frame"] = effected frame["pil_frame"] = effected
@ -459,6 +478,7 @@ def generate_pattern(
chars = PALETTES.get(palette_name, PALETTES["wingdings"]) chars = PALETTES.get(palette_name, PALETTES["wingdings"])
num_chars = len(chars) num_chars = len(chars)
fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
# Random parameters for variety # Random parameters for variety
freq1 = rng.uniform(0.02, 0.12) freq1 = rng.uniform(0.02, 0.12)
@ -627,7 +647,10 @@ def generate_pattern(
char = chars[idx] char = chars[idx]
if output_format == "html": if output_format == "html":
line.append(f'<span style="color:rgb({r},{g},{b})">{html_escape(char)}</span>') escaped = html_escape(char)
if fix_cells:
escaped = f'<span class="c">{escaped}</span>'
line.append(f'<span style="color:rgb({r},{g},{b})">{escaped}</span>')
elif output_format == "ansi": elif output_format == "ansi":
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m") line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
else: else:

View File

@ -257,9 +257,40 @@ async function generatePattern() {
randomBtn.disabled = false; randomBtn.disabled = false;
} }
// ── GIF Animation ────────────────────────────── // ── GIF Animation (runs even when tab is inactive) ──────────────────────────────
let animFrames = null; 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) { function startAnimation(frames) {
animFrames = frames; animFrames = frames;
@ -271,18 +302,15 @@ function startAnimation(frames) {
playPauseBtn.innerHTML = '&#x23F8;'; playPauseBtn.innerHTML = '&#x23F8;';
updateFrameCounter(); updateFrameCounter();
function nextFrame() { if (animationWorker) animationWorker.terminate();
if (!animationPlaying) return; animationWorker = createAnimationWorker();
frames[currentFrameIdx].style.display = 'none'; animationWorker.onmessage = advanceFrame;
currentFrameIdx = (currentFrameIdx + 1) % frames.length;
frames[currentFrameIdx].style.display = 'block'; scheduleNextFrame(parseInt(frames[0].dataset.duration) || 100);
updateFrameCounter();
animationInterval = setTimeout(nextFrame, parseInt(frames[currentFrameIdx].dataset.duration) || 100);
}
animationInterval = setTimeout(nextFrame, parseInt(frames[0].dataset.duration) || 100);
} }
function stopAnimation() { function stopAnimation() {
if (animationWorker) { animationWorker.postMessage({ cmd: 'stop' }); animationWorker.terminate(); animationWorker = null; }
if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; } if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; }
animationPlaying = false; animationPlaying = false;
animFrames = null; animFrames = null;
@ -298,21 +326,13 @@ playPauseBtn.addEventListener('click', () => {
if (animationPlaying) { if (animationPlaying) {
// Pause // Pause
animationPlaying = false; animationPlaying = false;
if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; } if (animationWorker) animationWorker.postMessage({ cmd: 'stop' });
playPauseBtn.innerHTML = '&#x25B6;'; playPauseBtn.innerHTML = '&#x25B6;';
} else { } else {
// Resume // Resume
animationPlaying = true; animationPlaying = true;
playPauseBtn.innerHTML = '&#x23F8;'; playPauseBtn.innerHTML = '&#x23F8;';
function nextFrame() { scheduleNextFrame(parseInt(animFrames[currentFrameIdx].dataset.duration) || 100);
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);
} }
}); });

View File

@ -419,6 +419,14 @@ header {
position: relative; position: relative;
} }
/* Fixed-width character cells for non-monospace glyphs */
.preview-area .c {
display: inline-block;
width: 1ch;
text-align: center;
overflow: hidden;
}
.placeholder { .placeholder {
position: absolute; position: absolute;
top: 50%; top: 50%;