One Command vs an Online Tool: Assembling Sprite Sheets
The command line vs online sprite sheet tools argument usually gets settled by whoever is doing the arguing. It should be settled by stage. Two hundred numbered PNGs at 64x64 need to become one atlas: a single montage invocation does it and the prompt comes back. Deciding whether frame 7 or frame 8 comes first is a different problem, and no shell flag answers it. Playback answers it in about two seconds. Most projects need both, sometimes in the same afternoon.
This post is about where the split actually falls. It is not about which camp is right. The batch case is genuinely faster in the shell, by a wide margin, and pretending otherwise wastes people's time. The iteration case is genuinely faster in a browser, and pretending otherwise wastes more of it.
The command line vs online sprite sheet tools question is a stage question
Sprite work has three stages, and they have different feedback loops.
Export stage: the animation tool writes out walk_000.png through walk_199.png. You are not making decisions here. You are converting a folder into a texture.
Assembly stage: frames become a sheet. The decisions are cell size, grid, order, padding, and whether the alpha channel survives. These decisions are almost always made once and then frozen, because every engine that consumes the sheet encodes them again — Unity's Grid By Cell Size, Godot's hframes/vframes, Phaser's frameWidth/frameHeight, or raw UV math in the 3D game builder.
Iteration stage: does it read? Does the run cycle look like a run, or like a person being dragged backwards? This is the stage where a browser tool wins and there is no close second.
The mistake is using one tool for all three. A shell script at the iteration stage means editing a filename, re-running the build, opening the GIF, and discovering the two frames you swapped were in the wrong order. A browser tool at the assembly stage means dragging 200 files by hand and having nothing to commit.
The montage one-liner, written out properly
The one-liner people quote is usually wrong in a way that does not announce itself. Here is the version I use, expanded:
montage $(printf 'walk_%03d.png ' $(seq 1 200)) \
-tile 8x \
-geometry 64x64+0+0 \
-background none \
-mode concatenate \
-depth 8 \
-strip \
sheet_walk.png
The printf/seq pair matters more than it looks. printf 'walk_%03d.png ' $(seq 1 200) expands to walk_001.png walk_002.png ... walk_200.png, explicitly zero-padded and explicitly ordered. It replaces the shell glob, and the shell glob is where most bad sheets come from.
On ImageMagick 7 the binary is magick montage rather than montage. On 6 it is montage. Same flags.
The flags that change the output
-tile 8x sets eight columns and lets montage infer rows. 200 frames at 8 columns is 25 rows, so the sheet is 512x1600 with 64-pixel cells. -tile x8 would give you eight rows and 25 columns instead, which is the correct choice for a sheet consumed as a horizontal strip.
-geometry 64x64+0+0 sets the cell size and the spacing. The +0+0 is the part people drop, and montage's defaults do add spacing between tiles plus an opaque background. Those defaults exist because montage was built to make photo contact sheets, not atlases.
-background none keeps transparency. Leave it out on a character with a soft drop shadow and you get a white rectangle behind every frame. This does not error. It just ships.
-mode concatenate places each frame at its native size instead of scaling it to fit the cell. Drop it and montage resamples every frame to fit, which is sometimes exactly what you want and is also how pixel art turns to mush.
-depth 8 and -strip are the reproducibility flags. -strip removes the timestamp, profile, and comment chunks, which is what makes two runs on different days produce byte-identical output. Without it, your sheet changes in git even when nothing changed in the art.
Then verify, because the exit code told you nothing:
identify -format "%wx%h %[channels]\n" sheet_walk.png
You want 512x1600 srgba. Put that assertion in the script: if the width is not columns * cell, fail the build.
If you ever see a sheet where the walk cycle plays frames 1, 10, 100, 101, 2, you have hit lexicographic sort, not an engine bug.
walk_1.pngsorts beforewalk_10.png. Zero-pad the filenames or enumerate them explicitly. There is no third option that survives contact with a build server.
The failure montage will not warn you about
montage has no concept of a cell. It has geometry, tile count, and its own defaults. Give it frames that are 68 pixels wide because your exporter added a 4-pixel shadow margin, ask for 64-pixel cells with -mode concatenate, and it will place the oversized frames anyway. They overlap their neighbours. The output is a 512x1600 PNG with a valid header and exit code 0, and every frame is 4 pixels out of register.
With the default resize mode instead of concatenate, the same input produces a clean sheet where every frame has been squeezed by roughly 6 percent horizontally. Also exit code 0.
The check is one line:
identify -format "%wx%h\n" walk_*.png | sort -u
One line of output means every frame agrees. Several lines means you have a normalization step to write before the montage is meaningful. Find the intended origin first, then decide whether to trim, pad, or fix the exporter.
What a deterministic script gives you that a GUI does not
A build script is an artifact. That is the whole advantage, and it compounds.
Reproducible output. Same inputs, same bytes, every machine. This is what -strip buys you, and it is why a sheet rebuilt on a colleague's laptop is comparable to the one on the build server.
The sheet in version control. Sheets are binary and diff badly, so commit the generator and a frames.txt manifest listing frame order, one filename per line. Reordering a swing is then a diff of two adjacent lines instead of a drag you cannot describe to anyone. Put the PNG itself in whatever large-file storage your repository uses, and regenerate it in CI so the committed copy is never the source of truth.
No palette surprises. Shell tools do exactly what you specified, including when the specification is bad. If you export GIF you must decide colours yourself: -colors 256 plus an explicit dither choice, and the knowledge that GIF has one bit of alpha. Partial-alpha shadow pixels become either fully opaque — a dark fringe on a light page — or fully transparent — a chewed edge. There is no middle state in the format, and a script will not protect you from that; it will just do it consistently.
Batch scale. 200 frames is one invocation. Twelve cycles of 40 frames is one for loop over a list of prefixes. There is no clicking, no drag target to miss, and no per-file dialog.
Headless operation. A browser tool cannot run at commit time on a machine with no display. A shell script can, which means the sheet is guaranteed to match the frames it was built from.
Where the shell approach costs you time
Everything about judging an animation is visual, and the shell gives you nothing visual without a round trip.
Previewing the loop means building a GIF first. GIF frame delays are in hundredths of a second, so -delay 4 is 25 fps and -delay 8 is 12.5 fps. Do not try to preview at high frame rates: several browsers rewrite delays below 2 to 10, so -delay 1 plays at 10 fps instead of the 100 you asked for. The preview lies, and it lies in the direction that makes your timing look wrong.
Finding the origin is manual measurement. -trim gets you the content box:
convert walk_001.png -trim -format "%wx%h%O\n" info:
A result of 58x61+3+2 means the drawn pixels occupy 58x61 starting at x=3, y=2 inside the 64x64 canvas. Useful for one frame. For 200 frames it is a loop that writes a table, and then you read the table to find the outlier. That is three steps where a person dragging a slider does it once.
Checking the alpha edge is the same story. Resampling produces fringing, usually one or two pixels of half-transparent colour along the silhouette. You can extract the alpha channel and compare per-frame means, and you will still miss the thing your eye catches immediately on a dark background: a faint line down the left edge of every frame, because the source art had a stray pixel in column 0.
Frame-order decisions have no feedback loop. Every change is a full rebuild followed by a preview. Three rebuilds in an hour is fine. Thirty is not.
The alpha fringe case is worth internalizing: it survives every automated check you will write, and it disappears the moment you view the sheet against a contrasting background at playback speed. Budget for one visual pass no matter how good your script is.
The browser side of command line vs online sprite sheet tools
A browser tool inverts the cost structure. Assembly is manual, iteration is free.
Agent Sprite Forge is the sprite tool in Neta Studio, and the workflow is the one you would expect: drop the frame folder, get playback. It runs in a browser tab with no install, which matters more than it sounds when your options are an ImageMagick build without the PNG delegate, or a locked-down work machine.
What it actually gives you:
Immediate playback. Loop the frames at a chosen rate and watch them. The run cycle that reads as a drag, the jump apex that holds one frame too long, the idle that is really just a two-frame flicker — all of it is visible before you export anything. This is the step that a for loop cannot do.
Frame order switching. Dragging frame 12 before frame 11 and watching the result is one gesture. In the shell it is two renames, a rebuild, and a GIF. Both work; one of them you will actually do while exploring.
A GIF out the other side. The exporter owns the delay table, so you get a preview asset and a devlog asset from the same pass, without arithmetic in hundredths of a second.
Visible cell boundaries. A grid overlay over the frames makes the wrong-cell-size problem obvious before export, rather than after an engine has rendered a flickering character.
The honest limits: it is one project at a time, by hand. There is no headless mode, so it cannot be the thing that runs on commit. A 200-frame import is a lot of files, and if your workflow is "the exporter writes a new folder every time the artist saves", the browser tool is downstream of a batch step that has to exist anyway.
A decision table for real projects
Situation | Shell | Browser | Why |
|---|---|---|---|
200 frames already at final cell size | Yes | No | One invocation. The sheet exists before you finish switching windows. |
Deciding frame order for a new cycle | No | Yes | Needs playback. No flag substitutes for watching it. |
One-frame fix after a review | Yes | No | A browser session leaves no artifact behind; a script does. |
Checking cell size and registration | Partial | Yes |
|
Sheet rebuilt at commit time | Yes | No | Headless, deterministic, testable. |
GIF for a devlog post | Either | Yes | The exporter handles the delay table and the palette. |
Frames at inconsistent sizes | Yes, with a normalize pass | Yes, with a crop step | Both need the origin decided first. Neither guesses it. |
The pattern in that table is not a compromise. It is a pipeline: browser to decide, script to produce, browser to verify. Fix three frames in the iteration tool, then rebuild all 200 in the shell with the same one-liner you used last week, unchanged.
The one-liner really is faster for a 200-frame batch. Not marginally. There is no sequence of drags that competes with a single command that reads a manifest and writes one file. Use the shell for the batch and stop feeling bad about the browser for everything else.
Where this lands in Neta Studio
The same split shows up when a sheet goes into the 3D game builder. Sprite-based characters in a Neta Studio world are billboards: a textured quad with a frame count and a playback rate, sampling a region of the sheet each frame. That means the mathematics is identical to Unity's or Phaser's. A sheet built on the wrong grid produces a character that flickers between two poses because the UV rectangle is stepping by 64 pixels across 68-pixel cells. The engine is not broken. The cell size is.
Set the filter to nearest when the art is pixel art, or you will spend an evening wondering why the 64x64 frames look soft in a scene where nothing else is. That is the same rule as Unity's Filter Mode: Point (no filter) with Compression set to None, or Godot's import filter set to Nearest. It is a property of sampling, not of any one engine.
For reference worlds, The Cyclops' Island and Mini World both use sprite-driven elements alongside their 3D geometry, and both went through the same loop: agree the cell size in the browser, generate the sheet in the shell, verify the playback in the browser.
The short version
Use the shell when the frames are final and the grid is decided. Zero-pad your filenames, state -tile, -geometry, -background none and -mode concatenate explicitly, strip the metadata, and assert the output dimensions in the script. Commit the manifest, not your memory of the drag order.
Use the browser when the answer is not known yet. Frame order, loop timing, and registration errors are visual questions. Answer them where you can see the animation.
Most projects need both stages, and the command line vs online sprite sheet tools distinction stops being an argument once you say which stage you are in. A one-liner for the batch of 200. A tab for the three frames that are wrong.
Keep going
- sprite sheet maker
- GIF to sprite sheet converter
- sprite sheet to GIF converter
- sprite animator
- image & video to sprite sheet
Make the world it walks in
A sprite is a character with nowhere to stand. Every world on the 3D game builder was made from one written description and published as a page you can play.