diff --git a/app.py b/app.py index c58d66c..ba49df4 100644 --- a/app.py +++ b/app.py @@ -7,13 +7,14 @@ 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.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from render import ( - PALETTES, PATTERN_TYPES, image_to_art, gif_to_art, is_animated_gif, - generate_pattern, + PALETTES, PATTERN_TYPES, EFFECT_NAMES, + image_to_art, gif_to_art, is_animated_gif, + generate_pattern, animate_image, animate_pattern, frames_to_gif, ) app = FastAPI(title="ASCII Art Generator", docs_url=None, redoc_url=None) @@ -35,6 +36,7 @@ async def index(request: Request): "palettes": list(PALETTES.keys()), "palette_previews": {k: v[:12] for k, v in PALETTES.items()}, "patterns": PATTERN_TYPES, + "effects": EFFECT_NAMES, "cache_bust": CACHE_BUST, }) @@ -88,6 +90,121 @@ async def get_palettes(): } +@app.post("/api/animate") +async def animate_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"), + effect: str = Form("color_cycle"), + num_frames: int = Form(20), + frame_duration: int = Form(100), + export_gif: str = Form("false"), +): + dither = dither.lower() in ("true", "1", "yes", "on") + double_width = double_width.lower() in ("true", "1", "yes", "on") + want_gif = export_gif.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(2000, width)) + num_frames = max(6, min(60, num_frames)) + frame_duration = max(20, min(500, frame_duration)) + if palette not in PALETTES: + palette = "wingdings" + if effect not in EFFECT_NAMES: + effect = "color_cycle" + + 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: + frames = animate_image( + tmp_path, width=width, palette_name=palette, bg=bg, + double_width=double_width, dither=dither, output_format="html", + effect=effect, num_frames=num_frames, frame_duration=frame_duration, + return_pil=want_gif, + ) + + if want_gif: + pil_frames = [f["pil_frame"] for f in frames] + durations = [f["duration"] for f in frames] + gif_bytes = frames_to_gif(pil_frames, durations) + return StreamingResponse( + iter([gif_bytes]), + media_type="image/gif", + headers={"Content-Disposition": "attachment; filename=ascii-animation.gif"}, + ) + + frame_divs = [] + for i, f in enumerate(frames): + display = "block" if i == 0 else "none" + frame_divs.append( + f'
{f["art"]}
' + ) + return HTMLResponse("\n".join(frame_divs)) + finally: + os.unlink(tmp_path) + + +@app.post("/api/animate-pattern") +async def animate_pattern_endpoint( + pattern: str = Form("plasma"), + width: int = Form(200), + height: int = Form(80), + palette: str = Form("mystic"), + seed: str = Form(""), + num_frames: int = Form(20), + frame_duration: int = Form(100), + export_gif: str = Form("false"), +): + want_gif = export_gif.lower() in ("true", "1", "yes", "on") + width = max(20, min(2000, width)) + height = max(10, min(250, height)) + num_frames = max(6, min(60, num_frames)) + frame_duration = max(20, min(500, frame_duration)) + if pattern not in PATTERN_TYPES: + pattern = "plasma" + if palette not in PALETTES: + palette = "mystic" + seed_val = int(seed) if seed.isdigit() else None + + frames = animate_pattern( + pattern_type=pattern, width=width, height=height, + palette_name=palette, output_format="html", + seed=seed_val, num_frames=num_frames, frame_duration=frame_duration, + return_pil=want_gif, + ) + + if want_gif: + pil_frames = [f["pil_frame"] for f in frames] + durations = [f["duration"] for f in frames] + gif_bytes = frames_to_gif(pil_frames, durations) + return StreamingResponse( + iter([gif_bytes]), + media_type="image/gif", + headers={"Content-Disposition": "attachment; filename=pattern-animation.gif"}, + ) + + frame_divs = [] + for i, f in enumerate(frames): + display = "block" if i == 0 else "none" + frame_divs.append( + f'
{f["art"]}
' + ) + return HTMLResponse("\n".join(frame_divs)) + + @app.post("/api/render") async def render_art( file: UploadFile = File(...), diff --git a/render.py b/render.py index be8b821..7f19118 100644 --- a/render.py +++ b/render.py @@ -10,7 +10,8 @@ import argparse import math import random from html import escape as html_escape -from PIL import Image, ImageOps, ImageSequence +from io import BytesIO +from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps, ImageSequence # ── Character palettes sorted dark → light ────────────────────────────── PALETTES = { @@ -303,6 +304,145 @@ def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]: return vi, p, q +def _rgb_to_hsv(r: int, g: int, b: int) -> tuple[float, float, float]: + """RGB (0-255) to HSV (0-1 range).""" + r1, g1, b1 = r / 255.0, g / 255.0, b / 255.0 + mx = max(r1, g1, b1) + mn = min(r1, g1, b1) + diff = mx - mn + if diff == 0: + h = 0.0 + elif mx == r1: + h = ((g1 - b1) / diff) % 6 / 6.0 + elif mx == g1: + h = ((b1 - r1) / diff + 2) / 6.0 + else: + h = ((r1 - g1) / diff + 4) / 6.0 + s = 0.0 if mx == 0 else diff / mx + return h, s, mx + + +# ── Image Animation Effects ─────────────────────────────────────────── + +EFFECT_NAMES = ["color_cycle", "wave", "typewriter", "pulse", "glitch"] + + +def _effect_color_cycle(img: Image.Image, t: float) -> Image.Image: + """Rotate hue by t * 360 degrees.""" + pixels = img.load() + w, h = img.size + out = img.copy() + out_px = out.load() + shift = t % 1.0 + for y in range(h): + for x in range(w): + r, g, b = pixels[x, y] + hue, sat, val = _rgb_to_hsv(r, g, b) + hue = (hue + shift) % 1.0 + out_px[x, y] = _hsv_to_rgb(hue, sat, val) + return out + + +def _effect_wave(img: Image.Image, t: float) -> Image.Image: + """Sine-wave horizontal row displacement.""" + w, h = img.size + out = Image.new("RGB", (w, h), (0, 0, 0)) + pixels = img.load() + out_px = out.load() + for y in range(h): + shift = int(math.sin(y * 0.3 + t * math.tau) * 3) + for x in range(w): + sx = (x + shift) % w + out_px[x, y] = pixels[sx, y] + return out + + +def _effect_typewriter(img: Image.Image, t: float) -> Image.Image: + """Progressive row reveal.""" + w, h = img.size + out = Image.new("RGB", (w, h), (0, 0, 0)) + visible_rows = max(1, int(t * h)) + out.paste(img.crop((0, 0, w, min(visible_rows, h))), (0, 0)) + return out + + +def _effect_pulse(img: Image.Image, t: float) -> Image.Image: + """Brightness oscillation.""" + factor = 0.5 + 0.5 * math.sin(t * math.tau) + factor = 0.3 + factor * 0.9 # range 0.3 to 1.2 + enhancer = ImageEnhance.Brightness(img) + return enhancer.enhance(factor) + + +def _effect_glitch(img: Image.Image, t: float) -> Image.Image: + """Seeded random row shifts + channel swaps.""" + w, h = img.size + rng = random.Random(int(t * 1000)) + out = img.copy() + pixels = img.load() + out_px = out.load() + # Random row shifts + for _ in range(max(1, h // 8)): + y = rng.randint(0, h - 1) + shift = rng.randint(-w // 4, w // 4) + block_h = rng.randint(1, max(1, h // 10)) + for dy in range(block_h): + yy = y + dy + if yy >= h: + break + for x in range(w): + sx = (x + shift) % w + r, g, b = pixels[sx, yy] + # Occasional channel swap + if rng.random() < 0.3: + r, g, b = g, b, r + out_px[x, yy] = (r, g, b) + return out + + +_EFFECTS = { + "color_cycle": _effect_color_cycle, + "wave": _effect_wave, + "typewriter": _effect_typewriter, + "pulse": _effect_pulse, + "glitch": _effect_glitch, +} + + +def animate_image( + image_path: str, + width: int = 80, + palette_name: str = "wingdings", + bg: str = "dark", + double_width: bool = False, + dither: bool = False, + output_format: str = "html", + effect: str = "color_cycle", + num_frames: int = 20, + frame_duration: int = 100, + return_pil: bool = False, +) -> list[dict]: + """Apply an animation effect to a static image, returning frame dicts.""" + img = Image.open(image_path) + wide = is_wide_palette(palette_name) + prepared = _prepare_frame(img, width, bg, wide_chars=wide) + chars = PALETTES.get(palette_name, PALETTES["wingdings"]) + effect_fn = _EFFECTS.get(effect, _effect_color_cycle) + + frames = [] + for i in range(num_frames): + t = i / num_frames + effected = effect_fn(prepared, t) + art = _render_frame(effected, chars, double_width, dither, output_format) + frame = {"art": art, "duration": frame_duration} + if return_pil: + frame["pil_frame"] = effected + frames.append(frame) + return frames + + +# ── Pattern Animation ───────────────────────────────────────────────── + def generate_pattern( pattern_type: str = "plasma", width: int = 200, @@ -310,6 +450,7 @@ def generate_pattern( palette_name: str = "wingdings", output_format: str = "html", seed: int | None = None, + t_offset: float = 0.0, ) -> str: """Generate a colorful pattern as Unicode art.""" if seed is None: @@ -323,9 +464,9 @@ def generate_pattern( 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) + phase1 = rng.uniform(0, math.tau) + t_offset * math.tau + phase2 = rng.uniform(0, math.tau) + t_offset * math.tau * 0.7 + phase3 = rng.uniform(0, math.tau) + t_offset * math.tau * 1.3 hue_offset = rng.uniform(0, 1) hue_speed = rng.uniform(0.002, 0.02) cx = width * rng.uniform(0.3, 0.7) @@ -497,6 +638,214 @@ def generate_pattern( return "\n".join(lines) +def _generate_pattern_pil( + pattern_type: str = "plasma", + width: int = 200, + height: int = 80, + seed: int | None = None, + t_offset: float = 0.0, +) -> Image.Image: + """Generate a pattern as a PIL Image (shares math with generate_pattern).""" + if seed is None: + seed = random.randint(0, 2**31) + rng = random.Random(seed) + + 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) + t_offset * math.tau + phase2 = rng.uniform(0, math.tau) + t_offset * math.tau * 0.7 + phase3 = rng.uniform(0, math.tau) + t_offset * math.tau * 1.3 + 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) + + img = Image.new("RGB", (width, height)) + px = img.load() + + for y in range(height): + for x in range(width): + 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 + 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": + 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, hue, sat, bri = 0, 0, 0, 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 + 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": + 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": + dx = x - cx + dy = (y - cy) * 2.2 + angle = math.atan2(dy, dx) + dist = math.sqrt(dx**2 + dy**2) + 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 + hue = (0.0 + val * 0.12 + hue_offset * 0.1) % 1.0 + sat = 0.8 + 0.2 * val + bri = 0.15 + 0.85 * val + elif pattern_type == "crystals": + min_dist = float('inf') + min_dist2 = float('inf') + for i in range(12): + ppx = rng.uniform(0, width) if i == 0 else (seed * (i + 1) * 7919) % width + ppy = rng.uniform(0, height) if i == 0 else (seed * (i + 1) * 6271) % height + d = math.sqrt((x - ppx)**2 + ((y - ppy) * 2.2)**2) + if d < min_dist: + min_dist2 = min_dist + min_dist = d + elif d < min_dist2: + min_dist2 = d + rng = random.Random(seed) + _ = [rng.uniform(0, 1) for _ in range(20)] + 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": + 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 + 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, hue, sat, bri = 0.5, 0.5, 0.5, 0.5 + + px[x, y] = _hsv_to_rgb(hue, sat, bri) + + return img + + +def animate_pattern( + pattern_type: str = "plasma", + width: int = 200, + height: int = 80, + palette_name: str = "wingdings", + output_format: str = "html", + seed: int | None = None, + num_frames: int = 20, + frame_duration: int = 100, + return_pil: bool = False, +) -> list[dict]: + """Generate animated pattern frames by advancing t_offset.""" + if seed is None: + seed = random.randint(0, 2**31) + + chars = PALETTES.get(palette_name, PALETTES["wingdings"]) + frames = [] + for i in range(num_frames): + t = i / num_frames + art = generate_pattern( + pattern_type=pattern_type, width=width, height=height, + palette_name=palette_name, output_format=output_format, + seed=seed, t_offset=t, + ) + frame = {"art": art, "duration": frame_duration} + if return_pil: + frame["pil_frame"] = _generate_pattern_pil( + pattern_type=pattern_type, width=width, height=height, + seed=seed, t_offset=t, + ) + frames.append(frame) + return frames + + +def frames_to_gif(pil_frames: list[Image.Image], durations: list[int]) -> bytes: + """Assemble PIL frames into an animated GIF, return bytes.""" + buf = BytesIO() + # Convert to P mode (palette) for GIF compatibility + converted = [] + for f in pil_frames: + # Ensure RGB, then quantize + rgb = f.convert("RGB") + converted.append(rgb.quantize(colors=256, method=Image.Quantize.MEDIANCUT)) + + converted[0].save( + buf, + format="GIF", + save_all=True, + append_images=converted[1:], + duration=durations, + loop=0, + optimize=False, + ) + return buf.getvalue() + + 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 0d12eae..8108e45 100644 --- a/static/app.js +++ b/static/app.js @@ -29,12 +29,26 @@ const imageControls = $('imageControls'); const patternControls = $('patternControls'); const patternHeight = $('patternHeight'); const heightVal = $('heightVal'); +const animateBtn = $('animateBtn'); +const animatePatternBtn = $('animatePatternBtn'); +const numFramesSlider = $('numFrames'); +const framesVal = $('framesVal'); +const frameSpeedSlider = $('frameSpeed'); +const speedVal = $('speedVal'); +const playPauseBtn = $('playPauseBtn'); +const frameCounter = $('frameCounter'); +const playbackControls = $('playbackControls'); +const gifDownloadBtn = $('gifDownloadBtn'); let currentFile = null; let animationInterval = null; let lastRenderedHtml = ''; let zoomScale = 1.0; let currentMode = 'image'; +let animationPlaying = true; +let currentFrameIdx = 0; +let totalFrames = 0; +let lastAnimParams = null; // ── Mode Switching ────────────────────────────── @@ -49,10 +63,13 @@ function switchMode(mode) { imageControls.style.display = mode === 'image' ? 'flex' : 'none'; patternControls.style.display = mode === 'pattern' ? 'flex' : 'none'; generateBtn.style.display = mode === 'image' ? '' : 'none'; + animateBtn.style.display = mode === 'image' ? '' : 'none'; randomBtn.style.display = mode === 'pattern' ? '' : 'none'; + animatePatternBtn.style.display = mode === 'pattern' ? '' : 'none'; if (mode === 'image') { generateBtn.disabled = !currentFile; + animateBtn.disabled = !currentFile; } } @@ -90,6 +107,7 @@ clearFile.addEventListener('click', e => { fileInfo.style.display = 'none'; dropZone.style.display = ''; generateBtn.disabled = true; + animateBtn.disabled = true; }); function setFile(file) { @@ -108,6 +126,7 @@ function setFile(file) { dropZone.style.display = 'none'; fileInfo.style.display = 'flex'; generateBtn.disabled = false; + animateBtn.disabled = false; // Auto-generate immediately on file select if (currentMode === 'image') { @@ -119,6 +138,8 @@ function setFile(file) { widthSlider.addEventListener('input', () => { widthVal.textContent = widthSlider.value; }); patternHeight.addEventListener('input', () => { heightVal.textContent = patternHeight.value; }); +numFramesSlider.addEventListener('input', () => { framesVal.textContent = numFramesSlider.value; }); +frameSpeedSlider.addEventListener('input', () => { speedVal.textContent = frameSpeedSlider.value; }); // Resolution presets sync the width slider const resPreset = $('resPreset'); @@ -238,22 +259,63 @@ async function generatePattern() { // ── GIF Animation ────────────────────────────── +let animFrames = null; + function startAnimation(frames) { - let current = 0; + animFrames = frames; + totalFrames = frames.length; + currentFrameIdx = 0; + animationPlaying = true; frames[0].style.display = 'block'; + playbackControls.style.display = 'flex'; + playPauseBtn.innerHTML = '⏸'; + updateFrameCounter(); + function nextFrame() { - frames[current].style.display = 'none'; - current = (current + 1) % frames.length; - frames[current].style.display = 'block'; - animationInterval = setTimeout(nextFrame, parseInt(frames[current].dataset.duration) || 100); + if (!animationPlaying) return; + frames[currentFrameIdx].style.display = 'none'; + currentFrameIdx = (currentFrameIdx + 1) % frames.length; + frames[currentFrameIdx].style.display = 'block'; + updateFrameCounter(); + animationInterval = setTimeout(nextFrame, parseInt(frames[currentFrameIdx].dataset.duration) || 100); } animationInterval = setTimeout(nextFrame, parseInt(frames[0].dataset.duration) || 100); } function stopAnimation() { if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; } + animationPlaying = false; + animFrames = null; + playbackControls.style.display = 'none'; } +function updateFrameCounter() { + frameCounter.textContent = `${currentFrameIdx + 1}/${totalFrames}`; +} + +playPauseBtn.addEventListener('click', () => { + if (!animFrames) return; + if (animationPlaying) { + // Pause + animationPlaying = false; + if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; } + playPauseBtn.innerHTML = '▶'; + } else { + // Resume + animationPlaying = true; + playPauseBtn.innerHTML = '⏸'; + function nextFrame() { + if (!animationPlaying || !animFrames) return; + animFrames[currentFrameIdx].style.display = 'none'; + currentFrameIdx = (currentFrameIdx + 1) % animFrames.length; + animFrames[currentFrameIdx].style.display = 'block'; + updateFrameCounter(); + animationInterval = setTimeout(nextFrame, parseInt(animFrames[currentFrameIdx].dataset.duration) || 100); + } + animationInterval = setTimeout(nextFrame, parseInt(animFrames[currentFrameIdx].dataset.duration) || 100); + } +}); + // ── Copy & Download ────────────────────────────── copyBtn.addEventListener('click', async () => { @@ -301,6 +363,155 @@ pre{font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.15;lette URL.revokeObjectURL(a.href); }); +// ── Animate Image ────────────────────────────── + +animateBtn.addEventListener('click', animateImage); + +async function animateImage() { + if (!currentFile) return; + stopAnimation(); + previewArea.innerHTML = ''; + spinner.style.display = 'flex'; + animateBtn.disabled = true; + copyBtn.style.display = 'none'; + downloadBtn.style.display = 'none'; + gifDownloadBtn.style.display = 'none'; + gifIndicator.style.display = 'none'; + + const formData = new FormData(); + formData.append('file', currentFile); + formData.append('width', widthSlider.value); + formData.append('palette', $('palette').value); + formData.append('bg', $('bg').value); + formData.append('dither', $('dither').checked); + formData.append('double_width', $('doubleWidth').checked); + formData.append('effect', $('effectType').value); + formData.append('num_frames', numFramesSlider.value); + formData.append('frame_duration', frameSpeedSlider.value); + + lastAnimParams = { mode: 'image', formData: formData }; + + try { + const resp = await fetch('/api/animate', { 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; + + const frames = previewArea.querySelectorAll('.art-frame'); + if (frames.length > 1) { + gifIndicator.style.display = ''; + gifIndicator.textContent = `ANIM ${frames.length}f`; + startAnimation(frames); + } + + copyBtn.style.display = ''; + downloadBtn.style.display = ''; + gifDownloadBtn.style.display = ''; + + compareImg.src = URL.createObjectURL(currentFile); + compareBox.style.display = ''; + compareBox.classList.remove('minimized'); + compareToggle.innerHTML = '▼'; + autoFitZoom(); + } catch (err) { + spinner.style.display = 'none'; + previewArea.innerHTML = `

