File size: 3,822 Bytes
d5147ba | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | """Look of the README animation: white background, one hue per loop, one text colour."""
import os
import shutil
import subprocess
from pathlib import Path
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap, to_rgb
BG = "#FFFFFF"
TEXT = "#1F2328"
WARP = "#D3D9E3"
LOOP_CMAP = LinearSegmentedColormap.from_list("loops", ["#3A5BD9", "#2BA89A", "#F08A4B"])
FPS = 24
W, H_PX, DPI = 12.0, 6.0, 100 # 1200 x 600 px
plt.rcParams.update({"font.family": "DejaVu Sans", "text.color": TEXT})
def loop_color(t, n):
return LOOP_CMAP(t / max(n - 1, 1))
def ease(p):
p = min(max(float(p), 0.0), 1.0)
return p * p * (3 - 2 * p)
def ease_out(p):
p = min(max(float(p), 0.0), 1.0)
return 1 - (1 - p) ** 3
def mix(c1, c2, p):
a, b = np.array(to_rgb(c1)), np.array(to_rgb(c2))
return tuple(a + (b - a) * min(max(p, 0.0), 1.0))
def new_frame():
fig = plt.figure(figsize=(W, H_PX), dpi=DPI, facecolor=BG)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_facecolor(BG)
ax.axis("off")
return fig, ax
def glow_line(ax, xs, ys, color, lw, alpha=1.0, z=3, glow=True):
if glow:
for k, a in ((5.0, 0.05), (2.6, 0.12)):
ax.plot(xs, ys, color=color, lw=lw * k, alpha=a * alpha, zorder=z, solid_capstyle="round")
ax.plot(xs, ys, color=color, lw=lw, alpha=alpha, zorder=z + 0.1, solid_capstyle="round")
def glow_dot(ax, x, y, color, size, alpha=1.0, z=5):
for k, a in ((5.0, 0.06), (2.6, 0.16)):
ax.scatter([x], [y], s=(size * k) ** 2, color=color, alpha=a * alpha, zorder=z, edgecolors="none")
ax.scatter([x], [y], s=size ** 2, color=color, alpha=alpha, zorder=z + 0.1, edgecolors="none")
def encode_gif(src_args, gif: Path, width=1000, colors=96, lossy=int(os.environ.get("GIF_LOSSY", "12"))):
"""ffmpeg palette GIF without dithering (dither speckles the dark background), then gifsicle."""
vf = (f"scale={width}:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors={colors}:stats_mode=full[p];"
"[b][p]paletteuse=dither=none")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *src_args, "-vf", vf, str(gif)], check=True)
if shutil.which("gifsicle"):
subprocess.run(["gifsicle", "-O3", *([f"--lossy={lossy}"] if lossy else []), "-b", str(gif)], check=True)
class Recorder:
"""Save frames, then encode a GIF (palette per clip) and an MP4."""
def __init__(self, frames_dir: Path):
self.dir = frames_dir
shutil.rmtree(self.dir, ignore_errors=True)
self.dir.mkdir(parents=True)
self.n = 0
# PREVIEW=12,80,300 saves only those frames (as PNG) and skips encoding.
raw = os.environ.get("PREVIEW", "").strip()
self.preview = {int(x) for x in raw.split(",")} if raw else None
def add(self, fig):
if self.preview is None or self.n in self.preview:
fig.savefig(self.dir / f"{self.n:05d}.png", facecolor=BG)
plt.close(fig)
self.n += 1
def encode(self, gif: Path, mp4: Path = None, width=1000, colors=96):
if self.preview is not None:
print("preview frames in", self.dir)
return 0.0
gif.parent.mkdir(parents=True, exist_ok=True)
src = ["-framerate", str(FPS), "-i", str(self.dir / "%05d.png")]
encode_gif(src, gif, width, colors)
if mp4 is not None:
mp4.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *src, "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-crf", "18", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", str(mp4)], check=True)
if not os.environ.get("KEEP_FRAMES"):
shutil.rmtree(self.dir)
return gif.stat().st_size / 1e6
|