mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Expand the pixel-art skill from 2 presets (arcade, snes) to 14 presets with hardware-accurate palettes (NES, Game Boy, PICO-8, C64, Apple II, MS Paint, CRT mono), plus a procedural video overlay pipeline. Ported from Synero/pixel-art-studio (MIT). Full attribution in ATTRIBUTION.md. What's in: - scripts/palettes.py — 28 named RGB palettes (hardware + artistic) - scripts/pixel_art.py — 14 presets, named palette support, CLI - scripts/pixel_art_video.py — 12 animation scenes (stars, rain, fireflies, snow, embers, lightning, etc.) → MP4/GIF via ffmpeg - references/palettes.md — palette catalog - SKILL.md — clarify-tool workflow (offer style, then optional scene) What's out (intentional): - Wu's quantizer (PIL's built-in quantize suffices) - Sobel edge-aware downsample (scipy dep not worth it) - Atkinson/Bayer dither (would need numpy reimpl) - Pollinations text-to-image (Hermes uses image_generate instead) Video pipeline uses subprocess.run with check=True (replaces os.system) and tempfile.TemporaryDirectory (replaces manual cleanup).
345 lines
12 KiB
Python
345 lines
12 KiB
Python
"""Pixel art video — overlay procedural animations onto a source image.
|
|
|
|
Takes any image (typically pre-processed with pixel_art()) and overlays
|
|
animated pixel effects (stars, rain, fireflies, etc.), then encodes to MP4
|
|
(and optionally GIF) via ffmpeg.
|
|
|
|
Scene animations ported from pixel-art-studio (MIT) — see ATTRIBUTION.md.
|
|
The generative/Pollinations code is intentionally dropped — Hermes uses
|
|
`image_generate` + `pixel_art()` for base frames instead.
|
|
|
|
Usage (import):
|
|
from pixel_art_video import pixel_art_video
|
|
pixel_art_video("frame.png", "out.mp4", scene="night", duration=6)
|
|
|
|
Usage (CLI):
|
|
python pixel_art_video.py frame.png out.mp4 --scene night --duration 6 --gif
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import random
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
|
|
# ── Pixel drawing helpers ──────────────────────────────────────────────
|
|
|
|
def _px(draw, x, y, color, size=2):
|
|
x, y = int(x), int(y)
|
|
W, H = draw.im.size
|
|
if 0 <= x < W and 0 <= y < H:
|
|
draw.rectangle([x, y, x + size - 1, y + size - 1], fill=color)
|
|
|
|
|
|
def _pixel_cross(draw, x, y, color, arm=2):
|
|
x, y = int(x), int(y)
|
|
for i in range(-arm, arm + 1):
|
|
_px(draw, x + i, y, color, 1)
|
|
_px(draw, x, y + i, color, 1)
|
|
|
|
|
|
# ── Animation init/draw pairs ──────────────────────────────────────────
|
|
|
|
def init_stars(rng, W, H):
|
|
return [(rng.randint(0, W), rng.randint(0, H // 2)) for _ in range(15)]
|
|
|
|
def draw_stars(draw, stars, t, W, H):
|
|
for i, (sx, sy) in enumerate(stars):
|
|
if math.sin(t * 2.0 + i * 0.7) > 0.65:
|
|
_pixel_cross(draw, sx, sy, (255, 255, 220), arm=2)
|
|
|
|
|
|
def init_fireflies(rng, W, H):
|
|
return [{"x": rng.randint(20, W - 20), "y": rng.randint(H // 4, H - 20),
|
|
"phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.3, 0.8)}
|
|
for _ in range(10)]
|
|
|
|
def draw_fireflies(draw, ff, t, W, H):
|
|
for f in ff:
|
|
if math.sin(t * 1.5 + f["phase"]) < 0.15:
|
|
continue
|
|
_px(draw,
|
|
f["x"] + math.sin(t * f["speed"] + f["phase"]) * 3,
|
|
f["y"] + math.cos(t * f["speed"] * 0.7) * 2,
|
|
(200, 255, 100), 2)
|
|
|
|
|
|
def init_leaves(rng, W, H):
|
|
return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),
|
|
"speed": rng.uniform(0.5, 1.5), "wobble": rng.uniform(0.02, 0.05),
|
|
"phase": rng.uniform(0, 6.28),
|
|
"color": rng.choice([(180, 120, 50), (160, 100, 40), (200, 140, 60)])}
|
|
for _ in range(12)]
|
|
|
|
def draw_leaves(draw, leaves, t, W, H):
|
|
for leaf in leaves:
|
|
_px(draw,
|
|
leaf["x"] + math.sin(t * leaf["wobble"] + leaf["phase"]) * 15,
|
|
(leaf["y"] + t * leaf["speed"] * 20) % (H + 40) - 20,
|
|
leaf["color"], 2)
|
|
|
|
|
|
def init_dust_motes(rng, W, H):
|
|
return [{"x": rng.randint(30, W - 30), "y": rng.randint(30, H - 30),
|
|
"phase": rng.uniform(0, 6.28), "speed": rng.uniform(0.2, 0.5),
|
|
"amp": rng.uniform(2, 6)} for _ in range(20)]
|
|
|
|
def draw_dust_motes(draw, motes, t, W, H):
|
|
for m in motes:
|
|
if math.sin(t * 2.0 + m["phase"]) > 0.3:
|
|
_px(draw,
|
|
m["x"] + math.sin(t * 0.3 + m["phase"]) * m["amp"],
|
|
m["y"] - (m["speed"] * t * 15) % H,
|
|
(255, 210, 100), 1)
|
|
|
|
|
|
def init_sparkles(rng, W, H):
|
|
return [(rng.randint(W // 4, 3 * W // 4), rng.randint(H // 4, 3 * H // 4),
|
|
rng.uniform(0, 6.28),
|
|
rng.choice([(180, 200, 255), (255, 220, 150), (200, 180, 255)]))
|
|
for _ in range(10)]
|
|
|
|
def draw_sparkles(draw, sparkles, t, W, H):
|
|
for sx, sy, phase, color in sparkles:
|
|
if math.sin(t * 1.8 + phase) > 0.6:
|
|
_pixel_cross(draw, sx, sy, color, arm=2)
|
|
|
|
|
|
def init_rain(rng, W, H):
|
|
return [{"x": rng.randint(0, W), "y": rng.randint(0, H),
|
|
"speed": rng.uniform(4, 8)} for _ in range(30)]
|
|
|
|
def draw_rain(draw, rain, t, W, H):
|
|
for r in rain:
|
|
y = (r["y"] + t * r["speed"] * 20) % H
|
|
_px(draw, r["x"], y, (120, 150, 200), 1)
|
|
_px(draw, r["x"], y + 4, (100, 130, 180), 1)
|
|
|
|
|
|
def init_lightning(rng, W, H):
|
|
return {"timer": 0, "flash": False, "rng": rng}
|
|
|
|
def draw_lightning(draw, state, t, W, H):
|
|
state["timer"] += 1
|
|
if state["timer"] > 45 and state["rng"].random() < 0.04:
|
|
state["flash"] = True
|
|
state["timer"] = 0
|
|
if state["flash"]:
|
|
for x in range(0, W, 4):
|
|
for y in range(0, H // 3, 3):
|
|
if state["rng"].random() < 0.12:
|
|
_px(draw, x, y, (255, 255, 240), 2)
|
|
state["flash"] = False
|
|
|
|
|
|
def init_bubbles(rng, W, H):
|
|
return [{"x": rng.randint(20, W - 20), "y": rng.randint(H, H * 2),
|
|
"speed": rng.uniform(0.3, 0.8), "size": rng.choice([1, 2, 2])}
|
|
for _ in range(15)]
|
|
|
|
def draw_bubbles(draw, bubbles, t, W, H):
|
|
for b in bubbles:
|
|
x = b["x"] + math.sin(t * 0.5 + b["x"]) * 3
|
|
y = b["y"] - (t * b["speed"] * 20) % (H + 40)
|
|
if 0 < y < H:
|
|
_px(draw, x, y, (150, 200, 255), b["size"])
|
|
|
|
|
|
def init_embers(rng, W, H):
|
|
return [{"x": rng.randint(0, W), "y": rng.randint(0, H),
|
|
"speed": rng.uniform(0.3, 0.9), "phase": rng.uniform(0, 6.28),
|
|
"color": rng.choice([(255, 150, 30), (255, 100, 20), (255, 200, 50)])}
|
|
for _ in range(18)]
|
|
|
|
def draw_embers(draw, embers, t, W, H):
|
|
for e in embers:
|
|
x = e["x"] + math.sin(t * 0.4 + e["phase"]) * 5
|
|
y = e["y"] - (t * e["speed"] * 15) % H
|
|
if math.sin(t * 2.5 + e["phase"]) > 0.2:
|
|
_px(draw, x, y, e["color"], 2)
|
|
|
|
|
|
def init_snowflakes(rng, W, H):
|
|
return [{"x": rng.randint(0, W), "y": rng.randint(-H, 0),
|
|
"speed": rng.uniform(0.3, 0.6), "wobble": rng.uniform(0.04, 0.09),
|
|
"size": rng.choice([2, 2, 3])}
|
|
for _ in range(40)]
|
|
|
|
def draw_snowflakes(draw, flakes, t, W, H):
|
|
for f in flakes:
|
|
x = f["x"] + math.sin(t * f["wobble"] + f["x"]) * 20
|
|
y = (f["y"] + t * f["speed"] * 8) % (H + 20) - 10
|
|
if f["size"] >= 3:
|
|
_pixel_cross(draw, x, y, (230, 235, 255), arm=1)
|
|
else:
|
|
_px(draw, x, y, (230, 235, 255), 2)
|
|
|
|
|
|
def init_neon_pulse(rng, W, H):
|
|
return [(rng.randint(0, W), rng.randint(0, H), rng.uniform(0, 6.28),
|
|
rng.choice([(255, 0, 200), (0, 255, 255), (255, 50, 150)]))
|
|
for _ in range(8)]
|
|
|
|
def draw_neon_pulse(draw, points, t, W, H):
|
|
for x, y, phase, color in points:
|
|
if math.sin(t * 2.5 + phase) > 0.5:
|
|
_pixel_cross(draw, x, y, color, arm=3)
|
|
|
|
|
|
def init_heat_shimmer(rng, W, H):
|
|
return [{"x": rng.randint(0, W), "y": rng.randint(H // 2, H),
|
|
"phase": rng.uniform(0, 6.28)} for _ in range(12)]
|
|
|
|
def draw_heat_shimmer(draw, points, t, W, H):
|
|
for p in points:
|
|
x = p["x"] + math.sin(t * 0.8 + p["phase"]) * 2
|
|
y = p["y"] + math.sin(t * 1.2 + p["phase"]) * 1
|
|
if abs(math.sin(t * 1.5 + p["phase"])) > 0.6:
|
|
_px(draw, x, y, (255, 200, 100), 1)
|
|
|
|
|
|
# ── Scene → animation mapping ──────────────────────────────────────────
|
|
|
|
SCENES = {
|
|
"night": ["stars", "fireflies", "leaves"],
|
|
"dusk": ["fireflies", "sparkles"],
|
|
"tavern": ["dust_motes", "sparkles"],
|
|
"indoor": ["dust_motes"],
|
|
"urban": ["rain", "neon_pulse"],
|
|
"nature": ["leaves", "fireflies"],
|
|
"magic": ["sparkles", "fireflies"],
|
|
"storm": ["rain", "lightning"],
|
|
"underwater": ["bubbles", "sparkles"],
|
|
"fire": ["embers", "sparkles"],
|
|
"snow": ["snowflakes", "sparkles"],
|
|
"desert": ["heat_shimmer", "dust_motes"],
|
|
}
|
|
|
|
# Map scene layer name to (init_fn, draw_fn).
|
|
_LAYERS = {
|
|
"stars": (init_stars, draw_stars),
|
|
"fireflies": (init_fireflies, draw_fireflies),
|
|
"leaves": (init_leaves, draw_leaves),
|
|
"dust_motes": (init_dust_motes, draw_dust_motes),
|
|
"sparkles": (init_sparkles, draw_sparkles),
|
|
"rain": (init_rain, draw_rain),
|
|
"lightning": (init_lightning, draw_lightning),
|
|
"bubbles": (init_bubbles, draw_bubbles),
|
|
"embers": (init_embers, draw_embers),
|
|
"snowflakes": (init_snowflakes, draw_snowflakes),
|
|
"neon_pulse": (init_neon_pulse, draw_neon_pulse),
|
|
"heat_shimmer": (init_heat_shimmer, draw_heat_shimmer),
|
|
}
|
|
|
|
|
|
def _ensure_ffmpeg():
|
|
if shutil.which("ffmpeg") is None:
|
|
raise RuntimeError(
|
|
"ffmpeg not found on PATH. Install via your package manager or "
|
|
"download from https://ffmpeg.org/"
|
|
)
|
|
|
|
|
|
def pixel_art_video(
|
|
base_image,
|
|
output_path,
|
|
scene="night",
|
|
duration=6,
|
|
fps=15,
|
|
seed=None,
|
|
export_gif=False,
|
|
):
|
|
"""Overlay pixel animations onto a base image and encode to MP4.
|
|
|
|
Args:
|
|
base_image: path to source image (ideally already pixel-art styled)
|
|
output_path: path to MP4 output (GIF sibling written if export_gif=True)
|
|
scene: key from SCENES (night, urban, storm, snow, fire, ...)
|
|
duration: seconds of animation
|
|
fps: frames per second (default 15 for retro feel)
|
|
seed: optional int for reproducible animation placement
|
|
export_gif: also write a GIF alongside the MP4
|
|
|
|
Returns:
|
|
(mp4_path, gif_path_or_None)
|
|
"""
|
|
if scene not in SCENES:
|
|
raise ValueError(
|
|
f"Unknown scene {scene!r}. Choose from: {sorted(SCENES)}"
|
|
)
|
|
_ensure_ffmpeg()
|
|
|
|
base = Image.open(base_image).convert("RGB")
|
|
W, H = base.size
|
|
|
|
rng = random.Random(seed if seed is not None else 42)
|
|
layers = []
|
|
for name in SCENES[scene]:
|
|
init_fn, draw_fn = _LAYERS[name]
|
|
layers.append((draw_fn, init_fn(rng, W, H)))
|
|
|
|
n_frames = fps * duration
|
|
os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True)
|
|
|
|
with tempfile.TemporaryDirectory(prefix="pixelart_frames_") as frames_dir:
|
|
for frame_idx in range(n_frames):
|
|
canvas = base.copy()
|
|
draw = ImageDraw.Draw(canvas)
|
|
t = frame_idx / fps
|
|
for draw_fn, state in layers:
|
|
draw_fn(draw, state, t, W, H)
|
|
canvas.save(os.path.join(frames_dir, f"frame_{frame_idx:04d}.png"))
|
|
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-loglevel", "error",
|
|
"-framerate", str(fps),
|
|
"-i", os.path.join(frames_dir, "frame_%04d.png"),
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
|
|
output_path],
|
|
check=True,
|
|
)
|
|
|
|
gif_path = None
|
|
if export_gif:
|
|
gif_path = output_path.rsplit(".", 1)[0] + ".gif"
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-loglevel", "error",
|
|
"-framerate", str(fps),
|
|
"-i", os.path.join(frames_dir, "frame_%04d.png"),
|
|
"-vf",
|
|
"scale=320:-1:flags=neighbor,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
|
|
"-loop", "0",
|
|
gif_path],
|
|
check=True,
|
|
)
|
|
|
|
return output_path, gif_path
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
p = argparse.ArgumentParser(description="Overlay pixel animations onto an image → MP4.")
|
|
p.add_argument("base_image")
|
|
p.add_argument("output")
|
|
p.add_argument("--scene", default="night", choices=sorted(SCENES))
|
|
p.add_argument("--duration", type=int, default=6)
|
|
p.add_argument("--fps", type=int, default=15)
|
|
p.add_argument("--seed", type=int, default=None)
|
|
p.add_argument("--gif", action="store_true")
|
|
args = p.parse_args()
|
|
mp4, gif = pixel_art_video(
|
|
args.base_image, args.output,
|
|
scene=args.scene, duration=args.duration,
|
|
fps=args.fps, seed=args.seed, export_gif=args.gif,
|
|
)
|
|
print(f"Wrote {mp4}")
|
|
if gif:
|
|
print(f"Wrote {gif}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|