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:
parent
588fe0028c
commit
c1295e8c81
37
render.py
37
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'<span class="c">{escaped}</span>'
|
||||
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'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>')
|
||||
run_chars = [html_escape(char)]
|
||||
run_chars = [escaped]
|
||||
prev_color = color
|
||||
if run_chars:
|
||||
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)
|
||||
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'<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":
|
||||
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
Loading…
Reference in New Issue