Initial release: ASCII Art Generator

- Image to colorful Unicode art with 20 palettes (wingdings, braille, hires, dots, kanji, etc.)
- GIF animation support (frame-by-frame rendering)
- Floyd-Steinberg dithering for fine detail
- FastAPI web app with drag-drop upload, zoom controls, fullscreen, original image comparison
- Width up to 500 characters for high-resolution output
- Docker deployment with Traefik routing at ascii.jeffemmett.com
- rSpace integration as rcreate app (#30)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-02 01:43:17 +00:00
commit ebd4b12628
9 changed files with 1282 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__/

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

131
app.py Normal file
View File

@ -0,0 +1,131 @@
"""ASCII Art Generator - FastAPI Web Application."""
import tempfile
import os
from pathlib import Path
from fastapi import FastAPI, File, Form, UploadFile, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from render import (
PALETTES, image_to_art, gif_to_art, is_animated_gif,
)
app = FastAPI(title="ASCII Art Generator", docs_url=None, redoc_url=None)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
BASE_DIR = Path(__file__).parent
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=BASE_DIR / "templates")
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"}
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {
"request": request,
"palettes": list(PALETTES.keys()),
"palette_previews": {k: v[:12] for k, v in PALETTES.items()},
})
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/api/internal/provision")
async def provision_space(request: Request):
"""rSpace provisioning webhook — stateless app, just acknowledge."""
body = await request.json()
space = body.get("space", "unknown")
return {"status": "ok", "space": space, "message": f"rcreate space '{space}' ready"}
@app.get("/api/palettes")
async def get_palettes():
return {
"palettes": [
{"name": k, "chars": v, "preview": v[:12]}
for k, v in PALETTES.items()
]
}
@app.post("/api/render")
async def render_art(
file: UploadFile = File(...),
width: int = Form(80),
palette: str = Form("wingdings"),
bg: str = Form("dark"),
dither: str = Form("false"),
double_width: str = Form("false"),
output_format: str = Form("html"),
):
# JS sends booleans as strings
dither = dither.lower() in ("true", "1", "yes", "on")
double_width = double_width.lower() in ("true", "1", "yes", "on")
if file.content_type not in ALLOWED_TYPES:
return JSONResponse({"error": f"Unsupported file type: {file.content_type}"}, 400)
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
return JSONResponse({"error": "File too large (max 10MB)"}, 400)
width = max(20, min(500, width))
if palette not in PALETTES:
palette = "wingdings"
if bg not in ("dark", "light"):
bg = "dark"
if output_format not in ("html", "ansi", "plain", "json"):
output_format = "html"
suffix = Path(file.filename or "img.png").suffix
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
animated = is_animated_gif(tmp_path)
if animated:
frames = gif_to_art(
tmp_path, width=width, palette_name=palette,
bg=bg, double_width=double_width, dither=dither,
output_format=output_format if output_format != "json" else "html",
)
if output_format == "json":
return JSONResponse({
"animated": True,
"frames": frames,
"frame_count": len(frames),
})
# For HTML output, wrap each frame
frame_divs = []
for i, f in enumerate(frames):
display = "block" if i == 0 else "none"
frame_divs.append(
f'<div class="art-frame" data-duration="{f["duration"]}" '
f'style="display:{display}">{f["art"]}</div>'
)
html = "\n".join(frame_divs)
return HTMLResponse(html)
else:
art = image_to_art(
tmp_path, width=width, palette_name=palette,
bg=bg, double_width=double_width, dither=dither,
output_format=output_format if output_format != "json" else "html",
)
if output_format == "json":
return JSONResponse({"animated": False, "art": art})
elif output_format == "plain":
return PlainTextResponse(art)
else:
return HTMLResponse(art)
finally:
os.unlink(tmp_path)

49
docker-compose.yml Normal file
View File

