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:
parent
82192346d0
commit
588fe0028c
123
app.py
123
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'<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")
|
||||
async def render_art(
|
||||
file: UploadFile = File(...),
|
||||
|
|
|
|||
357
render.py
357
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
|
||||
|
|
|
|||
221
static/app.js
221
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 = `<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 ──────────────────────────────
|
||||
|
||||
compareToggle.addEventListener('click', e => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -94,8 +94,30 @@
|
|||
</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="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)">🎲 Random</button>
|
||||
<button id="animatePatternBtn" class="btn-animate" style="display:none">Animate</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview (fills viewport) -->
|
||||
|
|
@ -110,9 +132,14 @@
|
|||
<button id="zoomIn" title="Zoom in (+)">+</button>
|
||||
<button id="zoomFit" class="btn-small" title="Fit to view" style="font-size:10px">Fit</button>
|
||||
</div>
|
||||
<div id="playbackControls" class="playback-controls" style="display:none">
|
||||
<button id="playPauseBtn" class="btn-small" title="Play/Pause">⏸</button>
|
||||
<span id="frameCounter" class="frame-counter">0/0</span>
|
||||
</div>
|
||||
<button id="fullscreenBtn" class="btn-fullscreen" title="Fullscreen (F)">⛶</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="gifDownloadBtn" class="btn-small" title="Download GIF" style="display:none">GIF</button>
|
||||
<span id="gifIndicator" class="gif-badge" style="display:none">GIF ▶</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue