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:
parent
963096eaef
commit
16a5dbca11
|
|
@ -9,4 +9,4 @@ COPY . .
|
||||||
|
|
||||||
EXPOSE 8000
|
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
4
app.py
|
|
@ -61,7 +61,7 @@ async def render_pattern(
|
||||||
seed: str = Form(""),
|
seed: str = Form(""),
|
||||||
output_format: str = Form("html"),
|
output_format: str = Form("html"),
|
||||||
):
|
):
|
||||||
width = max(20, min(500, width))
|
width = max(20, min(2000, width))
|
||||||
height = max(10, min(250, height))
|
height = max(10, min(250, height))
|
||||||
if pattern not in PATTERN_TYPES:
|
if pattern not in PATTERN_TYPES:
|
||||||
pattern = "plasma"
|
pattern = "plasma"
|
||||||
|
|
@ -108,7 +108,7 @@ async def render_art(
|
||||||
if len(content) > MAX_UPLOAD_SIZE:
|
if len(content) > MAX_UPLOAD_SIZE:
|
||||||
return JSONResponse({"error": "File too large (max 10MB)"}, 400)
|
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:
|
if palette not in PALETTES:
|
||||||
palette = "wingdings"
|
palette = "wingdings"
|
||||||
if bg not in ("dark", "light"):
|
if bg not in ("dark", "light"):
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ services:
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: "2"
|
cpus: "4"
|
||||||
memory: 512M
|
memory: 1G
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
|
|
|
||||||
85
render.py
85
render.py
|
|
@ -110,40 +110,75 @@ def _render_frame(
|
||||||
) -> 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
|
||||||
|
num_chars = len(chars)
|
||||||
|
pixels = img.load()
|
||||||
|
|
||||||
# Build luminance grid
|
# Build luminance grid using fast pixel access
|
||||||
lum_grid = []
|
lum_grid = [[0.0] * width for _ in range(height)]
|
||||||
for y in range(height):
|
for y in range(height):
|
||||||
row = []
|
|
||||||
for x in range(width):
|
for x in range(width):
|
||||||
r, g, b = img.getpixel((x, y))
|
r, g, b = pixels[x, y]
|
||||||
row.append(get_luminance(r, g, b))
|
lum_grid[y][x] = 0.299 * r + 0.587 * g + 0.114 * b
|
||||||
lum_grid.append(row)
|
|
||||||
|
|
||||||
if dither:
|
if dither:
|
||||||
lum_grid = _floyd_steinberg_dither(lum_grid, width, height, len(chars))
|
lum_grid = _floyd_steinberg_dither(lum_grid, width, height, num_chars)
|
||||||
|
|
||||||
lines = []
|
if output_format == "html":
|
||||||
for y in range(height):
|
# Optimized HTML: use CSS classes for color grouping
|
||||||
line = []
|
# Build line strings directly to reduce object creation
|
||||||
for x in range(width):
|
parts = []
|
||||||
r, g, b = img.getpixel((x, y))
|
for y in range(height):
|
||||||
lum = max(0.0, min(255.0, lum_grid[y][x]))
|
line_parts = []
|
||||||
idx = int(lum / 256 * len(chars))
|
prev_color = None
|
||||||
idx = min(idx, len(chars) - 1)
|
run_chars = []
|
||||||
char = chars[idx]
|
for x in range(width):
|
||||||
if double_width:
|
r, g, b = pixels[x, y]
|
||||||
char = char + " "
|
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)
|
||||||
|
|
||||||
if output_format == "ansi":
|
elif output_format == "ansi":
|
||||||
|
lines = []
|
||||||
|
for y in range(height):
|
||||||
|
line = []
|
||||||
|
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 + " "
|
||||||
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")
|
||||||
elif output_format == "html":
|
lines.append("".join(line))
|
||||||
line.append(f'<span style="color:rgb({r},{g},{b})">{html_escape(char)}</span>')
|
return "\n".join(lines)
|
||||||
else: # plain
|
|
||||||
line.append(char)
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
def image_to_art(
|
def image_to_art(
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,22 @@ function setFile(file) {
|
||||||
widthSlider.addEventListener('input', () => { widthVal.textContent = widthSlider.value; });
|
widthSlider.addEventListener('input', () => { widthVal.textContent = widthSlider.value; });
|
||||||
patternHeight.addEventListener('input', () => { heightVal.textContent = patternHeight.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) ──────────────────────────────
|
// ── Generate (Image Mode) ──────────────────────────────
|
||||||
|
|
||||||
generateBtn.addEventListener('click', generateImage);
|
generateBtn.addEventListener('click', generateImage);
|
||||||
|
|
@ -295,14 +311,14 @@ function toggleCompare() {
|
||||||
|
|
||||||
function setZoom(scale) {
|
function setZoom(scale) {
|
||||||
zoomScale = Math.max(0.1, Math.min(8.0, 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) + '%';
|
zoomLevel.textContent = Math.round(zoomScale * 100) + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoFitZoom() {
|
function autoFitZoom() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const containerWidth = previewArea.clientWidth - 24;
|
const containerWidth = previewArea.clientWidth - 24;
|
||||||
previewArea.style.fontSize = '8px';
|
previewArea.style.fontSize = '5px';
|
||||||
zoomScale = 1.0;
|
zoomScale = 1.0;
|
||||||
const artWidth = previewArea.scrollWidth;
|
const artWidth = previewArea.scrollWidth;
|
||||||
if (artWidth > containerWidth && artWidth > 0) {
|
if (artWidth > containerWidth && artWidth > 0) {
|
||||||
|
|
|
||||||
|
|
@ -358,11 +358,11 @@ header {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
padding: 8px;
|
padding: 4px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 8px;
|
font-size: 5px;
|
||||||
line-height: 1.05;
|
line-height: 1.0;
|
||||||
letter-spacing: 0px;
|
letter-spacing: 0px;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,18 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting">
|
<div class="setting">
|
||||||
<label for="width">W <span id="widthVal">200</span></label>
|
<label for="width">W <span id="widthVal">300</span></label>
|
||||||
<input type="range" id="width" min="40" max="500" value="200">
|
<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>
|
||||||
<div class="setting">
|
<div class="setting">
|
||||||
<label for="bg">BG</label>
|
<label for="bg">BG</label>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue