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 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
357
render.py
|
|
@ -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
|
||||||
|
|
|
||||||
221
static/app.js
221
static/app.js
|
|
@ -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 = '⏸';
|
||||||
|
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 = '▶';
|
||||||
|
} 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 ──────────────────────────────
|
// ── 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 = '▼';
|
||||||
|
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 => {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)">🎲 Random</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>
|
</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">⏸</button>
|
||||||
|
<span id="frameCounter" class="frame-counter">0/0</span>
|
||||||
|
</div>
|
||||||
<button id="fullscreenBtn" class="btn-fullscreen" title="Fullscreen (F)">⛶</button>
|
<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="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 ▶</span>
|
<span id="gifIndicator" class="gif-badge" style="display:none">GIF ▶</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue