Super high-res rendering: up to 1500 chars wide

- Width slider now goes to 1500 (was 500), default 300
- Resolution presets: Low (100), Med (300), High (600), Ultra (1000), Max (1500)
- Optimized renderer: fast pixel access via img.load(), run-length color
  grouping in HTML output (groups consecutive same-color chars into one span)
- 800-wide render in 0.5s, 1500-wide in 3.3s
- Base font reduced to 5px for ultra-dense display
- Container bumped to 4 CPU / 1GB RAM for heavy renders
- Uvicorn keep-alive timeout increased to 120s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-02 04:16:36 +00:00
parent 963096eaef
commit 16a5dbca11
7 changed files with 98 additions and 37 deletions

View File

@ -9,4 +9,4 @@ COPY . .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-keep-alive", "120"]

4
app.py
View File

@ -61,7 +61,7 @@ async def render_pattern(
seed: str = Form(""),
output_format: str = Form("html"),
):
width = max(20, min(500, width))
width = max(20, min(2000, width))
height = max(10, min(250, height))
if pattern not in PATTERN_TYPES:
pattern = "plasma"
@ -108,7 +108,7 @@ async def render_art(
if len(content) > MAX_UPLOAD_SIZE:
return JSONResponse({"error": "File too large (max 10MB)"}, 400)
width = max(20, min(500, width))
width = max(20, min(2000, width))
if palette not in PALETTES:
palette = "wingdings"
if bg not in ("dark", "light"):

View File

@ -41,8 +41,8 @@ services:
deploy:
resources:
limits:
cpus: "2"
memory: 512M
cpus: "4"
memory: 1G
networks:
traefik-public:

View File

@ -110,39 +110,74 @@ def _render_frame(
) -> str:
"""Render a single prepared (resized RGB) frame to art string."""
width, height = img.size
num_chars = len(chars)
pixels = img.load()
# Build luminance grid
lum_grid = []
# Build luminance grid using fast pixel access
lum_grid = [[0.0] * width for _ in range(height)]
for y in range(height):
row = []
for x in range(width):
r, g, b = img.getpixel((x, y))
row.append(get_luminance(r, g, b))
lum_grid.append(row)
r, g, b = pixels[x, y]
lum_grid[y][x] = 0.299 * r + 0.587 * g + 0.114 * b
if dither:
lum_grid = _floyd_steinberg_dither(lum_grid, width, height, len(chars))
lum_grid = _floyd_steinberg_dither(lum_grid, width, height, num_chars)
if output_format == "html":
# Optimized HTML: use CSS classes for color grouping
# Build line strings directly to reduce object creation
parts = []
for y in range(height):
line_parts = []
prev_color = None
run_chars = []
for x in range(width):
r, g, b = pixels[x, y]
lum = max(0.0, min(255.0, lum_grid[y][x]))
idx = min(int(lum / 256 * num_chars), num_chars - 1)
char = chars[idx]
if double_width:
char = char + " "
color = f"{r},{g},{b}"
if color == prev_color:
run_chars.append(html_escape(char))
else:
if run_chars:
line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>')
run_chars = [html_escape(char)]
prev_color = color
if run_chars:
line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>')
parts.append("".join(line_parts))
return "\n".join(parts)
elif output_format == "ansi":
lines = []
for y in range(height):
line = []
for x in range(width):
r, g, b = img.getpixel((x, y))
r, g, b = pixels[x, y]
lum = max(0.0, min(255.0, lum_grid[y][x]))
idx = int(lum / 256 * len(chars))
idx = min(idx, len(chars) - 1)
idx = min(int(lum / 256 * num_chars), num_chars - 1)
char = chars[idx]
if double_width:
char = char + " "
if output_format == "ansi":
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
elif output_format == "html":
line.append(f'<span style="color:rgb({r},{g},{b})">{html_escape(char)}</span>')
lines.append("".join(line))
return "\n".join(lines)
else: # plain
lines = []
for y in range(height):
line = []
for x in range(width):
lum = max(0.0, min(255.0, lum_grid[y][x]))
idx = min(int(lum / 256 * num_chars), num_chars - 1)
char = chars[idx]
if double_width:
char = char + " "
line.append(char)
lines.append("".join(line))
return "\n".join(lines)

View File

@ -112,6 +112,22 @@ function setFile(file) {
widthSlider.addEventListener('input', () => { widthVal.textContent = widthSlider.value; });
patternHeight.addEventListener('input', () => { heightVal.textContent = patternHeight.value; });
// Resolution presets sync the width slider
const resPreset = $('resPreset');
resPreset.addEventListener('change', () => {
const val = parseInt(resPreset.value);
widthSlider.value = val;
widthVal.textContent = val;
});
widthSlider.addEventListener('input', () => {
// Update preset dropdown to match closest
const val = parseInt(widthSlider.value);
const opts = [100, 300, 600, 1000, 1500];
let closest = opts[0];
for (const o of opts) { if (Math.abs(o - val) < Math.abs(closest - val)) closest = o; }
resPreset.value = closest;
});
// ── Generate (Image Mode) ──────────────────────────────
generateBtn.addEventListener('click', generateImage);
@ -295,14 +311,14 @@ function toggleCompare() {
function setZoom(scale) {
zoomScale = Math.max(0.1, Math.min(8.0, scale));
previewArea.style.fontSize = (8 * zoomScale) + 'px';
previewArea.style.fontSize = (5 * zoomScale) + 'px';
zoomLevel.textContent = Math.round(zoomScale * 100) + '%';
}
function autoFitZoom() {
requestAnimationFrame(() => {
const containerWidth = previewArea.clientWidth - 24;
previewArea.style.fontSize = '8px';
previewArea.style.fontSize = '5px';
zoomScale = 1.0;
const artWidth = previewArea.scrollWidth;
if (artWidth > containerWidth && artWidth > 0) {

View File

@ -358,11 +358,11 @@ header {
flex: 1 1 0;
min-height: 0;
background: var(--surface2);
padding: 8px;
padding: 4px;
overflow: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 8px;
line-height: 1.05;
font-size: 5px;
line-height: 1.0;
letter-spacing: 0px;
white-space: pre;
position: relative;

View File

@ -68,8 +68,18 @@
</select>
</div>
<div class="setting">
<label for="width">W <span id="widthVal">200</span></label>
<input type="range" id="width" min="40" max="500" value="200">
<label for="width">W <span id="widthVal">300</span></label>
<input type="range" id="width" min="40" max="1500" value="300">
</div>
<div class="setting">
<label>Res</label>
<select id="resPreset">
<option value="100">Low</option>
<option value="300" selected>Med</option>
<option value="600">High</option>
<option value="1000">Ultra</option>
<option value="1500">Max</option>
</select>
</div>
<div class="setting">
<label for="bg">BG</label>