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 @@
+
+
+ 0/0
+
+
GIF ▶