LibreSprite's tag UI sucks so I had to write a script

Estimated reading time 5 minutes

I suck at art. This isn't anything new to anyone who's followed along with my little game making adventures. I've got the equivalent to a toddler's sense of proportions and the same understanding of contrasting colors. That said, I recently incorporated LibreSprite's frame tagging features into how I created assets for some primitive game features and ran into a bit of an issue.

Table of contents

The problem

What you can see above in that screenshot is a spritesheet I'm working on for the next project. The way that things work is that I tag the frames of a sheet with names and states, then a little build file I have extracts those to produce a manifest file that includes JSON objects like this:

... buncha stuff and root level fields here ...
{
  "id": 4,
  "name": "portrait",
  "asset_path": "portrait.png",
  "frames": [
    {
      "x": 0, "y": 0, "w": 64, "h": 64, "duration_ms": 100
    }, ...
  ],
  "tags": [
    ...
    {
      "name": "talking-idle", "from": 1, "to": 2
    },
    {
      "name": "miku", "from": 1, "to": 4
    },
    {
      "name": "talking-mad", "from": 3, "to": 8
    },
    {
      "name": "yell", "from": 3, "to": 3
    },
    ...
  ]
}
...

I'm eliding a bunch of extra noise here, but you can see that there's a tags field and within it are objects that name and cover which pieces of data in the frames field correspond with the given tag. All of this is then used later on in life in the games via the highlighted bit of code below:

fn draw_character_slot(
    slot: Option<&Character>,
    placement: Rect,
    renderer: &mut dyn Renderer,
    catalog: &AssetCatalog,
) {
    let Some(character) = slot.as_ref() else {
        return;
    };
    let Some(src) = catalog
        .get_sprite_in_state(&character.name, character.state.as_str())
        .map(|s| s.get_rect())
    else {
        return;
    };
    renderer.send_command(RenderCommand::DrawRect {
        texture_id: character.texture_id,
        source: src,
        destination: placement,
    });
}

So you can see how we could look for a tag named miku, find the tag range from 1 to 4 in the JSON, then filter that range down by talking-idle to discover which frames specifically correspond to that character's state. Simple enough right? Yup! Works great!

Problem is this mess:

Maybe I'm just using the wrong tool for the job here, but libresprite has 0 way for me to click through or expand the tags being used in its interface. There are open issues about this with suggestions on how to change the interface to fix this sort of problem since 2015

And yet, here we are. In 2026.

Trying to work around it

I've been using libresprite long enough to notice the "scripts" folder in the menu:

And as you can see, my first instinct was to see if I might be able to do something there. Unfortunately, the SCRIPTING documentation doesn't document anything I can use. Hell, it's really just a java-doc sort of style explanation of classes and they leave you to your own devices to piece together that something like this will do anything:

app.command.RemoveFrameTag();

There's temptations like activeSprite and activeFrameNumber in the docs, but looking around at the tag there's nothing there to select a specific tag. You can do things like GotoPreviousFrameWithSameTag but that literally just changes the frame you're on, it has nothing to do with selecting a specific tag. So essentially, nothing in the LibreSprite UI or in it scripting API was going to make it possible for me to remove a tag that I couldn't click.

Which is unfortunate because when you make a new frame, it seems to inherit some of the tags sometimes or something weird like that? Because the reason I launched this quest was because I noticed my manifest had miku pegged to frames 1-8 when she was really only frames 1-5.

Thankfully, since I wrote that build script I mentioned, I knew that there were some crates out there in the rust world that could manipulate the ASE files.

  1. asefile lets you read the binary files and extract information
  2. asesprite-loader lets you read the binary and extract merged image data
  3. asesprite-io lets you read and write asesprite files

"Great!" you say, that last one sounds perfect!

On closer inspection of the documentation you can see that it provides a number of helpers to build frames, cels, and change pixels:

use aseprite::*;

let mut file = AsepriteFile::new(32, 32, ColorMode::Rgba);
let layer = file.add_layer("Background");
let frame = file.add_frame(100);
let pixels = Pixels::new(vec![0u8; 32 * 32 * 4], 32, 32, ColorMode::Rgba).unwrap();
file.set_cel(layer, frame, pixels, 0, 0).unwrap();

But hey, you know what it doesn't let you do?

Modify an existing tag. Or change the tags available in a file. That tags method returns an immutable list of tags to the caller. You can't change something that's already been added. Very good API. Very nice.

My solution

