commit ebd4b12628e3eb9a98477eeb64946bea41b65a5d Author: Jeff Emmett Date: Thu Apr 2 01:43:17 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed1633a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..f7fbd75 --- /dev/null +++ b/app.py @@ -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'
{f["art"]}
' + ) + 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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0292c2c --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/render.py b/render.py new file mode 100644 index 0000000..8c9ed92 --- /dev/null +++ b/render.py @@ -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'{html_escape(char)}') + 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)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bfc1e84 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..427b5e8 --- /dev/null +++ b/static/app.js @@ -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 = '▼'; + + // 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 = `

${err.message}

`; + } + + 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 = ` +
${lastRenderedHtml}
`; + + 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') ? '▲' : '▼'; +}); + +document.querySelector('.compare-header').addEventListener('click', () => { + compareBox.classList.toggle('minimized'); + compareToggle.innerHTML = compareBox.classList.contains('minimized') ? '▲' : '▼'; +}); + +// ── 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(); + } +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..543431f --- /dev/null +++ b/static/style.css @@ -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; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6e1a122 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,104 @@ + + + + + +ASCII Art Generator + + + + + + +
+

ASCII Art Generator

+
+ + +
+
+ 🎨 +

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ Preview +
+
+ + 100% + + +
+ + + + +
+
+
+

Your ASCII art will appear here

+
+ + +
+
+ + + + + +