GIF to Sprite Sheet Converter (keeps frame timing)
Drop a GIF: every frame is decoded with its delay and disposal, becomes a frame with that duration, and packs into a sheet with the animation data your engine reads.
Drop a GIF or APNG: Nerulio decodes every frame with its delay and disposal into a full picture, turns each delay into a frame duration (GIF hundredths × 10 = ms), and packs the frames into a sheet, trimming empty borders and storing identical frames once. Pick your engine's target and the timing travels with it: per-frame milliseconds for Phaser and Aseprite JSON, relative durations for Godot, clip keys for Unity. Delays of 0 or 1 hundredth play as 100 ms, like in a browser, unless you choose the file's own values.

- Godot 4Verified· Godot 4.7.2
- Unity 6Verified· Unity 6000.5.3f1
- Phaser 3 / 4Verified· Phaser 3.90 + 4.2
- PixiJS 8Verified· PixiJS 8.21
- Aseprite JSONVerified· Phaser load.aseprite · PixiJS 8.21
- .asepriteVerified· Aseprite 1.3.18
- Spine / libGDXVerified· spine-canvas 4.2
- Starling / Sparrow XMLVerified· Phaser 4.2
- LÖVEVerified· LÖVE 11.5
- DefoldBuilt· Defold bob.jar 1.13.1
- GameMakerUNVERIFIED
- CSS spritesVerified· Chromium
- GIF / APNGDecoded· Pillow
Why a GIF frame is not yet a sprite frame
A GIF stores changes, not always whole pictures. Each image can cover only part of the screen, and its disposal method says what happens before the next one: 0 or 1 leave it in place, 2 clears its rectangle to the background, 3 restores what was there before. The frame you see is the result of drawing all of that in order. Nerulio replays the file the same way and keeps each result as a full-canvas frame, so a sprite frame never misses the parts an earlier image drew.
Timing is in hundredths of a second per frame, so a delay of 4 means 40 ms. Delays of 0 or 1 are special: WebKit plays any frame of 10 ms or less as 100 ms, and the GIF was probably made to look right that way. Nerulio does the same by default and shows it as a decision, with the alternative of using the stored values. APNG stores each delay as a fraction (delay_num/delay_den seconds), so it arrives to the millisecond.
Looping comes from the NETSCAPE2.0 block: 0 means forever, and a count n is read as n + 1 plays; without the block the GIF plays once. The whole file becomes one animation named after the file, with that repeat count, ready for the engine's loop setting.
- Disposal method
- What happens to a GIF image before the next one: keep (0/1), clear to background (2), restore previous (3).
- Delay
- GIF: hundredths of a second. APNG:
delay_num/delay_denseconds. - Full-canvas frame
- The picture after compositing, as large as the GIF's logical screen.
- Alias
- Two frames with identical visible pixels are stored once in the sheet.
Example: a 12-frame explosion
explosion.gif 96 × 96, 12 frames, delays 4 4 4 4 8 8 8 8 8 8 8 8 (1/100 s), loop 0
durations 4 × 40 ms + 8 × 80 ms = 160 + 640 = 800 ms per cycle
frames 12 full 96 × 96 pictures (disposal applied), trimmed and packed
loop NETSCAPE 0 → repeat ∞
Phaser anims.json duration 40 × 4, 80 × 8 (ms) repeat -1
Godot FPS = 1000 / (800 / 12) = 1000 / 66.67 = 15 relative 0.6 × 4, 1.2 × 8
check 0.6 / 15 = 0.040 s 1.2 / 15 = 0.080 sIf the first frame had a delay of 0, it would be imported as 100 ms and the Import panel would offer “use the file's delays” instead.
What maps to what
| In the GIF / APNG | In Nerulio | In the sheet and data |
|---|---|---|
| Image + disposal (possibly a partial rectangle) | One full-canvas frame after compositing | A trimmed region on the PNG with its offset |
| Delay (GIF 1/100 s, APNG fraction) | Frame duration in ms; 0–1 cs → 100 ms unless you pick the file's values | Per-frame ms (Phaser, Aseprite JSON, PixiJS meta), relative durations (Godot), key times (Unity) |
NETSCAPE loop 0 / n / none (APNG num_plays) | Repeat ∞ / n + 1 plays / once (APNG: the count as stored) | Phaser repeat −1 / n / 0; Godot loop on or off |
| The whole file | One animation named after the file | One animation in the engine file |
| Identical frames (holds) | Stored once as an alias | Two entries, one region |
| Transparent index | Alpha 0 | Transparent pixels in the PNG |
| Palette colours | Exact RGBA | Exact colours in the PNG |
What it does
- All frames with their delays and disposal (GIF and APNG); nothing is dropped. A delay of 0 or 1 hundredth plays as 100 ms, as browsers show it, with an option to keep the value stored in the file.
- Decoder checked against Pillow: 408 of 408 frames exact on 29 real GIFs, 132 of 132 on 16 APNGs.
- Trim, dedupe identical frames and pack tightly, or keep full frames.
- Export the sheet with Phaser / PixiJS JSON, Godot SpriteFrames, Unity clips, Aseprite JSON and more.
How to do it
- Drop the GIF or APNG.
- The Studio opens Pack & Export with every frame packed; the Sprite tab shows the timeline with each frame's duration.
- Pick your engine's preset.
- Export the sheet and its data file.
Files you get (Phaser 3/4 target)
| File | What it is for |
|---|---|
explosion.png | The packed sheet. |
explosion.json | Atlas JSON (hash) with each frame's region, trim offset and pivot. |
explosion.anims.json | The animation for anims.fromJSON: frame order, per-frame duration in ms, repeat. |
README-PHASER.md | The loading code for this bundle. |
Play it in Phaser 3 or 4
- Copy the PNG and both JSON files into your assets folder.
- In
preload():this.load.atlas('explosion', 'explosion.png', 'explosion.json')andthis.load.json('explosion-anims', 'explosion.anims.json'). - In
create():this.anims.fromJSON(this.cache.json.get('explosion-anims')), thenthis.add.sprite(100, 100, 'explosion').play('explosion'). - For pixel art create the game with
pixelArt: true. For another engine pick its target instead: Godot, Unity, PixiJS.
Check the result
- The Sprite timeline shows as many frames as the GIF, each with its delay × 10 in ms.
- One cycle in the engine lasts the sum of the delays (800 ms in the example).
- The Packed frames list shows how many frames were trimmed or stored once; a GIF with long holds shrinks the most.
Troubleshooting
| Symptom | Likely cause | How to check | Fix |
|---|---|---|---|
| Some frames play much slower than expected | Their delay is 0 or 1 hundredth, played as 100 ms like in a browser | Import panel: the delays decision names how many frames | Choose the file's own delays if your engine should play them fast (a 0 becomes 1 ms) |
| The animation plays once and stops | The GIF has no NETSCAPE loop block, so it was read as “play once” | Animation panel: repeat | Set the repeat to ∞ before export |
| The sheet is larger than the GIF | Every frame becomes a full picture; GIF compression of unchanged areas does not carry over | Packed frames: trimmed and aliased counts | Keep trim on; identical frames are stored once, but dithered frames are rarely identical |
| Edges are hard or have a light or dark rim | GIF has 1-bit transparency, and edges were often blended against a background colour when the GIF was made | Zoom in on the outline | Nerulio cannot restore soft alpha that the GIF never stored; use the original frames if you have them |
| “More than 4096 frames” or “too large to decode” | The decoder stops at 4096 frames or about 268 million decoded pixels | Frame count × width × height | Split the GIF, or cut it to the part you need first |
Other ways to do it
| Option | When it is the better choice |
|---|---|
| The frames the GIF was made from | You have the source PNGs or the project: real alpha and exact timing beat any GIF. |
| An online splitter such as ezgif | You only want the separate frame images, without packing or engine data. |
| The other direction: sprite sheet to GIF | You have a sheet and need a GIF for sharing. |
Engine exports and how each was checked
"Verified" means the exported files were loaded by the real engine or tool on real CC0 assets and the result was compared with the source. Anything that was not loaded is labelled as such.
| Target | Files | Check |
|---|---|---|
| Godot 4 | .tres SpriteFrames · .tscn AnimatedSprite2D · .png.import | Verified Godot 4.7.2Loaded by Godot; every animation frame drawn and compared with the source file. The scene uses Nearest filtering; rotation is never used. |
| Unity 6 | .unity.json · Editor/NerulioSpriteImporter.cs · AnimationClip | Verified Unity 6000.5.3f1Batch mode: sprite rects, pivots, pixels, clip keys and durations checked. |
| Phaser 3 / 4 | atlas JSON (multiatlas) · .anims.json | Verified Phaser 3.90 + 4.2Frames and animations drawn by both versions. |
| PixiJS 8 | spritesheet JSON · animations · anchor · meta.scale | Verified PixiJS 8.21Rotated and multi-page atlases included. |
| Aseprite JSON | hash / array · frameTags · slices | Verified Phaser load.aseprite · PixiJS 8.21Tags keep their direction and repeat count. |
| .aseprite | layers · tags · durations · slices | Verified Aseprite 1.3.18Opened and rendered by the Aseprite command line. |
| Spine / libGDX | .atlas | Verified spine-canvas 4.2Every region drawn by the Spine runtime, rotated and trimmed; libGDX itself was not run. |
| Starling / Sparrow XML | XML per page | Verified Phaser 4.2Phaser 3.90 draws trimmed XML frames wrongly (its own bug); the "Sparrow XML (Phaser 3)" preset turns trim off and passes in 3.90 and 4.2. |
| LÖVE | Lua quads · nerulio_atlas.lua · main.lua | Verified LÖVE 11.5Every frame drawn and every animation stepped through the shipped helper. |
| Defold | .atlas · .tilesource | Built Defold bob.jar 1.13.1Built by Defold's command-line builder; the built texture's UVs were read back. |
| GameMaker | spr_<name>_stripN.png · gamemaker.json | UNVERIFIEDNot loaded in GameMaker (no headless GameMaker). The strips are cut the way its strip importer cuts them. |
| CSS sprites | .css · preview .html | Verified ChromiumEvery class drawn from the shipped stylesheet. |
| GIF / APNG | one file per animation | Decoded PillowEvery frame and delay decoded again after export; APNG is exact. |
Evidence
Exports: 49 runs in the real engines on real CC0 assets, 47 pass — the 2 failures are Phaser 3.90's trimmed-XML bug, which the Phaser 3 preset avoids. Packer head-to-head on 5 real frame sets (CodeAndWeb free web packer, GAPTools, free-tex-packer): smallest sheet on all 5 with rotation allowed, 4 of 5 without; every frame restored pixel-exact.
Limits
- The whole GIF becomes one animation; split it into tags on the Timeline if it holds several.
- Soft edges that the GIF never stored cannot be recovered.
- Polygon (mesh) packing is not offered: none of the verified engines draws a mesh atlas from exported data.
- GameMaker strips are UNVERIFIED in GameMaker itself.
- Scale variants (@0.5x–@4x) use nearest-neighbour only.
- Trimmed Starling XML is drawn wrongly by Phaser 3.90; use the "Sparrow XML (Phaser 3)" preset there.
Versions and sources
The GIF decoder was checked against Pillow on 29 real GIFs (408 of 408 frames exact) and matches Chromium's ImageDecoder; the APNG decoder on 16 files covering every dispose and blend mode (132 of 132 frames). The Phaser target was loaded by Phaser 3.90 and 4.2. The GIF and APNG fields follow the W3C specifications; the short-delay rule follows WebKit's source.
Official documentation used
Questions
Does the sheet keep the GIF timing?
Yes. Each frame's delay is stored as its duration and written into formats that carry per-frame timing (Godot, Unity, Phaser anims, Aseprite JSON, PixiJS meta).
Is the GIF uploaded?
No. It is decoded in this browser tab.
Is it free, and do I need an account?
Everything on these pages is free and works without an account. There is no watermark and no export that is held back.
Are my sprites uploaded?
No. The Studio decodes, packs and exports in this browser tab. Projects are autosaved in the browser (IndexedDB) and can be saved as a .nerulio file.
Does it need Aseprite or an engine plugin installed?
No. .aseprite files are read and written in the browser, and each engine bundle carries what that engine needs (for Godot, the import settings and scene; for Unity and the Godot TileSet, a small editor script).