Add animation output: 5 image effects, pattern animation, GIF export

Extends the ASCII Art Generator with animation capabilities:
- 5 image effects (color cycle, wave, typewriter, pulse, glitch)
- Pattern animation via time-stepping phase parameters
- Browser playback with play/pause and frame counter
- Animated GIF export via server-side Pillow rendering (no new deps)
- New /api/animate and /api/animate-pattern endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-02 15:33:53 -07:00
parent 82192346d0
commit 588fe0028c
5 changed files with 767 additions and 12 deletions

123
app.py
View File

@ -7,13 +7,14 @@ from pathlib import Path
from fastapi import FastAPI, File, Form, UploadFile, Request from fastapi import FastAPI, File, Form, UploadFile, Request
from fastapi.middleware.cors import CORSMiddleware 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from render import ( from render import (
PALETTES, PATTERN_TYPES, image_to_art, gif_to_art, is_animated_gif, PALETTES, PATTERN_TYPES, EFFECT_NAMES,
generate_pattern, 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) 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()), "palettes": list(PALETTES.keys()),
"palette_previews": {k: v[:12] for k, v in PALETTES.items()}, "palette_previews": {k: v[:12] for k, v in PALETTES.items()},
"patterns": PATTERN_TYPES, "patterns": PATTERN_TYPES,
"effects": EFFECT_NAMES,
"cache_bust": CACHE_BUST, "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'<div class="art-frame" data-duration="{f["duration"]}" '
f'style="display:{display}">{f["art"]}</div>'
)
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'<div class="art-frame" data-duration="{f["duration"]}" '
f'style="display:{display}">{f["art"]}</div>'
)
return HTMLResponse("\n".join(frame_divs))
@app.post("/api/render") @app.post("/api/render")
async def render_art( async def render_art(
file: UploadFile = File(...), file: UploadFile = File(...),

357
render.py
View File

@ -10,7 +10,8 @@ import argparse
import math import math
import random import random
from html import escape as html_escape 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 ────────────────────────────── # ── Character palettes sorted dark → light ──────────────────────────────
PALETTES = { PALETTES = {
@ -303,6 +304,145 @@ def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
return vi, p, q 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( def generate_pattern(
pattern_type: str = "plasma", pattern_type: str = "plasma",
width: int = 200, width: int = 200,
@ -310,6 +450,7 @@ def generate_pattern(
palette_name: str = "wingdings", palette_name: str = "wingdings",
output_format: str = "html", output_format: str = "html",
seed: int | None = None, seed: int | None = None,
t_offset: float = 0.0,
) -> str: ) -> str:
"""Generate a colorful pattern as Unicode art.""" """Generate a colorful pattern as Unicode art."""
if seed is None: if seed is None:
@ -323,9 +464,9 @@ def generate_pattern(
freq1 = rng.uniform(0.02, 0.12) freq1 = rng.uniform(0.02, 0.12)
freq2 = rng.uniform(0.01, 0.08) freq2 = rng.uniform(0.01, 0.08)
freq3 = rng.uniform(0.03, 0.15) freq3 = rng.uniform(0.03, 0.15)
phase1 = rng.uniform(0, math.tau) phase1 = rng.uniform(0, math.tau) + t_offset * math.tau
phase2 = rng.uniform(0, math.tau) phase2 = rng.uniform(0, math.tau) + t_offset * math.tau * 0.7
phase3 = rng.uniform(0, math.tau) phase3 = rng.uniform(0, math.tau) + t_offset * math.tau * 1.3
hue_offset = rng.uniform(0, 1) hue_offset = rng.uniform(0, 1)
hue_speed = rng.uniform(0.002, 0.02) hue_speed = rng.uniform(0.002, 0.02)
cx = width * rng.uniform(0.3, 0.7) cx = width * rng.uniform(0.3, 0.7)
@ -497,6 +638,214 @@ def generate_pattern(
return "\n".join(lines) 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): def render_demo(image_path: str, width: int = 70):
"""Render the same image in multiple palettes for comparison.""" """Render the same image in multiple palettes for comparison."""
from rich.console import Console from rich.console import Console

View File

@ -29,12 +29,26 @@ const imageControls = $('imageControls');
const patternControls = $('patternControls'); const patternControls = $('patternControls');
const patternHeight = $('patternHeight'); const patternHeight = $('patternHeight');
const heightVal = $('heightVal'); 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 currentFile = null;
let animationInterval = null; let animationInterval = null;
let lastRenderedHtml = ''; let lastRenderedHtml = '';
let zoomScale = 1.0; let zoomScale = 1.0;
let currentMode = 'image'; let currentMode = 'image';
let animationPlaying = true;
let currentFrameIdx = 0;
let totalFrames = 0;
let lastAnimParams = null;
// ── Mode Switching ────────────────────────────── // ── Mode Switching ──────────────────────────────
@ -49,10 +63,13 @@ function switchMode(mode) {
imageControls.style.display = mode === 'image' ? 'flex' : 'none'; imageControls.style.display = mode === 'image' ? 'flex' : 'none';
patternControls.style.display = mode === 'pattern' ? 'flex' : 'none'; patternControls.style.display = mode === 'pattern' ? 'flex' : 'none';
generateBtn.style.display = mode === 'image' ? '' : 'none'; generateBtn.style.display = mode === 'image' ? '' : 'none';
animateBtn.style.display = mode === 'image' ? '' : 'none';
randomBtn.style.display = mode === 'pattern' ? '' : 'none'; randomBtn.style.display = mode === 'pattern' ? '' : 'none';
animatePatternBtn.style.display = mode === 'pattern' ? '' : 'none';
if (mode === 'image') { if (mode === 'image') {
generateBtn.disabled = !currentFile; generateBtn.disabled = !currentFile;
animateBtn.disabled = !currentFile;
} }
} }
@ -90,6 +107,7 @@ clearFile.addEventListener('click', e => {
fileInfo.style.display = 'none'; fileInfo.style.display = 'none';
dropZone.style.display = ''; dropZone.style.display = '';
generateBtn.disabled = true; generateBtn.disabled = true;
animateBtn.disabled = true;
}); });
function setFile(file) { function setFile(file) {
@ -108,6 +126,7 @@ function setFile(file) {
dropZone.style.display = 'none'; dropZone.style.display = 'none';
fileInfo.style.display = 'flex'; fileInfo.style.display = 'flex';
generateBtn.disabled = false; generateBtn.disabled = false;
animateBtn.disabled = false;
// Auto-generate immediately on file select // Auto-generate immediately on file select
if (currentMode === 'image') { if (currentMode === 'image') {
@ -119,6 +138,8 @@ function setFile(file) {
widthSlider.addEventListener('input', () => { widthVal.textContent = widthSlider.value; }); widthSlider.addEventListener('input', () => { widthVal.textContent = widthSlider.value; });
patternHeight.addEventListener('input', () => { heightVal.textContent = patternHeight.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 // Resolution presets sync the width slider
const resPreset = $('resPreset'); const resPreset = $('resPreset');
@ -238,22 +259,63 @@ async function generatePattern() {
// ── GIF Animation ────────────────────────────── // ── GIF Animation ──────────────────────────────
let animFrames = null;
function startAnimation(frames) { function startAnimation(frames) {
let current = 0; animFrames = frames;
totalFrames = frames.length;
currentFrameIdx = 0;
animationPlaying = true;
frames[0].style.display = 'block'; frames[0].style.display = 'block';
playbackControls.style.display = 'flex';
playPauseBtn.innerHTML = '&#x23F8;';
updateFrameCounter();
function nextFrame() { function nextFrame() {
frames[current].style.display = 'none'; if (!animationPlaying) return;
current = (current + 1) % frames.length; frames[currentFrameIdx].style.display = 'none';
frames[current].style.display = 'block'; currentFrameIdx = (currentFrameIdx + 1) % frames.length;
animationInterval = setTimeout(nextFrame, parseInt(frames[current].dataset.duration) || 100); frames[currentFrameIdx].style.display = 'block';
updateFrameCounter();
animationInterval = setTimeout(nextFrame, parseInt(frames[currentFrameIdx].dataset.duration) || 100);
} }
animationInterval = setTimeout(nextFrame, parseInt(frames[0].dataset.duration) || 100); animationInterval = setTimeout(nextFrame, parseInt(frames[0].dataset.duration) || 100);
} }
function stopAnimation() { function stopAnimation() {
if (animationInterval) { clearTimeout(animationInterval); animationInterval = null; } 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 = '&#x25B6;';
} else {
// Resume
animationPlaying = true;
playPauseBtn.innerHTML = '&#x23F8;';
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 ────────────────────────────── // ── Copy & Download ──────────────────────────────
copyBtn.addEventListener('click', async () => { 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); 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 = '&#x25BC;';
autoFitZoom();
} catch (err) {
spinner.style.display = 'none';
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
}
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 = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
}
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 ────────────────────────────── // ── Compare Box ──────────────────────────────
compareToggle.addEventListener('click', e => { compareToggle.addEventListener('click', e => {

View File

@ -221,6 +221,57 @@ header {
height: 14px; 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 ─────────────────────────────── */ /* ── Buttons ─────────────────────────────── */
.btn-generate { .btn-generate {

View File

@ -94,8 +94,30 @@
</div> </div>
</div> </div>
<!-- Animation controls -->
<div class="anim-controls">
<div class="setting">
<label for="effectType">Effect</label>
<select id="effectType">
{% for e in effects %}
<option value="{{ e }}">{{ e.replace('_', ' ') }}</option>
{% endfor %}
</select>
</div>
<div class="setting">
<label for="numFrames">Fr <span id="framesVal">20</span></label>
<input type="range" id="numFrames" min="6" max="60" value="20">
</div>
<div class="setting">
<label for="frameSpeed">ms <span id="speedVal">100</span></label>
<input type="range" id="frameSpeed" min="20" max="500" value="100" step="10">
</div>
</div>
<button id="generateBtn" class="btn-generate" disabled>Generate</button> <button id="generateBtn" class="btn-generate" disabled>Generate</button>
<button id="animateBtn" class="btn-animate" disabled>Animate</button>
<button id="randomBtn" class="btn-generate" style="display:none;background:linear-gradient(135deg,#ff6b6b,#ffd93d,#6bff6b,#6bbbff,#bf5af2)">&#x1F3B2; Random</button> <button id="randomBtn" class="btn-generate" style="display:none;background:linear-gradient(135deg,#ff6b6b,#ffd93d,#6bff6b,#6bbbff,#bf5af2)">&#x1F3B2; Random</button>
<button id="animatePatternBtn" class="btn-animate" style="display:none">Animate</button>
</div> </div>
<!-- Preview (fills viewport) --> <!-- Preview (fills viewport) -->
@ -110,9 +132,14 @@
<button id="zoomIn" title="Zoom in (+)">+</button> <button id="zoomIn" title="Zoom in (+)">+</button>
<button id="zoomFit" class="btn-small" title="Fit to view" style="font-size:10px">Fit</button> <button id="zoomFit" class="btn-small" title="Fit to view" style="font-size:10px">Fit</button>
</div> </div>
<div id="playbackControls" class="playback-controls" style="display:none">
<button id="playPauseBtn" class="btn-small" title="Play/Pause">&#x23F8;</button>
<span id="frameCounter" class="frame-counter">0/0</span>
</div>
<button id="fullscreenBtn" class="btn-fullscreen" title="Fullscreen (F)">&#x26F6;</button> <button id="fullscreenBtn" class="btn-fullscreen" title="Fullscreen (F)">&#x26F6;</button>
<button id="copyBtn" class="btn-small" title="Copy plain text" style="display:none">Copy</button> <button id="copyBtn" class="btn-small" title="Copy plain text" style="display:none">Copy</button>
<button id="downloadBtn" class="btn-small" title="Download HTML" style="display:none">Download</button> <button id="downloadBtn" class="btn-small" title="Download HTML" style="display:none">Download</button>
<button id="gifDownloadBtn" class="btn-small" title="Download GIF" style="display:none">GIF</button>
<span id="gifIndicator" class="gif-badge" style="display:none">GIF &#x25B6;</span> <span id="gifIndicator" class="gif-badge" style="display:none">GIF &#x25B6;</span>
</div> </div>
</div> </div>