But obviously I wouldn't be blogging about this if I hadn't figured out a work around to share with the wild internet denizens that might stumble upon this in the middle of the night. So, congrats, you're in luck! And it's not even the obnoxious solution of "just iterate everything in one file and add only the non-filtered stuff to a new one!".

Even though the tags method is immutable, if you inspect the source of the crate you'll spot a useful method private to the crate:

pub(crate) fn tags_mut(&mut self) -> &mut Vec<Tag> {
    &mut self.tags
}

Now, I'm not really sure why this method is private to the crate. It seems like it'd be really really useful for anyone hoping to manipulate an existing ase file. But since the code is open source, we can clone the repository and just

pub fn tags_mut(&mut self) -> &mut Vec<Tag> {
    &mut self.tags
}

Snip!

Now its public. Then, we can write a little cargo toml file:

[package]
name = "aes-tag-tweaker" 1
version = "0.1.0"
edition = "2024"

# tags_mut is pub(crate) only, but we need it exposed to filter tags. So, local version:
[dependencies]
aseprite-io = { version = "0.3.0", path = "../../../opensource/aseprite-io" }

to point to our local package version (who I have bumped the version to 0.3.0 since I changed something), and then we can finally create a tool that can do what we want. Remove a tag that matches a given criteria:

use aseprite::AsepriteFile;
use std::env;
use std::fs;
use std::path::Path;

fn main() {
    let mut args = env::args();
    let mut file_path = None;
    let mut tag_name = None;
    let mut from_frame: Option<usize> = None;
    let mut to_frame: Option<usize> = None;
    let mut out_name = "out.ase".to_owned();
    args.next(); // Skip program name.
    while let Some(argument) = args.next() {
        match &argument[..] {
            "-file" => file_path = args.next(),
            "-name" => tag_name = args.next(),
            "-from" => {
                from_frame = args
                    .next()
                    .map(|string| string.parse().expect("Could not parse frame range (from) as number"))
            }
            "-to" => {
                to_frame = args
                    .next()
                    .map(|string| string.parse().expect("Could not parse frame range (to) as number"))
            }
            "-out" => out_name = args.next().expect("expected output file argument"),
            unknown => eprintln!("Unknown flag {unknown}"),
        };
    }
    let Some(file) = file_path else {
        panic!("pass a file with -file");
    };
    let Some(tag_name) = tag_name else {
        panic!("pass a tag name with -name");
    };
    let Some(from_frame) = from_frame else {
        panic!("pass a tag from range with -from");
    };
    let Some(to_frame) = to_frame else {
        panic!("pass a tag from range with -to");
    };
    let data = fs::read(Path::new(&file)).expect("could not load ase file");
    let mut file = AsepriteFile::from_reader(&data[..]).unwrap();

    eprintln!("{:?}", file);

    let tags = file.tags_mut();
    tags.retain(|tag| {
        let retain_true = !(tag_name == tag.name && tag.from_frame == from_frame && tag.to_frame == to_frame);
        eprintln!("keeping {} ({}-{})? {}", tag.name, tag.from_frame, tag.to_frame, retain_true);
        retain_true
    });

    eprintln!("{:?}", file);

    let mut output = fs::File::create(out_name).expect("could not create output file");
    file.write_to(&mut output)
        .expect("could not write file contents to output file");
}

Is this the best way to do it? Should you eprintln everywhere in a little script that you might use once in a blue moon? Should you just call expect in the match loop if you're going to just panic anyway? Eh, questions for someone trying to build a tool that would ever be installed via crates.io and not just one you run cargo build --release for and then symlink it into your /usr/bin for easy access.

The important thing is that I can now do this:

aes-tag-tweaker -name miku -from 0 -to 7 -file ~/src/personal/mikumikutactics/spritesheets/portrait.tmp.ase 
AsepriteFile { width: 64, height: 64, color_mode: Rgba, layers: 7, frames: 9, tags: 9, cels: 49 }
keeping background (0-0)? true
keeping miku (0-7)? false
keeping yell (3-3)? true
keeping talking-mad (3-8)? true
keeping meiko (5-8)? true
keeping talking-neutral (5-6)? true
keeping happy (5-5)? true
keeping talking-mad (7-8)? true
keeping mad (7-7)? true
AsepriteFile { width: 64, height: 64, color_mode: Rgba, layers: 7, frames: 9, tags: 8, cels: 49 }

And then open the corresponding out.ase file created wherever I just ran this and see that yes indeed, the tags are now fixed and I don't have delete every single tag within the libresprite ui in order to kill off one bad range. Bad UI, crushed! And I'll see you later when I finish the next game in the 20 games challenge!