Skip to content

geomotif.io.gif

Write an animated GIF, in pure standard library.

A design that draws itself on, a motif whose parameter sweeps, a figure turning: an animation says something a still image cannot, and GIF is the one animated format that plays everywhere with nothing installed -- a README, a chat window, an issue comment.

It is also, unusually for a 1989 format, small enough to write by hand. The file is a color table, a run of frames, and a trailer; the only real work is LZW, and GIF's variant of it fits on a page: build a dictionary of byte strings as you go, emit each match as a code, widen the code as the dictionary fills, and start again from empty when it is full at 4096 entries. That is the whole of it, and it is why an animation costs no dependency either.

The frames come from :mod:geomotif.animate and the pixels from :mod:geomotif.io.raster; this module is only the container::

from geomotif.animate import draw_on
from geomotif.io.gif import save_gif
from geomotif.motifs import KochSnowflake

save_gif(draw_on(KochSnowflake(depth=4).build(), frames=60), "koch.gif")

Functions:

Name Description
to_gif

Render a sequence of designs as an animated GIF.

save_gif

Write an animated GIF and return the path written.

to_gif

to_gif(frames: Sequence[Design], *, width: int = 480, height: int = 480, padding: float = 8.0, fps: float = 20.0, loop: int = 0, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, antialias: bool = False, aa_level: int = 8, dither: bool = True, transparent: bool = False) -> bytes

Render a sequence of designs as an animated GIF.

Every frame is drawn against the same world rectangle -- the union of all of their bounds -- and the same color table, so a drawing that grows stays put instead of swimming about as its own extent changes.

Parameters:

Name Type Description Default
frames sequence of Design

What to draw, in order. At least one is needed.

required
width int

Canvas size in pixels.

480
height int

Canvas size in pixels.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
fps float

Frames per second. GIF stores a delay in hundredths of a second, so the rate is rounded to what the format can actually say.

20.0
loop int

How many times to play; 0 means forever, which is what everyone expects of a GIF, and is the default. 1 plays it once and stops.

0
ink str

Default stroke color and the color behind everything. A stroke with a style of its own is drawn in that instead.

'#0b0b0b'
background str

Default stroke color and the color behind everything. A stroke with a style of its own is drawn in that instead.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
antialias bool

Supersample and blend edges. Off by default, so the output is exactly the hard-edged picture it has always been.

False
aa_level int

When antialiasing, how many shades an edge may blend into per color pair, which is what keeps the whole animation inside GIF's 256-color budget. Must be >= 1.

8
dither bool

Error-diffuse the round-off onto a gradient, which keeps an antialiased edge on a colored ground from banding inside the palette budget. On by default.

True
transparent bool

Leave the background empty. Index 0 is flagged transparent in the file, so the drawing sits over whatever the page shows instead of a painted ground. Off by default, so the picture stays what it has always been.

False

Returns:

Type Description
bytes

A complete GIF89a file.

Raises:

Type Description
ValueError

If there are no frames, the rate is not positive, the antialias level is zero, or the designs' own colors (the inks and background) exceed 256 between them -- which is GIF's limit, not this writer's.

Source code in src/geomotif/io/gif.py
def to_gif(
    frames: Sequence[Design],
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    fps: float = 20.0,
    loop: int = 0,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    antialias: bool = False,
    aa_level: int = 8,
    dither: bool = True,
    transparent: bool = False,
) -> bytes:
    """Render a sequence of designs as an animated GIF.

    Every frame is drawn against the **same** world rectangle -- the union of
    all of their bounds -- and the same color table, so a drawing that grows
    stays put instead of swimming about as its own extent changes.

    Parameters
    ----------
    frames : sequence of Design
        What to draw, in order. At least one is needed.
    width, height : int
        Canvas size in pixels.
    padding : float
        Margin reserved on all four sides, in pixels.
    fps : float
        Frames per second. GIF stores a delay in hundredths of a second, so
        the rate is rounded to what the format can actually say.
    loop : int
        How many times to play; ``0`` means forever, which is what everyone
        expects of a GIF, and is the default. ``1`` plays it once and stops.
    ink, background : str
        Default stroke color and the color behind everything. A stroke with
        a style of its own is drawn in that instead.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    antialias : bool
        Supersample and blend edges. Off by default, so the output is exactly
        the hard-edged picture it has always been.
    aa_level : int
        When antialiasing, how many shades an edge may blend into per color
        pair, which is what keeps the whole animation inside GIF's 256-color
        budget. Must be >= 1.
    dither : bool
        Error-diffuse the round-off onto a gradient, which keeps an
        antialiased edge on a colored ground from banding inside the palette
        budget. On by default.
    transparent : bool
        Leave the background empty. Index 0 is flagged transparent in the
        file, so the drawing sits over whatever the page shows instead of a
        painted ground. Off by default, so the picture stays what it has
        always been.

    Returns
    -------
    bytes
        A complete GIF89a file.

    Raises
    ------
    ValueError
        If there are no frames, the rate is not positive, the antialias level
        is zero, or the designs' own colors (the inks and background) exceed
        256 between them -- which is GIF's limit, not this writer's.
    """
    if not frames:
        raise ValueError("cannot write a GIF with no frames")
    if fps <= 0:
        raise ValueError(f"fps must be > 0, got {fps}")
    if loop < 0:
        raise ValueError(f"loop must be >= 0, got {loop}")
    if aa_level < 1:
        raise ValueError(f"aa_level must be >= 1, got {aa_level}")

    seeds = colors_in(frames, ink=ink, background=background)
    if len(seeds) > 256:
        raise ValueError(
            f"a GIF has at most 256 colors and these designs need {len(seeds)}; "
            f"restyle them onto fewer, or write them as SVG instead"
        )
    shared = _union(frames)
    delay = max(_MIN_DELAY, round(100.0 / fps))

    if antialias:
        frame_rgba = [
            rasterize_rgba(
                frame,
                width=width,
                height=height,
                padding=padding,
                bounds=shared,
                ink=ink,
                background=background,
                thickness=thickness,
                dot_radius=dot_radius,
                aa_level=aa_level,
                transparent=transparent,
            )
            for frame in frames
        ]
        rasters = quantize(
            frame_rgba, seeds=seeds, max_colors=256, dither=dither, transparent=transparent
        )
        palette = rasters[0].palette
    else:
        palette = seeds
        rasters = tuple(
            rasterize(
                frame,
                width=width,
                height=height,
                padding=padding,
                bounds=shared,
                palette=palette,
                thickness=thickness,
                dot_radius=dot_radius,
            )
            for frame in frames
        )

    depth = _depth(len(palette))
    parts = [_header(width, height, depth), _color_table(palette, depth)]
    # loop=1 is the absence of the extension, not a count of one: the block
    # says how many times to repeat *after* the first play, and there is no
    # value of it that means "stop after one".
    if len(rasters) > 1 and loop != 1:
        parts.append(_looping(loop))
    parts.extend(
        _frame(raster, delay=delay, animated=len(rasters) > 1, transparent=transparent)
        for raster in rasters
    )
    parts.append(b";")
    return b"".join(parts)

save_gif

save_gif(frames: Sequence[Design], path: str | PathLike[str], **kwargs: Any) -> Path

Write an animated GIF and return the path written.

Keyword arguments are passed straight through to :func:to_gif.

Source code in src/geomotif/io/gif.py
def save_gif(frames: Sequence[Design], path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Write an animated GIF and return the path written.

    Keyword arguments are passed straight through to :func:`to_gif`.
    """
    target = pathlib.Path(path)
    target.write_bytes(to_gif(frames, **kwargs))
    return target