scripts/pixel_art_video.py
scripts/pixel_art_video.pyBrowse 7 files
3,756 tokens
12,117 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""Pixel art video — overlay procedural animations onto a source image.2 3Takes any image (typically pre-processed with pixel_art()) and overlays4animated pixel effects (stars, rain, fireflies, etc.), then encodes to MP45(and optionally GIF) via ffmpeg.6 7Scene animations ported from pixel-art-studio (MIT) — see ATTRIBUTION.md.8The generative/Pollinations code is intentionally dropped — Hermes uses9`image_generate` + `pixel_art()` for base frames instead.10 11Usage (import):12 from pixel_art_video import pixel_art_video13 pixel_art_video("frame.png", "out.mp4", scene="night", duration=6)14 15Usage (CLI):16 python pixel_art_video.py frame.png out.mp4 --scene night --duration 6 --gif17"""18 19import math20import os21import random22import shutil23import subprocess24import tempfile25 26from PIL import Image, ImageDraw27 28 29# ── Pixel drawing helpers ──────────────────────────────────────────────30 31def _px(draw, x, y, color, size=2):32 x, y = int(x), int(y)33 W, H = draw.im.size34 if 0 <= x < W and 0 <= y < H:35 draw.rectangle([x, y, x + size - 1, y + size - 1], fill=color)36 37 38def _pixel_cross(draw, x, y, color, arm=2):39 x, y = int(x), int(y)40 for i in range(-arm, arm + 1):41 _px(draw, x + i, y, color, 1)42 _px(draw, x, y + i, color, 1)43 44 45# ── Animation init/draw pairs ──────────────────────────────────────────46 47def init_stars(rng, W, H):48 return [(rng.randint(0, W), rng.randint(0, H // 2)) for _ in range(15)]49 50def draw_stars(draw, stars, t, W, H):51 for i, (sx, sy) in enumerate(stars):52 if math.sin(t * 2.0 + i * 0.7) > 0.65:53 _pixel_cross(draw, sx, sy, (255, 255, 220), arm=2)54 55 56def init_fireflies(rng, W, H):57 return [{"x": rng.randint(20, W - 20), "y": rng.randint(H // 4, H - 20),58 "phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.3, 0.8)}59 for _ in range(10)]60 61def draw_fireflies(draw, ff, t, W, H):62 for f in ff:63 if math.sin(t * 1.5 + f["phase"]) < 0.15:64 continue65 _px(draw,66 f["x"] + math.sin(t * f["speed"] + f["phase"]) * 3,67 f["y"] + math.cos(t * f["speed"] * 0.7) * 2,68 (200, 255, 100), 2)69 70 71def init_leaves(rng, W, H):72 return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),73 "speed": rng.uniform(0.5, 1.5), "wobble": rng.uniform(0.02, 0.05),74 "phase": rng.uniform(0, 6.28),75 "color": rng.choice([(180, 120, 50), (160, 100, 40), (200, 140, 60)])}76 for _ in range(12)]77 78def draw_leaves(draw, leaves, t, W, H):79 for leaf in leaves:80 _px(draw,81 leaf["x"] + math.sin(t * leaf["wobble"] + leaf["phase"]) * 15,82 (leaf["y"] + t * leaf["speed"] * 20) % (H + 40) - 20,83 leaf["color"], 2)84 85 86def init_dust_motes(rng, W, H):87 return [{"x": rng.randint(30, W - 30), "y": rng.randint(30, H - 30),88 "phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.2, 0.5),89 "amp": rng.uniform(2, 6)} for _ in range(20)]90 91def draw_dust_motes(draw, motes, t, W, H):92 for m in motes:93 if math.sin(t * 2.0 + m["phase"]) > 0.3:94 _px(draw,95 m["x"] + math.sin(t * 0.3 + m["phase"]) * m["amp"],96 m["y"] - (m["speed"] * t * 15) % H,97 (255, 210, 100), 1)98 99 100def init_sparkles(rng, W, H):101 return [(rng.randint(W // 4, 3 * W // 4), rng.randint(H // 4, 3 * H // 4),102 rng.uniform(0, 6.28),103 rng.choice([(180, 200, 255), (255, 220, 150), (200, 180, 255)]))104 for _ in range(10)]105 106def draw_sparkles(draw, sparkles, t, W, H):107 for sx, sy, phase, color in sparkles:108 if math.sin(t * 1.8 + phase) > 0.6:109 _pixel_cross(draw, sx, sy, color, arm=2)110 111 112def init_rain(rng, W, H):113 return [{"x": rng.randint(0, W), "y": rng.randint(0, H),114 "speed": rng.uniform(4, 8)} for _ in range(30)]115 116def draw_rain(draw, rain, t, W, H):117 for r in rain:118 y = (r["y"] + t * r["speed"] * 20) % H119 _px(draw, r["x"], y, (120, 150, 200), 1)120 _px(draw, r["x"], y + 4, (100, 130, 180), 1)121 122 123def init_lightning(rng, W, H):124 return {"timer": 0, "flash": False, "rng": rng}125 126def draw_lightning(draw, state, t, W, H):127 state["timer"] += 1128 if state["timer"] > 45 and state["rng"].random() < 0.04:129 state["flash"] = True130 state["timer"] = 0131 if state["flash"]:132 for x in range(0, W, 4):133 for y in range(0, H // 3, 3):134 if state["rng"].random() < 0.12:135 _px(draw, x, y, (255, 255, 240), 2)136 state["flash"] = False137 138 139def init_bubbles(rng, W, H):140 return [{"x": rng.randint(20, W - 20), "y": rng.randint(H, H * 2),141 "speed": rng.uniform(0.3, 0.8), "size": rng.choice([1, 2, 2])}142 for _ in range(15)]143 144def draw_bubbles(draw, bubbles, t, W, H):145 for b in bubbles:146 x = b["x"] + math.sin(t * 0.5 + b["x"]) * 3147 y = b["y"] - (t * b["speed"] * 20) % (H + 40)148 if 0 < y < H:149 _px(draw, x, y, (150, 200, 255), b["size"])150 151 152def init_embers(rng, W, H):153 return [{"x": rng.randint(0, W), "y": rng.randint(0, H),154 "speed": rng.uniform(0.3, 0.9), "phase": rng.uniform(0, 6.28),155 "color": rng.choice([(255, 150, 30), (255, 100, 20), (255, 200, 50)])}156 for _ in range(18)]157 158def draw_embers(draw, embers, t, W, H):159 for e in embers:160 x = e["x"] + math.sin(t * 0.4 + e["phase"]) * 5161 y = e["y"] - (t * e["speed"] * 15) % H162 if math.sin(t * 2.5 + e["phase"]) > 0.2:163 _px(draw, x, y, e["color"], 2)164 165 166def init_snowflakes(rng, W, H):167 return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),168 "speed": rng.uniform(0.3, 0.6), "wobble": rng.uniform(0.04, 0.09),169 "size": rng.choice([2, 2, 3])}170 for _ in range(40)]171 172def draw_snowflakes(draw, flakes, t, W, H):173 for f in flakes:174 x = f["x"] + math.sin(t * f["wobble"] + f["x"]) * 20175 y = (f["y"] + t * f["speed"] * 8) % (H + 20) - 10176 if f["size"] >= 3:177 _pixel_cross(draw, x, y, (230, 235, 255), arm=1)178 else:179 _px(draw, x, y, (230, 235, 255), 2)180 181 182def init_neon_pulse(rng, W, H):183 return [(rng.randint(0, W), rng.randint(0, H), rng.uniform(0, 6.28),184 rng.choice([(255, 0, 200), (0, 255, 255), (255, 50, 150)]))185 for _ in range(8)]186 187def draw_neon_pulse(draw, points, t, W, H):188 for x, y, phase, color in points:189 if math.sin(t * 2.5 + phase) > 0.5:190 _pixel_cross(draw, x, y, color, arm=3)191 192 193def init_heat_shimmer(rng, W, H):194 return [{"x": rng.randint(0, W), "y": rng.randint(H // 2, H),195 "phase": rng.uniform(0, 6.28)} for _ in range(12)]196 197def draw_heat_shimmer(draw, points, t, W, H):198 for p in points:199 x = p["x"] + math.sin(t * 0.8 + p["phase"]) * 2200 y = p["y"] + math.sin(t * 1.2 + p["phase"]) * 1201 if abs(math.sin(t * 1.5 + p["phase"])) > 0.6:202 _px(draw, x, y, (255, 200, 100), 1)203 204 205# ── Scene → animation mapping ──────────────────────────────────────────206 207SCENES = {208 "night": ["stars", "fireflies", "leaves"],209 "dusk": ["fireflies", "sparkles"],210 "tavern": ["dust_motes", "sparkles"],211 "indoor": ["dust_motes"],212 "urban": ["rain", "neon_pulse"],213 "nature": ["leaves", "fireflies"],214 "magic": ["sparkles", "fireflies"],215 "storm": ["rain", "lightning"],216 "underwater": ["bubbles", "sparkles"],217 "fire": ["embers", "sparkles"],218 "snow": ["snowflakes", "sparkles"],219 "desert": ["heat_shimmer", "dust_motes"],220}221 222# Map scene layer name to (init_fn, draw_fn).223_LAYERS = {224 "stars": (init_stars, draw_stars),225 "fireflies": (init_fireflies, draw_fireflies),226 "leaves": (init_leaves, draw_leaves),227 "dust_motes": (init_dust_motes, draw_dust_motes),228 "sparkles": (init_sparkles, draw_sparkles),229 "rain": (init_rain, draw_rain),230 "lightning": (init_lightning, draw_lightning),231 "bubbles": (init_bubbles, draw_bubbles),232 "embers": (init_embers, draw_embers),233 "snowflakes": (init_snowflakes, draw_snowflakes),234 "neon_pulse": (init_neon_pulse, draw_neon_pulse),235 "heat_shimmer": (init_heat_shimmer, draw_heat_shimmer),236}237 238 239def _ensure_ffmpeg():240 if shutil.which("ffmpeg") is None:241 raise RuntimeError(242 "ffmpeg not found on PATH. Install via your package manager or "243 "download from https://ffmpeg.org/"244 )245 246 247def pixel_art_video(248 base_image,249 output_path,250 scene="night",251 duration=6,252 fps=15,253 seed=None,254 export_gif=False,255):256 """Overlay pixel animations onto a base image and encode to MP4.257 258 Args:259 base_image: path to source image (ideally already pixel-art styled)260 output_path: path to MP4 output (GIF sibling written if export_gif=True)261 scene: key from SCENES (night, urban, storm, snow, fire, ...)262 duration: seconds of animation263 fps: frames per second (default 15 for retro feel)264 seed: optional int for reproducible animation placement265 export_gif: also write a GIF alongside the MP4266 267 Returns:268 (mp4_path, gif_path_or_None)269 """270 if scene not in SCENES:271 raise ValueError(272 f"Unknown scene {scene!r}. Choose from: {sorted(SCENES)}"273 )274 _ensure_ffmpeg()275 276 base = Image.open(base_image).convert("RGB")277 W, H = base.size278 279 rng = random.Random(seed if seed is not None else 42)280 layers = []281 for name in SCENES[scene]:282 init_fn, draw_fn = _LAYERS[name]283 layers.append((draw_fn, init_fn(rng, W, H)))284 285 n_frames = fps * duration286 os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True)287 288 with tempfile.TemporaryDirectory(prefix="pixelart_frames_") as frames_dir:289 for frame_idx in range(n_frames):290 canvas = base.copy()291 draw = ImageDraw.Draw(canvas)292 t = frame_idx / fps293 for draw_fn, state in layers:294 draw_fn(draw, state, t, W, H)295 canvas.save(os.path.join(frames_dir, f"frame_{frame_idx:04d}.png"))296 297 subprocess.run(298 ["ffmpeg", "-y", "-loglevel", "error",299 "-framerate", str(fps),300 "-i", os.path.join(frames_dir, "frame_%04d.png"),301 "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",302 output_path],303 check=True,304 )305 306 gif_path = None307 if export_gif:308 gif_path = output_path.rsplit(".", 1)[0] + ".gif"309 subprocess.run(310 ["ffmpeg", "-y", "-loglevel", "error",311 "-framerate", str(fps),312 "-i", os.path.join(frames_dir, "frame_%04d.png"),313 "-vf",314 "scale=320:-1:flags=neighbor,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",315 "-loop", "0",316 gif_path],317 check=True,318 )319 320 return output_path, gif_path321 322 323def main():324 import argparse325 p = argparse.ArgumentParser(description="Overlay pixel animations onto an image → MP4.")326 p.add_argument("base_image")327 p.add_argument("output")328 p.add_argument("--scene", default="night", choices=sorted(SCENES))329 p.add_argument("--duration", type=int, default=6)330 p.add_argument("--fps", type=int, default=15)331 p.add_argument("--seed", type=int, default=None)332 p.add_argument("--gif", action="store_true")333 args = p.parse_args()334 mp4, gif = pixel_art_video(335 args.base_image, args.output,336 scene=args.scene, duration=args.duration,337 fps=args.fps, seed=args.seed, export_gif=args.gif,338 )339 print(f"Wrote {mp4}")340 if gif:341 print(f"Wrote {gif}")342 343 344if __name__ == "__main__":345 main()346