@ -0,0 +1,49 @@
services:
ascii-art:
container_name: ascii-art
build: .
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
networks:
- traefik-public
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
# Primary domain
- traefik.http.routers.ascii-art.rule=Host(`ascii.jeffemmett.com`)
- traefik.http.routers.ascii-art.entrypoints=web
- traefik.http.routers.ascii-art.priority=130
- traefik.http.routers.ascii-art.service=ascii-art
# rSpace: root domain
- traefik.http.routers.rcreate.rule=Host(`rcreate.online`) || Host(`www.rcreate.online`)
- traefik.http.routers.rcreate.entrypoints=web
- traefik.http.routers.rcreate.priority=130
- traefik.http.routers.rcreate.service=ascii-art
# rSpace: wildcard subdomains ({space}.rcreate.online)
- traefik.http.routers.rcreate-wildcard.rule=HostRegexp(`{sub:[a-z0-9-]+}.rcreate.online`)
- traefik.http.routers.rcreate-wildcard.entrypoints=web
- traefik.http.routers.rcreate-wildcard.priority=100
- traefik.http.routers.rcreate-wildcard.service=ascii-art
# Service port
- traefik.http.services.ascii-art.loadbalancer.server.port=8000
healthcheck:
test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/health\")'"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: "2"
memory: 512M
networks:
traefik-public:
external: true

244
render.py Normal file
View File

@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Colorful Unicode/Wingdings ASCII Art Renderer
Converts images and GIFs to terminal art using various character sets
with true-color ANSI output, Floyd-Steinberg dithering, and multiple output formats.
"""
import argparse
from html import escape as html_escape
from PIL import Image, ImageSequence
# ── Character palettes sorted dark → light ──────────────────────────────
PALETTES = {
"wingdings": "♠♣♦♥✦✧◆◇○●◐◑▲△▼▽★☆✪✫✿❀❁❃❋✾✽❖☀☁☂☃✈♛♚♞♜⚡⚛⚙",
"zodiac": "♈♉♊♋♌♍♎♏♐♑♒♓⛎☉☽☿♀♁♂♃♄♅♆⚳⚴⚵⚶⚷",
"chess": "♔♕♖♗♘♙♚♛♜♝♞♟⬛⬜◼◻▪▫",
"arrows": "↖↗↘↙⇐⇑⇒⇓⟵⟶⟷↺↻⤴⤵↯↮↭↬↫",
"music": "♩♪♫♬♭♮♯𝄞𝄡𝄢𝅗𝅥𝅘𝅥𝅘𝅥𝅮𝅘𝅥𝅯𝅘𝅥𝅰",
"braille": "⠀⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿⡀⡁⡂⡃⡄⡅⡆⡇⣀⣠⣤⣴⣶⣾⣿",
"blocks": " ░▒▓█▀▄▌▐▖▗▘▙▚▛▜▝▞▟",
"emoji": "🌑🌒🌓🌔🌕✨💫⭐🌟💎🔮🔥💧🌊🌿🍀🌸🌺🌻🎭🎪",
"cosmic": "·∙∘○◌◯◎●◉⊙⊚⊛⊜⊝◐◑◒◓◔◕⦿✪★✦✧❂☀☼",
"mystic": "·‥…∴∵∶∷∸∹∺⊹✧✦★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋",
"runes": "ᚠᚡᚢᚣᚤᚥᚦᚧᚨᚩᚪᚫᚬᚭᚮᚯᚰᚱᚲᚳᚴᚵᚶᚷᚸᚹᚺᚻᚼᚽᚾᚿᛀᛁᛂᛃᛄᛅᛆᛇᛈᛉᛊᛋᛌᛍ",
"dense": " .·:;+*#%@█",
"classic": " .:-=+*#%@",
# ── High-resolution palettes (70+ chars for fine gradation) ──
"hires": " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
"ultra": " .·:;'\"^~-_+<>!?|/\\()[]{}1iltfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$█",
"dots": "⠀⠁⠈⠐⠠⡀⢀⠃⠅⠆⠉⠊⠌⠑⠒⠔⠘⠡⠢⠤⠨⠰⡁⡂⡄⡈⡐⡠⢁⢂⢄⢈⢐⢠⣀⠇⠋⠍⠎⠓⠕⠖⠙⠚⠜⠣⠥⠦⠩⠪⠬⠱⠲⠴⠸⡃⡅⡆⡉⡊⡌⡑⡒⡔⡘⡡⡢⡤⡨⡰⢃⢅⢆⢉⢊⢌⢑⢒⢔⢘⢡⢢⢤⢨⢰⣁⣂⣄⣈⣐⣠⠏⠗⠛⠝⠞⠧⠫⠭⠮⠳⠵⠶⠹⠺⠼⡇⡋⡍⡎⡓⡕⡖⡙⡚⡜⡣⡥⡦⡩⡪⡬⡱⡲⡴⡸⢇⢋⢍⢎⢓⢕⢖⢙⢚⢜⢣⢥⢦⢩⢪⢬⢱⢲⢴⢸⣃⣅⣆⣉⣊⣌⣑⣒⣔⣘⣡⣢⣤⣨⣰⠟⠯⠷⠻⠽⠾⡏⡗⡛⡝⡞⡧⡫⡭⡮⡳⡵⡶⡹⡺⡼⢏⢗⢛⢝⢞⢧⢫⢭⢮⢳⢵⢶⢹⢺⢼⣇⣋⣍⣎⣓⣕⣖⣙⣚⣜⣣⣥⣦⣩⣪⣬⣱⣲⣴⣸⠿⡟⡯⡷⡻⡽⡾⢟⢯⢷⢻⢽⢾⣏⣗⣛⣝⣞⣧⣫⣭⣮⣳⣵⣶⣹⣺⣼⡿⢿⣟⣯⣷⣻⣽⣾⣿",
"shades": " ░░▒▒▓▓██",
"geometric": " .·˙∙•●○◌◦◯⊙⊚◐◑◒◓◔◕◖◗◍◎◉⦿⊛⊜⊝✦✧★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋",
"kanji": " 一二三四五六七八九十百千万丈与世中丸主乃久乗乙九了事二人仁今仏仕他付代令以仮仰仲件任企伏伐休会伝似位低住佐体何余作佳使例侍供依価侮侯侵便係促俊俗保信修俳俵俸倉個倍倒候借値倫倹偉偏停健側偵偶傍傑傘備催債傷傾僅働像僕僚僧儀億儒元兄充兆先光克免児入全",
}
MAX_GIF_FRAMES = 60
def get_luminance(r: int, g: int, b: int) -> float:
"""Perceived luminance (0-255)."""
return 0.299 * r + 0.587 * g + 0.114 * b
def _prepare_frame(img: Image.Image, width: int, bg: str) -> Image.Image:
"""Composite alpha onto background and resize for rendering."""
img = img.convert("RGBA")
bg_color = (0, 0, 0, 255) if bg == "dark" else (255, 255, 255, 255)
background = Image.new("RGBA", img.size, bg_color)
background.paste(img, mask=img.split()[3])
img = background.convert("RGB")
aspect = img.height / img.width
height = max(1, int(width * aspect * 0.45))
return img.resize((width, height), Image.LANCZOS)
def _floyd_steinberg_dither(pixels: list[list[float]], width: int, height: int, levels: int) -> list[list[float]]:
"""Apply Floyd-Steinberg dithering to a 2D luminance array."""
step = 255.0 / max(levels - 1, 1)
for y in range(height):
for x in range(width):
old = pixels[y][x]
new = round(old / step) * step
new = max(0.0, min(255.0, new))
pixels[y][x] = new
err = old - new
if x + 1 < width:
pixels[y][x + 1] += err * 7 / 16
if y + 1 < height:
if x - 1 >= 0:
pixels[y + 1][x - 1] += err * 3 / 16
pixels[y + 1][x] += err * 5 / 16
if x + 1 < width:
pixels[y + 1][x + 1] += err * 1 / 16
return pixels
def _render_frame(
img: Image.Image,
chars: str,
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
) -> str:
"""Render a single prepared (resized RGB) frame to art string."""
width, height = img.size
# Build luminance grid
lum_grid = []
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)
if dither:
lum_grid = _floyd_steinberg_dither(lum_grid, width, height, len(chars))
lines = []
for y in range(height):
line = []
for x in range(width):
r, g, b = img.getpixel((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)
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>')
else: # plain
line.append(char)
lines.append("".join(line))
return "\n".join(lines)
def image_to_art(
image_path: str,
width: int = 80,
palette_name: str = "wingdings",
bg: str = "dark",
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
) -> str:
"""Convert a static image to colorful Unicode art."""
img = Image.open(image_path)
img = _prepare_frame(img, width, bg)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
return _render_frame(img, chars, double_width, dither, output_format)
def gif_to_art(
image_path: str,
width: int = 80,
palette_name: str = "wingdings",
bg: str = "dark",
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
) -> list[dict]:
"""Convert an animated GIF to a list of frame dicts with art and duration."""
img = Image.open(image_path)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
frames = []
frame_list = list(ImageSequence.Iterator(img))
# Sample evenly if too many frames
if len(frame_list) > MAX_GIF_FRAMES:
step = len(frame_list) / MAX_GIF_FRAMES
indices = [int(i * step) for i in range(MAX_GIF_FRAMES)]
frame_list = [frame_list[i] for i in indices]
for frame in frame_list:
duration = frame.info.get("duration", 100)
prepared = _prepare_frame(frame.copy(), width, bg)
art = _render_frame(prepared, chars, double_width, dither, output_format)
frames.append({"art": art, "duration": max(duration, 20)})
return frames
def render_from_pil(
img: Image.Image,
width: int = 80,
palette_name: str = "wingdings",
bg: str = "dark",
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
) -> str:
"""Render from a PIL Image object directly."""
prepared = _prepare_frame(img, width, bg)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
return _render_frame(prepared, chars, double_width, dither, output_format)
def is_animated_gif(image_path: str) -> bool:
"""Check if a file is an animated GIF."""
try:
img = Image.open(image_path)
return getattr(img, "is_animated", False)
except Exception:
return False
def render_demo(image_path: str, width: int = 70):
"""Render the same image in multiple palettes for comparison."""
from rich.console import Console
from rich.panel import Panel
console = Console(force_terminal=True, color_system="truecolor", width=width + 10)
demos = ["wingdings", "cosmic", "braille", "runes", "mystic", "blocks"]
for pal in demos:
art = image_to_art(image_path, width=width, palette_name=pal)
console.print(Panel(
art,
title=f"[bold bright_cyan]{pal}[/]",
border_style="bright_blue",
width=width + 4,
))
console.print()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Image to colorful Unicode art")
parser.add_argument("image", help="Path to image file")
parser.add_argument("-w", "--width", type=int, default=80, help="Output width in chars")
parser.add_argument("-p", "--palette", default="wingdings", choices=list(PALETTES.keys()),
help="Character palette to use")
parser.add_argument("--bg", default="dark", choices=["dark", "light"],
help="Background color assumption")
parser.add_argument("--double", action="store_true", help="Double-width characters")
parser.add_argument("--dither", action="store_true", help="Enable Floyd-Steinberg dithering")
parser.add_argument("--format", default="ansi", choices=["ansi", "html", "plain"],
help="Output format")
parser.add_argument("--demo", action="store_true", help="Render in all palettes")
args = parser.parse_args()
if args.demo:
render_demo(args.image, args.width)
elif is_animated_gif(args.image):
frames = gif_to_art(args.image, args.width, args.palette, args.bg,
args.double, args.dither, args.format)
import time
try:
while True:
for frame in frames:
print("\033[H\033[J" + frame["art"], flush=True)
time.sleep(frame["duration"] / 1000)
except KeyboardInterrupt:
pass
else:
print(image_to_art(args.image, args.width, args.palette, args.bg,
args.double, args.dither, args.format))

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
python-multipart==0.0.20
Pillow==11.2.1
Jinja2==3.1.6

301
static/app.js Normal file
View File

@ -0,0 +1,301 @@
/* ASCII Art Generator - Frontend */
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const thumbPreview = document.getElementById('thumbPreview');
const fileName = document.getElementById('fileName');
const clearFile = document.getElementById('clearFile');
const generateBtn = document.getElementById('generateBtn');
const previewArea = document.getElementById('previewArea');
const spinner = document.getElementById('spinner');
const copyBtn = document.getElementById('copyBtn');
const downloadBtn = document.getElementById('downloadBtn');
const gifIndicator = document.getElementById('gifIndicator');
const widthSlider = document.getElementById('width');
const widthVal = document.getElementById('widthVal');
let currentFile = null;
let animationInterval = null;
let lastRenderedHtml = '';
let zoomScale = 1.0;
const zoomIn = document.getElementById('zoomIn');
const zoomOut = document.getElementById('zoomOut');
const zoomFit = document.getElementById('zoomFit');
const zoomLevel = document.getElementById('zoomLevel');
const fullscreenBtn = document.getElementById('fullscreenBtn');
const previewContainer = document.querySelector('.preview-container');
const compareBox = document.getElementById('compareBox');
const compareImg = document.getElementById('compareImg');
const compareToggle = document.getElementById('compareToggle');
// ── File Handling ──────────────────────────────
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) setFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) setFile(fileInput.files[0]);
});
clearFile.addEventListener('click', () => {
currentFile = null;
fileInput.value = '';
fileInfo.style.display = 'none';
dropZone.style.display = '';
generateBtn.disabled = true;
});
function setFile(file) {
const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowed.includes(file.type)) {
alert('Please upload a PNG, JPG, GIF, or WebP image.');
return;
}
if (file.size > 10 * 1024 * 1024) {
alert('File too large (max 10MB).');
return;
}
currentFile = file;
fileName.textContent = file.name;
thumbPreview.src = URL.createObjectURL(file);
dropZone.style.display = 'none';
fileInfo.style.display = 'flex';
generateBtn.disabled = false;
}
// ── Width Slider ──────────────────────────────
widthSlider.addEventListener('input', () => {
widthVal.textContent = widthSlider.value;
});
// ── Generate ──────────────────────────────
generateBtn.addEventListener('click', generate);
async function generate() {
if (!currentFile) return;
stopAnimation();
previewArea.innerHTML = '';
spinner.style.display = 'flex';
generateBtn.disabled = true;
copyBtn.style.display = 'none';
downloadBtn.style.display = 'none';
gifIndicator.style.display = 'none';
const formData = new FormData();
formData.append('file', currentFile);
formData.append('width', widthSlider.value);
formData.append('palette', document.getElementById('palette').value);
formData.append('bg', document.getElementById('bg').value);
formData.append('dither', document.getElementById('dither').checked);
formData.append('double_width', document.getElementById('doubleWidth').checked);
formData.append('output_format', 'html');
try {
const resp = await fetch('/api/render', { method: 'POST', body: formData });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: 'Request failed' }));
throw new Error(err.error || `HTTP ${resp.status}`);
}
const html = await resp.text();
lastRenderedHtml = html;
spinner.style.display = 'none';
previewArea.innerHTML = html;
// Check if animated (multiple frames)
const frames = previewArea.querySelectorAll('.art-frame');
if (frames.length > 1) {
gifIndicator.style.display = '';
startAnimation(frames);
}
copyBtn.style.display = '';
downloadBtn.style.display = '';
// Show original image for comparison
compareImg.src = URL.createObjectURL(currentFile);
compareBox.style.display = '';
compareBox.classList.remove('minimized');
compareToggle.innerHTML = '&#x25BC;';
// Auto-fit to preview area
requestAnimationFrame(() => {
const containerWidth = previewArea.clientWidth - 24;
const artWidth = previewArea.scrollWidth;
if (artWidth > containerWidth) {
setZoom(containerWidth / artWidth);
}
});
} catch (err) {
spinner.style.display = 'none';
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
}
generateBtn.disabled = false;
}
// ── GIF Animation ──────────────────────────────
function startAnimation(frames) {
let current = 0;
frames[0].style.display = 'block';
function nextFrame() {
frames[current].style.display = 'none';
current = (current + 1) % frames.length;
frames[current].style.display = 'block';
const duration = parseInt(frames[current].dataset.duration) || 100;
animationInterval = setTimeout(nextFrame, duration);
}
const duration = parseInt(frames[0].dataset.duration) || 100;
animationInterval = setTimeout(nextFrame, duration);
}
function stopAnimation() {
if (animationInterval) {
clearTimeout(animationInterval);
animationInterval = null;
}
}
// ── Copy & Download ──────────────────────────────
copyBtn.addEventListener('click', async () => {
// Re-render as plain text for clipboard
if (!currentFile) return;
const formData = new FormData();
formData.append('file', currentFile);
formData.append('width', widthSlider.value);
formData.append('palette', document.getElementById('palette').value);
formData.append('bg', document.getElementById('bg').value);
formData.append('dither', document.getElementById('dither').checked);
formData.append('double_width', document.getElementById('doubleWidth').checked);
formData.append('output_format', 'plain');
try {
const resp = await fetch('/api/render', { method: 'POST', body: formData });
const text = await resp.text();
await navigator.clipboard.writeText(text);
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
} catch {
copyBtn.textContent = 'Failed';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
}
});
downloadBtn.addEventListener('click', () => {
const wrapper = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><style>
body{background:#0d1117;margin:20px;display:flex;justify-content:center}
pre{font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.15;letter-spacing:0.5px}
</style></head><body><pre>${lastRenderedHtml}</pre></body></html>`;
const blob = new Blob([wrapper], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ascii-art.html';
a.click();
URL.revokeObjectURL(url);
});
// ── Compare Box ──────────────────────────────
compareToggle.addEventListener('click', e => {
e.stopPropagation();
compareBox.classList.toggle('minimized');
compareToggle.innerHTML = compareBox.classList.contains('minimized') ? '&#x25B2;' : '&#x25BC;';
});
document.querySelector('.compare-header').addEventListener('click', () => {
compareBox.classList.toggle('minimized');
compareToggle.innerHTML = compareBox.classList.contains('minimized') ? '&#x25B2;' : '&#x25BC;';
});
// ── Zoom ──────────────────────────────
function setZoom(scale) {
zoomScale = Math.max(0.25, Math.min(5.0, scale));
previewArea.style.fontSize = (8 * zoomScale) + 'px';
zoomLevel.textContent = Math.round(zoomScale * 100) + '%';
}
zoomIn.addEventListener('click', () => setZoom(zoomScale + 0.15));
zoomOut.addEventListener('click', () => setZoom(zoomScale - 0.15));
zoomFit.addEventListener('click', () => {
// Measure art content width vs container width
const containerWidth = previewArea.clientWidth - 32; // padding
// Temporarily reset to measure natural width
previewArea.style.fontSize = '8px';
const artWidth = previewArea.scrollWidth;
if (artWidth > 0) {
const fitScale = containerWidth / artWidth;
setZoom(Math.min(fitScale, 2.0)); // cap at 200%
} else {
setZoom(1.0);
}
});
// Ctrl+scroll to zoom
previewArea.addEventListener('wheel', e => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom(zoomScale + delta);
}
}, { passive: false });
// ── Fullscreen ──────────────────────────────
fullscreenBtn.addEventListener('click', toggleFullscreen);
function toggleFullscreen() {
previewContainer.classList.toggle('fullscreen');
if (previewContainer.classList.contains('fullscreen')) {
fullscreenBtn.textContent = '\u2716'; // ✖
fullscreenBtn.title = 'Exit fullscreen (Esc)';
} else {
fullscreenBtn.textContent = '\u26F6'; // ⛶
fullscreenBtn.title = 'Fullscreen (F)';
}
}
// ── Keyboard Shortcuts ──────────────────────────────
document.addEventListener('keydown', e => {
// Don't capture when typing in inputs
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === '+' || e.key === '=') { setZoom(zoomScale + 0.15); e.preventDefault(); }
if (e.key === '-') { setZoom(zoomScale - 0.15); e.preventDefault(); }
if (e.key === '0') { setZoom(1.0); e.preventDefault(); }
if (e.key === 'f' || e.key === 'F') { toggleFullscreen(); e.preventDefault(); }
if (e.key === 'Escape' && previewContainer.classList.contains('fullscreen')) {
toggleFullscreen();
e.preventDefault();
}
});

435
static/style.css Normal file
View File

@ -0,0 +1,435 @@
/* ── ASCII Art Generator ─────────────────────────────── */
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2333;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #00ddff;
--accent2: #bf5af2;
--accent3: #30d158;
--danger: #ff453a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header (minimal) ─────────────────────────────── */
header {
display: none;
}
/* ── Controls Bar (horizontal) ─────────────────────────────── */
.controls {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-wrap: nowrap;
}
/* ── Upload Zone ─────────────────────────────── */
.upload-zone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 8px 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: var(--surface2);
min-width: 140px;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--accent);
background: rgba(0, 221, 255, 0.05);
}
.upload-icon { font-size: 20px; margin-right: 4px; display: inline; }
.upload-zone p { font-size: 12px; color: var(--text-dim); display: inline; }
.upload-hint { display: none; }
.browse-link {
color: var(--accent);
cursor: pointer;
text-decoration: underline;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: var(--surface2);
border-radius: 6px;
border: 1px solid var(--border);
}
.thumb {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 4px;
}
.file-info div {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text);
}
.file-info span {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Settings (inline) ─────────────────────────────── */
.settings-grid {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.setting {
display: flex;
align-items: center;
gap: 4px;
}
.setting label {
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.3px;
white-space: nowrap;
}
.setting select {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 4px 6px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
}
.setting select:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.setting input[type="range"] {
-webkit-appearance: none;
width: 100px;
height: 4px;
border-radius: 2px;
background: var(--border);
cursor: pointer;
}
.setting input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
.checkbox-group {
gap: 10px !important;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
text-transform: none;
cursor: pointer;
white-space: nowrap;
}
.checkbox-group input[type="checkbox"] {
accent-color: var(--accent);
width: 14px;
height: 14px;
}
/* ── Buttons ─────────────────────────────── */
.btn-generate {
padding: 6px 20px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
transition: opacity 0.2s, transform 0.1s;
white-space: nowrap;
}
.btn-generate:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.btn-generate:disabled { opacity: 0.3; cursor: not-allowed; }
.btn-small {
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-dim);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.btn-small:hover { border-color: var(--accent); color: var(--accent); }
/* ── Preview (fills remaining space) ─────────────────────────────── */
.preview-wrapper {
flex: 1 1 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.preview-container {
flex: 1 1 0;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
overflow: hidden;
}
.preview-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.preview-title {
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preview-actions { display: flex; gap: 6px; align-items: center; }
.zoom-controls {
display: flex;
align-items: center;
gap: 3px;
margin-right: 6px;
padding-right: 6px;
border-right: 1px solid var(--border);
}
.zoom-controls button {
width: 24px;
height: 24px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
padding: 0;
}
.zoom-controls button:hover { border-color: var(--accent); color: var(--accent); }
.zoom-level {
font-size: 10px;
color: var(--text-dim);
min-width: 32px;
text-align: center;
font-family: 'JetBrains Mono', monospace;
}
.btn-fullscreen {
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btn-fullscreen:hover { border-color: var(--accent); color: var(--accent); }
.gif-badge {
font-size: 10px;
font-weight: 700;
color: var(--accent3);
background: rgba(48, 209, 88, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
.preview-area {
flex: 1 1 0;
min-height: 0;
background: var(--surface2);
padding: 8px;
overflow: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 8px;
line-height: 1.05;
letter-spacing: 0px;
white-space: pre;
position: relative;
}
.placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-dim);
font-family: -apple-system, sans-serif;
font-size: 14px;
}
/* ── Compare Box ─────────────────────────────── */
.compare-box {
position: absolute;
bottom: 12px;
right: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
z-index: 10;
transition: all 0.2s;
max-width: 240px;
}
.compare-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
font-size: 10px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
}
.compare-header button {
background: none;
border: none;
color: var(--text-dim);
font-size: 9px;
cursor: pointer;
padding: 2px;
}
.compare-img {
display: block;
width: 100%;
max-height: 180px;
object-fit: contain;
background: #000;
}
.compare-box.minimized { max-width: 80px; }
.compare-box.minimized .compare-img { display: none; }
.compare-box.minimized .compare-header { border-bottom: none; }
/* ── Spinner ─────────────────────────────── */
.spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--accent);
font-size: 13px;
z-index: 5;
}
.spin-anim {
width: 28px;
height: 28px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Footer (hidden to maximize space) ─────────────────────────────── */
footer { display: none; }
/* ── Fullscreen mode ─────────────────────────────── */
.preview-container.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
.preview-container.fullscreen .preview-area { border-radius: 0; }
.preview-container.fullscreen .preview-bar { border-radius: 0; }
/* ── Responsive ─────────────────────────────── */
@media (max-width: 768px) {
.controls { flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
.setting input[type="range"] { width: 70px; }
}

104
templates/index.html Normal file
View File

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASCII Art Generator</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>
<header>
<h1><span class="glyph"></span> ASCII Art Generator <span class="glyph"></span></h1>
</header>
<!-- Controls Bar -->
<div class="controls">
<div class="upload-zone" id="dropZone">
<span class="upload-icon">&#x1F3A8;</span>
<p><label for="fileInput" class="browse-link">Upload image</label></p>
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp" hidden>
</div>
<div id="fileInfo" class="file-info" style="display:none">
<img id="thumbPreview" class="thumb">
<div>
<span id="fileName"></span>
<button id="clearFile" class="btn-small" title="Clear">&times;</button>
</div>
</div>
<div class="settings-grid">
<div class="setting">
<label for="palette">Palette</label>
<select id="palette">
{% for name in palettes %}
<option value="{{ name }}" {{ 'selected' if name == 'hires' else '' }}>
{{ name }} &nbsp; {{ palette_previews[name] }}
</option>
{% endfor %}
</select>
</div>
<div class="setting">
<label for="width">Width <span id="widthVal">200</span></label>
<input type="range" id="width" min="40" max="500" value="200">
</div>
<div class="setting">
<label for="bg">BG</label>
<select id="bg">
<option value="dark" selected>Dark</option>
<option value="light">Light</option>
</select>
</div>
<div class="setting checkbox-group">
<label><input type="checkbox" id="dither"> Dither</label>
<label><input type="checkbox" id="doubleWidth"> 2x</label>
</div>
</div>
<button id="generateBtn" class="btn-generate" disabled>Generate</button>
</div>
<!-- Preview (fills viewport) -->
<div class="preview-wrapper">
<div class="preview-container">
<div class="preview-bar">
<span class="preview-title">Preview</span>
<div class="preview-actions">
<div class="zoom-controls">
<button id="zoomOut" title="Zoom out (-)">&#x2212;</button>
<span id="zoomLevel" class="zoom-level">100%</span>
<button id="zoomIn" title="Zoom in (+)">+</button>
<button id="zoomFit" class="btn-small" title="Fit to view" style="font-size:10px">Fit</button>
</div>
<button id="fullscreenBtn" class="btn-fullscreen" title="Fullscreen (F)">&#x26F6;</button>
<button id="copyBtn" class="btn-small" title="Copy plain text" style="display:none">Copy</button>
<button id="downloadBtn" class="btn-small" title="Download HTML" style="display:none">Download</button>
<span id="gifIndicator" class="gif-badge" style="display:none">GIF &#x25B6;</span>
</div>
</div>
<div id="previewArea" class="preview-area">
<p class="placeholder">Your ASCII art will appear here</p>
</div>
<div id="compareBox" class="compare-box" style="display:none">
<div class="compare-header">
<span>Original</span>
<button id="compareToggle" class="btn-small" title="Minimize">&#x25BC;</button>
</div>
<img id="compareImg" class="compare-img">
</div>
<div id="spinner" class="spinner" style="display:none">
<div class="spin-anim"></div>
<span>Rendering...</span>
</div>
</div>
</div>
<footer>
<p>Powered by Unicode &middot; <a href="https://jeffemmett.com">jeffemmett.com</a></p>
</footer>
<script src="/static/app.js"></script>
</body>
</html>