photoGIF
One photo, a full set of animated WeChat stickers
The workflow going around online has three steps: generate a 3×3 grid with an AI image model, cut out the background with bgsub, export a GIF from a video editor. It has three failure modes you can’t avoid — the animation’s background turns grey, the export goes over 1 MB and WeChat refuses it, and the AI watermark won’t come off.
This site collapses those three steps into one, and skips the manual grid-slicing and manual cut-out along the way. Apart from the AI generation step, matting, animation and compression all happen in your own browser — the photo never goes to a server.
What each failure mode actually is
| Problem | Cause | What happens here |
|---|---|---|
| Grey background | Video codecs carry no alpha, so transparency flattens to grey | No video at all — transform the transparent sticker frame by frame and encode straight to a transparent GIF |
| Over 1 MB | The video editor knows nothing about WeChat’s sticker limits | Treat 1 MB as an input constraint and step down through a compression ladder automatically |
| Watermark won’t come off | It shares an image with the subject | Connected-component filtering by area — the watermark is a small fragment and gets dropped |
Matting without a model
Forcing “pure black background, white outline, non-touching” into the prompt isn’t an aesthetic choice — it’s the input contract for the whole image pipeline. Given that, no matting model is needed at all:
- Flood-fill inward from the canvas edges to find the background, rather than thresholding on global brightness. A global threshold takes the subject’s black hair, black clothes and black pupils with it — the single most common way this breaks. A flood fill can’t reach inside the subject, so interior black survives by construction.
- Estimate alpha from the compositing model. With a pure black background, an anti-aliased edge pixel is observed as
α·F. A fixed brightness ramp is wrong here: a white outline pixel at 50% coverage reads as grey 128, and the ramp calls that fully opaque — leaving a grey fringe around the sticker that gives itself away the moment the chat background is white. The right move is to take the brightness of interior neighbours as the referenceFand solveα = observed / reference. - Undo the darkening:
F = C / αrestores edge colour that the background pulled down. Measured, mean edge brightness recovers from 138 to 255. - One connected-component pass does two jobs: splitting the 3×3 grid into nine separate stickers, and discarding any component too small to be one (watermarks, noise).
Animation and encoding
Twelve procedural effects, every one guaranteeing f(0) === f(1) so loops don’t jump. The renderer computes the animation’s full envelope before choosing the sticker’s canvas size — otherwise the frame at the top of a bounce gets clipped.
Encoding runs on gifenc in a Web Worker. Over budget, it steps down a ladder: colours first, then dimensions, frames last. The order matters — stickers are flat colour blocks with an outline, so dropping to 64 colours is nearly invisible, while dropping frames makes the motion visibly stutter. After frame-dropping, per-frame delays are scaled back up so the motion keeps its original speed.
Measured on a 900×900 grid: matting and splitting into nine, 169 ms; encoding and exporting all nine GIFs, 821 ms; output 240×240 at 35–65 KB, transparency flag per frame, with a Netscape looping block.
Known limits
- No HEIC. Browsers can’t decode the iPhone default, so for now it prompts you to convert. Supporting it means pulling in
libheif-wasm(about 1 MB, worth lazy-loading). - GIF has 1-bit alpha, so semi-transparent edges get hard-cut. The white outline in the prompt hides this; a sticker without one shows visible jaggies.
- If the model ignores the “non-touching” instruction, two stickers become one connected component and get cut together. Lowering the fragment-filter threshold and fixing it by hand is the workaround.
How it’s built
React, TypeScript and Vite, deployed on Cloudflare Workers. The only server-side code is a single Worker whose sole reason to exist is hiding the image-generation API key — the photo goes in, gets forwarded, the result comes back, and nothing is stored or logged. Everything else is computed in the browser.


- Launched
- 2026
- My role
- Solo developer
- Built with
- ReactTypeScriptWeb WorkerCloudflare Workers