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",
|
"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:
|
||||||
|
|
|
||||||
|
|
@ -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 = '⏸';
|
playPauseBtn.innerHTML = '⏸';
|
||||||
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 = '▶';
|
playPauseBtn.innerHTML = '▶';
|
||||||
} else {
|
} else {
|
||||||
// Resume
|
// Resume
|
||||||
animationPlaying = true;
|
animationPlaying = true;
|
||||||
playPauseBtn.innerHTML = '⏸';
|
playPauseBtn.innerHTML = '⏸';
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue