This release is a pre-release and may not be stable for production use.
headpat-gif
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. The default
# is (0.0, -0.15): the hand hangs 15% of the canvas above the picture, and the
# output is that much taller.
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
gif = petpet("pet.png", overlay_offset=(0.0, 0.0)) # canvas matches the picture
# 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.15) |
Moves the overlay, as a fraction of the canvas: negative y is up, (0.1, 0.0) is right. The canvas grows by whatever the nudge displaces, so the hand is not sliced off. The default lifts the hand 15% above the picture's top edge - that is why the default output is 15% taller than the input's aspect ratio. Pass (0.0, 0.0) for a canvas that matches the picture exactly. |
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.Fileis single-use. Build a freshPetPetGiffor every send, or reuse the bytes withio.BytesIO(gif.to_bytes()).
save(gif, path) -> pathlib.Path
Module level convenience wrapper around PetPetGif.save.
How it works
- The subject is scaled to fit a square of
max_size, then drawn atimage_scaleof the canvas, anchored 92% down so its feet stay put. - 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.
- 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 tooverlay_positionof the canvas,(0.0, 0.0)being the corner of the finished GIF. Anything the overlay puts past the canvas edge is cropped. - 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
fpstops 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, lowerduration, or passduration=Noneandspeedto shorten the tail. durationshorter than one cycle truncates the pat mid-way; usespeedwhen 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.1b3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| headpat_gif-0.2.1b3.tar.gz | 54.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| headpat_gif-0.2.1b3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 92.0 kB
Release files / headpat_gif-0.2.1b3.tar.gz
| Download URL | headpat_gif-0.2.1b3.tar.gz |
|---|---|
| Size | 54.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3ad56c530694699f0b34f9ed56138716af930baee2c601cb19d92820df11d8fa
|
|
BLAKE2b-256 checksum How to use checksums |
6eb4f59f302243620fce24d4e5e628e3c15527ea6d8684be0c1eaf3a2b1c1575
|
| 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.1b3-py3-none-any.whl
| Download URL | headpat_gif-0.2.1b3-py3-none-any.whl |
|---|---|
| Size | 38.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
135a35a566b43a3007a60a48a42fab3095efd0836541b76c7f68f612632f02f6
|
|
BLAKE2b-256 checksum How to use checksums |
3f817d887d767134c71bc98f77284f6655b192240e3b080ca961043297dbcc12
|
| 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.1b3
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.