Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

headpat-gif

PyPI Python versions License: MIT

Turn any image into a "pet-pet" GIF: a hand pats the subject on the head while the subject squashes flat and springs back, exactly like clicking a pet in the reference demo.

from headpat_gif import petpet

gif = petpet("pet.png", duration=2.0)
gif.save("pet.gif")

Everything is swappable: bring your own animated overlay (the thing that appears on the head) and your own subject. The result is an io.BytesIO subclass, so it goes straight into discord.File with no temporary file:

import discord
from headpat_gif import petpet

@bot.command()
async def pet(ctx, url: str):
    await ctx.send(file=discord.File(petpet(url, duration=2.0)))

Install

uv add headpat-gif          # or: pip install headpat-gif
uv add "headpat-gif[discord]"   # optional: pulls discord.py for the test suite

Requires Python 3.10+ and Pillow 10+. ASCII-art style GIFs are left to you; the only runtime dependency is Pillow.

Usage

from headpat_gif import petpet, save

# One pat, as long as the overlay takes to play.
gif = petpet("pet.png")

# Two seconds of continuous patting (loop is on by default).
gif = petpet("pet.png", duration=2.0)

# Two seconds of GIF, patting at double rate.
gif = petpet("pet.png", duration=2.0, speed=2.0)

# Pat once and then rest for the remainder of the two seconds.
gif = petpet("pet.png", duration=2.0, loop=False)

# A small hand resting on the head instead of filling the picture.
gif = petpet("pet.png", overlay_scale=0.4, overlay_position=(0.35, 0.2))

# Where the overlay hangs from: the fraction of the GIF its visible top-left
# corner is pinned to. (0, 0) - the default - is the GIF's own corner, so the
# hand touches the left and top edges.
gif = petpet("pet.png", overlay_position=(0.0, 0.0))    # flush with the corner
gif = petpet("pet.png", overlay_position=(0.5, 0.0))    # top edge, centred
gif = petpet("pet.png", overlay_position=(0.0, 0.5))    # half way down the left side

# Nudge the hand. Fractions of the canvas; negative y is up. The canvas grows
# by whatever the nudge displaces, so the hand is never sliced off.
gif = petpet("pet.png", overlay_offset=(0.0, -0.3))   # 30% taller, hand complete
gif = petpet("pet.png", overlay_offset=(0.1, 0.15))   # down and to the right

# Your own overlay: a path, bytes, an open file or a PIL image - GIF or still.
gif = petpet("pet.png", "heart.gif")
gif = petpet("pet.png", requests.get(url, timeout=10).content)

# Tune the look.
gif = petpet(
    "pet.png",
    "heart.gif",
    duration=1.5,
    squash=1.4,                 # harder squash
    overlay_scale=0.6,          # shrink the overlay to a hop-on-the-head size
    overlay_position=(0.3, 0.1),  # ...and hang it over the head
)

save(gif, "out/pet.gif")   # or: gif.save("out/pet.gif")

Images from anywhere

image and overlay accept all of these:

Input Example
Filename / path petpet("pet.png"), petpet(Path("pet.png"))
Raw bytes petpet(requests.get(url).content)
Binary file object petpet(open("pet.png", "rb")), petpet(io.BytesIO(data))
Pillow image petpet(Image.open("pet.png"))

Animated overlays (GIF, animated WebP, APNG) keep their own frame timing; the squash is synchronised to that timeline. A still overlay is shown for static_overlay_ms (500ms by default) per cycle.

API

petpet(image, overlay=None, *, ...) -> PetPetGif

Parameter Default Meaning
image required The subject being patted. Animated input uses its first frame.
overlay None What appears on the head. None uses the bundled patting hand.
duration None Total length of the GIF in seconds. None = exactly one cycle.
speed 1.0 Playback rate multiplier: 2.0 plays the pat twice as fast.
loop True Keep patting until duration is filled, instead of resting after one pat.
fps 25 Output sampling rate, 10–50.
max_size 256 Longest side of the canvas in pixels (aspect ratio preserved).
image_scale 0.8 Subject size inside the canvas once it is back to normal.
squash 1.0 Squash strength. 0 disables it, 1 matches the reference.
overlay_scale 0.8 The overlay is always scaled uniformly: the factor comes from whichever canvas axis asks for the bigger one, so it covers the picture without distorting and a portrait hand stays a portrait hand. 1.0 is that full cover fit; the default 0.8 leaves a little of the picture showing, and 0.3 is a small hand.
overlay_offset (0.0, 0.0) Moves the overlay, as a fraction of the canvas: (0.0, -0.2) up, (0.1, 0.0) right. The canvas grows by whatever the nudge displaces, so the hand is not sliced off.
overlay_position (0.0, 0.0) Where the overlay hangs from: the fraction of the canvas that its visible top-left corner is pinned to. (0.0, 0.0) is the canvas' top-left corner - the corner of the finished GIF, so the overlay touches the left and top edges - (0.5, 0.5) its middle, (1.0, 1.0) its bottom-right.
static_overlay_ms 500 Cycle length when overlay is a still image.
background None Colour to flatten transparency onto, e.g. "white".
name "petpet.gif" Filename reported to Discord.