${err.message}

`; + } + animateBtn.disabled = false; +} + +// ── Animate Pattern ────────────────────────────── + +animatePatternBtn.addEventListener('click', animatePatternFn); + +async function animatePatternFn() { + stopAnimation(); + previewArea.innerHTML = ''; + spinner.style.display = 'flex'; + animatePatternBtn.disabled = true; + copyBtn.style.display = 'none'; + downloadBtn.style.display = 'none'; + gifDownloadBtn.style.display = 'none'; + compareBox.style.display = 'none'; + + let patternType = $('patternType').value; + if (patternType === 'random') { + const opts = [...$('patternType').options].map(o => o.value).filter(v => v !== 'random'); + patternType = opts[Math.floor(Math.random() * opts.length)]; + } + + const formData = new FormData(); + formData.append('pattern', patternType); + formData.append('width', widthSlider.value); + formData.append('height', patternHeight.value); + formData.append('palette', $('palette').value); + formData.append('num_frames', numFramesSlider.value); + formData.append('frame_duration', frameSpeedSlider.value); + + lastAnimParams = { mode: 'pattern', formData: formData }; + + try { + const resp = await fetch('/api/animate-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; + + const frames = previewArea.querySelectorAll('.art-frame'); + if (frames.length > 1) { + gifIndicator.style.display = ''; + gifIndicator.textContent = `ANIM ${frames.length}f`; + startAnimation(frames); + } + + copyBtn.style.display = ''; + downloadBtn.style.display = ''; + gifDownloadBtn.style.display = ''; + autoFitZoom(); + } catch (err) { + spinner.style.display = 'none'; + previewArea.innerHTML = `

