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