diff --git a/app.py b/app.py index f7fbd75..3b36bf3 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,8 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from render import ( - PALETTES, image_to_art, gif_to_art, is_animated_gif, + PALETTES, PATTERN_TYPES, image_to_art, gif_to_art, is_animated_gif, + generate_pattern, ) app = FastAPI(title="ASCII Art Generator", docs_url=None, redoc_url=None) @@ -31,6 +32,7 @@ async def index(request: Request): "request": request, "palettes": list(PALETTES.keys()), "palette_previews": {k: v[:12] for k, v in PALETTES.items()}, + "patterns": PATTERN_TYPES, }) @@ -47,6 +49,32 @@ async def provision_space(request: Request): return {"status": "ok", "space": space, "message": f"rcreate space '{space}' ready"} +@app.post("/api/pattern") +async def render_pattern( + pattern: str = Form("plasma"), + width: int = Form(200), + height: int = Form(80), + palette: str = Form("mystic"), + seed: str = Form(""), + output_format: str = Form("html"), +): + width = max(20, min(500, width)) + height = max(10, min(250, height)) + if pattern not in PATTERN_TYPES: + pattern = "plasma" + if palette not in PALETTES: + palette = "mystic" + seed_val = int(seed) if seed.isdigit() else None + + art = generate_pattern( + pattern_type=pattern, width=width, height=height, + palette_name=palette, output_format=output_format, seed=seed_val, + ) + if output_format == "plain": + return PlainTextResponse(art) + return HTMLResponse(art) + + @app.get("/api/palettes") async def get_palettes(): return { diff --git a/render.py b/render.py index 8c9ed92..1aee92b 100644 --- a/render.py +++ b/render.py @@ -7,6 +7,8 @@ with true-color ANSI output, Floyd-Steinberg dithering, and multiple output form """ import argparse +import math +import random from html import escape as html_escape from PIL import Image, ImageSequence @@ -32,6 +34,25 @@ PALETTES = { "shades": " ░░▒▒▓▓██", "geometric": " .·˙∙•●○◌◦◯⊙⊚◐◑◒◓◔◕◖◗◍◎◉⦿⊛⊜⊝✦✧★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋", "kanji": " 一二三四五六七八九十百千万丈与世中丸主乃久乗乙九了事二人仁今仏仕他付代令以仮仰仲件任企伏伐休会伝似位低住佐体何余作佳使例侍供依価侮侯侵便係促俊俗保信修俳俵俸倉個倍倒候借値倫倹偉偏停健側偵偶傍傑傘備催債傷傾僅働像僕僚僧儀億儒元兄充兆先光克免児入全", + # ── Exotic palettes ── + "thai": " กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรลวศษสหฬอฮ", + "arabic": " ﺎﺏﺕﺙﺝﺡﺥﺩﺫﺭﺯﺱﺵﺹﺽﻁﻅﻉﻍﻑﻕﻙﻝﻡﻥﻩﻭﻱ", + "devanagari": " ँंःअआइईउऊऋएऐओऔकखगघचछजझटठडढणतथदधनपफबभमयरलवशषसह", + "ethiopic": " ሀሁሂሃሄህሆለሉሊላሌልሎሐሑሒሓሔሕሖመሙሚማሜምሞሰሱሲሳሴስሶረሩሪራሬርሮሸሹሺሻሼሽሾቀቁቂቃቄቅቆበቡቢባቤብቦተቱቲታቴትቶ", + "georgian": " აბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰ", + "tibetan": " ༁༂༃༄༅༆༇༈༉༊་༌།༎༏༐༑༒༓༔༕༖༗༘༙༚༛༜༝༞༟༠༡༢༣༤༥༦༧༨༩", + "hieroglyph": " 𓀀𓀁𓀂𓀃𓀄𓀅𓀆𓀇𓀈𓀉𓀊𓀋𓀌𓀍𓀎𓀏𓀐𓀑𓀒𓀓𓀔𓀕𓀖𓀗𓀘𓀙𓀚𓀛𓀜𓀝𓀞𓀟𓀠𓀡𓀢𓀣𓀤𓀥𓀦𓀧𓀨𓀩𓀪𓀫𓀬𓀭𓀮𓀯𓀰", + "cuneiform": " 𒀀𒀁𒀂𒀃𒀄𒀅𒀆𒀇𒀈𒀉𒀊𒀋𒀌𒀍𒀎𒀏𒀐𒀑𒀒𒀓𒀔𒀕𒀖𒀗𒀘𒀙𒀚𒀛𒀜𒀝𒀞𒀟𒀠𒀡𒀢𒀣𒀤𒀥𒀦𒀧𒀨𒀩𒀪𒀫𒀬𒀭", + "alchemical": " 🜀🜁🜂🜃🜄🜅🜆🜇🜈🜉🜊🜋🜌🜍🜎🜏🜐🜑🜒🜓🜔🜕🜖🜗🜘🜙🜚🜛🜜🜝🜞🜟🜠🜡🜢🜣🜤🜥🜦🜧🜨🜩🜪🜫🜬🜭🜮🜯🜰🜱🜲🜳🜴🜵🜶🜷🜸🜹🜺🜻🜼🜽🜾🜿", + "dominos": " 🁣🁤🁥🁦🁧🁨🁩🁪🁫🁬🁭🁮🁯🁰🁱🁲🁳🁴🁵🁶🁷🁸🁹🁺🁻🁼🁽🁾🁿🂀🂁🂂🂃🂄🂅🂆🂇🂈🂉🂊🂋🂌🂍🂎🂏🂐🂑🂒🂓", + "mahjong": " 🀀🀁🀂🀃🀄🀅🀆🀇🀈🀉🀊🀋🀌🀍🀎🀏🀐🀑🀒🀓🀔🀕🀖🀗🀘🀙🀚🀛🀜🀝🀞🀟🀠🀡", + "dingbats": " ✁✂✃✄✆✇✈✉✌✍✎✏✐✑✒✓✔✕✖✗✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❍❏❐❑❒❖❘❙❚❛❜❝❞", + "playing": " 🂡🂢🂣🂤🂥🂦🂧🂨🂩🂪🂫🂬🂭🂮🂱🂲🂳🂴🂵🂶🂷🂸🂹🂺🂻🂼🂽🂾🃁🃂🃃🃄🃅🃆🃇🃈🃉🃊🃋🃌🃍🃎🃑🃒🃓🃔🃕🃖🃗🃘🃙🃚🃛🃜🃝🃞", + "yijing": " ☰☱☲☳☴☵☶☷䷀䷁䷂䷃䷄䷅䷆䷇䷈䷉䷊䷋䷌䷍䷎䷏䷐䷑䷒䷓䷔䷕䷖䷗䷘䷙䷚䷛䷜䷝䷞䷟䷠䷡䷢䷣䷤䷥䷦䷧䷨䷩䷪䷫䷬䷭䷮䷯䷰䷱䷲䷳䷴䷵䷶䷷䷸䷹䷺䷻䷼䷽䷾䷿", + "box": " ─│┌┐└┘├┤┬┴┼╌╍╎╏═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳", + "math": " ∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝∞∟∠∡∢∣∤∥∦∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅", + "flora": " 🌱🌲🌳🌴🌵🌷🌸🌹🌺🌻🌼🌽🌾🌿🍀🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓", + "weather": " 🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚🌛🌜🌝🌞🌟🌠", } MAX_GIF_FRAMES = 60 @@ -191,6 +212,228 @@ def is_animated_gif(image_path: str) -> bool: return False +# ── Pattern Generators ────────────────────────────────────────────────── + +PATTERN_TYPES = [ + "plasma", "mandelbrot", "spiral", "waves", "nebula", + "kaleidoscope", "aurora", "lava", "crystals", "fractal_tree", +] + + +def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]: + """HSV (0-1 range) to RGB (0-255).""" + if s == 0: + c = int(v * 255) + return c, c, c + h6 = h * 6.0 + i = int(h6) + f = h6 - i + p = int(v * (1 - s) * 255) + q = int(v * (1 - s * f) * 255) + t = int(v * (1 - s * (1 - f)) * 255) + vi = int(v * 255) + if i == 0: return vi, t, p + if i == 1: return q, vi, p + if i == 2: return p, vi, t + if i == 3: return p, q, vi + if i == 4: return t, p, vi + return vi, p, q + + +def generate_pattern( + pattern_type: str = "plasma", + width: int = 200, + height: int = 80, + palette_name: str = "wingdings", + output_format: str = "html", + seed: int | None = None, +) -> str: + """Generate a colorful pattern as Unicode art.""" + if seed is None: + seed = random.randint(0, 2**31) + rng = random.Random(seed) + + chars = PALETTES.get(palette_name, PALETTES["wingdings"]) + num_chars = len(chars) + + # Random parameters for variety + freq1 = rng.uniform(0.02, 0.12) + freq2 = rng.uniform(0.01, 0.08) + freq3 = rng.uniform(0.03, 0.15) + phase1 = rng.uniform(0, math.tau) + phase2 = rng.uniform(0, math.tau) + phase3 = rng.uniform(0, math.tau) + hue_offset = rng.uniform(0, 1) + hue_speed = rng.uniform(0.002, 0.02) + cx = width * rng.uniform(0.3, 0.7) + cy = height * rng.uniform(0.3, 0.7) + spirals = rng.randint(2, 8) + zoom = rng.uniform(0.5, 3.0) + + lines = [] + for y in range(height): + line = [] + for x in range(width): + # Compute value (0-1) and hue based on pattern type + if pattern_type == "plasma": + v1 = math.sin(x * freq1 + phase1) + v2 = math.sin(y * freq2 + phase2) + v3 = math.sin((x + y) * freq3 + phase3) + v4 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq1) + val = (v1 + v2 + v3 + v4) / 4.0 + val = (val + 1) / 2 # normalize to 0-1 + hue = (val * 3 + hue_offset + x * hue_speed) % 1.0 + sat = 0.7 + 0.3 * math.sin(val * math.pi) + bri = 0.4 + 0.6 * val + + elif pattern_type == "mandelbrot": + # Map to complex plane + re = (x - width * 0.65) / (width * 0.3) * zoom + im = (y - height * 0.5) / (height * 0.5) * zoom + c = complex(re, im) + z = complex(0, 0) + iteration = 0 + max_iter = 80 + while abs(z) < 4 and iteration < max_iter: + z = z * z + c + iteration += 1 + if iteration == max_iter: + val = 0 + hue = 0 + sat = 0 + bri = 0.05 + else: + val = iteration / max_iter + hue = (val * 5 + hue_offset) % 1.0 + sat = 0.9 + bri = 0.3 + 0.7 * val + + elif pattern_type == "spiral": + dx = x - cx + dy = (y - cy) * 2.2 # aspect correction + dist = math.sqrt(dx**2 + dy**2) + angle = math.atan2(dy, dx) + val = (math.sin(dist * freq1 * 3 - angle * spirals + phase1) + 1) / 2 + hue = (angle / math.tau + dist * hue_speed + hue_offset) % 1.0 + sat = 0.8 + 0.2 * math.sin(dist * 0.1) + bri = 0.3 + 0.7 * val + + elif pattern_type == "waves": + v1 = math.sin(x * freq1 + y * freq2 * 0.5 + phase1) + v2 = math.sin(y * freq2 * 2 + x * freq1 * 0.3 + phase2) + v3 = math.cos(x * freq3 + phase3) * math.sin(y * freq1) + val = (v1 + v2 + v3 + 3) / 6 + hue = (val * 2 + y * hue_speed * 2 + hue_offset) % 1.0 + sat = 0.6 + 0.4 * abs(math.sin(val * math.pi * 2)) + bri = 0.3 + 0.7 * val + + elif pattern_type == "nebula": + # Layered noise-like patterns + v1 = math.sin(x * freq1 + phase1) * math.cos(y * freq2 + phase2) + v2 = math.sin((x + y) * freq3) * math.cos((x - y) * freq1 * 0.7) + v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq2 * 0.5 + phase3) + v4 = math.sin(x * freq2 * 1.3 + y * freq3 * 0.7 + phase1 * 2) + val = (v1 + v2 + v3 + v4 + 4) / 8 + hue = (val * 4 + hue_offset + math.sin(x * 0.01) * 0.2) % 1.0 + sat = 0.5 + 0.5 * val + bri = 0.1 + 0.9 * val ** 0.7 + + elif pattern_type == "kaleidoscope": + # Mirror and rotate + dx = x - cx + dy = (y - cy) * 2.2 + angle = math.atan2(dy, dx) + dist = math.sqrt(dx**2 + dy**2) + # Fold angle into segments + segments = spirals + angle = abs(((angle % (math.tau / segments)) - math.pi / segments)) + nx = dist * math.cos(angle) + ny = dist * math.sin(angle) + v1 = math.sin(nx * freq1 + phase1) * math.cos(ny * freq2 + phase2) + v2 = math.sin(dist * freq3 + phase3) + val = (v1 + v2 + 2) / 4 + hue = (dist * hue_speed + val + hue_offset) % 1.0 + sat = 0.7 + 0.3 * math.sin(val * math.pi) + bri = 0.2 + 0.8 * val + + elif pattern_type == "aurora": + wave = math.sin(x * freq1 + phase1) * 15 + math.sin(x * freq3 + phase3) * 8 + dist_from_wave = abs(y - height * 0.4 - wave) + val = max(0, 1 - dist_from_wave / (height * 0.4)) + val = val ** 0.5 + hue = (x * hue_speed + hue_offset + val * 0.3) % 1.0 + sat = 0.6 + 0.4 * val + bri = 0.05 + 0.95 * val + + elif pattern_type == "lava": + v1 = math.sin(x * freq1 + y * freq2 * 0.3 + phase1) + v2 = math.cos(y * freq2 + x * freq1 * 0.5 + phase2) + v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy * 0.8)**2) * freq3) + val = (v1 * v2 + v3 + 2) / 4 + val = val ** 1.5 # push towards extremes + hue = (0.0 + val * 0.12 + hue_offset * 0.1) % 1.0 # reds to oranges + sat = 0.8 + 0.2 * val + bri = 0.15 + 0.85 * val + + elif pattern_type == "crystals": + # Voronoi-like + min_dist = float('inf') + min_dist2 = float('inf') + # Use fixed seed points + for i in range(12): + px = rng.uniform(0, width) if i == 0 else (seed * (i + 1) * 7919) % width + py = rng.uniform(0, height) if i == 0 else (seed * (i + 1) * 6271) % height + d = math.sqrt((x - px)**2 + ((y - py) * 2.2)**2) + if d < min_dist: + min_dist2 = min_dist + min_dist = d + elif d < min_dist2: + min_dist2 = d + # Reset rng state + rng = random.Random(seed) + _ = [rng.uniform(0, 1) for _ in range(20)] # burn values to resync + edge = min_dist2 - min_dist + val = min(1, edge / 20) + hue = (min_dist * hue_speed + hue_offset) % 1.0 + sat = 0.5 + 0.5 * val + bri = 0.2 + 0.8 * val + + elif pattern_type == "fractal_tree": + # Barnsley fern inspired + nx = (x - cx) / (width * 0.3) + ny = (height - y) / (height * 0.8) + v1 = math.sin(nx * 8 + phase1) * math.cos(ny * 12 + phase2) + v2 = math.sin((nx * ny) * 5 + phase3) + v3 = math.cos(nx * freq1 * 50) * math.sin(ny * freq2 * 50) + val = (v1 + v2 + v3 + 3) / 6 + hue = (0.25 + val * 0.2 + ny * 0.1 + hue_offset * 0.3) % 1.0 # greens + sat = 0.5 + 0.5 * val + bri = 0.1 + 0.7 * val * (0.3 + 0.7 * max(0, 1 - abs(nx) * 0.8)) + + else: + val = 0.5 + hue = 0.5 + sat = 0.5 + bri = 0.5 + + r, g, b = _hsv_to_rgb(hue, sat, bri) + lum = get_luminance(r, g, b) + idx = int(lum / 256 * num_chars) + idx = min(idx, num_chars - 1) + char = chars[idx] + + if output_format == "html": + line.append(f'{html_escape(char)}') + elif output_format == "ansi": + line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m") + else: + line.append(char) + + lines.append("".join(line)) + + return "\n".join(lines) + + def render_demo(image_path: str, width: int = 70): """Render the same image in multiple palettes for comparison.""" from rich.console import Console diff --git a/static/app.js b/static/app.js index 427b5e8..cfada6b 100644 --- a/static/app.js +++ b/static/app.js @@ -19,6 +19,7 @@ let currentFile = null; let animationInterval = null; let lastRenderedHtml = ''; let zoomScale = 1.0; +let currentMode = 'image'; // 'image' or 'pattern' const zoomIn = document.getElementById('zoomIn'); const zoomOut = document.getElementById('zoomOut'); @@ -80,6 +81,90 @@ function setFile(file) { generateBtn.disabled = false; } +// ── Mode Switching ────────────────────────────── + +const modeTabs = document.querySelectorAll('.mode-tab'); +const imageControls = document.getElementById('imageControls'); +const patternControls = document.getElementById('patternControls'); +const randomBtn = document.getElementById('randomBtn'); +const patternHeight = document.getElementById('patternHeight'); +const heightVal = document.getElementById('heightVal'); + +modeTabs.forEach(tab => { + tab.addEventListener('click', () => { + modeTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentMode = tab.dataset.mode; + + if (currentMode === 'pattern') { + imageControls.style.display = 'none'; + patternControls.style.display = 'flex'; + generateBtn.style.display = 'none'; + randomBtn.style.display = ''; + } else { + imageControls.style.display = 'flex'; + patternControls.style.display = 'none'; + generateBtn.style.display = ''; + randomBtn.style.display = 'none'; + } + }); +}); + +patternHeight.addEventListener('input', () => { + heightVal.textContent = patternHeight.value; +}); + +randomBtn.addEventListener('click', generatePattern); + +async function generatePattern() { + stopAnimation(); + previewArea.innerHTML = ''; + spinner.style.display = 'flex'; + randomBtn.disabled = true; + copyBtn.style.display = 'none'; + downloadBtn.style.display = 'none'; + compareBox.style.display = 'none'; + + let patternType = document.getElementById('patternType').value; + if (patternType === 'random') { + const types = [...document.getElementById('patternType').options] + .map(o => o.value).filter(v => v !== 'random'); + patternType = types[Math.floor(Math.random() * types.length)]; + } + + const formData = new FormData(); + formData.append('pattern', patternType); + formData.append('width', widthSlider.value); + formData.append('height', patternHeight.value); + formData.append('palette', document.getElementById('palette').value); + formData.append('output_format', 'html'); + + try { + const resp = await fetch('/api/pattern', { method: 'POST', body: formData }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const html = await resp.text(); + lastRenderedHtml = html; + spinner.style.display = 'none'; + previewArea.innerHTML = html; + + copyBtn.style.display = ''; + downloadBtn.style.display = ''; + + // Auto-fit + 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}

`; + } + randomBtn.disabled = false; +} + // ── Width Slider ────────────────────────────── widthSlider.addEventListener('input', () => { diff --git a/static/style.css b/static/style.css index 543431f..c02fb46 100644 --- a/static/style.css +++ b/static/style.css @@ -45,6 +45,34 @@ header { flex-wrap: nowrap; } +/* ── Mode Tabs ─────────────────────────────── */ + +.mode-tabs { + display: flex; + gap: 2px; + background: var(--surface2); + border-radius: 6px; + padding: 2px; +} + +.mode-tab { + padding: 4px 10px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-dim); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.mode-tab:hover { color: var(--text); } +.mode-tab.active { background: var(--accent); color: #000; } + +.mode-controls { display: flex; align-items: center; gap: 8px; } + /* ── Upload Zone ─────────────────────────────── */ .upload-zone { diff --git a/templates/index.html b/templates/index.html index 6e1a122..0c294bb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16,19 +16,46 @@
-
- 🎨 -

- + +
+ +
-