Skip to content

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.

Drop frame images, a GIF or an .aseprite file to pack
Runs in your browser. Files are not uploaded.
Studio Pack & Export: the packed atlas page with every frame outlined, the used area and the export targets
The real Studio with a CC0 asset (OpenGameArt).
Exports to
  • 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_den seconds.
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 s

If 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 / APNGIn NerulioIn the sheet and data
Image + disposal (possibly a partial rectangle)One full-canvas frame after compositingA 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 valuesPer-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 fileOne animation named after the fileOne animation in the engine file
Identical frames (holds)Stored once as an aliasTwo entries, one region
Transparent indexAlpha 0Transparent pixels in the PNG
Palette coloursExact RGBAExact 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

  1. Drop the GIF or APNG.
  2. The Studio opens Pack & Export with every frame packed; the Sprite tab shows the timeline with each frame's duration.
  3. Pick your engine's preset.
  4. Export the sheet and its data file.

Files you get (Phaser 3/4 target)

FileWhat it is for
explosion.pngThe packed sheet.
explosion.jsonAtlas JSON (hash) with each frame's region, trim offset and pivot.
explosion.anims.jsonThe animation for anims.fromJSON: frame order, per-frame duration in ms, repeat.
README-PHASER.mdThe loading code for this bundle.

Play it in Phaser 3 or 4

  1. Copy the PNG and both JSON files into your assets folder.
  2. In preload(): this.load.atlas('explosion', 'explosion.png', 'explosion.json') and this.load.json('explosion-anims', 'explosion.anims.json').
  3. In create(): this.anims.fromJSON(this.cache.json.get('explosion-anims')), then this.add.sprite(100, 100, 'explosion').play('explosion').
  4. 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

SymptomLikely causeHow to checkFix
Some frames play much slower than expectedTheir delay is 0 or 1 hundredth, played as 100 ms like in a browserImport panel: the delays decision names how many framesChoose the file's own delays if your engine should play them fast (a 0 becomes 1 ms)
The animation plays once and stopsThe GIF has no NETSCAPE loop block, so it was read as “play once”Animation panel: repeatSet the repeat to ∞ before export
The sheet is larger than the GIFEvery frame becomes a full picture; GIF compression of unchanged areas does not carry overPacked frames: trimmed and aliased countsKeep trim on; identical frames are stored once, but dithered frames are rarely identical
Edges are hard or have a light or dark rimGIF has 1-bit transparency, and edges were often blended against a background colour when the GIF was madeZoom in on the outlineNerulio 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 pixelsFrame count × width × heightSplit the GIF, or cut it to the part you need first

Other ways to do it

OptionWhen it is the better choice
The frames the GIF was made fromYou have the source PNGs or the project: real alpha and exact timing beat any GIF.
An online splitter such as ezgifYou only want the separate frame images, without packing or engine data.
The other direction: sprite sheet to GIFYou 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.

TargetFilesCheck
Godot 4.tres SpriteFrames · .tscn AnimatedSprite2D · .png.importVerified 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 · AnimationClipVerified Unity 6000.5.3f1Batch mode: sprite rects, pivots, pixels, clip keys and durations checked.
Phaser 3 / 4atlas JSON (multiatlas) · .anims.jsonVerified Phaser 3.90 + 4.2Frames and animations drawn by both versions.
PixiJS 8spritesheet JSON · animations · anchor · meta.scaleVerified PixiJS 8.21Rotated and multi-page atlases included.
Aseprite JSONhash / array · frameTags · slicesVerified Phaser load.aseprite · PixiJS 8.21Tags keep their direction and repeat count.
.asepritelayers · tags · durations · slicesVerified Aseprite 1.3.18Opened and rendered by the Aseprite command line.
Spine / libGDX.atlasVerified spine-canvas 4.2Every region drawn by the Spine runtime, rotated and trimmed; libGDX itself was not run.
Starling / Sparrow XMLXML per pageVerified 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ÖVELua quads · nerulio_atlas.lua · main.luaVerified LÖVE 11.5Every frame drawn and every animation stepped through the shipped helper.
Defold.atlas · .tilesourceBuilt Defold bob.jar 1.13.1Built by Defold's command-line builder; the built texture's UVs were read back.
GameMakerspr_<name>_stripN.png · gamemaker.jsonUNVERIFIEDNot loaded in GameMaker (no headless GameMaker). The strips are cut the way its strip importer cuts them.
CSS sprites.css · preview .htmlVerified ChromiumEvery class drawn from the shipped stylesheet.
GIF / APNGone file per animationDecoded 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).