duration and speed are independent knobs. duration is the total runtime of the file; speed scales how long the pat itself takes inside it. So duration=2.0, speed=2.0 means "two seconds of GIF, patting at double rate".

With loop=True (the default) the total is divided into a whole number of pats, so an infinitely looping GIF never jumps mid-pat; the cycle length is nudged by less than one frame to make that fit. Pass loop=False for the one-pat-then-rest look.

PetPetGif

An io.BytesIO subclass holding the encoded GIF.

gif.save("out.gif")     # -> pathlib.Path
gif.to_bytes()          # -> bytes, without moving the read position
gif.name                # -> "petpet.gif", the name discord.py picks up
gif.size                # -> (width, height)
gif.n_frames            # -> frame count
gif.duration_ms         # -> total runtime in milliseconds
gif.cycle_ms            # -> length of one pat cycle in milliseconds

discord.File(gif) validates seekable()/readable() and takes the attachment name from .name, so the object is accepted as-is.

Careful: a discord.File is single-use. Build a fresh PetPetGif for every send, or reuse the bytes with io.BytesIO(gif.to_bytes()).

save(gif, path) -> pathlib.Path

Module level convenience wrapper around PetPetGif.save.

How it works

  1. The subject is scaled to fit a square of max_size, then drawn at image_scale of the canvas, anchored 92% down so its feet stay put.
  2. A triangular envelope drives the squash: untouched at the start of a cycle, widest and shortest in the middle, untouched again at the end. The amplitude matches pet-pet-gif (0.8 → 0.9 wide, 0.8 → 0.55 tall), so the motion feels like the GIFs people already know.
  3. The overlay is pasted on top, sampled against its own frame durations, and plays exactly once per pat. It is scaled uniformly: one factor, from whichever canvas axis asks for the bigger one, then multiplied by overlay_scale (0.8 by default). Its visible top-left corner - measured from the alpha channel, so a padded GIF does not leave a gap - is pinned to overlay_position of the canvas, (0.0, 0.0) being the corner of the finished GIF. Anything the overlay puts past the canvas edge is cropped.
  4. Every frame is converted to palette mode with a dedicated transparent index and saved with disposal=3 ("restore to previous"). Without the palette step Pillow writes black boxes around transparent areas; with disposal 2 instead of 3, decoders restore the background colour and the result is either an opaque black box (FFmpeg) or a stale copy of the previous frame (Pillow) - the classic transparent GIF bug, and very visible the moment an overlay moves or leaves. Both halves are covered by tests.

Housekeeping notes

  • GIF frame delays below 20ms are silently clamped to 100ms by browsers and Discord, so fps tops out at 50 and every delay is at least 20ms.
  • Output is byte-for-byte deterministic: no randomness is involved, which keeps caching and snapshot tests sane.
  • Want a smaller file? Lower max_size, lower duration, or pass duration=None and speed to shorten the tail.
  • duration shorter than one cycle truncates the pat mid-way; use speed when the goal is a faster pat rather than a shorter one.

Development

uv sync                                        # environment
uvx ruff check --fix .                         # style
uvx ty check                                   # types
uv run pytest                                  # tests

scripts/check.ps1 (PowerShell) and scripts/check.sh (bash) run all four in order. They also redirect UV_CACHE_DIR / UV_TOOL_DIR into .uv/, which is needed when working inside a sandbox that cannot write to %APPDATA%\uv.

If Windows refuses to run the script with "running scripts is disabled on this system", either run it as powershell -ExecutionPolicy Bypass -File scripts/check.ps1, or just run the three commands above directly.

License

MIT - see LICENSE. Bundled artwork and ported code are credited in NOTICE.

Metadata

Release files for headpat-gif 0.2.1b2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for headpat-gif 0.2.1b2
File Size Uploaded Provenance
headpat_gif-0.2.1b2.tar.gz 53.3 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for headpat-gif 0.2.1b2
File Interpreter ABI Platform
headpat_gif-0.2.1b2-py3-none-any.whl Python 3 none any Details

Total release size: 91.0 kB

Release files / headpat_gif-0.2.1b2.tar.gz

Download URL headpat_gif-0.2.1b2.tar.gz
Size 53.3 kB
Tags Source
SHA-256 checksum
How to use checksums
d6c4c2d7bc5e3cf510858629ffbe8a22fac6ed3bb8a9bde5cadbd610989148f9
BLAKE2b-256 checksum
How to use checksums
2cfbed819837e5166af86f3f4bf48095dce8b49f3873266efac218ab8add510e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / headpat_gif-0.2.1b2-py3-none-any.whl

Download URL headpat_gif-0.2.1b2-py3-none-any.whl
Size 37.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
79c745f3f5b65203ec516169955e686c9d6c644f79784beb3ed8535488d4b18a
BLAKE2b-256 checksum
How to use checksums
64ec1fa3de0c015b8ded26133ff6a864bf4b065da198d3d76e2d83e20423a2ca
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for headpat-gif 0.2.1b2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page