${err.message}

`; + } + animatePatternBtn.disabled = false; +} + +// ── GIF Download ────────────────────────────── + +gifDownloadBtn.addEventListener('click', async () => { + if (!lastAnimParams) return; + gifDownloadBtn.textContent = '...'; + gifDownloadBtn.disabled = true; + + try { + const formData = new FormData(); + // Copy all entries from saved params + for (const [key, val] of lastAnimParams.formData.entries()) { + formData.append(key, val); + } + formData.set('export_gif', 'true'); + + const endpoint = lastAnimParams.mode === 'image' ? '/api/animate' : '/api/animate-pattern'; + const resp = await fetch(endpoint, { method: 'POST', body: formData }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const blob = await resp.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = lastAnimParams.mode === 'image' ? 'ascii-animation.gif' : 'pattern-animation.gif'; + a.click(); + URL.revokeObjectURL(a.href); + } catch (err) { + alert('GIF download failed: ' + err.message); + } + gifDownloadBtn.textContent = 'GIF'; + gifDownloadBtn.disabled = false; +}); + // ── Compare Box ────────────────────────────── compareToggle.addEventListener('click', e => { diff --git a/static/style.css b/static/style.css index 1774a51..1f2b89a 100644 --- a/static/style.css +++ b/static/style.css @@ -221,6 +221,57 @@ header { height: 14px; } +/* ── Animation Controls ─────────────────────────────── */ + +.anim-controls { + display: flex; + align-items: center; + gap: 10px; + padding: 0 6px; + border-left: 1px solid var(--border); + margin-left: 2px; +} + +.btn-animate { + padding: 8px 16px; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + background: linear-gradient(135deg, var(--accent3), #0abf53); + color: #fff; + transition: all 0.2s; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(48, 209, 88, 0.25); + letter-spacing: 0.3px; +} + +.btn-animate:hover:not(:disabled) { + opacity: 0.9; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(48, 209, 88, 0.4); +} + +.btn-animate:disabled { opacity: 0.3; cursor: not-allowed; box-shadow: none; } + +.playback-controls { + display: flex; + align-items: center; + gap: 4px; + margin-right: 6px; + padding-right: 6px; + border-right: 1px solid var(--border); +} + +.frame-counter { + font-size: 10px; + color: var(--accent3); + font-family: 'JetBrains Mono', monospace; + min-width: 36px; + text-align: center; +} + /* ── Buttons ─────────────────────────────── */ .btn-generate { diff --git a/templates/index.html b/templates/index.html index 8b7d879..6674328 100644 --- a/templates/index.html +++ b/templates/index.html @@ -94,8 +94,30 @@ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + @@ -110,9 +132,14 @@ + +